1 定时器回调的闭包问题
- 下面这段代码会打印什么?
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
function App() {
const [count, setCount] = useState(0);
const handleBtnClick = () => {
setCount(count + 1);
};
useEffect(() => {
handleBtnClick();
setTimeout(() => {
console.log("setTimeout count", count);
}, 1000);
}, []);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
- 因为useEffect的回调只执行了一次,这个回调函数作用域中的闭包永远引用着App函数第一次执行时创建的作用域;所以这里的setTimeout回调读取的count值是始终是闭包中的count值0;
- 下面修改下代码,给useEffect增加count的依赖
function App() {
const [count, setCount] = useState(0);
const handleBtnClick = () => {
setCount(count + 1);
};
useEffect(() => {
if (count === 0) { //防止不停触发更新
handleBtnClick();
}
setTimeout(() => {
console.log("setTimeout count", count);
}, 1000);
}, [count]);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
- 这里打印了两遍,因为useEffect的回调执行了两次创建了两个定时器,其中第二次已经可以获取到新的count值,因为第二次执行回调时App函数已经重新render并计算了新的count,这个回调函数执行时创建了新的作用域,里面的闭包引用了App函数重新render执行后的作用域;
2 事件监听绑定的处理函数闭包问题
function App() {
const [count, setCount] = useState(0);
const handleBtnClick = () => {
setCount(count + 1);
};
const clickCb = () => {
console.log("clickCb count", count);
};
useEffect(() => {
if (count === 0) {
handleBtnClick();
}
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, []);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
- 可以看见click事件的回调函数clickCb作用域中形成了闭包引用了App函数的作用域
- 所以无论我们如何修改count值,这个事件处理函数读取到的永远是App组件首次render时创建的作用域
- 下面修改下代码给useEffect增加依赖项
function App() {
const [count, setCount] = useState(0);
const handleBtnClick = () => {
setCount(count + 1);
};
const clickCb = () => {
console.log("clickCb count", count);
};
useEffect(() => {
if (count === 0) {
handleBtnClick();
}
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, [count]);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
- 发现此时可以打印预期的count值,因为这里每次count变化时useEffect的回调都会重新执行,移除旧的监听事件并重新绑定监听事件的处理函数,这个新的事件处理函数的作用域中的闭包是最新的App函数render后的作用域;
3 从源码分析是谁在决定useEffect的回调是否需要重新执行
- 函数fiber节点的memoizedState属性是一个链表,保存了这个函数组件上的所有hook;
- 而每一个hook的memoizedState属性保存的值根据hook类型不同而不同,对于useEffect来说memoizedState是一个环状链表,保存的是对应函数组件的最后一个effect
//effect的数据结构
function pushEffect(tag, create, destroy, deps) {
const effect: Effect = {
tag, //只有传入的是HookHasEffect才会在commit阶段执行这个effect中的回调
create,
destroy,
deps,
next: (null: any),
};
-
从下面流程图中可以看出,在updateEffect函数中会调用areHookInputsEqual(nextDeps, prevDeps)判断新旧dep是否相同(里面会调用Object.is做浅比较),如果相同则会提前return而不会标记HookHasEffect的tag,这样在commit阶段就不会执行这个effet
-
下图是commit阶段最后一个子阶段layout阶段的执行流程图
-
对于useMemo和useCallback的闭包陷阱问题也是类似的;
4 解决办法
4.1 在依赖项数组中增加对应依赖项
...
useEffect(() => {
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, [count]);//增加count的依赖
...
- 但是这么做会导致每次count变化都会执行useEffect中的回调,移除旧的事件监听再重新建立监听事件绑定处理函数,这会导致不必要的开销也不符合正常的开发的思路(理论上我们只需要在组件mount时绑定一次监听事件,在组件销毁时移除对应事件监听即可),所以更佳的做法是使用useRef
4.2 useRef
4.2.1 使用useRef保存count值并手动同步更新
function App() {
const [count, setCount] = useState(0);
const countRef = useRef(0); // +
const handleBtnClick = () => {
const newCount = count + 1; // +
setCount(newCount); // +
countRef.current = newCount; // + 这里是手动档更新countRef的值
};
const clickCb = () => {
console.log("clickCb countRef.current", countRef.current); // +
};
useEffect(() => {
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, []);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
- 这时候我们发现事件处理函数中读取的countRef.current可以获取到正确的count值;
4.2.2 使用useRef保存count值并使用useLayoutEffect自动同步更新
function App() {
const [count, setCount] = useState(0);
const countRef = useRef(0);
const handleBtnClick = () => {
setCount(count + 1);
};
const clickCb = () => {
console.log("clickCb countRef.current", countRef.current);
};
useLayoutEffect(() => { // +
countRef.current = count; // + 这里是每当count改变自动同步更新countRef的值
}, [count]); // +
useEffect(() => {
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, []);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
- 发现我们可以在事件处理函数中读取到正确的count值,因为每次count改变useLayoutEffect的回调都自动帮我们同步更新了countRef的值;
- 这里为什么用useLayoutEffect而不是useEffect,因为useLayoutEffect的回调函数是在commit阶段的最后一个子阶段layout阶段的commitLayoutEffects方法中被同步执行的,所以每次组件重新render后的commit阶段都能在click事件处理函数调用前更新好countRef的值;
- 但是useEffect的回调是在commit阶段代码同步执行完后再异步调度的
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();//在commit阶段完成后异步执行回调遍历pendingPassiveHookEffectsMount链表执行useEffect中注册的回调
return null;
});
}
- 我们尝试使用useEffect更新countRef的值,这样会导致我们在点击+1按钮时触发组件重新render,react帮我们异步调度useEffect的回调,然后也触发了click事件打印countRef的值,但是此时useEffect中的回调还没执行,所以这时候countRef还没更新,打印的还是0
//useLayoutEffect(() => { //改为useEffect尝试看看会发生什么
useEffect(() => {
countRef.current = count;
}, [count]);
- 但是当我们点击其余空白处再次触发click事件时发现打印了正确的countRef的值,因为这时候useEffect中回调已经被异步调度执行了,也就是已经更新了countRef的值
4.2.3 使用useRef保存count值并在函数组件顶层更新ref
- 和放在useLayoutEffect回调中的区别是无论count值是否变化,只要函数组件render都会重新给countRef赋值
function App() {
const [count, setCount] = useState(0);
const countRef = useRef(null);
countRef.current = count; // +
const handleBtnClick = () => setCount(count + 1);
const clickCb = () => {
console.log("clickCb countRef.current", countRef.current);
};
useEffect(() => {
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, []);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
4.2.4 封装自定义hook使用useRef保存count值并在函数组件顶层更新ref--> useLatest
- 原理同4.2.3
export function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function App() {
const [count, setCount] = useState(0);
const latestCountRef = useLatest(count);
const handleBtnClick = () => setCount(count + 1);
const clickCb = () => {
console.log("clickCb latestCountRef", latestCountRef.current);
};
useEffect(() => {
document.addEventListener("click", clickCb);
return () => document.removeEventListener("click", clickCb);
}, []);
return (
<>
<button onClick={handleBtnClick}>+1</button>
<span>count:{count}</span>
</>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
4.3 为什么用useRef就可以?
-
useRef返回一个包含current属性对象,这个对象在组件的整个生命周期内一直存在 -
从源码中看到这个hook的数据结构很简单,就mount时创建一个ref对象并保存在hook.memoizedState中,当组件update重新调用useRef函数时直接返回这个对象
-
这里的事件处理函数clickCb作用域链中引用了函数App的作用域形成了闭包,这个闭包中的变量对象始终是App函数首次执行时的作用域,所以可以从下图中看到count仍然是0,但是countRef对象中的current属性是1,因为这个闭包中countRef对象和函数App多次重新render时操作的countRef对象是同一个内存地址(useRef在hook.memoizedState保存着),所以是可以获取到预期的值的。
最后
- 以上是我对react hooks闭包陷阱问题的理解,如果发现有误希望可以指正,大家一起交流学习哈;