2026年7月27日

React useDeepCompareEffect:修復 useEffect 的物件依賴問題(2026)

你接了一個請求。API 要一個 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 物件的資料請求。 最經典的場景,也就是本文開頭那個——內聯拼出來傳給 API 的篩選條件、分頁、排序狀態。
  • 按 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 在資料變化時觸發——而不是在物件字面量變化時。