Files
Mdeical_Sur_Report/工程分析/实现方案-2026-04-16-19-28-04.md

5.7 KiB
Raw Blame History

实现方案 — 2026-04-16-19-28-04

根因分析

当前 ReportEditor.tsx 的自动保存机制过度依赖两个 useRefstateRefcontentRef)作为"数据快照"

  1. 用户操作时:各事件处理器先更新 React state再手动同步 stateRef.current,然后调用 saveDraftToStorage() 写入 localStorage。
  2. 组件卸载时useEffect 的 cleanup 调用 save(),同样读取 stateRef.currentcontentRef.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。

变更 3useLayoutEffect 安全网添加 [] 依赖

当前:

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?.innerHTMLcontentRef 仍可保留供其他旧代码使用,不影响功能。

风险点

风险 级别 应对措施
saveDraftToStorage dependency 数组较长,可能导致 effect 频繁重新注册 重新注册事件监听器的开销极小,远小于 localStorage 写入本身
editorRef.current?.innerHTML 在卸载时读取可能拿到不完整 DOM 极低 editorRef 指向的 DOM 在 cleanup 执行时尚未被 React 移除,内容完整
useLayoutEffect 添加 [] 后闭包值陈旧 useLayoutEffect 内部仅读取 reportId(来自 URL在生命周期内不变和 localStorage不受影响

回滚策略

本次修改仅重构保存函数的实现方式,不改变数据结构或存储 key。如出现异常可直接 git revert 回滚。


⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。