React 基础体系 · 第 6/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Effect 完整指南:同步外部系统、依赖、清理和竞态
useEffect 经常被概括为“组件副作用”,但这个说法不够精确。Effect 的核心职责是:
在 React 提交组件输出之后,把 React 管理的状态同步到 React 不负责管理的外部系统;当依赖变化或组件卸载时,再撤销上一次同步。
“外部系统”包括:
- 浏览器 DOM API,例如
document.title、事件监听器、IntersectionObserver; - 定时器、WebSocket、WebRTC、地图 SDK、第三方图表库;
- 订阅系统,例如事件总线、Redux store、媒体查询;
- 网络请求,以及请求对应的加载、成功和失败状态。
Effect 不是“所有异步代码的容器”,也不是“把生命周期方法搬到函数组件”的通用替代品。理解它需要同时掌握组件渲染、状态快照、提交阶段、依赖比较、清理函数和异步竞态。
1. 先区分:渲染、提交和 Effect
一个 React 更新大致经过三个阶段:
- 触发更新:调用
setState、父组件重新渲染,或外部订阅触发更新; - 渲染(render):React 调用组件函数,计算新的 JSX;
- 提交(commit):React 把差异应用到 DOM;
- 运行 Effect:浏览器绘制后,React 运行
useEffect的 setup 函数。
import { useEffect, useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
console.log('render', count);
useEffect(() => {
console.log('effect', count);
});
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
}
点击按钮时,典型顺序是:
调用 setCount
↓
组件函数重新执行:render 1
↓
React 提交新的按钮文本
↓
浏览器绘制
↓
Effect 执行:effect 1
Effect 运行时,DOM 通常已经反映了本次渲染的结果。因此,Effect 适合连接 DOM 之外的系统,或者在 DOM 更新后读取、操作外部环境。
1.1 Effect 中读取到的是某次渲染的快照
每次渲染都会创建一个新的闭包。下面的 count 不是一个会自动变化的变量,而是当前渲染的状态快照:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = window.setTimeout(() => {
console.log(count);
}, 1000);
return () => window.clearTimeout(id);
}, [count]);
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
}
当 count 为 0 时创建的定时器,闭包里保存的就是 0。之后即使组件状态变为 1,这个定时器也不会自动读取新值。依赖 [count] 的作用是:
count变化;- React 先清理旧定时器;
- 再创建捕获新
count的定时器。
这与“状态快照”直接相关:状态更新不会修改已经执行过的那次组件函数中的局部变量。
2. useEffect 的精确定义:setup 与 cleanup
useEffect 的形式是:
useEffect(setup, dependencies?)
setup 可以返回一个清理函数:
useEffect(() => {
// setup:建立同步关系
return () => {
// cleanup:撤销同步关系
};
}, dependencies);
可以把它抽象为一组状态转换:
旧依赖 D_old
↓
运行 cleanup(D_old)
↓
新依赖 D_new
↓
运行 setup(D_new)
当组件卸载时,React 会运行最后一次 setup 对应的 cleanup。
2.1 三种依赖写法
没有第二个参数:每次提交后运行
useEffect(() => {
console.log('每次组件提交后运行');
});
这包括初次提交和后续每次重新渲染。它适合非常少见的“每次提交都要同步”的场景,否则通常会产生多余工作。
空数组:只针对一次挂载过程建立同步
useEffect(() => {
console.log('建立一次连接');
return () => {
console.log('断开连接');
};
}, []);
在生产环境中,通常是挂载时执行一次 setup,卸载时执行一次 cleanup。但在开发环境的 <StrictMode> 下,React 会额外执行一次:
setup → cleanup → setup
这是故意的开发检查,用来发现 setup 不可重复、cleanup 不完整的问题。它不是生产环境中真实的重复业务连接,也不能通过删除 Strict Mode 来掩盖资源泄漏。
指定依赖:依赖变化时重新同步
useEffect(() => {
const connection = connectToRoom(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
初次提交时建立 roomId 对应的连接;以后只有 roomId 变化时,才会:
断开旧 roomId 的连接
↓
建立新 roomId 的连接
React 会使用 Object.is 逐项比较前后依赖。因此,依赖不是深比较:
Object.is(1, 1); // true
Object.is({}, {}); // false
3. Effect 的正确模型:同步外部系统,而不是响应式生命周期回调
一个好的 Effect 可以回答两个问题:
- 它正在连接或更新哪个外部系统?
- cleanup 是否能完全撤销这次连接或更新?
例如,订阅聊天室:
import { useEffect } from 'react';
type ChatConnection = {
connect(): void;
disconnect(): void;
};
declare function createChatConnection(
serverUrl: string,
roomId: string,
): ChatConnection;
type ChatRoomProps = {
serverUrl: string;
roomId: string;
};
export function ChatRoom({ serverUrl, roomId }: ChatRoomProps) {
useEffect(() => {
const connection = createChatConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
return <p>正在连接到房间:{roomId}</p>;
}
这个 Effect 的同步关系是:
React props: serverUrl, roomId
↓
外部系统:对应服务器上的对应聊天室连接
当 roomId 改变时,不能只建立新连接而不关闭旧连接,否则会出现:
- 同时接收两个房间的消息;
- 重复处理事件;
- WebSocket 连接泄漏;
- 组件卸载后仍然更新外部系统。
3.1 cleanup 应满足“撤销 setup”
如果 setup 做了这些事:
window.addEventListener('resize', handleResize);
const timerId = window.setInterval(refresh, 5000);
const subscription = store.subscribe(handleChange);
cleanup 就应该分别撤销:
window.removeEventListener('resize', handleResize);
window.clearInterval(timerId);
subscription.unsubscribe();
下面这个写法是错误的:
useEffect(() => {
window.addEventListener('resize', () => {
console.log(window.innerWidth);
});
return () => {
window.removeEventListener('resize', () => {
console.log(window.innerWidth);
});
};
}, []);
两个箭头函数虽然代码相同,但不是同一个函数对象。浏览器无法用第二个函数移除第一个监听器。
正确做法是保存同一个函数引用:
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
4. Effect 不是事件处理器,也不是派生状态工具
4.1 用户动作应优先放在事件处理器中
下面的代码不必要地绕了一圈:
function Form() {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendAnalyticsEvent('form_submitted');
}
}, [submitted]);
return (
<button onClick={() => setSubmitted(true)}>
提交
</button>
);
}
提交事件本身就是发送分析事件的原因,直接写在事件处理器中更准确:
function Form() {
function handleSubmit() {
sendAnalyticsEvent('form_submitted');
}
return <button onClick={handleSubmit}>提交</button>;
}
判断标准是:
- 由某次用户动作直接触发:事件处理器;
- 由渲染结果需要同步到外部系统:Effect;
- 仅由已有数据计算得到:渲染期间直接计算或使用
useMemo,不需要 Effect。
4.2 不要用 Effect 计算派生状态
错误示例:
function UserName({ firstName, lastName }: Props) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <p>{fullName}</p>;
}
这会产生一次多余的过程:
props 改变
↓
第一次渲染仍使用旧 fullName
↓
Effect 执行 setFullName
↓
第二次渲染得到新 fullName
直接计算即可:
function UserName({ firstName, lastName }: Props) {
const fullName = `${firstName} ${lastName}`;
return <p>{fullName}</p>;
}
如果计算确实昂贵,可以考虑:
const fullName = useMemo(
() => expensiveFormatName(firstName, lastName),
[firstName, lastName],
);
但 useMemo 是性能优化工具,不是让派生数据变得“更正确”的必要机制。
5. 依赖数组:它描述的是闭包需要的响应式输入
依赖数组不是“希望 Effect 什么时候运行”的开关,而是一个声明:
这个 Effect 的 setup 使用了哪些可能随渲染变化的值?
例如:
function Room({ serverUrl, roomId }: Props) {
const options = {
serverUrl,
roomId,
};
useEffect(() => {
const connection = connect(options);
return () => connection.disconnect();
}, [options]);
return null;
}
options 在每次渲染时都是新对象,即使 serverUrl 和 roomId 没变:
Object.is(optionsFromRender1, optionsFromRender2); // false
因此 Effect 会在每次渲染后重启。更直接的写法是把对象创建放入 Effect:
function Room({ serverUrl, roomId }: Props) {
useEffect(() => {
const options = { serverUrl, roomId };
const connection = connect(options);
return () => connection.disconnect();
}, [serverUrl, roomId]);
return null;
}
此时真正决定同步关系的是两个原始值。
5.1 函数依赖也会导致重新同步
function Logger({ value }: { value: string }) {
const format = () => value.toUpperCase();
useEffect(() => {
console.log(format());
}, [format]);
return null;
}
每次渲染都会创建新的 format,因此它被视为变化的依赖。可以将函数逻辑移入 Effect:
function Logger({ value }: { value: string }) {
useEffect(() => {
const format = () => value.toUpperCase();
console.log(format());
}, [value]);
return null;
}
也可以使用 useCallback,但只有在确实需要稳定函数身份时才值得这样做:
const format = useCallback(() => value.toUpperCase(), [value]);
useCallback 不会让函数“忽略”它读取的值;它只是根据依赖决定是否复用函数对象。
5.2 不要随意关闭 exhaustive-deps 检查
下面的代码会制造陈旧闭包:
useEffect(() => {
const id = window.setInterval(() => {
console.log(count);
}, 1000);
return () => window.clearInterval(id);
}, []); // 缺少 count
它只会打印初次渲染捕获的 count。React 官方 ESLint 规则中的 exhaustive-deps 通常能发现这类问题。
如果 Effect 不应该因某个值变化而重启,应先重新设计同步边界,而不是简单删掉依赖。常见方案包括:
- 把不需要响应式变化的逻辑移出组件;
- 把对象或函数创建移入 Effect;
- 使用
useRef保存最新值; - 在当前 React 19 小版本和框架支持的情况下,考虑
useEffectEvent表达“读取最新值但不作为 Effect 重启条件”的逻辑。
6. 读取最新值,但不因它重启 Effect
有时连接只由 roomId 决定,但收到消息时要读取当前主题:
function ChatRoom({
roomId,
theme,
}: {
roomId: string;
theme: 'light' | 'dark';
}) {
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('connected', () => {
showNotification('已连接', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return null;
}
这里 theme 被闭包读取,所以加入依赖是规范且安全的;代价是主题变化会重连。若业务要求主题变化只影响通知样式、不应重连,就需要把这两个同步关系分离。
在支持 useEffectEvent 的 React 19 版本中,可以表达这种意图:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({
roomId,
theme,
}: {
roomId: string;
theme: 'light' | 'dark';
}) {
const onConnected = useEffectEvent(() => {
showNotification('已连接', theme);
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return null;
}
这里:
roomId决定连接何时建立和销毁;onConnected执行时能读取最新theme;theme不再直接作为连接 Effect 的重启依赖。
该 API 的可用性、Lint 支持和框架版本需要与项目实际 React 19 小版本核对。若项目不使用它,可以用 useRef 保存最新值,但要明确那是手动维护的可变引用:
function ChatRoom({ roomId, theme }: Props) {
const themeRef = useRef(theme);
themeRef.current = theme;
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('connected', () => {
showNotification('已连接', themeRef.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return null;
}
ref 的变化不会触发渲染,也不会自动通知 React;因此它适合保存“事件发生时要读取的最新值”,不适合替代需要驱动 UI 的 state。
7. Effect 的清理时序和 Strict Mode
假设依赖从 A 变成 B,React 的逻辑顺序是:
render(B)
↓
commit(B)
↓
cleanup(A)
↓
setup(B)
对于 useEffect,具体与浏览器绘制的相对时机受 React 调度影响;不能把它当作一个同步、立即执行的生命周期回调。通常它会在浏览器绘制后运行。若必须在浏览器绘制前同步测量布局,可以使用 useLayoutEffect,但它会阻塞绘制,应谨慎使用。
Strict Mode 开发检查可以形式化为:
mount:
setup()
cleanup()
setup()
unmount:
cleanup()
因此 setup 必须允许重复建立,cleanup 必须足够完整。例如:
useEffect(() => {
const controller = new AbortController();
startSomething(controller.signal);
return () => {
controller.abort();
};
}, []);
如果 startSomething 无法承受 setup → cleanup → setup,问题通常在资源生命周期设计,而不是 Strict Mode 本身。
8. 网络请求:请求本身、结果提交和竞态
网络请求是 Effect 中最容易出错的场景。原因在于:
- 请求开始顺序不代表响应结束顺序;
- 组件可能在请求结束前卸载;
- 依赖变化会启动多个请求;
- 旧请求可能晚于新请求返回。
考虑搜索框:
useEffect(() => {
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((response) => response.json())
.then((data) => {
setResults(data);
});
}, [query]);
用户依次输入 a、ab、abc 时,可能出现:
t0: 请求 A 开始
t1: 请求 AB 开始
t2: 请求 ABC 开始
t3: 请求 ABC 返回,显示 ABC
t4: 请求 A 返回,覆盖为 A
最终 UI 显示了旧查询的结果。这就是竞态条件:多个并发操作的完成顺序影响了最终状态。
8.1 最小的“忽略旧结果”方案
import { useEffect, useState } from 'react';
type SearchResult = {
id: string;
title: string;
};
export function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<SearchResult[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let ignore = false;
setError(null);
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as SearchResult[];
})
.then((data) => {
if (!ignore) {
setResults(data);
}
})
.catch((reason: unknown) => {
if (!ignore) {
setError(
reason instanceof Error
? reason
: new Error('搜索失败'),
);
}
});
return () => {
ignore = true;
};
}, [query]);
if (error) {
return <p role="alert">{error.message}</p>;
}
return (
<ul>
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
这里的 ignore 是每次 Effect 执行独立创建的局部变量:
请求 A 的 ignore_A = false
请求 AB 的 ignore_AB = false
query 从 A 变成 AB
↓
cleanup(A): ignore_A = true
setup(AB): ignore_AB = false
如果请求 A 后返回,它只能通过 ignore_A 判断,因此不会提交结果。请求 AB 仍能正常提交。
这解决的是结果提交竞态,但没有停止网络传输。
8.2 使用 AbortController 取消请求
浏览器的 fetch 支持 AbortSignal:
import { useEffect, useState } from 'react';
type User = {
id: string;
name: string;
};
export function UserPanel({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setStatus('loading');
setError(null);
async function load() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = (await response.json()) as User;
if (!controller.signal.aborted) {
setUser(data);
setStatus('success');
}
} catch (reason: unknown) {
if (controller.signal.aborted) {
return;
}
setError(
reason instanceof Error
? reason
: new Error('加载用户失败'),
);
setStatus('error');
}
}
void load();
return () => {
controller.abort();
};
}, [userId]);
if (status === 'loading') {
return <p>加载中……</p>;
}
if (status === 'error') {
return <p role="alert">{error?.message}</p>;
}
return <p>{user?.name ?? '暂无用户'}</p>;
}
每次 userId 变化时:
- cleanup 调用
abort(); - 与旧 signal 关联的
fetch通常会拒绝并停止后续处理; - 新 Effect 创建新的
AbortController; - 新请求开始;
- 只有未取消的请求可以提交结果。
需要注意两个边界:
AbortController只会通知支持该 signal 的 API;它不能自动取消任意 Promise;- 如果响应已经完成,代码进入了自定义的异步处理阶段,仍然需要检查请求是否已失效,或使用请求序号/忽略标志。
8.3 只取消不够:错误状态也要区分
取消通常是正常的生命周期行为,不应显示为业务错误:
catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
setError(error);
}
但不同运行环境、fetch 实现和封装库的取消错误类型可能不同。更稳妥的做法是以 signal.aborted 或库自身提供的取消判断为准。
8.4 请求状态机
一个请求 Effect 可以看作状态机:
idle
↓ 开始请求
loading
├── 成功且仍有效 → success
├── 失败且仍有效 → error
└── 依赖变化/卸载 → aborted 或失效,不提交结果
如果旧请求在新请求之后返回,它的状态迁移必须被拒绝:
loading(query=A)
↓ A 返回,但 A 已失效
不允许写入 success(A)
这比单纯记住“要加 AbortController”更重要:取消是实现手段,核心不变量是:
只有当前仍然有效的请求,才能更新当前 UI 状态。
9. 请求缓存、并发和框架数据能力
直接在 Effect 中请求数据可以工作,但它没有自动提供:
- 请求缓存;
- 相同请求去重;
- 预加载;
- 服务端渲染时的数据准备;
- 路由级错误边界;
- Suspense 集成;
- 离线和重新验证策略。
现代 React 应用通常还会使用框架的数据加载能力,或专门的数据管理库。框架可能在服务端、路由切换阶段或缓存层执行请求,从而减少“组件挂载后才开始请求”的瀑布。
这不意味着 Effect 请求永远错误。它适合:
- 客户端专属数据;
- 简单页面;
- 没有路由级 loader 的局部交互;
- 与浏览器环境绑定的请求触发逻辑。
但如果请求属于页面路由的主要数据,应先确认框架是否提供 loader、预取、缓存或 Suspense 方案。Effect 只负责“客户端提交后的同步”,不能单独解决整个数据获取架构。
9.1 Suspense 与 Effect 的边界
Effect 中的异步请求通常通过本地 state 表达:
loading → success/error
Suspense 则要求数据读取层在渲染期间以 React 可识别的方式“暂停”,由边界渲染 fallback。二者不是同一个机制:
- Effect 请求:组件先提交,再开始请求;
- Suspense 数据读取:渲染过程由资源状态决定;
- 框架 loader:可能在渲染前或路由转换阶段加载数据。
不要因为使用了 <Suspense> 就认为 Effect 中的 fetch 会自动被 Suspense 管理;普通 Effect 请求不会自动触发 Suspense。
10. 服务端与客户端边界
useEffect 依赖浏览器提交阶段,因此不会在服务端渲染过程中运行。服务端只能生成 HTML,不能执行浏览器中的:
window.addEventListener(...)
document.title = ...
new WebSocket(...)
在支持 Server Components 的框架中,包含 useEffect 的组件必须位于客户端组件边界内。例如某些框架要求文件顶部声明:
'use client';
import { useEffect } from 'react';
这条指令属于框架约定,不是 React 核心 API 本身。具体边界规则取决于框架。
10.1 浏览器 API 必须放在客户端执行路径
错误示例:
const width = window.innerWidth;
如果这个模块在服务端被导入,就可能直接抛出:
ReferenceError: window is not defined
客户端 Effect 中读取则不会在服务端执行:
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
初次服务端 HTML 与客户端第一次渲染还必须保持可匹配,否则可能产生 hydration 不一致。不能把“首屏一定显示浏览器真实宽度”建立在服务端无法获取的信息上;可以先渲染稳定的占位值,提交后再同步浏览器状态。
11. 外部订阅与 useSyncExternalStore
对于外部 store,手写 Effect 订阅可能遗漏并发渲染下的一致性问题:
function Component() {
const [value, setValue] = useState(store.get());
useEffect(() => {
return store.subscribe(() => {
setValue(store.get());
});
}, []);
return <p>{value}</p>;
}
这种写法能表达基本订阅逻辑,但 React 为外部 store 提供了专门 API:
import { useSyncExternalStore } from 'react';
function useStoreValue() {
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot,
);
}
其中:
subscribe:注册和注销订阅;getSnapshot:读取当前客户端快照;getServerSnapshot:服务端渲染时读取快照,可选但在 SSR 场景很重要。
如果数据源本质上是“外部可变 store”,应优先使用 useSyncExternalStore,而不是把订阅事件转存进本地 state。Effect 更适合连接一般外部系统;useSyncExternalStore 更适合让 React 安全读取外部状态快照。
12. Effect 与状态更新、批处理的关系
Effect 中的状态更新会触发新的渲染:
useEffect(() => {
setCount(count + 1);
}, [count]);
这会形成:
渲染 count=0
↓
Effect 设置 count=1
↓
渲染 count=1
↓
Effect 设置 count=2
↓
无限循环
依赖中的值必须在 Effect 中被读取,并且 Effect 中的更新不能无条件地再次改变同一同步关系。
如果更新依赖之前的状态,应使用函数式更新:
useEffect(() => {
const id = window.setInterval(() => {
setCount((value) => value + 1);
}, 1000);
return () => window.clearInterval(id);
}, []);
这里不需要把 count 放入依赖,因为回调没有读取闭包中的 count,而是把更新交给 React:
当前 state → React 提供 value → value + 1
React 18 及以后,现代 React 通常会对同一任务中的多个 state 更新进行批处理;这不会改变 Effect 的依赖规则,也不会让同一闭包中的状态变量自动变成最新值。批处理减少渲染次数,状态快照仍然存在。
13. useEffect、useLayoutEffect 和 useInsertionEffect
三者用途不同:
useEffect
用于一般外部同步:
useEffect(() => {
document.title = title;
}, [title]);
通常不会阻塞浏览器绘制。
useLayoutEffect
用于必须在浏览器绘制前完成的布局测量或同步修正:
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (rect) {
setHeight(rect.height);
}
}, []);
它可能导致服务端环境警告,因为服务端没有布局。除非确实会出现视觉闪烁或测量时序问题,否则优先使用 useEffect。
useInsertionEffect
主要为 CSS-in-JS 库等底层工具准备,用于在布局读取前插入样式。普通业务组件通常不应使用它。
不要把 useLayoutEffect 当作“更可靠的 useEffect”;它的代价是阻塞绘制。
14. 常见失败表现与诊断路径
14.1 Effect 每次渲染都执行
可能原因:
const options = { roomId };
useEffect(() => {
connect(options);
}, [options]);
诊断方法:
- 在 Effect 和 cleanup 中打印依赖值;
- 使用
Object.is(previous, current)检查身份; - 检查是否每次都创建了对象、数组或函数;
- 将不必要的对象创建移入 Effect;
- 仅在确有需要时使用
useMemo或useCallback。
14.2 开发环境连接两次
如果日志是:
connect
disconnect
connect
首先检查是否启用了 Strict Mode。若只在开发环境出现,这通常是 React 的重复 setup 检查。真正需要修复的是:
- setup 是否重复注册监听器;
- cleanup 是否真的注销;
- 是否保存了正确的资源引用;
- 第一次连接是否能在 cleanup 中关闭。
14.3 旧请求覆盖新请求
表现为搜索词已经是 abc,结果却来自 a。应记录请求身份:
const requestId = useRef(0);
useEffect(() => {
const id = ++requestId.current;
void fetchData(query).then((data) => {
if (id === requestId.current) {
setResults(data);
}
});
}, [query]);
请求序号方案能保证只有最后一次请求写入,但不会取消网络。生产实现通常组合:
AbortController减少无用工作;- 请求序号或失效标志保证结果提交正确。
14.4 卸载后仍然触发副作用
检查 Effect 是否创建了:
- 未清理的 timer;
- 未移除的 DOM 监听器;
- 未关闭的 WebSocket;
- 未取消的 observer;
- 未停止的第三方 SDK 实例;
- 未处理的异步结果。
在 React 现代版本中,“卸载后 setState”不应被简单理解为总会出现一个 React 警告;即使没有警告,未清理的资源仍可能泄漏或继续执行。
15. 一个完整的订阅示例:媒体查询
下面的 Hook 展示了建立订阅、读取初始值、处理变化和清理:
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const update = () => {
setMatches(mediaQuery.matches);
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, [query]);
return matches;
}
执行过程如下:
query变化后,创建新的MediaQueryList;update()立即把当前浏览器状态同步到 React state;- 浏览器条件变化时,事件处理器触发
setMatches; query再变化或组件卸载时,移除旧监听器。
如果项目需要把媒体查询作为共享外部状态,并且要兼容服务端快照、并发渲染和多个组件订阅,可以进一步使用 useSyncExternalStore 封装。
16. 一个 Effect 的审查方法
审查 Effect 时,可以按以下因果顺序检查,而不是先看依赖数组:
第一步:确认外部系统
问:
如果删除这个 Effect,哪个 React 之外的系统会失去同步?
如果答案是“没有”,它可能是在计算派生状态或响应用户事件,应该改用渲染计算或事件处理器。
第二步:列出 setup 的资源
例如:
创建 WebSocket
注册 resize 监听
创建 interval
启动 fetch
第三步:为每个资源找到反向操作
WebSocket.close()
removeEventListener()
clearInterval()
AbortController.abort()
如果找不到反向操作,通常存在泄漏风险。
第四步:列出闭包读取的响应式值
包括:
- props;
- state;
- 组件函数中声明的变量;
- 普通函数、对象和数组。
再判断哪些值真正决定外部系统的生命周期,哪些只是事件发生时需要读取的最新值。
第五步:验证竞态
如果 setup 启动异步工作,列出所有可能顺序:
旧请求先返回
旧请求后返回
组件先卸载
依赖先变化
请求被取消
请求失败
然后确认每条路径都不会把无效结果提交到当前 UI。
17. 规范保证、实现细节与工程取舍
需要区分三类结论:
React 规范层面的核心保证
- Effect 的 setup 在客户端提交后运行;
- 依赖变化时,旧 cleanup 会先于新 setup;
- 卸载时会执行最后的 cleanup;
- 依赖按
Object.is比较; - Effect 中捕获的是对应渲染的值;
- Effect 不在服务端渲染阶段运行。
常见但不应过度依赖的实现细节
- Effect 通常在浏览器绘制后运行;
- React 可能延后或合并工作;
- 开发 Strict Mode 会额外执行 setup → cleanup → setup;
- 某些请求取消行为取决于 fetch 或第三方库的实现。
不要依赖具体日志顺序来构建业务正确性;应依赖 cleanup、依赖声明和有效性检查表达不变量。
工程取舍
- 简单客户端请求可以用 Effect;
- 页面主数据通常更适合框架 loader、缓存库或 Suspense 兼容的数据层;
- 外部 store 优先考虑
useSyncExternalStore; - 需要布局测量才使用
useLayoutEffect; - 需要稳定事件读取时,根据 React 19 小版本支持情况选择
useEffectEvent、useRef或拆分 Effect。
结语:Effect 的核心不变量
可以用一句形式化的话总结 Effect:
对于当前依赖快照
D,React 至多维护一个有效的外部同步S(D);当依赖从D₁变为D₂时,必须先撤销S(D₁),再建立S(D₂);任何异步结果只有在仍属于当前有效同步时,才能更新 UI。
因此,写 Effect 时不要先问“依赖数组该怎么填”,而应按以下顺序推导:
- 哪个外部系统需要同步;
- 当前渲染提供了哪些输入;
- setup 建立了什么资源;
- cleanup 如何完整撤销资源;
- 依赖变化时旧同步如何退出;
- 异步任务乱序返回时,如何阻止失效结果提交;
- 这个问题是否其实应该由事件处理器、派生计算、
useSyncExternalStore或框架数据层解决。
当这些关系清楚后,useEffect 就不再是“什么时候执行代码”的技巧,而是一个描述 React 状态与外部系统之间同步边界的工具。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React State 与 Hooks:useState、useReducer、批处理和状态快照
- 下一篇:React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界
- 延伸:React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论