组件里启动了定时器,组件消失以后,定时器不会因为 JSX 没了就自动停止。订阅、事件监听也是一样,需要对应的清理。
useEffect 的返回函数负责这一部分。它会在依赖变化后、下一次 setup 前运行,也会在组件卸载时运行。可以用一个计数器把这几个时机看出来。
一个可以切换和卸载的计时器
创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。
npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9
创建 build.cjs:
const webpack = require("webpack");
const path = require("path");
webpack(
{
mode: "development",
entry: path.resolve("main.js"),
output: { path: path.resolve("dist"), filename: "main.js" },
devtool: "source-map",
},
(error, stats) => {
if (error || stats.hasErrors()) {
console.error(error || stats.toString("errors-only"));
process.exitCode = 1;
}
},
);
创建 index.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
<title>React 实验</title>
<div id="root"></div>
<script src="./dist/main.js"></script>
</html>
把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。
import React, {
useState,
useRef,
useEffect,
useMemo,
useContext,
memo,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
const e = React.createElement;
window.lab = { effects: [], contexts: { stable: 0, unstable: 0 }, measurements: [] };
function Timer({ label }) {
const [ticks, setTicks] = useState(0);
useEffect(() => {
window.lab.effects.push("setup:" + label);
const id = setInterval(() => setTicks((v) => v + 1), 100);
return () => {
clearInterval(id);
window.lab.effects.push("cleanup:" + label);
};
}, [label]);
return e("p", { id: "ticks" }, label + ":" + ticks);
}
function Effects() {
const [visible, setVisible] = useState(true);
const [label, setLabel] = useState("A");
return e(
"section",
null,
e("h2", null, "Effect 清理"),
e(
"button",
{ id: "change-label", onClick: () => setLabel((v) => (v === "A" ? "B" : "A")) },
"切换依赖",
),
e(
"button",
{ id: "toggle-timer", onClick: () => setVisible((v) => !v) },
"挂载 / 卸载",
),
visible && e(Timer, { label }),
);
}
createRoot(document.querySelector("#root")).render(e(StrictMode, null, e(Effects)));
打开控制台,输入 window.lab.effects 查看记录。首次加载,在根节点开启 StrictMode 的 React 18 开发构建下,记录是 setup:A、cleanup:A、setup:A。这个额外循环是在检查清理是否能抵消 setup,生产构建不会这样重复检查。
点击切换依赖,最后两条是 cleanup:A、setup:B。清理函数拿到的是上一次执行时的 label,不是新 label。点击挂载 / 卸载,又会出现 cleanup:B,计数器从页面消失。
setTicks 用函数更新,定时器不用捕获某一次渲染的 ticks,也就不用把 ticks 放进依赖数组。label 用到了,所以放进依赖。空数组不是“只运行一次”的万能开关。
清理的是资源,不只是写一行日志
把 clearInterval 临时注释掉,再卸载和重新挂载,会留下旧定时器。日志看起来有 cleanup,不代表真正清理了资源。
请求的清理可以使用 AbortController,但还要考虑已经完成的异步步骤是否会写回旧结果。AbortSignal 只能影响接受它的操作,不会自动取消所有 Promise。
不要把 effect 回调直接声明成 async。async 返回 Promise,React 需要的是清理函数。可以在 effect 里声明异步函数,再调用它。
effect 适合让组件和外部系统保持同步。根据两个 state 算一个显示值,通常可以在渲染时直接计算,没必要再启动 effect 存第三份 state。执行规则参考 React useEffect 文档。