奇特 state 用法导致的奇特 bug
今天群友在群里问了一个有趣的问题,就是下面这段代码为什么没法得到他想要的结果
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 里的值的,那么这里的问题出在哪儿呢?
我们把打断点的范围移到上一层词法作用域,也就是 handleClick
函数定义的那一层,看看在他定义的时候,这个 obj
是什么值。这里的 obj
显示 a3b2 都很正常,可是当我们展开 handleClick
时,却发现它捕获的闭包,依然是 a1b2 。这时我发现了一个之前一直被忽略的细节,也就是在 handleClick
内部,将 obj
复制到了新对象里,其中的操作会修改 a b 的值,但是最初定义的 handleClick
函数却原封不动地传入了新对象,并在下一次函数组件返回时,作为里面 onClick
函数里的 obj.handleClick
被执行,这也就是无论点击多少次之后,里面的 obj
仍然是 a1b2 的原因了。
为了验证 useState
返回的 obj
的 handleClick
是最初的那一个函数,我使用了一个累加数字来区分不同次执行时定义的 handleClick
。为了避免反复进入断点,我已经将 React.StrictMode 去掉。在点击 h2
时果然看到,handleClick
函数定义的时候虽然已到 2,但 obj
里的却是 1。
结论
那么一切都很清晰了,这一切的始作俑者,希望利用函数闭包,让每次执行的时候 handleClick
捕获不同的 obj
以在点击时可以更新 obj
内部的 a b 值,但没想到的是,React 在赋了初值之后,将不再使用传入的参数来作为 useState
的返回值,正如后续执行这个函数组件 a1b2 这个参数不会改变,但 useState
返回的值却永远是最新值,也就是 setObj 里面赋的值,也就是说,里面来自旧对象的 handleClick
被赋到了最新的对象上,被绑定到了 onClick
上。
没想到这么一个问题,竟能以如此方式涉及 React 自身特性以及 JS 闭包
© ShiftWatchOut.RSS