陌上人如玉
公子世无双

8.1 useEffect 的正确使用

8.1 useEffect 的正确使用

一句话:useEffect 的语义是「把组件与某个外部系统同步」——能不用就不用;派生数据直接算、事件逻辑写在事件处理里、重置状态用 key,只有「真的在与外部系统同步」时才用 Effect。

一、正确的心智模型

❌ 错误模型:
   「useEffect = componentDidMount / componentDidUpdate」
   → 于是把「数据派生」「事件响应」「初始化」都往里塞

✅ 正确模型:
   「Effect 是「同步」:把 React 的状态与某个【外部系统】对齐」
   · 外部系统:浏览器 API、第三方库、网络、订阅、定时器、DOM 命令式操作
   · 它描述的是「要不要同步、同步什么」,而不是「在什么时机执行」

判断标准:
   「这段代码是因为【渲染】而运行,还是因为【某个交互】而运行?」
   · 因渲染而运行(需要与外部对齐)→ 可能需要 Effect
   · 因交互而运行(点了按钮)→ 写在事件处理里

二、四个「不该用 Effect」的场景

1) 派生数据

// ❌ 用 Effect 把 props 派生到 state
function Bad({ firstName, lastName }) {
  const [fullName, setFullName] = useState('');

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);      // ⚠️ 多一次渲染、可能不一致
  }, [firstName, lastName]);

  return <div>{fullName}</div>;
}

// ✅ 渲染时直接算
function Good({ firstName, lastName }) {
  const fullName = `${firstName} ${lastName}`;     // ⭐ 没有额外渲染、永远一致
  return <div>{fullName}</div>;
}
// ✅ 计算昂贵时用 useMemo(而不是 Effect + state)
function Good2({ items, keyword }) {
  const filtered = useMemo(
    () => items.filter((it) => it.name.includes(keyword)),
    [items, keyword]
  );
  return <List items={filtered} />;
}

2) 事件响应

// ❌ 用 Effect 监听「某个值变化」来触发事件
function Bad({ productId, onView }) {
  useEffect(() => {
    if (productId) onView(productId);              // ⚠️ 「用户看了商品」是事件
  }, [productId, onView]);

  return <Product id={productId} />;
}

// ✅ 写在「触发这件事」的地方
function Good({ product, onView }) {
  const handleSelect = () => {
    onView(product.id);                            // ⭐ 明确「因为点击而触发」
  };
  return <button onClick={handleSelect}>查看</button>;
}
为什么事件不该用 Effect?
① Effect 无法区分「用户主动触发」与「其它原因导致值变化」
② 容易「重复触发」(依赖变化就执行)
③ 触发链难以追踪(「谁让它跑起来的?」)

3) 重置状态

// ❌ 用 Effect 清空状态
function Bad({ userId }) {
  const [comment, setComment] = useState('');

  useEffect(() => {
    setComment('');                                 // ⚠️ 要列出所有要重置的状态(容易漏)
    setTab('info');
    setDraft('');
  }, [userId]);

  return <Profile userId={userId} />;
}
// ✅ 用 key 让组件重建(更符合「不同用户 = 不同实例」的语义)
function Good({ userId }) {
  return <Profile key={userId} userId={userId} />;
}

function Profile({ userId }) {
  const [comment, setComment] = useState('');       // ⭐ userId 变化 → 组件重建 → 自动重置
  // ...
}

4) 通知父组件

// ❌ 用 Effect 把内部状态「同步」给父组件
function Bad({ onChange }) {
  const [value, setValue] = useState('');

  useEffect(() => {
    onChange(value);                                // ⚠️ 容易造成循环(onChange 又改 props)
  }, [value, onChange]);
}
// ✅ 方案一:在「导致 value 变化的地方」调用 onChange
function Good1({ onChange }) {
  const [value, setValue] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    setValue(next);
    onChange(next);                                 // ⭐ 明确因果
  };

  return <input value={value} onChange={handleChange} />;
}

// ✅ 方案二:把状态提升(受控组件)
function Good2({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

三、该用 Effect 的场景

✅ 真正的「与外部系统同步」:

① 订阅(数据源、事件总线、store)
   useEffect(() => { const un = store.subscribe(fn); return un; }, [store]);

② 浏览器事件监听(需要与 window/document 同步)
   useEffect(() => { window.addEventListener('resize', fn); return () => window.removeEventListener('resize', fn); }, []);

③ 定时器
   useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); }, [delay]);

④ Observer(IntersectionObserver / ResizeObserver / MutationObserver)
   useEffect(() => { const io = new IntersectionObserver(fn); io.observe(el); return () => io.disconnect(); }, []);

⑤ 第三方库实例(图表、地图、编辑器)
   useEffect(() => { const inst = new Lib(el); return () => inst.destroy(); }, []);

⑥ 网络请求(配合取消)
   useEffect(() => { const ac = new AbortController(); fetch(url, { signal: ac.signal }).then(...); return () => ac.abort(); }, [url]);

⑦ 与浏览器状态同步(title、localStorage、body 样式)
   useEffect(() => { document.title = title; }, [title]);

⑧ 在 DOM 更新后做「不需要阻塞绘制」的命令式操作
   useEffect(() => { videoRef.current?.play(); }, [isPlaying]);
⭐ 共同特征:
   「有一个【外部系统】,React 的状态需要与它保持一致」
   —— 并且这个一致性不是「渲染结果」能表达的(DOM 属性、订阅、网络、定时器)

四、依赖数组的规范

// ✅ 依赖「Effect 里用到的每一个响应式值」
useEffect(() => {
  doSomething(a, b);
}, [a, b]);

// ✅ 函数依赖:把函数移到 Effect 内部(推荐)
useEffect(() => {
  function handle() { doSomething(a); }            // ⭐ 依赖变成 a
  window.addEventListener('keydown', handle);
  return () => window.removeEventListener('keydown', handle);
}, [a]);

// ✅ 需要「读最新值但不重新执行」→ 用 ref
const latestRef = useRef(a);
useEffect(() => { latestRef.current = a; });       // 每次渲染同步(不触发执行)

useEffect(() => {
  const id = setInterval(() => console.log(latestRef.current), 1000);
  return () => clearInterval(id);
}, []);                                            // ⭐ 依赖为空但不缺值
三条规范:
① 【写全依赖】——漏了会产生闭包陷阱(读到旧值)
② 【不要靠删依赖消警告】——那是在掩盖 bug(`exhaustive-deps` 几乎总是对的)
③ 【依赖不稳定就修它】——把常量提到模块外、用 useMemo/useCallback 稳定、
   或依赖原始值(`user.id` 而非 `user`)

五、清理函数的规范

// ✅ 每个 Effect 都要问自己:「如果它再执行一次 / 组件卸载了,需要撤销什么?」
useEffect(() => {
  // ① 事件监听
  const onResize = () => setSize(getSize());
  window.addEventListener('resize', onResize);
  const ac = new AbortController();                // ⭐ 或用 signal 统一管理多个监听
  window.addEventListener('scroll', onScroll, { signal: ac.signal });

  // ② 定时器
  const timer = setInterval(tick, 1000);

  // ③ Observer
  const io = new IntersectionObserver(onIntersect);
  io.observe(sentinelRef.current!);

  // ④ 第三方实例
  const chart = echarts.init(chartRef.current!);

  return () => {
    window.removeEventListener('resize', onResize);
    ac.abort();
    clearInterval(timer);
    io.disconnect();
    chart.dispose();
  };
}, []);
⚠️ 清理函数的三个要点:
① 它的作用是「撤销本次同步」——所以「建立了什么就要撤销什么」
② 执行时机:依赖变化后(下次执行前)与卸载时
③ 不要「只在卸载时清理」的思维——依赖变化时会「先清理再重建」,
   这正是 Effect 比 componentDidUpdate 更安全的地方

六、一个完整的重构示例

// ❌ 一个「Effect 用得很糟」的组件
function BadSearch({ keyword, onResults }) {
  const [results, setResults] = useState([]);
  const [filtered, setFiltered] = useState([]);
  const [count, setCount] = useState(0);

  // ① 派生数据用 Effect
  useEffect(() => {
    setFiltered(results.filter((r) => r.visible));
  }, [results]);

  // ② 派生计数用 Effect
  useEffect(() => {
    setCount(filtered.length);
  }, [filtered]);

  // ③ 请求没有取消
  useEffect(() => {
    fetch(`/search?q=${keyword}`).then((r) => r.json()).then(setResults);
  }, [keyword]);

  // ④ 通知父组件用 Effect
  useEffect(() => {
    onResults(filtered);
  }, [filtered, onResults]);

  return <List items={filtered} count={count} />;
}
// ✅ 重构后:只剩「真正需要 Effect」的那一个(请求 + 取消)
function GoodSearch({ keyword, onResults }: Props) {
  // ① 请求:需要与外部系统(网络)同步 → 用 Effect,并处理竞态
  const { data: results = [] } = useQuery({
    queryKey: ['search', keyword],
    queryFn: ({ signal }) => api.search(keyword, { signal }),
  });
  // ⭐ 如果用 TanStack Query,连 Effect 都不需要写

  // ② 派生数据:渲染时直接算
  const filtered = useMemo(() => results.filter((r) => r.visible), [results]);
  const count = filtered.length;

  // ③ 通知父组件:在「导致变化的事件」里做(这里没有事件 → 用 Effect 也可以,
  //    但更推荐「状态提升」或直接由父组件从 results 派生)
  useEffect(() => {
    onResults(filtered);
  }, [filtered, onResults]);

  return <List items={filtered} count={count} />;
}
重构的四个动作:
① 派生数据(filter、count)→ 从 Effect 改为直接计算/useMemo
② 请求 → 交给查询库(连取消与竞态都不用自己写)
③ 通知父组件 → 优先考虑「状态提升」或「父组件自己派生」
④ 只剩「确实需要与外部同步」的部分才用 Effect

七、常见坑

// ① 用 Effect 做派生数据(多一次渲染 + 可能不同步)
useEffect(() => { setFullName(`${f} ${l}`); }, [f, l]);

// ② 用 Effect 触发事件(应该写在事件处理里)
// ③ 用 Effect 重置状态(应该用 key)
// ④ 无依赖数组 + setState → 无限循环
useEffect(() => { setCount(count + 1); });

// ⑤ 漏依赖 → 闭包陷阱(读到旧值)
useEffect(() => { console.log(value); }, []);       // ⚠️

// ⑥ 依赖里有新对象/新函数 → Effect 反复执行
useEffect(() => {}, [{ a: 1 }]);                     // ❌

// ⑦ 忘记清理(订阅、定时器、监听、Observer、第三方实例)
// ⑧ 在清理函数里访问已卸载的 DOM
// ⑨ 用 Effect 做「初始化」(应该用 useState 惰性初始化或 useRef)
useEffect(() => { setInitial(compute()); }, []);     // ⚠️ 应该 useState(() => compute())
// ⑩ async 函数直接作为 effect(返回 Promise)→ 报错
useEffect(async () => {}, []);                       // ❌
// ✅ 内部定义 async 函数并调用
// ⑪ 认为 Effect「在渲染前执行」(其实在绘制后)
// ⑫ 用 Effect 同步「内部状态到内部状态」(应该直接算)
// ⑬ StrictMode 下「请求两次」→ 用 ref 绕过(应该补清理)
// ⑭ 用 Effect 做「表单字段联动」(大部分应该用受控 props 直接算)
// ⑮ 一个 Effect 做太多事(应该按「外部系统」拆分)
// ⑯ 把「事件处理函数」放进依赖导致反复重建(用 ref 或移到内部)
// ⑩ 的正确写法
useEffect(() => {
  let cancelled = false;
  (async () => {
    const data = await fetchData();
    if (!cancelled) setData(data);
  })();

  return () => { cancelled = true; };
}, []);

八、面试延伸

  1. 「useEffect 是生命周期钩子吗?」
    不是。它的语义是「把组件与外部系统同步」——描述的是「需要与什么保持一致」,而不是「在哪个时机执行」。useEffect(() => {...}, []) 看起来像 componentDidMount,但这个类比会误导你「把什么都塞进 Effect」。正确的心智模型是「Effect 是同步机制」:订阅、监听、定时器、网络、第三方实例、浏览器状态,这些「外部系统」才需要它。

  2. 「什么时候不该用 useEffect?」
    四种情况(React 官方文档明确列出):① 派生数据——渲染时直接计算(必要时 useMemo),不要用 Effect 同步到 state;② 响应某个事件——写在事件处理函数里(Effect 是「因渲染而运行」,不是「因交互」);③ 重置状态——用 key 让组件重建(比「列出所有要重置的 state」更可靠);④ 通知父组件/部分 props 变化时的调整——优先「状态提升」或在「导致变化的地方」调用回调。

  3. 「Effect 和『生命周期』的对应关系是什么?」
    粗略对应:componentDidMount → useEffect(fn, []);componentWillUnmount → useEffect(() => () => {...}, []) 的清理函数;componentDidUpdate → useEffect(fn) 或 useEffect(fn, [deps])。但关键差异是:类组件的「挂载/更新」是两个独立时机(你要在 componentDidUpdate 里手动比较前后值),而 useEffect 是一个同步过程——依赖变化时会自动「先清理、再建立」,不需要手动比较。

  4. 「为什么清理函数很重要?」
    因为 Effect 可能在依赖变化时多次执行(不只是「挂载一次」)。如果不清洗上一次的「同步」(如订阅、监听、定时器、Observer、第三方实例),就会产生:① 重复的订阅/监听(一个事件触发多次回调);② 内存泄漏(定时器、实例一直存在);③ 状态错乱(旧的定时器还在改已过期的 state);④ 在 StrictMode 下会立刻暴露这些问题(挂载 → 卸载 → 再挂载)。所以规则是「建立了什么就撤销什么」。

  5. 「useEffect 的依赖数组怎么理解?」
    它是「什么时候需要重新同步」的声明。三种写法:不写 → 每次渲染后都同步(几乎总是错的);[] → 只在挂载时同步一次(清理在卸载时);[a, b] → 挂载时 + a/b 变化时同步(变化时先清理旧同步)。它不是性能优化开关——写 [] 不会让代码更快,只会改变语义(并可能引入闭包陷阱)。规范是「写全 Effect 里用到的每一个响应式值」。

  6. 「依赖里有对象/函数导致 Effect 反复执行怎么办?」
    三种解法:① 把常量提到模块作用域(不依赖任何值的东西不该在组件里创建);② 用 useMemo/useCallback 稳定引用(或让上游稳定它);③ 依赖「原始值」而不是对象(如 [user.id] 而不是 [user])。此外如果「函数是在 Effect 内部用」的,直接把它移到 Effect 内部(依赖就变成函数用到的值)。最后要反思:如果加了依赖就无限循环,也许这段逻辑本就不该放在 Effect 里。

  7. 「怎么判断一个 useEffect 该不该存在?」
    问三个问题:① 「有没有一个外部系统需要同步?」——如果没有(只是「算一个值」「通知一个回调」),就不该用 Effect;② 「这段逻辑是因为渲染而跑,还是因为用户交互而跑?」——后者应该写在事件处理里;③ 「如果我把这段 Effect 删掉,功能还正常吗?」——如果正常,说明它可能是「派生数据」或「多余的同步」。经验:一个组件里的 useEffect 越少越好(很多优秀的 React 组件一个都没有)。

  8. 「useEffect 用得少是不是说明水平高?」
    在某种程度上是的——因为 Effect 主要是「与外部系统打交道」的胶水代码,而好的代码应该让「业务逻辑」与「副作用」尽量分离。一个「几乎没有 Effect」的组件通常意味着:① 数据获取交给了查询库(而不是手写 useEffect + useState);② 派生数据是计算出来的(而不是「同步」出来的);③ 事件逻辑写在事件处理里;④ 状态重置用 key。这些都指向「更少的状态同步、更清晰的因果」。

一句话速记

useEffect = 「与外部系统同步」(不是生命周期钩子);四个不该用的场景是「派生数据(直接算)、事件响应(写在处理函数里)、重置状态(用 key)、通知父组件(状态提升或在事件里调)」;该用的场景是「订阅、事件监听、定时器、Observer、第三方实例、网络请求、浏览器状态」;依赖要写全(不要靠删依赖消警告,不稳定就修依赖或用 ref 存最新值);清理函数必须写(建立了什么就撤销什么,它在「依赖变化后」与「卸载时」执行);async 函数不能直接作为 effect;一个组件的 useEffect 越少越好。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.1 useEffect 的正确使用

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫