•
阅读时长 3 分钟

React Function组件 useEffect 多次执行原理

目录

组件里启动了定时器,组件消失以后,定时器不会因为 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 文档。