讲解

渲染函数必须是纯函数——同样的 props 和 state 进去,同样的 JSX 出来,中间不许碰外部世界。那请求数据、订阅事件、操作 DOM、定时器这些「副作用」放哪?useEffect:useEffect(() => { /* 副作用 */ }, [依赖]),React 在渲染提交到 DOM 之后执行它。

依赖数组是 useEffect 的灵魂,三种形态:不传——每次渲染后都执行(几乎总是 bug);[]——只在挂载后执行一次(初始化请求、订阅);[a, b]——挂载后 + 任何依赖变化后执行。依赖必须写全:effect 里用到的所有响应式值都要列上,eslint-plugin-react-hooks 会盯着你。为了少列依赖而把数据移出依赖数组是自欺——正确做法是重构(把逻辑移进 effect、用函数式更新、或把值收进 ref)。

清理函数是另一半:return () => { /* 清理 */ },在「下次执行前」和「卸载时」调用。订阅配退订、addEventListener 配 removeEventListener、定时器配 clearInterval——不对称就是内存泄漏。请求场景用它处理竞态:清理函数里设 cancelled = true,响应回来时检查标志,过期响应直接丢弃(或干脆 AbortController 取消请求)。

严格模式下 effect 会「挂载→清理→再挂载」双跑,帮你暴露没写清理的 bug——本地看到请求发两次不要慌,这正是体检在工作。

示例

模拟 effect 的「依赖变化 → 先清理再重跑」,以及竞态处理(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 迷你 effect 系统:依赖数组决定何时重跑
function createEffectRunner() {
  let cleanup = null;
  let prevDeps = null;
  return {
    run(effect, deps) {
      const changed =
        prevDeps === null || deps === undefined || deps.some((d, i) => !Object.is(d, prevDeps[i]));
      if (!changed) return '跳过';
      if (cleanup) cleanup(); // 重跑前先清理上一次
      cleanup = effect() || null;
      prevDeps = deps;
      return '已执行';
    },
    unmount() {
      if (cleanup) cleanup();
    },
  };
}

const log = [];
const runner = createEffectRunner();
const query = { value: 'vue' };

// 搜索 effect:发请求 + 竞态保护
function searchEffect() {
  let cancelled = false;
  const q = query.value;
  log.push('请求: ' + q);
  Promise.resolve().then(() => {
    if (!cancelled) log.push('响应: ' + q);
  });
  return () => {
    cancelled = true; // 清理:标记过期
  };
}

runner.run(searchEffect, [query.value]);
query.value = 'react';
runner.run(searchEffect, [query.value]); // 依赖变了 → 清理旧的再跑
runner.run(searchEffect, [query.value]); // 依赖没变 → 跳过

await Promise.resolve(); // 让两个响应回来
assert.deepStrictEqual(log, ['请求: vue', '请求: react', '响应: react']); // vue 的响应被丢弃

runner.unmount();
console.log(log.join(' | '));
console.log('过期的 vue 响应被清理函数丢弃,只有最新结果生效');

真实组件:

import { useEffect, useState } from 'react';

function SearchResults({ keyword }) {
  const [result, setResult] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setResult(null);
    fetch('/api/search?q=' + encodeURIComponent(keyword))
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setResult(data); // 过期响应直接丢
      });
    return () => {
      cancelled = true; // 关键词变了/组件卸载 → 标记过期
    };
  }, [keyword]); // 依赖写全

  useEffect(() => {
    const onResize = () => console.log(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize); // 对称清理
  }, []);

  return <pre>{result ? JSON.stringify(result, null, 2) : '加载中…'}</pre>;
}

常见坑

  • 依赖漏写:effect 里用了 keyword 依赖里却不写,数据永远是旧的;听 ESLint 的 exhaustive-deps 警告。
  • 为了过 lint 删依赖:用 ref 存可变值、函数式 setState、把逻辑移进 effect 才是正解。
  • 忘记清理:定时器/订阅/监听不对称清理,组件卸载后还在跑,内存泄漏 + 幽灵更新。
  • 在 effect 里做渲染推导:「A 变了算 B」用渲染期计算或 useMemo,不是 effect;effect 是「和外部系统同步」用的。
  • 把 async 直接当 effect 函数useEffect(async () => ...) 返回的是 Promise 不是清理函数;里面定义 async 函数再调用。

小结

useEffect = 渲染后与外部世界同步;依赖数组决定何时重跑且必须写全;清理函数管退订/取消失效。下一章看它的好搭档:useRef。