返回博客

自动保存 + 关 Tab 时旧闭包把新内容覆盖回去了

做一个带自动保存的编辑器,用户加了一条步骤,左下角也出现了"已自动保存 ✓",关掉标签页再重开——步骤消失了。第一反应是网络问题或 PATCH 失败,但服务器日志显示那次请求是 200。

打开 DevTools Network 慢放复现,发现关 Tab 前后有两条 PATCH 打到同一个接口,间隔不到 4 秒,两者都返回 200,但第二条的 body 比第一条少了一个字段。数据库以最后写入为准,留下了旧版本。

现象

  • 用户编辑内容 → 自动保存 debounce 触发,PATCH 发出,含最新状态(14 项)
  • 用户立即关 Tab → beforeunload 触发 flush,又发一条 PATCH,含旧状态(13 项)
  • 两条请求都 200,服务端 last-write-wins,最终落库 13 项

服务端日志片段(已脱敏):

21:17:29 PATCH /api/flow/xxx  body: stepTypes count=14
21:17:33 PATCH /api/flow/xxx  body: stepTypes count=13

根因

自动保存和关闭刷新分别用了两个 useEffect,关键区别在于依赖数组

// 自动保存:deps 包含状态,每次编辑都重新注册 → 闭包始终是最新的
useEffect(() => {
  const timer = setTimeout(() => persistFlow(), 220)
  return () => clearTimeout(timer)
}, [name, subflows, ...])   // 每次 subflows 变化都重建
 
// flush on close:deps 只有 flow.id,整个 session 只注册一次
useEffect(() => {
  function flushOnUnload() {
    const patch = buildFlowPatch()  // ← 这里的 buildFlowPatch 是首次渲染时的版本
    fetch('/api/flow/xxx', { method: 'PATCH', body: JSON.stringify(patch), keepalive: true })
  }
  window.addEventListener('beforeunload', flushOnUnload)
  return () => window.removeEventListener('beforeunload', flushOnUnload)
}, [flow.id])   // flow.id 不变 → 永远不重新注册

useEffect(fn, [deps]) 只在 deps 变化时才重新执行 fn,所以 flushOnUnload 里捕获的 buildFlowPatch挂载那一刻的闭包,读到的 subflows 是初始 SSR 数据(13 项)。之后用户加的那一项只存在于 React state 里,这个闭包完全不知道。

fetch(..., { keepalive: true }) 保证了请求在 unload 期间能发出去,却不能修复它携带的是旧 body 这件事。

坑:unload handler 的 useEffect deps 不变 = 闭包永远停在挂载时刻

只要 useEffect 的依赖数组里不包含 state,注册进去的事件监听器就永远读首次渲染的值。beforeunload / pagehide / visibilitychange 这类"生命周期末端"事件尤其危险——它们触发时 React 已经不再 re-render,没有机会修复闭包了。

正解

ref-latest 模式:保持一个 ref 指向最新的 buildFlowPatch 函数,unload 监听器从 ref 读,而不是从闭包读。

// 每次渲染都把最新版本写进 ref(无 deps = 每轮都跑)
const buildFlowPatchRef = useRef(buildFlowPatch)
useEffect(() => {
  buildFlowPatchRef.current = buildFlowPatch
})
 
// unload 监听器只注册一次,但通过 ref 读最新闭包
useEffect(() => {
  function flushOnUnload() {
    const patch = buildFlowPatchRef.current()   // ← 每次调用都是最新状态
    const snapshot = JSON.stringify(patch)
    // 与上一次自动保存的 snapshot 对比,相同则跳过,防止冗余写入
    if (snapshot === lastSavedSnapshotRef.current) return
    fetch(url, { method: 'PATCH', body: snapshot, keepalive: true }).catch(() => {})
    lastSavedSnapshotRef.current = snapshot
  }
  window.addEventListener('beforeunload', flushOnUnload)
  window.addEventListener('pagehide', flushOnUnload)
  document.addEventListener('visibilitychange',
    () => document.visibilityState === 'hidden' && flushOnUnload())
  return () => {
    window.removeEventListener('beforeunload', flushOnUnload)
    window.removeEventListener('pagehide', flushOnUnload)
    document.removeEventListener('visibilitychange', flushOnUnload)
  }
}, [flow.id])   // deps 不变也没问题,因为 handler 只读 ref

两处修复同时落地:

  1. buildFlowPatchRef.current() 每次调用时才解引用,读到的永远是当前 state
  2. snapshot === lastSavedSnapshotRef.current 去重,防止自动保存刚发完、unload 又发一条相同内容的冗余请求
正解:ref-latest 让 unload handler 每次调用都读最新闭包

把"需要每轮更新"的函数/值塞进 ref,unload handler 通过 ref.current() 取用。deps 不变照样没问题——deps 控制"监听器注册几次",ref 控制"调用时读哪个版本",两者正交。

验证方法

修复后复现步骤:

  1. 打开编辑器,添加一条新项
  2. 等自动保存指示器出现("已自动保存 ✓")
  3. 立刻关 Tab(不要停顿)
  4. 重开页面,新项应该还在
  5. DevTools Network:关 Tab 时最多出现一条 PATCH,或两条 body 完全一致(dedup 生效时第二条会被 early return 拦住)

若仍看到旧数据:搜索源码里所有注册 window / document 监听器的 useEffect,确认它们读值走的是 ref 而非闭包变量。

一句话外卖

凡是在 useEffect 里注册了事件监听器、且 deps 不随 state 变化——调用时必须通过 ref.current 读值,否则永远是挂载快照。