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

- Name
- Jack Qin
React 的依赖追踪建立在一个朴素却影响深远的决定上:它用引用相等(Object.is)而非值相等来判断"依赖变没变"。useEffect、useMemo、useCallback 的依赖数组都走这套语义。这个选择是有道理的——值相等对任意对象是昂贵且不可判定的,引用相等是 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 时求值右操作数。而 [] 是一个数组字面量表达式,每次求值都构造一个新的数组对象。所以当 data 是 undefined 时,data ?? [] 在每次渲染都产出一个全新的 []:两次都是空数组,值上相等,但 Object.is 判定它们不等。
什么时候 data 会持续是 undefined?恰恰是这些"窗口"——query 被禁用、还在加载、或尚未开始:
enabled: false(或enabled: someFlag而someFlag === 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——一个 setState。setState 触发父组件重渲染,重渲染让 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: false让data持续为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