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/coreuseDeepCompareEffectuseEffect 的直接替代品,它按比较依赖,而不是按引用。签名一样、清理语义一样——只是当依赖并没有真正变化时,effect 不再触发。以下都是真实实现,TypeScript 优先,包括那些确实要付出代价的部分。

为什么 useEffect 看不出来

React 用 Object.is 逐项比较依赖数组。对基本类型这正是你要的:5 就是 5'desc' 就是 'desc'。但对任何带身份的东西——对象、数组、DateMap、函数——它比较的是引用,而写在组件体里的字面量每一次渲染都会产生一个全新的引用:

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 变成字符串;MapSet 变成 {};遇到循环引用直接抛错。它就是一次语义更差、还没有提前退出的深比较。

关掉 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、按内容而非插入顺序比较的 MapSet

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

它到底怎么工作的(以及它多花的那一次渲染)

值得理解一下,因为它解释了唯一一个会让人意外的行为。useDeepCompareEffectuseCustomCompareEffect 传入 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 会在开发环境主动警告你的错误:

useDeepCompareEffect should 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——签名一致、清理语义一致,底层是 lodash isEqual(覆盖嵌套结构、DateMapSetRegExp)。
  • 一次真正的变化会多花一次渲染。 因为是 layout effect,不会有闪烁——但在热点路径上用之前,先知道它的存在。
  • 依赖里的函数会让它失效。 isEqual 按引用比较函数,一个内联回调就能污染整个对象。行为交给 useLatest / useEvent,依赖数组里只放数据。
  • 大依赖用 useCustomCompareEffect——比较一个 id 和一个 updatedAt,别去遍历 5000 行。
  • exhaustive-deps 加上 additionalHooks,并且在只有基本类型或空依赖数组时干脆别用这个 hook。

@reactuses/core 拿来用,让你的 effect 在数据变化时触发——而不是在对象字面量变化时。