5.7 KiB
5.7 KiB
实现方案 — 2026-04-16-19-28-04
根因分析
当前 ReportEditor.tsx 的自动保存机制过度依赖两个 useRef(stateRef 和 contentRef)作为"数据快照":
- 用户操作时:各事件处理器先更新 React state,再手动同步
stateRef.current,然后调用saveDraftToStorage()写入 localStorage。 - 组件卸载时:
useEffect的 cleanup 调用save(),同样读取stateRef.current和contentRef.current来保存 draft。
这个机制存在致命缺陷:
- React 18
StrictMode在开发/预览环境下会"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,stateRef.current还是组件创建时的初始空值(videos: []、capturedFrames: []、reportData: 默认值)。cleanup 中的save()会用这个空值 覆盖 localStorage 里已经存在的正确 draft。 - 即使不在 StrictMode 下,只要任何恢复路径或用户操作路径遗漏了
stateRef.current的同步,卸载保存时就会丢失数据。
此前两次修复只把 stateRef.current 同步移到了更多恢复分支中,但没有从根本上消除对 stateRef 的依赖,因此问题依旧。
修改文件清单
| 文件 | 修改类型 | 说明 |
|---|---|---|
src/pages/ReportEditor.tsx |
修改 | 重构 saveDraftToStorage + 自动保存 effect + useLayoutEffect 依赖 |
具体代码变更
变更 1:重构 saveDraftToStorage
当前实现(依赖 ref):
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId]);
修改为(直接从最新 state 和 DOM 读取):
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: editorRef.current?.innerHTML || '',
draftReportId: reportId || null,
reportData,
videos,
capturedFrames,
activeTab,
loadedTemplateId
});
}
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]);
效果:
saveDraftToStorage的闭包永远绑定当前渲染周期的最新 state,不再需要stateRef作为中转。- 编辑器内容直接从
editorRef.current?.innerHTML读取,不再依赖可能滞后的contentRef。 - 用户操作中所有调用
saveDraftToStorage()的地方(表单 onChange、上传视频、截图等)会自动生效。
变更 2:重构自动保存 effect
当前实现:
useEffect(() => {
const save = () => { ... };
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', save);
return () => {
window.removeEventListener('beforeunload', save);
document.removeEventListener('visibilitychange', save);
save();
};
}, [reportId]);
修改为:
useEffect(() => {
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [saveDraftToStorage]);
效果:
- 由于
saveDraftToStorage的 dependency 包含了所有关键 state,每次 state 变化后 effect 都会重新注册,但最重要的是 cleanup 中调用的saveDraftToStorage永远指向最新的闭包,不会因为 ref 滞后而用空值覆盖 draft。
变更 3:给 useLayoutEffect 安全网添加 [] 依赖
当前:
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
});
修改为:
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
}, []);
效果:
- 避免在每次渲染后重复执行安全网,防止潜在的意外覆盖或性能损耗。
- 保留其原有功能:仅在组件挂载时,如果
useEffect初始化因 ref 未 ready 未能恢复内容,则作为兜底恢复编辑器 DOM。
变更 4(可选但建议):移除对 contentRef 的强依赖
contentRef 在旧代码中用于 draft 保存。修改 saveDraftToStorage 后直接读取 editorRef.current?.innerHTML,contentRef 仍可保留供其他旧代码使用,不影响功能。
风险点
| 风险 | 级别 | 应对措施 |
|---|---|---|
saveDraftToStorage dependency 数组较长,可能导致 effect 频繁重新注册 |
低 | 重新注册事件监听器的开销极小,远小于 localStorage 写入本身 |
editorRef.current?.innerHTML 在卸载时读取可能拿到不完整 DOM |
极低 | editorRef 指向的 DOM 在 cleanup 执行时尚未被 React 移除,内容完整 |
useLayoutEffect 添加 [] 后闭包值陈旧 |
低 | useLayoutEffect 内部仅读取 reportId(来自 URL,在生命周期内不变)和 localStorage,不受影响 |
回滚策略
本次修改仅重构保存函数的实现方式,不改变数据结构或存储 key。如出现异常,可直接 git revert 回滚。
⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。