代表性面试主题

前端面试:如何定位并修复 React 中的陈旧闭包?

前端困难
OfferCC 编辑团队发布 更新

题干

一个 React 19.2 组件订阅实时订单,但回调总是读取初始的租户、筛选条件和计数。请解释原因、推导故障,并在不随每次状态变化重连的前提下修复组件,同时比较 Effect 依赖项、函数式状态更新、useEffectEvent、ref 与异步取消。

题干与适用场景

考虑一个使用 React 19.2 的实时订单看板:

tsx
function LiveOrders({ tenantId }: { tenantId: string }) {
  const [filter, setFilter] = useState("all")
  const [count, setCount] = useState(0)

  useEffect(() => {
    const connection = connect(tenantId)

    connection.on("order", (order) => {
      if (matches(order, filter)) {
        setCount(count + 1)
      }
    })

    return () => connection.close()
  }, [])

  // 省略渲染与控件。
}

用户修改筛选条件后,回调仍按 "all" 处理。若短时间连续收到多笔订单,计数可能一直 停在 1。组件收到新的 tenantId 后,也仍连接第一个租户。把所有被引用的值都放进 依赖项似乎能解决旧值问题,但计数或筛选条件每次变化都会关闭并重建连接。

请解释这些故障为什么发生,如何证明究竟哪个值已过期,以及如何让订阅保持正确生命周期, 同时让回调读取最新一次已提交渲染中的值。

这是一道具有现实代表性的前端面试题:公开的中英文 React 面试材料都直接考察陈旧闭包、 Hooks 规则和 useEffect 依赖项。真正的考点不是背诵某个补丁,而是判断一个值应该重新 启动 Effect、参与状态转换,还是只由 Effect 管理的回调进行非响应式读取。

面试官考察点

第一,候选人能否准确解释机制。每次渲染都会得到一份 props 和 state 快照,本次渲染创建 的函数会闭包捕获这份快照。后续渲染不会修改原有函数中的 filter、count 或 tenantId 局部变量。外部系统若一直持有第一次渲染创建的回调,该回调就会一直读取第一次的值。 闭包行为本身符合 JavaScript 语义,问题在于 Effect 声明的同步关系不完整。

第二,候选人能否区分四类需求:

需求正确工具改变的行为
外部资源必须跟随某个响应式值Effect 依赖项清理并重新同步 Effect
新状态由旧状态推导函数式更新基于 React 队列中的当前状态计算
Effect 管理的回调要读取最新提交值,但不应重新订阅React 19.2 的 useEffectEvent非响应式读取当前 props 和 state
命令式且不参与渲染的可变数据需要稳定身份ref保存由开发者手动同步的可变值

第三,候选人能否识别伪修复。useCallback(fn, []) 只能稳定函数身份,也会保留第一次渲染 的闭包。禁用 exhaustive-deps 只是隐藏证据。每次计数变化都重建 WebSocket 虽然能拿到 新值,却定义了错误的资源生命周期。

最后,候选人是否覆盖清理与异步顺序。回调读到最新值,不代表旧请求已取消,也不能阻止 较晚返回的旧响应覆盖新结果,更不能自动移除泄漏的事件监听器。这些是独立的并发与生命周期 责任。

回答前需要澄清的问题

  • 哪些值决定订阅身份? 如果 tenantId 决定服务端事件流,变化时必须重连;本地显示

筛选通常不需要重连。

  • 筛选条件变化后是否要重新处理历史事件? 这决定筛选条件只用于回调,还是也要发起

新查询或新订阅。

  • 回调是否从旧状态推导新状态? 计数加一应使用函数式更新,即使回调中的其他读取都是

最新值。

  • 线上使用哪个 React 版本? useEffectEvent 从 React 19.2 起可用。旧版本可把谨慎同步

的 ref 作为兼容方案。

  • 外部 API 能否只替换监听器而不重连? 有些库把 subscribe 与 updateHandler 分开,

可以采用更小的生命周期边界。

  • 回调是否会启动异步请求? 如果会,除了修复捕获值,还要定义取消或请求代次校验。
  • 开发环境是否启用 Strict Mode? 额外的一次建立和清理可以暴露清理缺失,但不会制造

陈旧闭包。

30 秒回答框架

“React 回调读取的是创建它那次渲染中的 props 和 state 快照。这里的 Effect 只运行一次, 所以连接一直持有首个租户、筛选条件和计数。我会让订阅依赖 tenantId,因为它决定连接哪个 外部资源;计数使用 setCount(current => current + 1),因为新值由旧值推导。对于订单回调, React 19.2 可以用 useEffectEvent 读取最新筛选条件,而不因筛选变化重连。我不会禁用依赖 检查,也不会把 useCallback([]) 当成新鲜度修复。ref 可作为不参与渲染数据的手动兼容方案; 异步结果仍需取消或版本校验。”

分步骤深入解答

第一步:把每次渲染视为不可变快照。

假设第一次渲染时的值是:

text
tenantId = "tenant-a"
filter = "all"
count = 0

Effect 创建连接,并创建引用这些局部变量的回调。之后执行 setFilter("paid") 会生成另一次 渲染和一个新的 filter 变量,并不会修改原回调捕获的变量。空依赖数组声明 Effect 永远不需 再次同步,于是外部连接一直持有最初的回调。

不需要猜测,就能推导出三个故障:

  1. matches(order, filter) 一直使用 "all"。
  2. 每个匹配事件都执行 setCount(0 + 1),连续事件只是反复把状态设为 1,无法累加。
  3. props 变化后,连接仍属于 "tenant-a"。

调试时可以同时记录渲染序号、渲染期间看到的值、回调中看到的值,并单独记录连接 ID。这样能 分清是回调过期、订阅没有重建,还是服务端发送了错误数据。日志不要包含订单隐私内容。

第二步:修改依赖项前,先按语义给每个读取分类。

逐一询问 Effect 为什么读取这些值:

  • tenantId 决定同步哪条外部事件流,具有响应性,应该放进依赖项。
  • count 只是用于计算下一个计数。改成函数式更新后,不再需要捕获它。
  • filter 决定未来事件如何处理,但变化时不应断开租户连接。监听器需要读取最新值,却不应

让订阅对筛选条件产生响应。

这一步可以避免两个极端:一个是永不更新的空依赖数组,另一个是任何渲染相关值变化都重启 昂贵资源的依赖数组。

第三步:用函数式更新修复状态转换。

把:

tsx
setCount(count + 1)

改成:

tsx
setCount((current) => current + 1)

React 会把更新函数加入队列,并在计算下一次渲染时传入当时的待处理状态。因此,即使回调很早 就注册,或 React 对更新进行了批处理,十个事件也会组合成十次递增。这个改动只消除了对 count 的捕获,不能让 filter 或 tenantId 自动变新。

第四步:把真正需要重新同步资源的值声明为依赖项。

连接身份包含 tenantId,所以 Effect 必须依赖它。租户变化时,React 先执行上一个 Effect 的 清理,再建立新连接。清理必须移除监听器或关闭旧连接,避免旧租户的事件更新当前页面。

把 filter 和 count 也加进去,确实能让每次新回调读取当前值,但同时改变了连接生命周期。 一次计数更新就会断开再连接,可能丢失事件、在重叠清理期间产生重复事件、重置服务端游标,并 制造额外负载。依赖数组是行为规范,不是为了消除 lint 报警而随意调整的性能提示。

第五步:在 React 19.2 中,用 Effect Event 读取最新的非响应式值。

修复后的组件可以分开连接生命周期与回调新鲜度:

tsx
function LiveOrders({ tenantId }: { tenantId: string }) {
  const [filter, setFilter] = useState("all")
  const [count, setCount] = useState(0)

  const onOrder = useEffectEvent((order: Order) => {
    if (matches(order, filter)) {
      setCount((current) => current + 1)
    }
  })

  useEffect(() => {
    const connection = connect(tenantId)
    connection.on("order", onOrder)

    return () => connection.close()
  }, [tenantId])

  // 省略渲染与控件。
}

onOrder 始终读取最新一次已提交的 filter,Effect 仍只在 tenantId 变化时重新同步。 Effect Event 应由 Effect 或与该 Effect 相连的代码调用,例如它注册的监听器;它不是普通事件 处理器的通用替代品,不应在组件树中任意传递,也不能用来掩盖本来就应该重新同步资源的值。

不要把 Effect Event 加进依赖数组。当前 React 的 lint 规则理解它的非响应式用途。如果 lint 报告其他值,应把警告视为设计反馈并调整代码结构,而不是禁用规则。

第六步:理解 ref 的适用边界与成本。

在 React 19.2 之前,常见兼容做法是把最新值放进 ref:

tsx
const filterRef = useRef(filter)

useEffect(() => {
  filterRef.current = filter
}, [filter])

useEffect(() => {
  const connection = connect(tenantId)
  connection.on("order", (order) => {
    if (matches(order, filterRef.current)) {
      setCount((current) => current + 1)
    }
  })

  return () => connection.close()
}, [tenantId])

ref 对象在多次渲染之间保持稳定,修改 current 不会触发渲染。因此,ref 适合保存最新处理器、 计时器 ID 或外部 API 句柄等不直接展示的命令式值。代价是需要手动同步:依赖检查器无法证明 filterRef.current 一定正确更新,漏掉同步 Effect 就会悄悄恢复旧值问题。不要为了减少渲染而 把页面展示状态移进 ref;除文档规定的初始化模式外,也不要在渲染期间读写 ref。

第七步:让 useCallback 和 memo 只承担身份职责。

useCallback 会缓存函数身份,直到某个依赖项变化。它适用于 memo 子组件或外部 API 关心 函数身份的场景,但不会自动提供最新值:

tsx
const onOrder = useCallback((order: Order) => {
  if (matches(order, filter)) {
    setCount((current) => current + 1)
  }
}, [])

这个版本仍捕获初始筛选条件。加上 [filter] 会生成新函数,但外部连接还必须正确替换监听器。 如果移除监听器要求同一个函数身份,那么每次 Effect 的建立和清理必须使用那次渲染中的同一 回调实例。memo 解决身份问题,不解决同步生命周期问题。

第八步:单独解决异步结果顺序。

假设最新筛选条件会发起请求。旧的 "all" 请求可能比新的 "paid" 请求更晚返回,并覆盖 新结果。即使回调读取最新筛选条件,也不能阻止旧请求完成。API 支持取消时,应在 Effect 清理 中使用 AbortController 终止过期工作;否则给每次请求分配单调递增的代次,只允许当前代次 写入状态。

清理还必须保持对称:

  • 关闭本次建立创建的那个连接;
  • 移除本次建立注册的那个监听器;
  • 清除 interval 和 timeout;
  • 取消或作废尚未完成的异步工作;
  • 让清理后迟到的回调不会产生副作用。

Strict Mode 在开发环境执行的额外“建立—清理—建立”循环正好可以提供证据。出现重复连接, 说明清理不完整;这不表示 Strict Mode 制造了陈旧闭包。

第九步:每次只改变一个维度进行验证。

测试预期结果
React 再次渲染前连续发送三笔匹配订单计数增加三次
修改 filter 后发送一笔订单按新筛选条件处理,连接不重建
修改 tenantId旧连接关闭一次,新租户连接一次
清理后从旧连接发送事件当前页面不变化
两个请求按相反顺序返回只有最新请求可以写入
开发环境使用 Strict Mode建立与清理对称,不残留重复监听器
重新启用 Hook 依赖 lint 规则不存在被禁用或无法解释的依赖警告

线上可以监控连接建立/清理次数、活动连接 ID、租户切换、迟到事件丢弃数和取消请求数。不要 为了排查回调新鲜度而记录订单隐私字段。

高质量示范回答

“我会先从 React 的渲染模型解释。每次渲染都有一份 props 和 state 快照,本次渲染创建的 函数会闭包捕获这份快照。这个 Effect 使用空依赖数组,所以外部连接一直持有第一次渲染创建 的回调,自然会看到首个租户、筛选条件和计数。

修改依赖项之前,我会先按用途给每个值分类。tenantId 决定连接哪个外部资源,应该进入依赖 数组;它变化时必须关闭旧连接并打开新连接。count 只用于推导下一个状态,所以应改为 setCount(current => current + 1),让连续事件基于 React 队列中的状态累加。订单监听器需要 读取最新 filter,但筛选变化不应重建连接。当前应用使用 React 19.2,因此我会把筛选逻辑 放进 useEffectEvent,再由只依赖 tenantId 的 Effect 注册这个 Effect Event。

我不会禁用 exhaustive-deps,因为依赖数组描述同步关系;也不会用 useCallback([]) 修复 新鲜度,因为它仍保留初始闭包。旧版 React 可以用由 filter 同步的 ref 作为兼容方案,但 这个做法依赖人工维护,依赖分析也看不见它。

最后,我会验证连续事件累加、筛选变化不重连、租户变化恰好执行一次清理和一次建立、旧连接 迟到事件无效,以及开发环境 Strict Mode。若回调会发起请求,还要取消过期请求或校验代次, 因为闭包读到最新值并不能解决乱序返回。”

常见错误

  • 把所有闭包都称为缺陷 → 回调捕获渲染快照是预期行为 → **定位“被长期持有的回调”与

“预期同步关系”之间的矛盾。**

  • 禁用 exhaustive-deps → 代码一边读取响应式值,一边声称它们永远不影响同步 →

调整代码,让依赖数组如实描述 Effect。

  • 把所有 state 都加入依赖项 → 每次更新都通过重建昂贵订阅获得新值 → **拆开资源身份与

回调的最新值读取。**

  • 使用 useCallback(fn, []) → 稳定身份也冻结了第一次闭包 → **给 useCallback 正确

依赖,或选择符合目标语义的工具。**

  • 用 ref 取代 state → ref 写入不会触发渲染,可能与可见界面分离 → **展示数据留在 state,

ref 只保存命令式可变数据。**

  • 用 useEffectEvent 隐藏真实依赖 → 外部资源该变化时,Effect 不再响应 → **决定资源

身份的值仍必须进入依赖项。**

  • 修复筛选条件,却保留 setCount(count + 1) → 连续事件仍从捕获的计数覆盖状态 →

从旧状态推导新状态时使用函数式更新。

  • 忽略清理和请求顺序 → 泄漏监听器与迟到响应仍会破坏页面 → **按生命周期移除、关闭、

取消或校验每项操作。**

追问及应对

追问 1:为什么点击处理器里的 setTimeout 会读取旧 state?

timeout 回调属于发生点击的那次渲染。更新 state 会安排新渲染,不会修改该处理器中的局部 state 变量。如果延迟动作本来就要报告点击时的值,这份快照是正确的;如果必须读取最新提交 值,应在 Effect 相关代码中明确使用 Effect Event,或为命令式回调谨慎维护 ref。

追问 2:为什么不直接把 filter 放进 Effect 依赖项?

如果筛选条件变化就应该重新同步外部系统,这样做是正确的。例如服务端订阅本来就按筛选条件 建立,就应加入依赖并重连。本题假设租户事件流不变,筛选只是本地处理策略,因此重连会定义 错误的生命周期。回答前应先说明产品与 API 假设。

追问 3:useEffectEvent 会取代普通事件处理器吗?

不会。按钮点击通常由渲染期间创建的事件处理器处理。Effect Event 用于由 Effect 或与它相连 的代码触发的非响应式逻辑,例如定时器或订阅监听器。它应留在使用它的 Effect 附近,不能变成 组件间传递回调的通用方式。

追问 4:函数式更新能解决所有陈旧闭包吗?

不能。它只解决“某个 state 的新值由该 state 的旧值推导”这一类状态转换,无法让 prop、其他 state 或外部配置自动变新。本题中它解决计数累加,不能修复租户连接或筛选读取。

追问 5:什么时候应该用 ref 而不是 state?

一个值需要跨渲染保留、变化时不应刷新界面,并且只以命令式方式使用时,可以用 ref,例如 计时器 ID、DOM 节点、连接句柄或兼容性的最新值容器。渲染结果依赖的值应使用 state。ref 把 新鲜度责任交给开发者,因此必须记录并测试同步位置。

追问 6:Strict Mode 如何帮助排查这个问题?

开发环境的 Strict Mode 会为 Effect 额外执行一次建立和清理。如果最终留下两组监听器或连接, 说明清理不完整,或清理使用了错误的回调身份。清理正确时,额外循环后只应留下一个活动订阅。 无论是否启用 Strict Mode,旧快照都遵循正常的 JavaScript 闭包语义。

追问 7:如果事件恰好在旧连接清理和新连接建立之间到达怎么办?

需要明确外部系统的投递保证。客户端可能需要可恢复游标、序列号、重放窗口,或“先取快照再接 事件流”的协议。React 依赖项只能管理本地生命周期,不能保证服务端重连期间零丢失;这是独立 的协议需求,也应单独验证。

追问 8:如何避免旧 fetch 覆盖新结果?

API 支持取消时,在 Effect 清理中终止过期请求;否则为每个请求分配代次,只有完成时仍属于 当前代次的请求才能更新 state。只读取最新值的 ref 不会取消网络任务,不能当成顺序保证。

公开来源

同类题目