useEffect:副作用的正确使用方式
第6章:useEffect:副作用的正确使用方式
useEffect 的核心语义是将 React 状态与外部系统同步。当你把它理解成'渲染后执行代码'时,各种 bug 就随之而来。
本章核心问题:useEffect 的核心语义是什么?依赖数组的契约意味着什么? 读完本章你将理解:
- useEffect 是'将外部系统与 React 状态同步',而非'在某个时机执行代码'
- 依赖数组是完整性契约,cleanup 函数是'清理上一次同步'
- React 19 的 use() Hook 代表了数据获取的新方向
Level 1 · 你需要知道的(1-3年经验)
"副作用"到底意味着什么
React 组件的职责是根据当前的 props 和 state 返回 UI 描述。一切与这个职责无关的操作,都叫副作用(side effect):网络请求、订阅事件、修改 DOM、读写 localStorage、设置定时器……
useEffect 的核心语义是:将 React 状态与外部系统同步。注意措辞——是"同步",不是"监听"、不是"在某个时机执行某段代码"。
当你把 useEffect 理解成"在渲染后执行代码"时,你就会把它用成事件处理器的替代品,产生各种 bug。当你把它理解成"让外部世界与当前状态保持同步"时,useEffect 的行为就会变得清晰可预测。
// 错误理解:"组件挂载时发一次请求"
useEffect(() => {
fetchUser(userId);
}, []);
// 正确理解:"让用户数据与 userId 保持同步"
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // userId 变了就重新同步
依赖数组契约
useEffect(fn, deps) 的依赖数组不是"触发条件",而是一个正确性契约:Effect 内部使用了哪些来自组件作用域的响应式值(props、state、派生计算),就必须在依赖数组里列出。
这个契约是 ESLint 插件 eslint-plugin-react-hooks 的 exhaustive-deps 规则强制的。很多人把这个 lint 报错当成烦人的警告来绕过,这是危险的。
为什么依赖必须完整?
function UserCard({ userId }) {
const [user, setUser] = useState(null);
// 错误:依赖数组漏掉了 userId
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // 只在挂载时运行,userId 改变时不重新同步
// 正确:依赖完整
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
}
漏掉依赖的后果:组件挂载时用 userId = 1 获取了数据,后来 userId 变成了 2,Effect 不会重新运行,你永远显示的是用户 1 的数据。这是一个经典的过期依赖 bug。
依赖过多时怎么办?
有时依赖数组变得很长,暗示着 Effect 做的事太多。解决方案不是删减依赖,而是拆分 Effect 或重构逻辑:
// Effect 做了两件不相关的事
useEffect(() => {
document.title = `用户:${username}`;
analytics.track('page_view', { page });
}, [username, page]);
// 拆分:两件事独立同步
useEffect(() => {
document.title = `用户:${username}`;
}, [username]);
useEffect(() => {
analytics.track('page_view', { page });
}, [page]);
Level 2 · 它是怎么运行的(3-5年经验)
cleanup 函数:副作用的生命周期
useEffect 返回的函数叫做 cleanup(清理函数)。它的执行时机有两个:
- 下一次 Effect 运行之前(依赖变化时)
- 组件卸载时
这个语义设计让 React 能正确地"清理旧的同步,建立新的同步":
useEffect(() => {
// 建立同步:订阅 userId 的数据流
const subscription = dataStream.subscribe(userId, setData);
return () => {
// 清理旧同步:取消订阅
subscription.unsubscribe();
};
}, [userId]);
当 userId 从 1 变为 2:
- React 先调用上一次 Effect 的 cleanup(取消对用户 1 的订阅)
- 再运行新的 Effect(订阅用户 2 的数据流)
这种"先清理,再同步"的模式确保了不会有悬空的订阅或泄露的资源。
网络请求的正确清理
不清理的网络请求会导致两个问题:组件卸载后更新状态(React 警告),以及竞态条件(旧请求覆盖新结果)。
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) {
setUser(data);
}
});
return () => {
cancelled = true; // 标记取消,忽略旧请求结果
};
}, [userId]);
// 更现代的方式:AbortController
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [userId]);
AbortController 是浏览器原生 API,它不仅能避免状态更新问题,还能真正取消飞行中的 HTTP 请求,减少服务器负载。
严格模式的双重调用
React 18 的严格模式(<React.StrictMode>)在开发环境中会故意调用 Effect 两次:
- 挂载 → 运行 Effect
- 卸载 → 运行 cleanup
- 再挂载 → 再运行 Effect
这个设计是为了暴露没有正确实现 cleanup 的 Effect。如果你的 Effect 被调用两次后行为异常(比如弹出两次弹窗,或者建立了两个 WebSocket 连接),说明你的 cleanup 不完整。
// 问题:没有 cleanup,双重调用会建立两个 WebSocket
useEffect(() => {
const ws = new WebSocket('wss://example.com');
ws.onmessage = handleMessage;
}, []);
// 正确:cleanup 关闭连接
useEffect(() => {
const ws = new WebSocket('wss://example.com');
ws.onmessage = handleMessage;
return () => ws.close();
}, []);
严格模式只在开发环境生效,生产环境 Effect 只运行一次。但如果你的代码在开发环境的双重调用下工作不正确,很可能在生产环境的某些边缘情况下也会出问题(比如组件被 Suspense 挂起后重新挂载)。
useEffect 的替代品
React 提供了三个 Effect Hook,适用于不同场景:
useLayoutEffect
useLayoutEffect 在 DOM 更新之后、浏览器绘制之前同步运行。适合需要读取 DOM 布局并立即修改的场景:
useLayoutEffect(() => {
// DOM 已更新,但浏览器还没有绘制
const height = ref.current.getBoundingClientRect().height;
setTooltipHeight(height); // 在绘制前同步更新,避免闪烁
}, []);
如果用 useEffect 做同样的事,因为 Effect 在绘制之后运行,你会看到一帧的闪烁。useLayoutEffect 牺牲了响应性(它是同步的,会阻塞绘制),换来了视觉上的一致性。
SSR 注意:useLayoutEffect 在服务端渲染时会报警告,因为服务端没有 DOM。如果必须在 SSR 场景中使用,用条件判断包裹,或者用 useEffect + 状态来规避闪烁问题。
useInsertionEffect
useInsertionEffect 是为 CSS-in-JS 库设计的,在 DOM 变更之前运行,比 useLayoutEffect 还早。你在应用代码中几乎不会直接用它,它是给 styled-components、emotion 这类库内部使用的。
Level 3 · 规范怎么定义的(资深)
React 19:use() Hook 与数据获取
React 19 引入了 use() Hook,它可以直接在渲染期间消费 Promise 和 Context,配合 Suspense 使用:
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
// use() 会挂起组件,直到 Promise resolve
const user = use(userPromise);
return <div>{user.name}</div>;
}
// 父组件负责创建 Promise(注意:不能在渲染中创建,否则每次都是新 Promise)
function App() {
const [userPromise] = useState(() => fetchUser(1));
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
use() 并非完全替代 useEffect 的数据获取,但它代表了 React 团队推荐的方向:数据获取应该由框架层(Next.js、Remix)或专门的数据获取库(React Query、SWR)处理,而不是散落在组件的 useEffect 中。
为什么 useEffect 做数据获取有局限?
- 无法在服务端运行(SSR 场景缺失数据)
- 无内置缓存(每次挂载都重新请求)
- 没有请求去重(多个组件请求同一资源时各自发请求)
- 竞态条件需要手动处理
use() + Suspense + 服务端组件的组合解决了这些问题的根源,而不是在症状层面打补丁。
一个完整的数据同步示例
把本章的知识整合起来,实现一个生产级别的数据获取 Effect:
function useUserData(userId: string | null) {
const [state, setState] = useState<{
data: User | null;
loading: boolean;
error: Error | null;
}>({ data: null, loading: false, error: null });
useEffect(() => {
if (!userId) return; // 条件判断不能在 Effect 外部
const controller = new AbortController();
setState(prev => ({ ...prev, loading: true, error: null }));
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name !== 'AbortError') {
setState({ data: null, loading: false, error: err });
}
});
return () => controller.abort();
}, [userId]);
return state;
}
这个自定义 Hook 正确处理了:依赖追踪、loading 状态、错误状态、请求取消(防止组件卸载后更新状态和竞态条件)。
Level 4 · 边界与陷阱(所有人)
useEffect 的常见错误模式
模式一:在 Effect 内部响应事件
// 错误:Effect 用来响应按钮点击
useEffect(() => {
if (submitted) {
sendForm(formData);
}
}, [submitted]);
// 正确:事件直接在事件处理器里处理
function handleSubmit() {
sendForm(formData);
setSubmitted(true);
}
用 Effect 来响应用户事件是一种反模式。事件是一次性的离散动作,不需要"同步"语义。
模式二:链式 Effect 更新状态
// 错误:用 Effect 链来派生状态
const [data, setData] = useState(null);
const [filtered, setFiltered] = useState([]);
useEffect(() => {
if (data) {
setFiltered(data.filter(item => item.active));
}
}, [data]);
// 正确:在渲染时直接派生
const filtered = data ? data.filter(item => item.active) : [];
每多一个"更新状态的 Effect",就多一次不必要的渲染。能在渲染期间计算的值,就应该在渲染期间计算。
模式三:对象和函数作为依赖
function Component({ config }) {
useEffect(() => {
doSomething(config);
}, [config]); // 如果 config 是每次渲染新创建的对象,Effect 每次都会运行
}
// 父组件
<Component config={{ timeout: 3000 }} /> // 每次渲染都创建新对象
对象字面量在每次渲染时都是新的引用,即使内容相同。解决方案是在父组件 memoize 对象,或者在 Effect 内部只解构需要的原始值:
useEffect(() => {
doSomething(config.timeout);
}, [config.timeout]); // 只依赖原始值