Published on

引用稳定性是 Hook 的隐含契约:`data ?? []` 为什么在移动端会变成死循环

Authors
  • avatar
    Name
    Jack Qin
    Twitter

React 的依赖追踪建立在一个朴素却影响深远的决定上:它用引用相等Object.is)而非值相等来判断"依赖变没变"。useEffectuseMemouseCallback 的依赖数组都走这套语义。这个选择是有道理的——值相等对任意对象是昂贵且不可判定的,引用相等是 O(1) 的。但它把一个责任悄悄推给了开发者:凡是会进依赖数组的值,必须在底层数据没变时保持同一个引用

这条责任平时被各种库(包括 TanStack Query 的 structural sharing)默默扛着,以至于我们几乎从不显式想它。直到某个自定义 hook 的返回语句里出现一个看起来人畜无害的 data ?? []——它每次渲染都新建一个空数组,违反了这条契约。本文想拆的不是这一个 bug,而是它背后的机制账:引用相等的依赖语义、空字面量的求值时机、以及 RN 与 Web 在"违反契约"后果上的根本差异。理解这三层,你就能预判这类 hook,而不是等死循环崩在脸上才去查。


机制一:每次渲染都求值的右操作数

先看违规现场:

// Hook
export function useSensorsQuery(siteId: string | null) {
  const { data, isLoading, isError } = useQuery(/* … */)
  return {
    rangers: data ?? [], // ← 每次渲染都是全新的数组
    isLoading,
    isError,
  }
}

?? 是一个普通的二元运算符——当左操作数为 null/undefined 时求值右操作数。而 [] 是一个数组字面量表达式,每次求值都构造一个新的数组对象。所以当 dataundefined 时,data ?? [] 在每次渲染都产出一个全新的 []:两次都是空数组,值上相等,但 Object.is 判定它们不等

什么时候 data 会持续是 undefined?恰恰是这些"窗口"——query 被禁用、还在加载、或尚未开始:

  • enabled: false(或 enabled: someFlagsomeFlag === false);
  • 依赖一个尚未 resolve 的 id 的条件 query key;
  • queryFn 返回了 undefined(它不该这样,但确实会发生)。

在本例里触发条件是 siteId === null → enabled: false → data 永远 undefined。于是 rangers 在这个屏幕的整个生命周期里,每一帧都是一个新引用。


机制二:新引用喂给依赖数组 = 永远触发的 effect

现在接上消费方:

useEffect(() => {
  setSelectedAssetIds(rangers.map((r) => r.assetId))
}, [rangers]) // rangers 每帧换引用 → effect 每帧触发 → setState → 重渲染 → 又一个新 [] → …

因为 rangers 每帧都是新引用,[rangers] 这个依赖数组在 React 看来每帧都变了,effect 于是每帧都重跑。而这个 effect 里调了 setSelectedAssetIds——一个 setStatesetState 触发父组件重渲染,重渲染让 hook 重跑,hook 又吐出一个全新的 [],effect 再次触发……闭环就此成立。

一个容易被冤枉的细节:TanStack Query 自己的 data 字段默认是引用稳定的(它内部做了 structural sharing,数据没变时返回同一引用)。bug 不在 query,而在包装 hook 的返回语句——fallback 几乎总是在那一行被手加上去的。query 把稳定性交到你手上,你又用一个字面量把它丢了。

如果 hook 的返回类型对外宣称是 T[],调用方会合理地认为可以把它放进依赖数组。这就是"隐含契约"的含义:类型签名承诺的是 T[],但调用方据此做出的引用稳定假设,hook 也必须兑现。


机制三:为什么 Web 只是浪费,移动端却致命

同样的违规模式,在 Web 和 RN 上的后果不在一个量级。Web 端,每帧多余的渲染通常只是浪费——浏览器的渲染调度和 React 的批处理往往会把它磨平,看起来"卡一下"而已,很少形成可见死循环。

RN 不同。它的 setState 触发 re-render,re-render 又喂给下一轮 effect,整条链路在 JS 线程上以极快节奏空转,最终撞上 React 的循环保护:

ERROR  Maximum update depth exceeded. This can happen when a component
calls setState inside useEffect, but useEffect either doesn't have a
dependency array, or one of the dependencies changes on every render.

这条差异本身就是一个可迁移的提醒:同一个引用稳定性违规,在不同运行环境里的"放大系数"不同。Web 的容错让这类 bug 长期潜伏,一旦同样的代码进到移动端,潜伏的契约违反就被放大成硬崩溃。


三层机制的交汇

叠起来,死循环就是一种结构性必然:

enabled: falsedata 持续为 undefined(机制一的触发器)→ data ?? [] 每帧新建数组、违反引用稳定(机制一)→ 消费方 useEffect([rangers]) 每帧触发(机制二)→ effect 里的 setState 引发重渲染 → 回到机制一 → RN 把这个循环放大成 Maximum update depth exceeded(机制三)。

三件事单独看都不"错":?? fallback 是常规写法;把数组放进依赖数组是常规用法;RN 的 setState-驱动-重渲染是它的核心模型。错的是它们交汇处那个被违反的隐含前提——进依赖数组的值必须引用稳定


修复,以及它逼出的契约

首选:模块级常量

const EMPTY_SENSORS: SensorOption[] = []

export function useSensorsQuery(siteId: string | null) {
  const { data, isLoading, isError } = useQuery(/* … */)
  return {
    rangers: data ?? EMPTY_SENSORS, // ← 引用稳定
    isLoading,
    isError,
  }
}

模块级常量在整个进程生命周期里是同一个引用,零额外分配、不产生多余渲染。热路径首选。

当 fallback 依赖渲染期输入时:useMemo

const rangers = useMemo(() => data ?? EMPTY_SENSORS, [data])

只在 fallback 本身依赖渲染期输入时才这么写;否则 data ?? EMPTY_SENSORS 就够了,还省掉 memo 的记账开销。

修复本身是一行,但它逼出一张按字段类型分的稳定性契约——这才是能复用的部分:

Hook 返回的字段类型稳定性要求
原始类型(string / number / boolean不适用——值相等即结构相等
数组 T[]数据集没变时,跨渲染保持同一引用
对象 / record字段没变时,跨渲染保持同一引用
null / undefined用那个单例——绝不用临时构造的空占位符

这条契约要落成回归守卫。 移动端的包装 hook 除了断言值正确,还应断言跨渲染的引用稳定性。由于本项目 renderHook 不可用,实际做法是把决策逻辑抽成纯函数来测:

用例断言
enabled: false(siteId === null)——渲染两次两次返回的数组 Object.is 相等
query 已 resolve、输入不变——渲染两次两次数组 Object.is 相等(TanStack Query 默认行为)
query resolve 出新的服务端数据引用改变(这样消费方才会响应)

第一条专门钉住引出本文的那个 bug;第三条同样重要——稳定不是"永远同一引用",而是"数据变才变",否则消费方会对真实更新失聪。


可迁移的那一层

抛开 React 和 TanStack Query 的具体 API,真正可迁移的认知是:

一个用引用相等做变更检测的系统,会把"引用稳定性"变成一条隐含的、必须在每个返回点兑现的契约。 React 的依赖数组只是最常见的载体;任何"靠引用判断脏/净"的机制(memoization、React.memo、selector 比较、虚拟列表的 key diff)都共享这条契约。违反它最廉价的方式,就是在返回点用一个临时字面量(?? []?? {}、内联 () => {})做 fallback。

排查这类问题时,与其追"谁在 setState",不如反过来问:这个进了依赖数组的值,在底层数据没变时是不是同一个引用? 顺手扫一遍同目录的同类模式也值得——比如 data: allSites = [] 这种默认参数解构是同一形状的潜伏 bug,今天没循环只是因为暂时没有消费方在 setState-firing 的 effect 里监听它。

rg -nP "(data|items|list|rows)\s*[?]{2}\s*\[\s*\]" apps/mobile/src
rg -nP ":\s*[A-Z][A-Za-z]*\[\]\s*=\s*\[\s*\]" apps/mobile/src