142 lines
5.7 KiB
Markdown
142 lines
5.7 KiB
Markdown
# 实现方案 — 2026-04-16-19-28-04
|
||
|
||
## 根因分析
|
||
|
||
当前 `ReportEditor.tsx` 的自动保存机制过度依赖两个 `useRef`(`stateRef` 和 `contentRef`)作为"数据快照":
|
||
|
||
1. **用户操作时**:各事件处理器先更新 React state,再手动同步 `stateRef.current`,然后调用 `saveDraftToStorage()` 写入 localStorage。
|
||
2. **组件卸载时**:`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):**
|
||
```tsx
|
||
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 读取):**
|
||
```tsx
|
||
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
|
||
|
||
**当前实现:**
|
||
```tsx
|
||
useEffect(() => {
|
||
const save = () => { ... };
|
||
window.addEventListener('beforeunload', save);
|
||
document.addEventListener('visibilitychange', save);
|
||
return () => {
|
||
window.removeEventListener('beforeunload', save);
|
||
document.removeEventListener('visibilitychange', save);
|
||
save();
|
||
};
|
||
}, [reportId]);
|
||
```
|
||
|
||
**修改为:**
|
||
```tsx
|
||
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` 安全网添加 `[]` 依赖
|
||
|
||
**当前:**
|
||
```tsx
|
||
React.useLayoutEffect(() => {
|
||
if (contentLoadedRef.current || !editorRef.current) return;
|
||
// ... 恢复逻辑 ...
|
||
});
|
||
```
|
||
|
||
**修改为:**
|
||
```tsx
|
||
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` 回滚。
|
||
|
||
---
|
||
|
||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|