如何修复 React 内存泄漏警告
如果你曾经使用过 React 函数组件和useEffect hook,那么你几乎不可能从未遇到过这个警告:
Warning: Can't perform a React state update on an unmounted
component. This is a no-op, but it indicates a memory leak in
your application. To fix, cancel all subscriptions and
asynchronous tasks in a useEffect cleanup function.
这就是我所指的React 内存泄漏警告,因为如果您不了解发生了什么,它很容易触发并且很难摆脱。
解释警告
这里有4个重要概念:
Can't perform a React state update
on an unmounted component.
To fix, cancel all subscriptions and asynchronous tasks
in a useEffect cleanup function.
我不会解释什么是内存泄漏,相反,我鼓励您阅读我关于Javascript 内存管理的首选文章。
什么是状态更新?
给定以下状态初始化:
const [isVisible, setIsVisible] = useState(true);
状态更新如下:
setIsVisible(false);
什么是未安装的组件?
当组件从 DOM 中移除时,它就被卸载了。这是 React 组件生命周期的最后一步。
什么是订阅和异步任务?
异步任务是发送到事件循环回调队列的回调。它们之所以是异步的,是因为它们只有在满足某些条件时才会执行。
任何可以将回调添加到回调队列中,从而推迟其执行直到满足某个条件的机制都可以被视为订阅:
-
履行或拒绝时的承诺
-
setTimeout
当setInterval
一段时间过去后 -
事件发生时的事件
我跳过了它,setImmediate
因为它不是一个网络标准,并且我通过引用一个唯一的回调队列来简化事情,而实际上存在多个具有不同优先级的队列。
案例 1 - Promise 处理程序中的异步任务
someAsyncFunction().then(() => {
// Here is the asynchronous task.
});
someAsyncFunction()
返回一个Promise
我们可以订阅的函数,通过调用then()
带有回调函数的方法来作为解析时执行的任务someAsyncFunction()
。
setTimeout
案例 2 -处理程序中的异步任务
setTimeout(() => {
// Here is the asynchronous task.
});
setTimeout
通常以延迟作为第二个参数来调用,但是当留空时,事件处理程序将在事件循环开始处理回调队列时立即执行,但它仍然是异步的,并且很有可能在组件卸载后执行。
案例 3 - 事件处理程序中的异步任务
Dimensions.addEventListener('change', ({ screen }) => {
// Here is the asynchronous task.
});
订阅事件是通过添加事件监听器并向监听器传递回调函数来完成的。
直到事件监听器被移除或者事件发射器被销毁,回调函数将会在每次事件发生时被添加到回调队列中。
异步任务有副作用
在 React 函数组件中,任何副作用(例如数据获取或事件处理)都应该在 useEffect 中完成:
useEffect(() => {
someAsyncFunction().then(() => {
// Here is an asynchronous task.
});
Dimensions.addEventListener('change', ({ screen }) => {
// There is another asynchronous task.
});
}, []);
什么是useEffect 清理函数?
每个 effect 都可能返回一个用于清理的函数。该函数在组件卸载时被调用。
useEffect(() => {
return () => {
// This is the cleanup function
}
}, []);
怎么了?
React 告诉我们停止尝试更新已删除的组件的状态。
案例 1 - Promise 处理程序中的异步任务
useEffect(() => {
someAsyncFunction().then(() => {
setIsVisible(false);
});
}, []);
因为我们已经订阅了一个 Promise,所以有一个待处理的回调,等待 Promise 完成,无论它是否已被实现或被拒绝。
如果在 Promise 完成之前卸载 React 组件,则待处理的回调无论如何都会保留在回调队列中。
一旦 Promise 确定,它将尝试更新不再存在的组件的状态。
setTimeout
案例 2 -处理程序中的异步任务
useEffect(() => {
setTimeout(() => {
setIsVisible(false);
}, 5000);
}, []);
这段代码和上一个情况很接近,只是回调的执行条件是等待 5000ms。
如果 React 组件在此时间之前被卸载,它还将尝试更新不再存在的组件的状态。
案例 3 - 事件处理程序中的异步任务
useEffect(() => {
Dimensions.addEventListener('change', ({ screen }) => {
setDimensions(screen);
});
}, []);
将处理程序附加到事件与以前的情况不同,因为事件可以发生多次,因此可以多次触发相同的回调。
如果我们绑定事件处理程序的事件发射器在卸载 React 组件时没有被销毁,它仍然存在并将在每次事件发生时执行。
在上面的例子中,事件处理程序绑定到全局变量Dimensions
,即事件发射器,它存在于组件范围之外。
因此,当组件卸载时,事件处理程序不会被解除绑定或被垃圾收集,并且即使组件不再存在,事件发射器将来也可能会触发回调。
解决问题
案例 1 - Promise 处理程序中的异步任务
由于无法取消 Promise,因此解决方案是setIsVisible
在组件已卸载时阻止调用该函数。
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
let cancel = false;
someAsyncFunction().then(() => {
if (cancel) return;
setIsVisible(false);
});
return () => {
cancel = true;
}
}, []);
通过利用词法作用域,我们可以在回调函数和清理函数之间共享一个变量。
我们使用清理函数来修改cancel
变量,并在回调函数中触发提前返回,以防止状态更新。
setTimeout
案例 2 -处理程序中的异步任务
要删除绑定到计时器的回调,请删除计时器:
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
});
return () => {
clearTimeout(timer);
}
}, []);
案例 3 - 事件处理程序中的异步任务
要取消对事件的订阅,请删除事件处理程序:
const onChange = ({ screen }) => {
setDimensions(screen);
};
useEffect(() => {
Dimensions.addEventListener('change', onChange);
return () => {
Dimensions.removeEventListener('change', onChange);
};
}, []);
结论
-
全局变量永远不会被垃圾收集,因此如果事件发射器存储在全局变量中,请不要忘记手动删除事件处理程序。
-
删除与事件发射器绑定的所有事件处理程序,这些事件处理程序在卸载组件时可能不会被删除。
-
useEffect
承诺无法取消,但您可以使用词法作用域通过触发早期返回或短路状态更新来更改清理函数的回调行为。 -
尽量避免使用计时器,如果不能,请务必始终使用
clearTimeout
或取消它们clearInterval
。
照片由Aarón Blanco Tejedor在Unsplash上拍摄