五千字长文 搞懂 useEffect 闭包问题
前言
前同事的代码中无意中发现很多,在 useEffect
中修改状态的时候,没有使用到函数式更新和依赖项的对应添加。
所以今天特意写一篇小文章来撸一撸到底是怎么一个事。我们碰到这样的问题应该如何去做,在面试的时候被问到应该如何回答更具竞争力。带着问题我们开始来研究一下这个到底是怎么一个事吧。
产生问题的原因
在 React JavaScript 中,闭包会捕获其创建时的环境。由于 useEffect
的依赖数组并不会自动更新,因此如果依赖项没有在数组中明确列出,闭包捕获的状态或属性值将保持不变。这样,当组件状态更新时,useEffect
内部的代码可能会继续使用旧的状态或属性值,导致逻辑错误。
问题产生的场景
示例:计数器场景
假设有一个计数器组件,在每次计数器变化时要执行一些副作用,例如打印计数器的值:
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Count is: ${count}`);
}, []); // 依赖数组为空
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
在上述代码中,useEffect
的依赖数组为空,意味着 useEffect
只在组件挂载时运行一次。即使 count
状态变化,console.log
中打印的 count
始终是初始值 0。
示例:使用 setInterval
的定时 计数器场景
假设我们有一个计数器组件,该组件每秒钟递增一次:
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Count: ${count}`);
setCount(count + 1); // 这里的 count 始终是初始值 0
}, 1000);
return () => clearInterval(interval); // 清理定时器
}, []); // 空依赖数组
return (
<div>
<p>{count}</p>
</div>
);
}
export default Counter;
解决办法
示例1:解决办法
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Count is: ${count}`);
}, [count]); // 将 count 列为依赖项
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
这样,每次 count
发生变化时,useEffect
都会重新执行,从而确保 console.log
中的 count
是最新的值。
分步解决方法
- 识别问题: 找出在
useEffect
中使用但未列为依赖项的状态或属性。 - 更新依赖数组: 将所有在副作用函数中使用的状态或属性添加到依赖数组中。
- 测试: 运行并测试组件,确保副作用函数中的状态或属性是最新的。
通过这种方法,可以避免因闭包捕获旧的状态或属性值而导致的问题。
示例2:解决办法
- 将
count
作为依赖项:- 在依赖数组中添加
count
,这样每次count
变化时,useEffect
都会重新执行,定时器回调中捕获的count
就是最新的值。
- 在依赖数组中添加
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(interval); // 清理定时器
}, [count]); // count 依赖
return (
<div>
<p>{count}</p>
</div>
);
}
export default Counter;
- 使用函数式更新:
- 使用
setCount
的函数形式,这样setInterval
内部的回调函数会始终使用最新的count
值。
- 使用
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(prevCount => prevCount + 1); // 使用函数式更新
}, 1000);
return () => clearInterval(interval); // 清理定时器
}, []); // 空依赖数组
return (
<div>
<p>{count}</p>
</div>
);
}
export default Counter;
分步解决方法
-
识别问题:
- 在
useEffect
中的定时器回调函数中使用了状态count
,但未将count
列为依赖项,导致捕获了初始值。
- 在
-
使用函数式更新:
- 将
setCount(count + 1)
修改为setCount(prevCount => prevCount + 1)
,确保回调函数始终使用最新的状态。
- 将
-
将
count
作为依赖项- 添加 count 到 useEffect 的第二个参数的数组当中,方便监听
通过这种方法,可以避免因闭包捕获旧的状态而导致的问题。
useEffect 的内部代码是如何实现监听状态和属性值呢
useEffect
的内部实现依赖于 React 的调度和协调机制。当我们将依赖项传递给 useEffect
时,React 会监视这些依赖项的变化,并在依赖项发生变化时重新执行 useEffect
中的副作用函数。
工作原理
-
初始挂载:
- 当组件初次渲染时,
useEffect
会立即执行一次。 - React 会保存当前的依赖数组。
- 当组件初次渲染时,
-
依赖变化检测:
- 在后续的渲染中,React 会将新的依赖数组与之前保存的依赖数组进行比较。
- 如果依赖数组中的任意一个值发生了变化,
useEffect
就会重新执行。
-
清理副作用:
- 在执行新的副作用之前,React 会先执行上一次渲染中返回的清理函数(如果有)。
- 这种清理机制确保了副作用的正确管理,避免了内存泄漏和副作用冲突。
React 内部的实现
以下是一个简化的解释,展示了 useEffect
的核心逻辑:
我们用 伪代码来模拟实现这个 useEffect 功能,这样我们更加能理解清楚,useEffect 出现闭包问题的原因
// 伪代码
function useEffect(effect, dependencies) {
const currentDependencies = getCurrentDependencies(dependencies); // 获取当前依赖项中全部的数据收集
if (!dependenciesAreEqual(currentDependencies, dependencies)) {
// 执行清理函数
if (typeof currentDependencies.cleanup === 'function') {
currentDependencies.cleanup();
}
// 执行副作用函数并保存清理函数
const cleanup = effect();
setCurrentDependencies({ dependencies, cleanup });
}
}
// 新旧状态对比机,实际逻辑比这个复杂
function dependenciesAreEqual(oldDeps, newDeps) {
if (oldDeps === undefined) {
return false;
}
if (oldDeps.length !== newDeps.length) {
return false;
}
for (let i = 0; i < oldDeps.length; i++) {
// 实际这里的比较很复杂,这里省略
// 判断多种数据类型之间对比,对象,数组,数字,字符串等等
if (oldDeps[i] !== newDeps[i]) {
return false;
}
}
return true;
}
监听状态或属性值变化
在 React 内部,状态(state)和属性(props)的变化都会触发组件的重新渲染。在重新渲染的过程中,useEffect
会比较新的依赖数组和之前保存的依赖数组,判断是否需要重新执行副作用。
-
状态变化:
- 当状态变化时,组件会重新渲染。
useEffect
会检查依赖数组中的状态是否发生了变化。
-
属性变化:
- 当组件接收到新的属性时,组件也会重新渲染。
useEffect
会检查依赖数组中的属性是否发生了变化。
示例
以下示例展示了 useEffect
如何通过依赖 数组 监听状态和属性的变化:
import React, { useState, useEffect } from 'react';
function ExampleComponent({ propValue }) {
const [stateValue, setStateValue] = useState(0);
useEffect(() => {
console.log(`State value is: ${stateValue}`);
console.log(`Prop value is: ${propValue}`);
return () => {
console.log('Cleanup on state or prop change');
};
}, [stateValue, propValue]); // 监听 stateValue 和 propValue 的变化
return (
<div>
<p>State: {stateValue}</p>
<p>Prop: {propValue}</p>
<button onClick={() => setStateValue(stateValue + 1)}>Increment State</button>
</div>
);
}
在这个示例中,每当 stateValue
或 propValue
发生变化时,useEffect
中的副作用函数都会重新执行,并且在执行前会先执行上一次的清理函数。通过这种方法,可以避免因闭包捕获旧的状态而导致的问题。
总结
在这个问题还是比较常见的在我们的日常工作中,主要的解决方法有两个,上文中有提到的。
- 在依赖项中添加对应的变量
- 在写修改的函数的时候,使用函数式更新的办法来处理。
- 遇到定时器的时候要谨慎,确认好变更的到底是什么变量,注意书写规范,减少过多的函数嵌套
希望我的文章能够帮到你进步,希望留个点赞对我来说是莫大的鼓励和帮助,谢谢大家看到这里
转载自:https://juejin.cn/post/7399453056973045798