React 项目踩坑实录:useEffect 闭包陷阱、Context 性能陷阱与 React 18 升级避坑排查手册
React 的很多"玄学 bug",表面上看是某个 API 用错,追到底其实是同一件事:没有把一次更新的时序建立成稳定的心智模型。渲染阶段创建了哪份作用域、提交阶段改了什么、Effect 在什么时刻执行并捕获了什么、下一次渲染又重建了什么闭包------这四步只要有一环想当然,就会分别长成 useEffect 过期闭包、memo 全失效、升级 React 18 后"莫名其妙刷新"这三类事故。
本文面向已有 React 项目经验、正在维护中大型代码库的开发者,按"症状 → 根因 → 多解法对比 → 选型建议"组织,并在文末给出可直接打印的速查表。需要说明的是:本文引用的实证材料来自公开技术社区文章14612131516,这些材料未包含 React 官方文档链接,因此文中涉及 API 语义(如依赖项比较规则、useSyncExternalStore 约束)的表述以框架公开行为为准,落地前建议对照官方文档核对当前版本;文章不引用无法核验的性能数字作为通用基准。
开篇:三类问题,一个共同根因------对渲染时序的误解
先把一次更新拆开看。React 的一个更新周期大致是这样:
- Render(渲染):执行组件函数,读取当前的 state / props / context,生成新的 JSX 描述。此时会为这次渲染创建新的闭包作用域。
- Commit(提交) :把渲染结果应用到宿主环境(DOM),更新 ref、触发
useLayoutEffect。 - Effect(副作用) :
useEffect的清理函数与执行函数在提交后被调度执行,它是"把组件状态与外部系统同步"的窗口,不是生命周期钩子的替代品4。 - 下一次 Render:重新执行组件函数,重建所有闭包。上一次 Effect 里捕获的变量不会自动更新。

三类高频问题都能在这条时间线上找到落点:
| 问题类别 | 典型症状 | 时序根因 | 主章节 |
|---|---|---|---|
| useEffect 事故 | 读到旧值、请求重复、定时器翻倍、页面卡死 | 闭包捕获的是"那一次渲染"的值;依赖声明与真实同步来源不一致 | §1 |
| 性能优化陷阱 | 加了 memo 仍全页重渲染 |
props 传播路径与 Context 订阅路径是两条独立通道;引用不稳定导致浅比较失效 | §2 |
| React 18 升级坑 | 开发态重复请求、"该刷的不刷"、白屏 | 批处理范围扩大、StrictMode 开发态双执行暴露非幂等副作用、createRoot 迁移遗漏 |
§3 |
这份映射表就是本文的阅读地图:赶时间可以直接跳到文末速查表;有半小时以上的排查时间,建议按顺序读完 §1,因为相当一部分"性能问题"最终都指向依赖数组写错。
一、useEffect 的四类事故与六种解法
1.1 四类典型症状:先分清你遇到的是哪一种
排查 useEffect 的第一步永远是分类,而不是改代码。四类事故的控制台线索和最小复现差别很大。
症状 A:漏写依赖,Effect 不再执行或读到旧值。
tsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// count 永远是 0:Effect 只在挂载时创建过一次闭包
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖数组漏写 count
return <div>{count}</div>;
}
现象是计数器停在固定值或反复回到同一值。react-hooks/exhaustive-deps 规则会提示缺少 count。
症状 B:过期闭包,异步回调拿到旧数据。
tsx
useEffect(() => {
let alive = true;
fetchData(dateRange).then(res => {
if (alive) setData(res); // 若 dateRange 是上一次渲染捕获的,res 来自旧区间
});
return () => { alive = false; };
}, [fetchData]);
这里往往叠加第二个问题:fetchData 在每次渲染时都是新函数,依赖数组里的引用每次都变,Effect 被反复触发13。症状表现是"请求发了两次/数据串号"。
症状 C:缺少 cleanup,订阅与定时器累积。
tsx
useEffect(() => {
window.addEventListener('resize', onResize);
// 忘记返回 () => window.removeEventListener('resize', onResize)
}, []);
组件反复挂载卸载后,回调触发次数翻倍,内存与日志量同步上涨。常见触发时机是路由切换、弹层开关,以及 StrictMode 开发态的双执行15。
症状 D:无限循环,页面卡死或控制台报错。
tsx
useEffect(() => {
setFilters({ ...filters, page: 1 }); // 在 Effect 中写入自己的依赖
}, [filters]);
filters 每次都是新对象 → Effect 重跑 → 又产生新对象。React 在检测到连续渲染次数超过阈值时会抛出 "Too many re-renders" 类错误;依赖中放对象或数组字面量([{}]、[{ ... }])即使值没变也会触发新一轮同步13。
| 症状 | 典型代码特征 | 错误表现 | 根因 |
|---|---|---|---|
| 漏依赖 | 依赖数组为空或缺项 | 读到旧值、Effect 不更新 | 依赖与真实同步来源不一致 |
| 过期闭包 | 异步回调 / 定时器捕获 state | 数据串号、点数不变 | 闭包捕获"那一次渲染"的值 |
| 缺 cleanup | 无返回清理函数 | 订阅翻倍、内存上涨 | 外部系统未解除绑定 |
| 无限循环 | Effect 内 setState 且自身在依赖中 | 卡死、Too many re-renders | 依赖每次新建引用,触发自激 |
1.2 过期闭包的机制:闭包捕获的是"那一次渲染"的值
很多人把依赖数组理解成"控制执行次数的开关",这是误区的源头。更准确的说法是:依赖数组是在声明这个 Effect 与哪些渲染期数据需要保持同步 。React 用 Object.is 逐项比较依赖项,只要有一项变化就重跑 Effect,并在重跑前执行上一次的 cleanup12。
用时间线看最清楚:
text
render #1:count = 0 → 创建 effect 闭包 E1(捕获 count = 0)
commit #1:执行 E1,E1 里的 setInterval 每秒调用 E1 的闭包
render #2:count = 1 → 创建新闭包 E2(捕获 count = 1)
但依赖数组是 [],React 不会执行 E2,E1 依然存活
结果:定时器永远看到 count = 0

修复版就是让"同步来源"诚实化,或者让更新不再依赖旧值:
tsx
// 修法一:诚实声明依赖,Effect 随 count 变化重建
useEffect(() => {
const timer = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(timer);
}, [count]);
// 修法二:函数式更新,彻底摆脱对旧值的捕获
useEffect(() => {
const timer = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(timer);
}, []);
两段代码行为不同:修法一会每秒重建定时器;修法二定时器稳定且不依赖 count。计数场景显然选修法二。
这里需要纠偏一个流传很广的说法:"useCallback 能消除闭包陷阱"。useCallback 解决的是函数引用稳定 ,从而让 Effect 的依赖项不因无关渲染而变化,减少不必要的重执行13;它不改变闭包捕获语义,也不能替代依赖数组的正确性。依赖仍然写错时,useCallback 只是把 bug 变得更隐蔽。
1.3 六种解法逐个拆解
素材中把 useEffect 问题的解法归纳为六类12,下面按"适用症状|写法|优点|代价|不适用场景"统一展开。
解法一:补全依赖数组(默认首选)
tsx
useEffect(() => {
refresh(dateRange, userId);
}, [dateRange, userId]);
适用:Effect 确实需要跟随某几个渲染期值。优点是语义清晰、配合 eslint-plugin-react-hooks 可自动化约束。代价是依赖多时 Effect 触发频繁,需要配合请求去重或取消。不适用:依赖中有每次渲染新建的对象/函数而不做稳定化处理,会退化成"每帧都跑"。
解法二:函数式更新
tsx
setCount(c => c + 1);
setItems(prev => prev.filter(i => i.id !== id));
适用:更新只依赖"自身前一个值"。优点是零依赖、天然幂等。代价是只能解决状态更新这一类问题,无法用于读取外部值的副作用。不适用:Effect 中需要拿最新 props 做网络请求。
解法三:useRef 保存最新值或最新回调
tsx
function useLatest<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
function Dashboard({ dateRange }: Props) {
const latestRange = useLatest(dateRange);
useEffect(() => {
const timer = setInterval(() => {
load(latestRange.current); // 每次读取最新值,无需重建定时器
}, 5000);
return () => clearInterval(timer);
}, []);
}
适用:长生命周期对象(定时器、订阅、第三方实例)需要读到最新值,但不希望因值变化而重建。优点是重建成本为零。代价是引入了一个"绕过渲染数据流"的通道,读写时序必须想清楚:ref 的赋值发生在 Effect 阶段,渲染期间读取 ref 不保证与本次渲染一致,不要在渲染路径中依赖 ref.current 参与输出。不适用:需要可追溯、可回放的状态更新逻辑。
解法四:useReducer 收敛更新逻辑
tsx
type State = { page: number; keyword: string };
type Action =
| { type: 'setKeyword'; keyword: string }
| { type: 'nextPage' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'setKeyword': return { page: 1, keyword: action.keyword };
case 'nextPage': return { ...state, page: state.page + 1 };
}
}
适用:多个关联状态、更新规则复杂、Effect 依赖面过宽。优点是把"怎么变"集中到纯函数,依赖数组从十几个字段收敛为 dispatch(dispatch 引用稳定)。代价是样板代码更多,简单场景过度设计。不适用:单字段状态。
解法五:cleanup 保证幂等
tsx
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [url]);
适用:一切订阅、定时器、请求、第三方实例。cleanup 不只是"防止内存泄漏",它同时保证 Effect 可以被安全地执行任意多次,这正是 StrictMode 开发态双执行要检验的性质15。代价是必须处理取消后的竞态(忽略 AbortError 或用 alive 标志)。不适用:纯同步、无外部资源的操作------那种写法本身就不该放在 Effect 里。
解法六:自定义 Hook 封装
tsx
function useInterval(callback: () => void, delay: number | null) {
const saved = useLatest(callback);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => saved.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// 使用侧:陷阱只在 Hook 内部出现一次
useInterval(() => load(latestRange.current), 5000);
适用:团队内反复出现的同一类副作用(轮询、事件监听、WebSocket、页面标题同步)。优点是把陷阱封装一次、统一修复一次。代价是自定义 Hook 需要清晰的契约文档和测试,否则它会成为"隐藏的复杂度仓库"。不适用:只出现一次的一次性逻辑。
六解法对比矩阵
| 解法 | 覆盖症状 | 维护成本 | 可读性 | 团队协作 | 被滥用风险 |
|---|---|---|---|---|---|
| 补依赖 | A、B | 低 | 高 | 高 | 低 |
| 函数式更新 | A(自更新类) | 低 | 高 | 高 | 低 |
| useRef / useLatest | B(长生命周期读值) | 中 | 中 | 中 | 高,易绕过数据流 |
| useReducer | A、B(多状态) | 中 | 高 | 高 | 中 |
| cleanup | C、D(竞态类) | 中 | 高 | 高 | 低 |
| 自定义 Hook | 全部 | 前期高、后期低 | 高 | 最高 | 中 |
关于实测数据的说明:有案例记录在用 useCallback 稳定请求函数引用后,重复请求导致的开销从约 200ms 降到接近 0ms13,但该文未给出组件规模、列表长度与测量工具口径。它可以作为"引用不稳定会造成可观浪费"的方向性证据,不宜当作通用基准;自己的项目请用 React DevTools Profiler 复现后再下结论。
1.4 选择决策树
拿到一个 Effect bug,按下面的顺序问问题,通常两轮就能收敛:
text
这个逻辑真的需要 Effect 吗?
├─ 否:派生数据用 useMemo / 渲染期计算;
│ 事件响应逻辑放事件处理器;
│ 状态初始化用 useState 惰性初始值
└─ 是:它在和什么外部系统同步?
├─ 无外部系统 → 多半写错了位置
├─ 订阅/定时器/请求 → 必须有 cleanup(解法五)
└─ 仅同步渲染期数据 → 依赖能否诚实声明?
├─ 能 → 解法一(配 lint)
├─ 依赖只是"自己的旧值" → 解法二
├─ 依赖不稳定但同步对象长生命周期 → 解法三
├─ 依赖面过宽 → 解法四
└─ 团队反复踩同一坑 → 解法六

"是否需要 Effect"这一步值得单独强调:派生数据、事件触发的状态重置、与 props 同步的 state,这三类最常见的"伪副作用"都可以在渲染期或事件里直接完成,写进 Effect 只会引入时序问题。社区多篇实践文章都把"先判断要不要写 Effect"列为第一原则46。
二、性能优化陷阱:memo 家族能做什么,救不了什么
2.1 memo / useMemo / useCallback 各自的承诺与边界
三者的职责经常被混为一谈:
React.memo:包裹组件,在 props 浅比较相等时跳过本次渲染。它拦截的是父到子的 props 更新路径。useMemo:缓存一次计算结果,依赖不变则复用。它省的是计算,不是渲染。useCallback:缓存函数引用,依赖不变则返回同一函数。它省的是引用变化带来的下游重渲染或 Effect 重执行16。
三者都依赖同一个前提:依赖项引用稳定。下面是最常见的"包了等于没包":
tsx
const Row = React.memo(function Row({ item, onSelect }: RowProps) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
// 失效写法:每次渲染都是新的对象与函数
<Table rows={rows} style={{ height: 400 }} onSelect={id => pick(id)} />
// 稳定写法
const style = useMemo(() => ({ height: 400 }), []);
const handleSelect = useCallback((id: string) => pick(id), [pick]);
<Table rows={rows} style={style} onSelect={handleSelect} />
反过来,useMemo 包一个廉价计算是净负收益:缓存本身要比较依赖、保存结果,成本可能高于重新计算。判断清单:
- 这个计算是否昂贵(大数组排序、复杂格式化、构造大对象)?不是就别包。
- 这个值是否作为 props 传给
memo组件或作为 Effect 依赖?是才值得稳定化。 - 依赖项本身稳定吗?不稳定则包了也白包。
- 有没有先用 Profiler 证明这里确实有浪费?没有就先测量。
还有一点要如实说明:useMemo 属于性能提示而非语义保证,React 不承诺在所有版本与场景中永久保留缓存结果;把正确性建立在 useMemo 上是错误的。若团队考虑引入 React Compiler 这类自动记忆化方案,需单独核对其当前发布状态与项目构建链路兼容性,本文不给出未经验证的版本建议。
2.2 Context 的真实问题:Provider value 变化 → 全部消费者重渲染
这是"加了 memo 照样卡"最常见的根因,也是素材中被反复指出的痛点18。
先看问题代码:
tsx
const ThemeContext = createContext<{ theme: string; toggle: () => void }>(null!);
function App() {
const [theme, setTheme] = useState('light');
// 每次渲染都产生新的 value 对象
return (
<ThemeContext.Provider value={{ theme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }}>
<Layout />
</ThemeContext.Provider>
);
}
value 每次渲染都是新对象,所有 useContext(ThemeContext) 的消费者都会被强制更新,哪怕它们只关心 theme。
关键在于理解两条传播路径:
- props 路径 :父组件渲染 → 传给子组件 →
React.memo可以在浅比较相等时拦截。 - Context 路径 :Provider 的 value 变化 → React 直接通知所有消费者 → 与中间组件是否
memo无关。
因此"中间层套 memo 救不回来"的准确解释不是"memo 被内部逻辑绕过",而是 memo 拦截的是 props 更新,Context 消费者由 Context 系统直接通知,根本不走那条比较路径 。验证方式是在中间层加 React.memo,用 Profiler 录制一次 theme 切换,会看到中间层未渲染而深层消费者仍然渲染。

第一步的低成本修复是把 value 稳定下来:
tsx
const [theme, setTheme] = useState('light');
const toggle = useCallback(() => setTheme(t => (t === 'light' ? 'dark' : 'light')), []);
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
这能消除"每次渲染都变",但解决不了"任何一个字段变,全部消费者都刷"的粒度问题------只要 theme 变,连只用 toggle 的消费者也会更新。
2.3 三条路线对比:拆分 Provider / Redux Toolkit / useSyncExternalStore
路线一:拆分 Provider(低成本,默认先做)
把状态与派发动作分成两个 Context,或按领域拆成多个 Provider:
tsx
const ThemeStateContext = createContext<string>('light');
const ThemeActionContext = createContext<{ toggle: () => void }>({ toggle: () => {} });
// 只用 toggle 的消费者不再因 theme 变化而重渲染
function ThemeButton() {
const { toggle } = useContext(ThemeActionContext);
return <button onClick={toggle}>切换主题</button>;
}
适用:变化频率低、消费者数量可控。它"部分改善"粒度问题:ActionContext 的 value 只需 useCallback 稳定,就能做到零更新。但它对高频更新(如鼠标位置、实时图表数据)不友好,因为每次真变化仍要通知该 Provider 下的全部消费者。
路线二:Redux Toolkit(多组件共享、复杂更新)
tsx
const selectFilteredIds = createSelector(
[(s: RootState) => s.todos.items, (s: RootState) => s.todos.filter],
(items, filter) => items.filter(i => i.done === filter).map(i => i.id)
);
function TodoList() {
const ids = useSelector(selectFilteredIds);
// 只有 selector 结果变化时才重渲染
}
useSelector 提供 selector 级订阅,配合记忆化 selector 保持返回值引用稳定,就能把更新收敛到真正关心该数据的组件。适用:状态量大、更新来源多、需要 DevTools 时间旅行与中间件。代价是引入额外依赖与约定,小型局部状态不必上。需要注意 selector 返回对象时保持引用稳定(记忆化或按字段拆分订阅),否则会回到"每次新对象、每次重渲染"的老问题1。
路线三:useSyncExternalStore 自建细粒度订阅
tsx
import { useSyncExternalStore } from 'react';
function useCursor() {
return useSyncExternalStore(
cursorStore.subscribe, // (onStoreChange) => unsubscribe
cursorStore.getSnapshot, // 必须返回缓存快照,不能每次新建对象
cursorStore.getServerSnapshot // SSR 场景需要
);
}
适用:已有非 React store(WebSocket 状态、第三方库、跨框架共享单例),需要完全自控订阅粒度。它的约束很硬:getSnapshot 必须返回可比较的稳定快照,每次调用都新建对象会导致无限循环或撕裂;SSR 下要提供 getServerSnapshot。旧版本 React 环境可能需要 use-sync-external-store shim 包,请按项目实际依赖版本核对。
| 维度 | 拆分 Provider | Redux Toolkit | useSyncExternalStore |
|---|---|---|---|
| 适用场景 | 低频变化、消费者少 | 多组件共享、复杂更新、需 DevTools | 已有外部 store / 第三方库桥接 |
| 细粒度订阅 | 部分改善(value 稳定即可) | selector 级 | 完全自控 |
| 样板成本 | 低 | 中 | 高 |
| 并发安全性 | 由 React 管理 | 由 React 管理 | 需自行保证快照一致性 |
| 主要风险 | 高频更新下仍是全量通知 | selector 引用不稳定 | getSnapshot 语义写错直接炸 |
选择顺序建议:先用 Provider 拆分 + value 稳定化解决 80% 的问题;状态规模和更新复杂度上来了再考虑 Redux Toolkit;只有在需要桥接非 React 数据源时才自建 useSyncExternalStore。

2.4 先测量再优化:用 Profiler 定位
优化前后的结论都必须来自同一套测量口径:
- 在 React DevTools 的 Profiler 中录制一次真实交互(切换 tab、提交表单)。
- 看火焰图中哪些组件被渲染、每个 commit 的耗时。
- 对可疑组件开启 "Record why each component rendered"(该功能在部分版本中标注为实验性,界面名称可能不同),确认是 props 变化、Context 变化还是自身 state。
- 修复后用同样的交互、同样的数据规模重录,对比组件渲染次数与 commit 耗时。
只看"感觉快了"没有意义;只优化没有出现在 Profiler 热点里的组件,通常是在做无用功。
三、React 18 升级避坑:批处理、StrictMode、白屏与并发
3.1 自动批处理:代码"看起来没变",行为变了
React 18 把状态更新的批处理范围扩展到 Promise 回调、setTimeout、原生事件等场景57。React 17 里这段代码会渲染两次,React 18 只渲染一次:
tsx
function onDone() {
setA(1);
setB(2);
// React 17:Promise/setTimeout 中两次 setState 各触发一次渲染
// React 18:合并为一次渲染,提交后再读取
}
真正会坏掉的是"setState 后立即读 DOM 或读旧 state"的旧代码,例如在同一个回调里 setState 后立刻 document.querySelector 量高度。排查方法是把这类读取挪到 useEffect 或 useLayoutEffect,让读取发生在提交之后。
确有交互需求时可以用 flushSync 强制同步刷新,但它会打断批处理、显著降低性能,官方语义中它只应作为最后手段,不要拿它当"恢复旧行为"的开关。
3.2 StrictMode 双执行:开发态的"迷之 bug"
React 18 开发模式下 <StrictMode> 会刻意让 Effect 呈现"执行 → 清理 → 再执行"的时序,用来暴露副作用不幂等的问题15。生产构建不包含这一行为,所以典型现象是"本地开发请求发两次,线上正常"。

tsx
// 不幂等:StrictMode 下会发两次请求,且旧响应可能覆盖新数据
useEffect(() => {
fetch(url).then(r => r.json()).then(setData);
}, [url]);
// 幂等:cleanup 取消前一次请求
useEffect(() => {
const ac = new AbortController();
fetch(url, { signal: ac.signal })
.then(r => r.json())
.then(setData)
.catch(e => { if (e.name !== 'AbortError') throw e; });
return () => ac.abort();
}, [url]);
要点是:不要为了"消掉开发态的双执行"去删 <StrictMode> 或加一次性标志位。双执行暴露的是真实缺陷------在路由快速切换、快速输入搜索时,同样的竞态照样会发生,只是概率更低。判断标准很简单:生产环境也会出现的竞态,就是真 bug;只有开发态重复的,是 StrictMode 帮你提前看到的真 bug。不同 React 版本对双执行的具体机制描述可能略有差异,落地排查时以当前版本文档与 Profiler 观察为准。
3.3 升级白屏排查:一条从入口到渲染的检查链
白屏且控制台无报错时,按下面的顺序查,基本不会漏:
- 挂载节点 :
ReactDOM.render迁移到createRoot后,createRoot(document.getElementById('root'))的 id 是否与 HTML 中一致。节点找不到时可能只有一条不起眼的告警。 - 入口导入 :构建期
Module not found,检查 import 路径大小写与构建产物是否完整15。 - 被吞掉的渲染错误 :ErrorBoundary 是否把异常写进了日志但界面上只渲染了
null。临时在 boundary 的componentDidCatch里打全量错误对象。 - 第三方库兼容 :检查
peerDependencies是否存在 React 版本冲突:
bash
npm ls react react-dom
npm explain @types/react
类型包版本错配(如 @types/react 与 react 主版本不一致)在 TypeScript 项目里会以奇怪的编译错误或运行时 undefined 的形式出现1014。
- Suspense 与 lazy :
React.lazy配合Suspense时是否缺少fallback,路由级拆包是否有加载失败兜底10。
3.4 useTransition 误用:不是"性能开关"
useTransition 的语义是"把这次更新标记为非紧急,允许 React 中断和延后它",适合与用户输入竞争的大渲染,例如大列表过滤、Tab 切换后的重排:
tsx
const [isPending, startTransition] = useTransition();
function onFilter(keyword: string) {
setKeyword(keyword); // 紧急:输入框必须立刻响应
startTransition(() => {
setFiltered(heavyFilter(keyword)); // 非紧急:大列表可以延后
});
}
return (
<>
<input value={keyword} onChange={e => onFilter(e.target.value)} />
{isPending ? <Spinner /> : <List items={filtered} />}
</>
);
常见误用与症状:
- 把它当防抖 :
startTransition不合并时间窗口内的多次调用,只是改变调度优先级。 - 包住同步昂贵计算:被包的仍然是同步代码,主线程照样被占住,只是 UI 的状态更新被延后,用户会觉得"点了没反应"。
- 忽略
isPending:界面长时间停在旧结果又没有任何反馈,观感比不用更差7。 - 多个 transition 同时竞争:若同时存在多个长时间未完成的 transition,更新可能被反复延后。这一点社区文章有提及10,但"竞争"的确切调度行为建议在目标版本上用 Profiler 实测后再定论。
原则:紧急更新直接 setState,只有"可以让用户先看到旧界面"的更新才进 startTransition。把所有更新都包进去,等于给整个应用降优先级。
3.5 外部 store 的并发适配:useSyncExternalStore
并发渲染下,组件若直接在渲染期间读取外部可变 store,可能读到与本次渲染不一致的数据,出现"渲染结果与真实状态对不上"的撕裂现象。适配的最小改造面是把读取换成 useSyncExternalStore:
tsx
import { useSyncExternalStore } from 'react';
const store = {
state: { online: true },
listeners: new Set<() => void>(),
subscribe(cb: () => void) {
store.listeners.add(cb);
return () => store.listeners.delete(cb);
},
getSnapshot() {
return store.state; // 必须返回缓存引用,不能每次新建对象
},
getServerSnapshot() {
return { online: true };
},
};
function useOnline() {
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot).online;
}
约束要点:subscribe 必须返回清理函数;getSnapshot 必须返回缓存快照,若每次返回新对象,React 会因快照始终不等而反复重渲染;SSR 场景必须实现 getServerSnapshot,否则服务端渲染与客户端首次渲染可能不一致。项目中若仍在 React 18 之前的版本共存代码,请确认是否需要 use-sync-external-store shim。
四、常见问题速查表
| 表现 | 可能原因 | 排查思路 | 解决方向 | 章节 |
|---|---|---|---|---|
| Effect 里读到旧值 | 过期闭包 / 漏依赖 | 看 lint 告警;打印依赖引用是否变化 | 补依赖 / 函数式更新 / useLatest | §1 |
| 计时器、订阅翻倍 | 缺 cleanup | 在回调里打日志计数 | 返回 cleanup,取消请求用 AbortController | §1 |
| 页面卡死或 Too many re-renders | Effect 中 setState 且依赖含自身 | 检查依赖里是否有对象/数组字面量 | 函数式更新 / 拆状态 / useReducer | §1 |
| 同一个请求发两次 | 函数引用每次新建 + StrictMode | 看是否仅开发态复现 | useCallback 稳定引用 + cleanup 幂等 | §1、§3 |
| 加了 memo 仍重渲染 | 内联对象/函数导致浅比较失效 | Profiler 看 props 是否每次不同 | useMemo / useCallback 稳定化 | §2 |
| 中间层不渲染,深层仍渲染 | Context 订阅路径不走 memo | Profiler 开启渲染原因记录 | 提升 value / 拆 Provider / selector 订阅 | §2 |
| 改一处全局状态全页刷 | Context 粒度太粗 | 数消费者数量 | Provider 拆分 / Redux Toolkit / uSES | §2 |
| 升级后白屏无报错 | createRoot 节点、吞错、依赖冲突 | 按 §3.3 检查链逐条查 | 修正挂载节点 / 补 ErrorBoundary 日志 | §3 |
| 升级后"不该刷的不刷"了 | 自动批处理范围扩大 | 对比 17/18 渲染次数 | 按新时序写,必要时才用 flushSync | §3 |
| useTransition 后界面更卡 | 包了同步昂贵计算 / 忽略 isPending | 看交互响应时间 | 只包非紧急更新 + 提供 pending 反馈 | §3 |
| 外部 store 与 UI 不一致 | 并发下直接读外部可变状态 | 检查是否有渲染期直读 | 改用 useSyncExternalStore | §3 |
结语
把这三类问题放在一起看,会发现它们共享同一套解题动作:先确定数据的同步来源 ,再决定同步的时机 ,最后处理同步过程中的清理与竞态 。useEffect 的六种解法是在选同步方式,Context 的三条路线是在选订阅粒度,React 18 的批处理与 StrictMode 则是在检验你的副作用是否真正幂等。与其背 API 清单,不如在项目里建立两个习惯:所有 Effect 都能回答"我在和什么外部系统同步",所有性能优化都以 Profiler 的同一口径测量前后差异。这两个问题回答得清楚,本文列出的坑基本就不会再踩第二次。
参考资料
1 《从项目里回看 React 生态:React 18/19 新特性、我们踩过的坑,以及那些"用旧写法写出的新思想"》,掘金,https://juejin.cn/post/7669663925182005258
2 《90%前端写React+TS都踩坑!从组件类型、单向数据流到本地存储完整实战》,掘金,https://juejin.cn/post/7667777232393912335
3 《React性能优化避坑指南:90%的人都踩过的"闭包陷阱"和"无效优化"》,掘金,https://juejin.cn/post/7595815509234860072
4 《useEffect 完整使用指南:依赖数组、闭包陷阱、清理函数实战》,掘金,https://juejin.cn/post/7675372916835811354
5 《React进阶修炼:从批处理原理到Hooks陷阱与跨端项目实战》,CSDN,https://blog.csdn.net/weixin_29231725/article/details/164658682
6 《React useEffect依赖项实战:闭包陷阱与无限循环解决方案》,CSDN,https://blog.csdn.net/weixin_29007243/article/details/167350518
7 《React 18并发渲染最佳实践:Suspense、Transition、自动批处理特性深度解析与应用》,CSDN,https://blog.csdn.net/yueye_wu/article/details/156728709
8 《React项目性能优化实战:从渲染瓶颈到长列表与跨端避坑》,CSDN,https://blog.csdn.net/weixin_30368981/article/details/164661194
9 《React性能优化实战:如何用React.memo、useCallback和useMemo减少重渲染》,CSDN,https://blog.csdn.net/weixin_27960253/article/details/158630576
10 《React 18升级实战:并发渲染与性能优化核心指南》,CSDN,https://blog.csdn.net/weixin_29001655/article/details/166172039
11 《React 18 并发渲染实战:useTransition、Suspense 与自动批处理》,CSDN,https://blog.csdn.net/TrisighT0/article/details/160058607
12 《Hooks 底层原理:useState/useEffect 闭包陷阱完整解决方案》,CSDN,https://blog.csdn.net/chaosweet/article/details/163505273
13 《React的useEffect又背着我偷偷执行了?记一次闭包陷阱踩坑》,CSDN,https://blog.csdn.net/qq_43546721/article/details/165807253
14 《TypeScript + React 全栈工程实战:类型安全、性能优化与踩坑指南》,CSDN,https://blog.csdn.net/weixin_33100393/article/details/165774621
15 《Vue 2 老项目重构为 React:实践与踩坑总结》,CSDN,https://blog.csdn.net/weixin_29006187/article/details/166159232
16 《React 性能优化深度实战:memo、useMemo、useCallback 的边界与取舍》,CSDN,https://blog.csdn.net/weixin_29009669/article/details/165835239
说明:以上来源均来自本次研究资料清单,其中社区文章中的个别实测数字(如引用稳定前后的耗时对比)缺少组件规模与测量口径说明,本文仅作为方向性参考,未作为通用性能基准;文中涉及 React API 精确语义与版本差异的部分,建议以目标版本官方文档与本地实测为准。