react 中的副作用
什么是副作用?
-
在函数式编程里,副作用(side effect)指函数除了返回值之外,对外部世界产生的影响:修改外部变量、发请求、操作 DOM、读写存储、订阅/计时器等。
-
在 React 中,渲染(render)必须是纯的:只根据 props/state 计算出要渲染的 UI,不做任何副作用;一切与外界交互都要放到 React 的"提交阶段(commit)"里,通过 Effect 等机制完成。
在 React 里,哪些是副作用
常见副作用清单(应该放到 effect 或事件中,而非 render):
- 数据请求/写入:fetch、axios、IndexedDB、本地存储(localStorage/sessionStorage)。
- 订阅/连接:WebSocket、SSE、EventSource、自定义事件、RxJS 订阅。
- 计时器:setTimeout/setInterval、requestAnimationFrame。
- DOM 操作:读写布局、手动添加/移除事件、第三方非 React 组件初始化/销毁。
- 日志/埋点:console、监控上报。
- 突变外部状态:修改全局变量、单例、window、document、全局缓存等。
- 路由跳转:navigate(通常在事件里做,或在 effect 中根据状态变化做)。
为什么不能在渲染中做副作用
- 渲染可能多次 、被中断 、回滚 (React 18 并发特性 + StrictMode 开发模式会故意二次调用组件的 render / mount,以帮助你发现"不纯"的副作用)。
- 如果在 render 里发请求/改 DOM,会重复执行、产生竞态甚至内存泄漏。
该把副作用放哪
优先顺序(官方推荐):
-
事件处理器 (最好):
用户触发才发生的事(点击发请求/写入存储/上报),直接放 onClick/onChange;不占用渲染生命周期,简单、可控。
-
useEffect(渲染后、异步执行,不阻塞绘制):根据 state/props 的变化去"同步外部世界":加载数据、订阅、计时器、写存储、日志等。支持清理。
-
useLayoutEffect(DOM 提交后、绘制前 、同步执行):需要测量布局 、同步修正 DOM(避免闪烁)时用,比如读取元素尺寸然后立即设置样式/滚动。
-
useInsertionEffect(极早期,用于样式注入库):一般只给 CSS-in-JS 库用,业务几乎不用。
useEffect 的工作方式(要点)
- 时机:浏览器完成绘制后再执行,不阻塞渲染。
- 依赖数组 :决定 effect 何时运行/重跑;漏依赖会导致"陈旧闭包"。
- 清理函数:返回函数在下次重跑前或组件卸载时执行(用来取消订阅、清计时器、Abort 请求)。
- React 18 StrictMode(仅开发):会 mount → cleanup → 再 mount,帮你检查 effect 是否能正确清理;生产环境只执行一次。
示例(请求 + 竞态处理):
tsx
useEffect(() => {
const ctrl = new AbortController();
(async () => {
try {
const res = await fetch(`/api/posts?user=${userId}`, { signal: ctrl.signal });
if (!res.ok) throw new Error('Network');
const data = await res.json();
setPosts(data);
} catch (e) {
if ((e as any).name !== 'AbortError') setError(e as Error);
}
})();
return () => ctrl.abort();
}, [userId]); // 别漏依赖
useEffect vs useLayoutEffect
useEffect :不阻塞绘制;适合网络、订阅、日志等绝大多数副作用。
useLayoutEffect :在绘制前同步执行;适合读写布局、同步 DOM 修正,否则可能闪烁。SSR 场景会有告警(可在客户端条件使用)。
示例(测量 DOM):
tsx
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const r = ref.current!;
const { width } = r.getBoundingClientRect();
r.style.height = width * 0.5625 + 'px';
}, []);
常见陷阱与最佳实践
-
事件里能做的,就别放 effect
例如点击"加载更多"→ 在 onClick 里请求即可,而不是 watch 某个 state 再在 effect 请求。
-
拆分 effect
不相关的副作用分开写,避免交叉依赖和重复触发。
-
依赖数组写全
开启 eslint-plugin-react-hooks;如果因性能原因不想重跑,考虑把不稳定的函数用
useCallback、对象用useMemo或 用 ref 保存可变值。 -
避免在 effect 里无条件 setState 造成死循环
如果 setState 基于异步结果/条件变化,确保依赖正确且不会每次导致同样的更新。
-
处理异步竞态
组件在请求未完成前卸载或依赖变化,要 Abort 或比对请求标识,避免"后到的旧数据覆盖新数据"。
-
第三方库初始化/销毁
在 effect 里初始化实例,返回清理函数销毁/解绑事件。
-
SSR
useEffect不在服务端执行;useLayoutEffect在 SSR 下会告警,按需改为条件调用或仅在客户端渲染。 -
把"派生数据"放到 memo 而不是 effect
纯计算(由现有 state/props 得出)用
useMemo,不要用 effect 去 setState 派生,这会多一次渲染并可能出错。
该放事件还是 effect?(速判)
用户动作直接触发 :事件(onClick/onSubmit...)。
状态/路由变化带来的外部同步 :effect。
需要读/改布局 :layout effect。
只是算个新值:memo,而不是 effect + setState。
实用片段
订阅
tsx
useEffect(() => {
const sub = bus.on('message', setMsg);
return () => sub.off(); // 清理
}, []);
计时器
tsx
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
写本地存储(响应 state 变化)
tsx
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
避免陈旧闭包(把可变值放 ref)
tsx
const latestFilter = useRef(filter);
useEffect(() => { latestFilter.current = filter; }, [filter]);
useEffect(() => {
const id = setInterval(() => {
doSearch(latestFilter.current); // 始终拿到最新 filter
}, 5000);
return () => clearInterval(id);
}, []);
一句话记忆:
渲染要纯;副作用放事件或 effect;布局相关用 layout effect;依赖写全、及时清理、处理竞态。