自动保存 + 关 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 这件事。
只要 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两处修复同时落地:
buildFlowPatchRef.current()每次调用时才解引用,读到的永远是当前 statesnapshot === lastSavedSnapshotRef.current去重,防止自动保存刚发完、unload 又发一条相同内容的冗余请求
把"需要每轮更新"的函数/值塞进 ref,unload handler 通过 ref.current() 取用。deps 不变照样没问题——deps 控制"监听器注册几次",ref 控制"调用时读哪个版本",两者正交。
验证方法
修复后复现步骤:
- 打开编辑器,添加一条新项
- 等自动保存指示器出现("已自动保存 ✓")
- 立刻关 Tab(不要停顿)
- 重开页面,新项应该还在
- DevTools Network:关 Tab 时最多出现一条 PATCH,或两条 body 完全一致(dedup 生效时第二条会被 early return 拦住)
若仍看到旧数据:搜索源码里所有注册 window / document 监听器的 useEffect,确认它们读值走的是 ref 而非闭包变量。
一句话外卖
凡是在
useEffect里注册了事件监听器、且 deps 不随 state 变化——调用时必须通过ref.current读值,否则永远是挂载快照。