原文:https://dev.to/infoinlet1/maximum-update-depth-exceeded-4-useeffect-dependency-bugs-that-all-passed-review-104(作者 @infoinlet1)
先看一个依赖数组,用户第一次把鼠标悬停到按钮上,它就把 React 干崩了:
useLayoutEffect(() => {
if (!tip || !boxRef.current) return
const { width, height } = boxRef.current.getBoundingClientRect()
const x = /* … 钳制到窗口范围内 … */
const y = /* … 在控件上方,或者下方 … */
if (!pos || pos.x !== x || pos.y !== y) setPos({ x, y })
}, [tip, pos])
按常规规则来看,这段代码没有任何毛病。它读了 tip,读了 pos,两个都列进了依赖数组。react-hooks/exhaustive-deps 对它非常满意。甚至还有一道防线——那个 if 就是专门用来拦住循环的。
可它照样循环。每一次都是。任何 tooltip 第一次出现,就抛出 Maximum update depth exceeded。
我又翻了我们自己的项目历史,另外找出了三个同类问题——同一类 bug,四种完全不同的伪装。一个会把你的滚动位置硬拽回起点的电子表格。一个为一条从未发出的请求报错的引导步骤。一个指着空气的引导教程。
这四个全部上了线。全部通过了代码评审。其中三个之所以存在,正是因为有人照着 lint 规则的要求去做了。
下面说说这背后到底发生了什么,以及我现在拿到任何依赖数组都会问的四个问题。
linter 看不见的鸿沟
react-hooks/exhaustive-deps 回答的是一个问题:
这个 effect 读了什么?
而你的依赖数组要回答的是另一个问题:
这个 effect 什么时候应该再跑一次?
大多数时候,这两个问题的答案是一致的,这正是这条规则能生效的原因,也是我们早就不再思考它的原因。下面的每一个 bug,都是两个答案分道扬镳的案例——而 linter 只检查第一个问题,所以它会对第二个问题的错误答案欣然放行。更糟的是,它还会强制你写上这个答案。
Bug 1:你测量出来的依赖
回到那个 tooltip。为什么那道防线拦不住?
因为 x 和 y 是对一个带 CSS transform 的元素调用 getBoundingClientRect() 算出来的。测量一个被平移过的盒子,每跑一次都会得到相差亚像素的宽度——这次是 247.99998474121094,下次是 248.00001525878906。于是 pos.x !== x 永远为真。永远。
完整的循环是这样:
- effect 运行,测量,设置
pos -
pos变了,effect 重新运行 - 它再测一次,得到的值差了万分之一像素
- 防线喊一声“变了!”,设置
pos - 回到第 2 步
React 在更新深度上限处把这一切掀翻,所以它的外在表现是崩溃,而不是页面变卡。
修复代码比 bug 代码还短一个字符:
// 只用 `tip` 作为依赖。以 `pos` 触发重跑、再比较结果决定是否落定,
// 正是会死锁的形态:测量一个被平移过的盒子,每跑一次都会得到相差亚像素的宽度,
// 于是“变没变?”的判断永远得不出 false,React 最终在更新深度上限处把渲染循环掀翻。
// 每次悬停测一次就够了——控件不会在指针底下移动,而任何会移动它的操作
// (滚动、缩放、点击)都会让 tooltip 消失。
useLayoutEffect(() => {
if (!tip || !boxRef.current) return
const { width, height } = boxRef.current.getBoundingClientRect()
// …
// 取整像素:让文字保持清晰,也顺手脱离了上面的亚像素跑步机。
setPos({ x: Math.round(x), y: Math.round(y) })
}, [tip])
这里有两处改动值得分开讲,因为真正的修复只有其中一处:
- `[tip]`,而不是 `[tip, pos]`。 这才是修复本身。effect 不需要在自己的输出变化时重跑;它需要的是每次悬停跑一次。控件不会在指针底下移动,而任何会移动它的操作——滚动、缩放、点击——都会让 tooltip 消失。
- `Math.round`。 这是双保险。它顺带还能让文字更清晰,因为按亚像素定位的文字,就是按亚像素渲染的文字。
问题 1:这里的依赖里,有没有哪个是我*测量*出来的,而不是别人*给*我的?
>
测量出来的值——
getBoundingClientRect、scrollHeight、offsetWidth,任何位于布局下游的值——都不是稳定的标识。你测量出来的依赖,是一个永远不等于它自身的依赖。
真正该警惕的通用形态是:一个 effect 把自己的输出当输入,并靠一次比较来决定何时停下。 这时那次比较成了承重结构,而它比较的浮点数根本不是你生产出来的。
Bug 2:身份不断变化的依赖项
换了个文件,还是同一类问题。电子表格需要把活动单元格保持在可视区域内——这一点很必要,因为网格是虚拟化渲染的,键盘导航或公式跳转之后,被选中的单元格可能根本就没有被渲染出来。
useEffect(() => {
const el = gridRef.current
if (!el) return
const di = display.indexOf(active.r)
if (di < 0) return
const z = zoom / 100
const top = offsetTop(di) * z
const bottom = top + rowHeight(active.r) * z
if (top < el.scrollTop) el.scrollTop = top
else if (bottom > el.scrollTop + el.clientHeight - HEADER_H * z) /* … */
}, [active.r, display, zoom, offsetTop, rowHeight])
教科书式的完整依赖(exhaustive deps)。它读了五个东西,就列了五个东西。就算你漏写,linter 也会替你把后四个补上。
症状:你从刚点击过的单元格上滚开,视图立刻又弹回去对准它。
原因:display 每次都是全新的数组,offsetTop / rowHeight 每次都是全新的回调——只要可视网格发生扩张就是如此,而朝边缘滚动这件事,做的恰恰就是让可视网格扩张。于是整条链路是:你滚动 → 表格扩张 → memo 产出一个新的数组身份 → effect 重新执行 → 把你滚回 active.r。两个方向都逃不掉,因为列的扩张和行的扩张弄失效的是同一个 memo。
代码的意图从来都是“当选区移动时”。而依赖数组表达的却是“这五个身份里任何一个发生变化时”。从引入虚拟化的那一刻起,这两者就分道扬镳了,而且没有任何工具标记出这个问题,因为没有任何工具能够标记它。
// 下方"滚动进可视区"effect 所需的布局信息,存进 ref,免得它们反过来再次触发
// 这个 effect。`display` 每次可视网格扩张时都是新数组(offsetTop/rowHeight 也是新回调)
// ——而朝边缘滚动恰恰就会导致可视网格扩张——所以把它们放进依赖数组,意味着每一次扩张了
// 表格的滚动都会把视图猛地拽回你点过的那个单元格,双向皆然(扩张"列"与扩张行
// 失效的是同一个 memo)。从选区上滚开是有意为之;只有选区变化才应该把视图拉回来。
const scrollLayout = useRef({ display, zoom, offsetTop, rowHeight })
scrollLayout.current = { display, zoom, offsetTop, rowHeight }
useEffect(() => {
const el = gridRef.current
if (!el) return
const { display, zoom, offsetTop, rowHeight } = scrollLayout.current
// ……函数体相同……
}, [active.r])
这正是 useEffectEvent RFC 想要转正的模式,而今天你用四行代码加一个 ref 就能用上它。它所编码的规则是:
问题 2:这个 effect 到底是在*响应*这个值,还是仅仅*读取*它?
>
需要响应的东西,放进依赖数组;只是读取的东西,放进 ref。linter 分不清这两者的区别,会把所有东西一股脑归档到“响应”名下。
如果这篇文章你只带走一条经验,那就带走这一条。它是这类问题里最常见的一种,而且遥遥领先;与亚像素那个案例不同,它从不崩溃——只是会让你的应用表现得像中了邪。
Bug 3:依赖数组收到了自己的回声
这个 bug 产出了四份报告里最诡异的一份:在“你想做什么?”输入框里敲下一个字母,界面立刻打印出“刚才无法完成检查,请重试”——瞬间出现,没有任何请求在途,而且从头到尾一个请求都没发出去过。
这套结构是每个向导流程里每个表单都会用的写法:父组件持有文本,这样用户离开这一步再回来时文本还在;子组件是受控的,把变更往上推:
const [query, setQuery] = useState(need)
useEffect(() => { setQuery(need) }, [need]) // “问题变化时向下同步”
父组件又把这个值原样作为 need 传回来。于是 need 会因两个完全不同的原因发生变化,而这个 effect 分不清这两种情况:
- 有人带着之前保存的句子导航回到这一步 → 真正的新值,应该重置
- 用户敲了一个字符,我们把它推上去,它又传回来 → 自己的回声,应该什么都不做
结果就是每一次按键都会重新进入重置 effect。结果被清空。ranFor 被设成了打了一半的文本。而渲染层把“没有结果加上问题非空”解读为一次失败的检查——于是对于没走落地页搜索就直接注册的用户,失败提示在敲下第一个字母时就出现了。
修法是记住自己推上去过什么:
/** 我们最后一次通过 `onQueryChange` 向上推的值。
*
* 调用方会把它原样作为 `need` 传回来——setup 把句子存进了自己的 state,
* 好让它熬过用户离开这一步的过程——而这个回声以前会重新进入下面的重置
* effect,就好像它是来自外部的新问题。于是每一次按键都会清掉用户正在
* 阅读的结果,对于没先选任何东西就注册的用户,还会把它们换成一条针对
* 从未发出的请求的失败提示。只有不是我们造成的变更才算新问题。 */
const echoed = useRef(need)
const started = useRef(false)
useEffect(() => {
// 只有来自外部的问题变化才是新问题——见 `echoed`。
if (started.current && need === echoed.current) return
started.current = true
echoed.current = need
setQuery(need)
// …
}, [need, /* … */])
问题 3:这个依赖会因为*这个组件*自己的行为而变化吗?
>
如果会,
[dep]的含义就不再是“外部世界变化时”——而是“外部世界变化时,或者我自己变化时”。你必须能区分这两种情况,而且只有你区分得了。
这份 bug 报告里还埋着第二个教训,也是我真正想裱起来挂墙上的那一条。错误提示是靠从屏幕上的现状推断状态渲染出来的:没有结果 + 问题非空 = 失败。所以这次修复还包括把状态真正存下来——idle | loading | ok | failed——只在某次调用确实返回为空时才渲染失败提示那一行。派生状态是对过去的一种猜测。如果用户能分清“还没问”和“问过了但失败了”,你的组件也必须能分清。
Bug 4:那个根本还不存在的依赖
最后一个 bug 的依赖数组几乎是空的,却照样出问题——这正是它被收进这篇文章的原因:同一个错误换了一件外套。
引导教程在 setup 结束的瞬间就被启动:
close() // 导航到 /app startLaunchTour(launch) // ……在同一个 tick 里
drive(driver.js)会过滤掉所有锚点此刻不可见的步骤。而在外壳绘制出来的前一个 tick,不可见的是全部锚点。于是这个个性化的欢迎教程悄悄退化成了那两个恰好什么都不指向的弹出气泡。
严格来说,这里没有任何字面意义上的依赖数组 bug。它是同一个误解往上升了一层:把“组件跑了”当成“它指向的东西存在”。 effect 函数体执行了,这只说明 React 树上发生了什么,并不代表你的库接下来要查询的那部分 DOM 也在。
/** 等工作区真正出现在屏幕上。
*
* setup 现在是一个路由,所以教程是从一个正被外壳替换掉的页面里启动的——
* `close()` 完成导航,然后在同一个 tick 里把教程交接出去。`drive` 会过滤掉
* 所有锚点此刻不可见的步骤,所以没有这段代码,欢迎教程会在侧栏存在之前
* 就抵达,并悄悄退化成那两个什么都不指向的弹出气泡。
*
* 侧栏或输入区任一个都够了:两者都属于外壳,任意一个完成布局都说明
* 工作区已经绘制出来。设了上限,因为在窄屏布局下侧栏是真的永远不出现,
* 而一个永远等下去的教程,比一个在现有内容上硬跑的教程更糟。 */
function waitForShell(): Promise<void> {
const anchors = ['[data-tour="surfaces"]', '[data-tour="composer"]']
return new Promise((resolve) => {
const deadline = Date.now() + 4000
const tick = () => {
if (anchors.some(tourTargetVisible) || Date.now() > deadline) resolve()
else setTimeout(tick, 120)
}
tick()
})
}
注意那个截止时间。窄屏布局下侧栏是真的永远不出现,而一个永远等下去的教程,比一个在现有内容上硬跑的教程更糟。任何不带上限的等待 DOM 的辅助函数,都是一个你还没遇到的卡死。
问题 4:这个 effect 是否假设了 React 之外的什么东西已经就绪?
>
挂载不等于绘制。导航不等于抵达。如果你要把一个元素交给一个会查询 DOM 的库,就等这个元素出现——并给它一个截止时间。
四个问题
把这几个问题贴在 lint 规则旁边,而不是用它取代规则:
- 这里的依赖是不是我测量出来的值? 测量值永远不会等于它自身。
- 这个 effect 是在“响应”这个值,还是只是“读取”它? 只是读取的话,放进 ref。
- 这个依赖会不会因为这个组件自己的行为而改变? 如果会,你就必须能认出自己的回声。
- 这里是否假设了 React 之外的什么东西已经就绪? 挂载不等于绘制完成。等那个元素出现,但要设一个期限。
60 秒审计
在你自己的代码库里 grep 这四种模式。就我们这篇的例子而言,一旦知道自己在找什么,每一种不到一分钟就能确认:
- 依赖数组里列了 `x` 的 effect 中调用了 `setX`。 每一处这样的代码,要么是一个循环,要么是一个承担着关键作用的守卫判断。两者都值得再看一眼。
- 任何来自 `useMemo`/`useCallback` 的数组、对象或函数依赖。 问一句:什么会让这个 memo 失效?如果答案里包含用户持续进行的任何操作——滚动、打字、调整窗口大小——你就撞上了 bug 2。
- 受控子组件,而父组件又把值作为 prop 回传给它。 把这条往返链路追踪一遍。如果子组件分不清某个值是不是自己的回声,你就撞上了 bug 3。
- effect 里的 `querySelector`,或者交给第三方库的锚点元素。 问一句:是什么保证它存在?“effect 已经执行过”不算答案。
我不会再说的话
我已经不觉得“把它加进依赖数组就行了”是好建议了,尽管这话我自己说过很多次。对于 linter 问的那个问题,它是正确答案;而对于你的 effect 真正在问的问题,它又往往是个错误答案。
这条规则是个出色的烟雾报警器,但它不是设计评审。它没法告诉你:你加进去的那个值是测量出来的、是频繁变动的、还是你自己的回声传了回来——而在上面四个 bug 里的三个,恰恰是严格照它的建议做,才把 bug 发上线的。
依赖数组不是一份“你读取了什么”的清单,而是一份“这件事为什么应该再次发生”的理由清单。按后一个问题来写它,这一大类 bug 大部分就会消失。
原文:https://dev.to/infoinlet1/maximum-update-depth-exceeded-4-useeffect-dependency-bugs-that-all-passed-review-104(作者 @infoinlet1)



