奇特 state 用法导致的奇特 bug

ShiftWatchOut,JSReact

今天群友在群里问了一个有趣的问题,就是下面这段代码为什么没法得到他想要的结果

群聊

function App() {
  const [obj, setObj] = useState({a:1, b:2, handleClick})
  function handleClick(key, val) {
    const newObj = {...obj} // 克隆与此处效果一致
    if (key == 'a') {
      newObj.a = val
    } else {
      newObj.b = val
    }
    setObj(newObj)
  }

  return <div>
    <h2 onClick={() => {obj.handleClick('a', 3)}}>{obj.a}</h2>
    <p onClick={() => {obj.handleClick('b', 3)}}>{obj.b}</p>
  </div>
}

可以看出来是刚接触 React 函数组件以及 Hooks 的人,想往一个单一 state 内存放一个包含这个组件里所有数据和方法的大对象,在实际开发中肯定是不推荐这种写法的。很快啊,就有其他群友给出了符合 React 日常开发逻辑的代码,也得到了在求助的群友想要的效果。但这位朋友解决了问题后,想要了解问题是怎么产生的,那我们就来一探究竟。

预期

obj 初始值 { a: 1, b: 2 },页面显示 1 2,在点击了 h2 标签之后,希望对象值变成 { a: 3, b: 2 },页面显示 3 2,这之后继续点击 p 标签,希望对象值变成 { a: 3, b: 3 } ,页面显示 3 3。

结果

在点击了 h2 标签之后,对象值变成 { a: 3, b: 2 },页面显示 3 2,这之后继续点击 p 标签,对象值变成 { a: 1, b: 3 },页面显示 1 3,继续点击 h2 标签,对象值变成 { a: 3, b: 2 },页面显示 3 2,仿佛在 handleClick 内的 obj 始终都是初始的 { a: 1, b: 3 } 一样。

分析

第一眼看到这种问题肯定是往函数闭包上想,毕竟 JS 有着闭包这一捕获定义时词法作用域内变量的特性。最初想的就是 handleClick 会捕获 obj ,这个 obj 初始值是 { a: 1, b: 2 }。在第一次点击之后,obj 变为 { a: 3, b: 2 },这时新定义的 handleClick 应当也捕获了这个 obj 啊,照理来说,再次点击,被复制到新对象里的 obj 应该就是新的 { a: 3, b: 2 } 了啊,明显我们的提问者就是这样设想的,我第一眼看上去也没发现问题所在。可是,在这时进行点击 b 所在的 <p> 标签时,已经变为 3 的属性 a 这时居然变成了 1,仿佛他捕获的变量是 { a: 1, b: 2 } 一样,这就使得我的好奇心和求知欲犹如王八退房——憋不住了啊。

实践

这时该使出我的传统艺能“打断点调试大法”了,我们在 handleClick 内部打上断点,当 a 已经变为 3 之后来看里面的 obj 究竟是谁,惊讶地发现里面竟然真是 a 为 1 的对象,鼠标移至这个断点的 useState 返回的 obj 值上,发现在 UI 为 a3b2 的时候,这个 obj 里却是 a1b2 。这令我百思不得其解,React UI 肯定是忠实地显示自己的 state 里的值的,那么这里的问题出在哪儿呢?

debug 1

我们把打断点的范围移到上一层词法作用域,也就是 handleClick 函数定义的那一层,看看在他定义的时候,这个 obj 是什么值。这里的 obj 显示 a3b2 都很正常,可是当我们展开 handleClick 时,却发现它捕获的闭包,依然是 a1b2 。这时我发现了一个之前一直被忽略的细节,也就是在 handleClick 内部,将 obj 复制到了新对象里,其中的操作会修改 a b 的值,但是最初定义的 handleClick 函数却原封不动地传入了新对象,并在下一次函数组件返回时,作为里面 onClick 函数里的 obj.handleClick 被执行,这也就是无论点击多少次之后,里面的 obj 仍然是 a1b2 的原因了。

debug 3

为了验证 useState 返回的 objhandleClick 是最初的那一个函数,我使用了一个累加数字来区分不同次执行时定义的 handleClick 。为了避免反复进入断点,我已经将 React.StrictMode 去掉。在点击 h2 时果然看到,handleClick 函数定义的时候虽然已到 2,但 obj 里的却是 1。

debug 2

结论

那么一切都很清晰了,这一切的始作俑者,希望利用函数闭包,让每次执行的时候 handleClick 捕获不同的 obj 以在点击时可以更新 obj 内部的 a b 值,但没想到的是,React 在赋了初值之后,将不再使用传入的参数来作为 useState 的返回值,正如后续执行这个函数组件 a1b2 这个参数不会改变,但 useState 返回的值却永远是最新值,也就是 setObj 里面赋的值,也就是说,里面来自旧对象的 handleClick 被赋到了最新的对象上,被绑定到了 onClick 上。

没想到这么一个问题,竟能以如此方式涉及 React 自身特性以及 JS 闭包

© ShiftWatchOut.RSS