记一次 hooks 闭包陷阱问题
11月11日, 2022 JavaScript Tumars 1,094 views次
11月11日, 2022 1,094 views次
题图为作者拍于稻城亚丁五色海。
hooks 的闭包陷阱是个老生常谈的问题了,但依然很容易在开发时忽略。近日再次遭遇了这个问题,记录一下。
发现 bug
需求很简单:一个卡片上有多个下载按钮,点击后请求文件地址。大概实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
export default function App() { const [loadingMap, setLoadingMap] = useState({}); const handleClick = async (key) => { setLoadingMap({ ...loadingMap, [key]: true }); await delay(3000); setLoadingMap({ ...loadingMap, [key]: false }); }; return ( <div className="App"> <button onClick={() => handleClick("a")}> {loadingMap.a ? "下载中" : "下载a"} </button> <button onClick={() => handleClick("b")}> {loadingMap.b ? "下载中" : "下载b"} </button> </div> ); } |
测试时发现问题:如果连续点击 a 和 b 按钮,当 b 下载结束时,a 按钮会被重置为”下载中”。🙃
显然,这段简单的代码忽略了闭包问题,下图描述了这段逻辑里闭包问题产生的原因:
如图描述,两次按钮点击产生了 4 次 setState,因此导致 4 次 re-render,算上初始化的 render,App 共执行了 5 次。
每次渲染都有独立的 state 上下文。
b 按钮的点击发生在第二次渲染后,对应的闭包中 loadingMap
的值为 {a: true}
,当前渲染中的 eventHandler 只能获取到本次渲染环境对应的 state。
因此,当在 eventHandler 中通过 setState 触发第五次渲染时,此处的 loadingMap
依然是第二次渲染时的旧值,而非最新的第四次渲染时的值。
解决方案
关键点在于:在执行 setState 时,如何能获取到最新的 state,而不是本次渲染时的 state。
方案一:useRef
解决不了的问题就上 useRef 能让你脱离掉发
useRef 是 react 开发者的老朋友,是解决 hooks 问题的万金油。它能存储一个指向不变的变量。
代码做如下更改:
1 2 3 4 5 6 7 8 |
const loadingMap = useRef({}); const forceUpdate = useForceUpdate(); const setLoading = (key, value) => { loadingMap.current[key] = value; forceUpdate(); }; |
详见:eventHandler 的闭包问题(ref处理方案)
😀 如代码所示,把 loadingMap 存在 ref. current 里,就永远能找到它了,所有的渲染里,它的指向都是唯一的。
🐸 唯一的问题是,修改 ref. current 是不会触发 re-render 的,因此需要使用 forceUpdate 强制刷新。
方案二:setState 的函数式更新
函数式更新好,获取 state 没烦恼
setState 可以接收一个函数作为参数,即函数式更新,我们把这个函数称为更新器,这种更新方式不会直接触发 re-render,而是会把更新器推到一个队列里,最后经过计算统一触发一次更新。更新器的参数是上一个计算后该状态的值,可以理解为最新的 state。
代码做如下更改:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const setMap = (key, value) => { setLoadingMap((v) => ({ ...v, [key]: value })); }; const handleClick = async (key) => { setMap(key, true); await delay(3000); setMap(key, false); }; |
详见:eventHandler 的闭包问题 (函数式更新处理方案)
😀 可以说非常完美了。函数式更新有如下优点:
- 总是可以拿到最新的 state
- 没有依赖,如果要在 useEffect 中更新 state,但又不想因 state 更新触发 effect 时,用函数式更新很合适
- 批量更新时只会触发一次 re-render,这可以避免不必要的 re-render
干净简洁、没有负担,函数式更新可以说是处女座之友。
🐸 不过函数式更新只能用在 setState 时,如果需要获取到最新的 state 做其他事情(比如发送一个请求),就无法使用这个方案。
方案三:useReducer
useRef 的难兄难弟,解决 hooks 的疑难杂症
reducer 的情况跟 ref 类似,它也是将 state 的状态管理转移了,得以避免 hooks 的闭包陷阱。
代码修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
const reducer = (state, action) => { return { ...state, ...action }; }; const [loadingMap, dispatch] = useReducer(reducer, {}); const handleClick = async (key) => dispatch({ [key]: true }); await delay(3000); dispatch({ [key]: false }); }; |
代码详见:eventHandler 的闭包问题(reducer处理方案)
😀 某种程度上,reducer 被称为 hooks 的作弊模式,它可以解耦业务逻辑和更新方法的关系。和函数式更新一样,它也不需要依赖当前渲染上下文的 state,因此很适合在以下场景使用:需要在 useEffect 这类需要传入依赖的 hooks 中修改状态,但又不想因依赖改变触发 effect 的场景。
🐸 不过使用 useReducer 往往意味着更多的代码逻辑(action、reducer、dispatch... )。
总结
hooks 的闭包陷阱是非常常见的问题,当代码中有【异步逻辑+get/setState】的场景时,需要注意可能产生闭包陷阱。
本文的三种方法除了解决闭包陷阱问题,在批量更新、依赖诚实、避免不必要的 effect 等场景下也都提供了解决方案。
最后,希望 useEvent 尽快到来。
faoxer