2026年7月27日
React useDeepCompareEffect:修复 useEffect 的对象依赖问题(2026)
你接了一个请求。接口要一个 query 对象,于是你把它放进依赖数组。effect 触发、setState、组件重新渲染、query 对象被重新构建——一个内容完全相同的全新对象——effect 又一次触发。你写出了一个无限循环,而 React 认为自己完全照你说的做了。
function Results({ term, page }: Props) {
const [rows, setRows] = useState([]);
const query = { term, page, sort: 'desc' }; // 每次渲染都是新对象
useEffect(() => {
fetchRows(query).then(setRows); // setRows → 重渲染 → 新 query → 🔁
}, [query]);
}
@reactuses/core 的 useDeepCompareEffect 是 useEffect 的直接替代品,它按值比较依赖,而不是按引用。签名一样、清理语义一样——只是当依赖并没有真正变化时,effect 不再触发。以下都是真实实现,TypeScript 优先,包括那些确实要付出代价的部分。
为什么 useEffect 看不出来
React 用 Object.is 逐项比较依赖数组。对基本类型这正是你要的:5 就是 5,'desc' 就是 'desc'。但对任何带身份的东西——对象、数组、Date、Map、函数——它比较的是引用,而写在组件体里的字面量每一次渲染都会产生一个全新的引用:
Object.is({ term: 'react' }, { term: 'react' }); // false —— 不同的对象
所以按 React 的定义,依赖每次渲染都「变了」。这不是 useEffect 的 bug;引用比较是唯一 O(1) 的比较方式,而 React 要在每个组件的每次渲染上都跑一遍。值比较的成本是真实存在的,React 拒绝替你承担。
于是这笔账落到了你头上——用这种或那种方式。
常见的绕法,以及它们在哪里散架
把对象 memo 掉。 正确,而且在只有一个依赖时就是标准答案:
const query = useMemo(() => ({ term, page, sort: 'desc' }), [term, page]);
它散架在对象不归你管的时候。数据来自一次 fetch、一个 context、一个表单库、一个可以随意重渲染的父组件——你没法在源头 useMemo 一个 prop,于是你 memo 了一份拷贝,现在你要维护一个平行的依赖数组,它必须和对象的结构保持同步。加一个字段、忘了改 memo,你就发布了一个不会更新的 effect。
把基本类型摊进数组。 同样正确,同样脆弱:
useEffect(() => { fetchRows(query); }, [query.term, query.page, query.sort]);
一旦对象嵌套、可选、或者含有不受你控制的字段,它就不行了。[config.retry.limit, config.retry.backoff, config.auth?.scheme] 这种依赖数组,会在某人加字段的那天悄悄出错。
JSON.stringify 一下依赖。 很诱人,也确实流行:
useEffect(() => { fetchRows(query); }, [JSON.stringify(query)]);
但它每次渲染都序列化,不管有没有变化;键顺序会影响结果({a,b} 和 {b,a} 被认为「不同」);undefined 和函数会静默消失;Date 变成字符串;Map 和 Set 变成 {};遇到循环引用直接抛错。它就是一次语义更差、还没有提前退出的深比较。
关掉 lint 规则然后祈祷。 这是真正会被发布出去的那个方案,也是半年后引发闭包过期 bug 的那个。
useDeepCompareEffect
import { useDeepCompareEffect } from '@reactuses/core';
function Results({ term, page }: Props) {
const [rows, setRows] = useState([]);
const query = { term, page, sort: 'desc' };
useDeepCompareEffect(() => {
let cancelled = false;
fetchRows(query).then((r) => !cancelled && setRows(r));
return () => { cancelled = true; };
}, [query]);
}
签名和 useEffect 完全一致:
function useDeepCompareEffect(
effect: EffectCallback, // 可以返回清理函数
deps: DependencyList
): void;
没有任何新概念:effect 在挂载后执行,在依赖列表与上一次深度不相等时重新执行,返回的清理函数在每次重跑前和卸载时执行。上面那个循环在第一次请求后就停了,因为 { term: 'react', page: 1, sort: 'desc' } 和上一次渲染的对象深度相等。
深比较来自 lodash 的 isEqual,覆盖面是靠谱的那种——嵌套对象和数组、按时间戳比较的 Date、按 source 和 flags 比较的 RegExp、按内容而非插入顺序比较的 Map 和 Set:
isEqual(new Date(0), new Date(0)); // true
isEqual(new Map([['a', 1]]), new Map([['a', 1]])); // true
isEqual(new Set([1, 2]), new Set([2, 1])); // true
isEqual({ a: { b: [1, 2] } }, { a: { b: [1, 2] } }); // true
它到底怎么工作的(以及它多花的那一次渲染)
值得理解一下,因为它解释了唯一一个会让人意外的行为。useDeepCompareEffect 是 useCustomCompareEffect 传入 isEqual 作为比较器的一层薄封装,核心大概十行:
const ref = useRef<TDeps | undefined>(undefined);
const forceUpdate = useUpdate();
if (!ref.current) ref.current = deps;
useIsomorphicLayoutEffect(() => {
if (!depsEqual(deps, ref.current)) {
ref.current = deps; // 采纳新依赖
forceUpdate(); // 并重新渲染,好让 useEffect 看到它们
}
});
useEffect(effect, ref.current); // React 永远只看到那个稳定的 ref 数组
诀窍在于:React 拿到的从来不是你新建的那个数组,而是 ref.current——只有当比较认定「确实变了」时它才会被换掉。深度相等的渲染交给 React 的是同一个数组,于是 React 正确地得出结论:无事可做。
代价——这是必须诚实说明的部分——是一次真正的依赖变化会多花一次渲染。layout effect 发现变化时,useEffect 已经带着旧数组注册完了,所以它更新 ref 并强制重渲染;effect 在第二趟才触发。因为用的是 layout effect,这一切发生在浏览器绘制之前,你不会看到任何闪烁。但如果你在一个高频组件里数渲染次数,把这一次算上。(依赖已经引用相等时比较会被完全跳过——isEqual 在 === 上短路,所以稳态开销很低。)
这也意味着内部的 useIsomorphicLayoutEffect 保证了整体的 SSR 安全:服务端渲染时不会出现 useLayoutEffect 警告。
陷阱:依赖数组里的函数
这个坑很多人踩,而且没有任何深比较 hook 能救你。lodash 的 isEqual 按引用比较函数——两个源码完全相同的函数永远不相等:
isEqual(() => {}, () => {}); // false
isEqual({ url: '/api', onDone: () => {} },
{ url: '/api', onDone: () => {} }); // false ← 整个对象也一样
第二行才是致命的。对象里任何一处内联回调,都会让整个对象永久不相等,你的 useDeepCompareEffect 就静默退化成了一个每次渲染都触发的普通 useEffect——无限循环回来了,而且每次还附赠一次额外渲染。
// 🔴 永远触发 —— onSuccess 每次渲染都是新函数
useDeepCompareEffect(() => {
subscribe(config);
}, [{ ...config, onSuccess: (d) => setData(d) }]);
解法是把函数从被比较的值里拿出去。用一个永远指向最新版本的 ref 持有回调,只依赖数据:
// ✅ 比较数据;通过 ref 读回调
const onSuccess = useLatest((d: Data) => setData(d));
useDeepCompareEffect(() => {
subscribe(config, (d) => onSuccess.current(d));
}, [config]);
useLatest 每次渲染都把 ref 钉在最新的值上,于是 effect 调用的是「今天的」回调,却不依赖它的身份。如果你更想直接把回调传出去,useEvent 做同样的事,但给你一个身份稳定的函数。两者共同推出的规则是:依赖数组里放数据,不放行为。
深比较太贵的时候:useCustomCompareEffect
isEqual 会遍历整个结构。对一个小小的配置对象来说这不算什么——几次属性读取,比它省下的那次渲染还便宜。但对一个 5000 行的 API 响应,它就是每次渲染都做一次完整遍历,你用「一次不必要的 effect」换来了「一次必然的遍历」。
当你清楚真正重要的是什么时,就只比较那部分:
import { useCustomCompareEffect } from '@reactuses/core';
useCustomCompareEffect(
() => { renderChart(dataset); },
[dataset],
([prev], [next]) => prev.id === next.id && prev.updatedAt === next.updatedAt,
);
两次字段读取,取代 5000 个元素的遍历。比较器接收上一次和下一次的依赖数组,在应该被视为相等时返回 true——和 isEqual 履行的是同一份契约,只是烤进了你的领域知识。任何带版本号、ETag、updatedAt 或稳定 id 的东西都是候选。
一个粗略的决策规则:
| 依赖 | 选择 |
|---|---|
| 只有基本类型 | 原生 useEffect |
| 小的 config / options / query 对象 | useDeepCompareEffect |
| 一个你自己拥有并控制的对象 | 在源头 useMemo |
| 大数据,或天然有版本字段 | useCustomCompareEffect |
| 一个函数 | useLatest / useEvent,然后只依赖数据 |
让 exhaustive-deps 继续工作
react-hooks/exhaustive-deps 这条 lint 规则不知道你的自定义 hook 也接收依赖数组,所以它干脆不检查了——闭包过期就是这么溜进来的。告诉它:
// eslint.config.js
{
rules: {
'react-hooks/exhaustive-deps': ['warn', {
additionalHooks: '(useDeepCompareEffect|useCustomCompareEffect)',
}],
},
}
现在你在 useEffect 上依赖的那些「缺少依赖」告警,在这里同样生效。值得第一天就配上——一个依赖数组不完整的深比较 hook 比普通 effect 更难调试,因为「重跑得太频繁」至少会自己吵出来,而「永远不重跑」只会安静地端出过期数据。
两条能避开大部分意外的规则
不要在只有基本类型或空依赖时用它。 当每个依赖都是字符串或数字时,深比较相比 Object.is 什么也没多买到——纯粹是开销,外加一次可能的额外渲染。传空数组是这个 hook 会在开发环境主动警告你的错误:
useDeepCompareEffectshould not be used with no dependencies. Use React.useEffect instead.
只跑一次的 effect,用 useMount 更能表达意图。想跳过首次执行,用 useUpdateEffect。
保持数组长度固定。 这是 React 自己的约束,不是这个 hook 的:依赖数组在两次渲染之间改变长度会触发开发环境警告,比较行为也未定义。不要条件式地拼数组——[config, ...(flag ? [extra] : [])] 是一个在等某个周二爆发的 bug。把那个条件值放进对象依赖里面,交给深比较处理。
真实使用场景
- 带 query 对象的数据请求。 最经典的场景,也就是本文开头那个——内联拼出来传给接口的筛选条件、分页、排序状态。
- 按 config 建立的订阅。 WebSocket 主题、event-source 频道、由 options 对象配置的 observer——每次渲染都重新订阅是一个带心跳的资源泄漏。
- 图表和地图库。 这类命令式库接收 options 对象,重新配置一次要花真实的毫秒数。深比较一个 config 对象,远比一次无谓的
chart.setOption()便宜。 - 由解析数据驱动的 effect。 解析成对象的 URL search params、从
localStorage读出的 JSON、解码后的 JWT payload——每次渲染都是新引用,实际内容却稳定不变。 - 不归你管的 props。 第三方组件递给你一个它内部重建的 options 对象。你没法在源头 memo,但可以在使用端按值比较。
要点回顾
useEffect按引用比较,所以内联的对象或数组依赖每次渲染都是「新的」——这就是无限循环和请求风暴的成因,不是你逻辑写错了。useDeepCompareEffect就是按值比较的useEffect——签名一致、清理语义一致,底层是 lodashisEqual(覆盖嵌套结构、Date、Map、Set、RegExp)。- 一次真正的变化会多花一次渲染。 因为是 layout effect,不会有闪烁——但在热点路径上用之前,先知道它的存在。
- 依赖里的函数会让它失效。
isEqual按引用比较函数,一个内联回调就能污染整个对象。行为交给useLatest/useEvent,依赖数组里只放数据。 - 大依赖用
useCustomCompareEffect——比较一个id和一个updatedAt,别去遍历 5000 行。 - 给
exhaustive-deps加上additionalHooks,并且在只有基本类型或空依赖数组时干脆别用这个 hook。
从 @reactuses/core 拿来用,让你的 effect 在数据变化时触发——而不是在对象字面量变化时。