字幕文本刚改完,输入框还没有失焦,用户就点击了导出。此时页面显示的是新文本,服务端保存的可能还是上一版。导出函数本身即使正确,也可能拿到旧内容。
Vimorph 的字幕编辑已经使用防抖、保存队列和操作前等待。本文以这条流程为背景,做一个独立的 React + TypeScript 示例:两个手写文本片段、本地内存保存接口、自动保存和 TXT 导出。它集中演示同一页面里的保存协调,不调用真实转写服务;示例新增的状态处理与导出锁也不等于产品已经具备相同保护。
防抖与保存顺序分开处理
连续输入时,每个字符都发一次请求没有必要。防抖让用户停止输入一段时间后再开始保存,但它没有约束已经发出的请求。
例如请求 A 正在保存旧文本,用户又编辑出 B。如果两次写入并行,后端可能先完成 B,再完成 A。即使前端忽略 A 的响应,后端数据仍可能回到旧文本。因此,控制页面状态和协调写入顺序需要分开考虑。
当前 Vimorph 原文行使用 800ms 防抖,失焦或修改说话人也会触发保存;译文主要在失焦或统一 flush 时提交。800ms 是当前实现选择,并不是所有编辑器都适用的最佳值。
保存队列只安排当前页面经它提交的操作。假设 A 在客户端超时,队列随后发出 B,服务端却仍在处理 A:B 先写入、A 后写入,旧内容依然可能覆盖新内容。
真实接入可以为每次编辑维护本地版本,并在保存请求中携带服务端的预期版本。服务端将"检查预期版本、写入文本、推进版本"放在同一次原子条件更新中;版本不匹配就返回冲突,由客户端提示用户或进入明确的重新加载流程。请求重试也要约定幂等语义,避免超时重发被当成另一笔更新。这是接入建议,本文没有实现这套服务端机制;数据库更新的条件匹配可参考 MySQL UPDATE 文档。
防抖降低提交频率,队列协调本地写入顺序,服务端版本机制保障跨请求的数据一致性,三者各有职责。
区分草稿、已确认内容与保存中的请求
示例按行保留三类信息:
| 状态 | 含义 | 什么时候更新 |
|---|---|---|
| draft | 输入框当前内容 | 每次编辑 |
| saved | 最近一次成功保存的内容 | 保存函数成功返回后 |
| inFlight | 这一行正在进行的提交流程 | 从 commit 开始,到该轮流程结束 |
判断是否还有草稿,看 draft !== saved;不能只看有没有请求在执行。防抖等待期间,输入已经变化,保存流程可能还没有开始。
保存 A 期间输入变成 B,A 成功后只能将 saved 更新为 A,不能直接写成当时的 draft。接着再次比较两者,仍不相同就保存 B。还有一个容易忽略的情况:用户在 A 请求期间改回原来的内容。等 A 成功后,这次回改依然需要提交,不能因它曾等于旧 saved 就丢掉。

图中的 A、B 是内容快照,不是性能测试数据。防抖负责启动一轮保存;该轮进行期间出现的新草稿,在前一次成功后继续处理,不额外等待一次完整防抖周期。
用 Promise 队列串行保存
下面是完整的 src/autosave-controller.ts,与配套工程一致。persist(id, text) 是注入的保存函数:本例写入内存,真实项目可以换成接口调用。控制器接收固定片段集合,不在保存期间拆分或替换片段身份。
typescript
export type Segment = { id: string; text: string };
export type EditorState = {
rows: { id: string; draft: string; saved: string; saving: boolean; error: string | null }[];
pending: number;
};
type Row = {
id: string; draft: string; saved: string; error: string | null;
timer?: ReturnType<typeof setTimeout>; inFlight?: Promise<void>;
};
export function createAutosaveController(
initial: Segment[],
persist: (id: string, text: string) => Promise<void>,
{ debounceMs = 800 }: { debounceMs?: number } = {},
) {
if (!Array.isArray(initial) || !initial.length) throw new Error("initial 必须是非空数组");
if (typeof persist !== "function") throw new Error("persist 必须是函数");
if (!Number.isFinite(debounceMs) || debounceMs < 0) throw new Error("防抖时间必须是非负有限数值");
for (const item of initial) {
if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id.trim()
|| typeof item.text !== "string") throw new Error("每个片段需要非空字符串 id 和字符串 text");
}
const rows = initial.map(({ id, text }): Row => ({ id, draft: text, saved: text, error: null }));
if (new Set(rows.map(row => row.id)).size !== rows.length) throw new Error("片段 id 不能重复");
const listeners = new Set<(state: EditorState) => void>();
let tail: Promise<unknown> = Promise.resolve();
let disposed = false;
const released = () => new Error("编辑器已释放");
const find = (id: string) => {
const row = rows.find(item => item.id === id);
if (!row) throw new Error("片段不存在");
return row;
};
const clearTimer = (row: Row) => {
if (row.timer !== undefined) clearTimeout(row.timer);
row.timer = undefined;
};
const getState = (): EditorState => ({
rows: rows.map(({ id, draft, saved, error, inFlight }) => ({ id, draft, saved, error, saving: !!inFlight })),
pending: rows.filter(row => row.inFlight).length,
});
const notify = () => {
if (disposed) return;
for (const listener of [...listeners]) {
try { listener(getState()); }
catch (error) { console.error("示例订阅回调异常;保存结果不受影响", error); }
}
};
function commit(id: string): Promise<void> {
if (disposed) return Promise.reject(released());
const row = find(id);
clearTimer(row);
if (row.inFlight) return row.inFlight;
if (row.draft === row.saved) return Promise.resolve();
const job = Promise.resolve().then(async () => {
if (disposed) throw released();
while (!disposed && row.draft !== row.saved) {
const target = row.draft;
const operation = tail.catch(() => {}).then(() => {
if (disposed) throw released();
const result = persist(row.id, target);
if (!result || typeof result.then !== "function") throw new Error("persist 必须返回 Promise");
return result;
});
tail = operation;
await operation;
if (disposed) throw released();
row.saved = target;
row.error = null;
notify();
}
}).catch(error => {
clearTimer(row);
if (!disposed) {
row.error = error instanceof Error ? error.message : String(error);
notify();
}
throw error;
});
row.inFlight = job.finally(() => {
row.inFlight = undefined;
notify();
});
notify();
return row.inFlight;
}
return {
getState,
subscribe(listener: (state: EditorState) => void) {
if (disposed) throw released();
listeners.add(listener);
return () => { listeners.delete(listener); };
},
edit(id: string, text: string) {
if (disposed) throw released();
const row = find(id);
row.draft = text;
row.error = null;
clearTimer(row);
row.timer = setTimeout(() => { void commit(id).catch(() => {}); }, debounceMs);
notify();
},
commit,
async flush(): Promise<Segment[]> {
if (disposed) throw released();
do {
await Promise.all(rows.map(row => commit(row.id)));
if (disposed) throw released();
} while (rows.some(row => row.inFlight || row.draft !== row.saved));
return rows.map(({ id, saved }) => ({ id, text: saved }));
},
dispose() {
disposed = true;
rows.forEach(clearTimer);
listeners.clear();
// 不强行中止已经开始的 persist;忽略其晚到结果并阻止后续任务。
},
};
}
export type AutosaveController = ReturnType<typeof createAutosaveController>;
tail.catch(() => {}).then(...) 将下一次写入接到前一次操作之后。前一项失败不永久堵住队列,当前 commit 仍会把自己的错误交给调用方。
每行复用一个 inFlight Promise,所以失焦、定时器和导出前 flush 同时触发时,会等待同一轮提交。全局 tail 则串行执行不同片段的 persist;它不是数据库锁,也不协调别的页面。
flush() 触发各行 commit,再等待结果。如果成功后仍有新草稿,就继续检查;全部满足约定后返回 saved 快照。任意一行失败都会拒绝这次 flush,但不会撤销其他行已经成功的保存。调用方因此停止导出,失败草稿和错误继续保留,没有自动无限重试。
某个片段保存失败时,导出会停止,但不代表其他已经开始的保存请求也同时结束。重新导出前需要再次确认所有片段状态。比如 A 失败、B 仍在途时立即重导,新的 flush 会复用 B 的 inFlight,等待 B 并重试 A;只有全部确认成功才生成 TXT。
示例中的 pending 是活动提交行数,包括等待队列的行,不是网络请求数,也不是未保存草稿数。页面需要同时看 draft、saved 和 error,才能给出清楚的状态。
在 React 中创建和清理控制器
控制器通过 ref 保存,输入变化时调用 edit,失焦时调用 commit;订阅把最新状态交给 React 渲染。下面是 src/main.tsx 的 Effect 节选,省略组件状态声明和界面。initial 是两个固定片段组成的 Segment\[\],controller 是 useRef<AutosaveController | null>(null);slowRef 和 failNext 控制模拟慢请求与下一次失败,releaseRef 保存手动完成请求的回调,urlRef 保存当前下载地址。setView、setLogs 等是组件的状态更新函数。
tsx
useEffect(() => {
const stored = new Map(initial.map(({ id, text }) => [id, text]));
const isCurrent = () => controller.current === instance;
const log = (text: string) => { if (isCurrent()) setLogs(items => [...items.slice(-29), text]); };
const instance = createAutosaveController(initial, async (id, text) => {
const fail = failNext.current;
failNext.current = false;
if (isCurrent()) setFailureArmed(false);
log(`开始保存 ${id}:${text}`);
if (slowRef.current) {
await new Promise<void>(resolve => {
releaseRef.current = { owner: instance, release: resolve };
if (isCurrent()) setWaiting(true);
});
if (releaseRef.current?.owner === instance) releaseRef.current = null;
if (isCurrent()) setWaiting(false);
} else await Promise.resolve();
if (fail) { log(`保存失败 ${id}(本地测试注入)`); throw new Error("本地 Fake 保存失败,请手动重试"); }
stored.set(id, text); // 本页内存写入,不是 HTTP 请求或后端持久化。
log(`保存成功 ${id}:${stored.get(id)}`);
});
controller.current = instance;
const unsubscribe = instance.subscribe(setView);
setView(instance.getState());
return () => {
unsubscribe();
instance.dispose();
if (controller.current === instance) controller.current = null;
if (releaseRef.current?.owner === instance) {
releaseRef.current.release(); // 释放本地测试门闩,让已开始的 Fake Promise 结束。
releaseRef.current = null;
}
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
};
}, []);
Effect 每次 setup 都新建实例,cleanup 解除订阅并 dispose,不能重新使用已经释放的控制器。这也对应 React 官方文档说明的开发环境 Strict Mode 检查。
dispose 会清理防抖计时器、停止新任务和后续通知。已经开始的 persist 仍可能完成;这里只忽略旧实例的晚到结果,不声称取消了服务端写入。
导出前先等待保存
按钮的 loading 状态主要用于显示。为了在第一个 await 之前挡住重复点击,示例用 ref 保存同步锁;src/export-lock.ts 的完整实现如下:
typescript
export async function withExportLock<T>(
lock: { current: boolean },
operation: () => Promise<T>,
): Promise<T | undefined> {
if (lock.current) return undefined;
lock.current = true; // 首个 await 之前同步设置,可传入 React useRef。
try { return await operation(); }
finally { lock.current = false; }
}
下载处理的 src/main.tsx 节选如下。helper 的 lock 参数对应组件里的 exportingRef,即 useRef(false),锁在导出流程结束时释放。current 是本次操作捕获的控制器;clearOutput 释放旧地址并清空旧结果,setExporting、setExportError、setOutput 更新界面。实际代码会在锁持有期间禁止编辑,并在实例释放后跳过结果生成。
tsx
async function exportText() {
const current = controller.current;
if (!current) return;
try {
await withExportLock(exportingRef, async () => {
setExporting(true);
clearOutput();
setExportError("");
try {
const snapshot = await current.flush();
if (controller.current !== current) return;
const text = snapshot.map(segment => segment.text).join("\n\n") + "\n";
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
urlRef.current = url;
setOutput({ url, text });
setLogs(items => [...items.slice(-29), "flush 成功:生成已确认快照 TXT"]);
} finally {
if (controller.current === current) setExporting(false);
}
});
} catch (error) {
if (controller.current === current) setExportError(error instanceof Error ? error.message : String(error));
}
}
输入入口也必须遵守同一把锁。下面是同一组件中 textarea 的节选;row 和 position 分别来自 view.rows.map 的当前片段状态和位置索引,其他 ref 和函数沿用上文。disabled={exporting} 负责界面,onChange 与 onBlur 直接检查同步 ref,避免状态尚未渲染时继续改草稿。
tsx
<textarea aria-label={`片段 ${position + 1} 草稿`} disabled={exporting} value={row.draft} onChange={event => {
if (exportingRef.current) return;
clearOutput(); setExportError(""); controller.current?.edit(row.id, event.target.value);
}} onBlur={() => { if (!exportingRef.current) void controller.current?.commit(row.id).catch(() => {}); }} />
关键是先 await flush,再用它返回的已确认快照生成 TXT。锁在 operation 执行以及第一个 await 之前同步置为 true,成功、拒绝或同步异常都会经过 finally 释放;setExporting(true) 只负责界面提示。flush 本身允许继续编辑,本页由输入入口禁止导出期间的修改,明确快照边界。失败时保留草稿并提示,不生成文件;释放后可以再次导出。当前产品的 flush 本身不锁输入。
flush 返回后还要检查控制器实例是否仍是当前实例,防止旧任务在卸载后生成下载文件。Blob URL 在替换结果和卸载时释放。保存请求、控制器与下载结果各有自己的生命周期,释放控制器并不等于撤销已经生成的下载地址。
运行完整示例
完整示例工程包含 React 页面、控制器、本地模拟接口、测试和构建配置。解压后运行:
text
cd autosave-demo
npm install
npm test
npm run build
npm run dev
本例要求 Node.js 22.12.0 或更高;依赖锁定为 React 19.2.7、TypeScript 5.9.3、Vite 8.2.1、Vitest 4.1.10。npm test 运行受控时序测试,npm run build 完成类型检查和构建。
打开页面后,可以暂停模拟保存的响应,在请求期间继续编辑,观察后续提交顺序;也可以设置下一次保存失败,再重试或点击导出。本地保存接口不使用网络,不把这类故障注入当作真实服务器验收。

实际验证与接入范围
现有 Vimorph 保存队列的 2 项纯函数测试于 2026 年 10 月 14 日通过。独立示例于 10 月 15 日在 macOS arm64、Node.js 26.7.0 下运行,34 项测试、TypeScript 检查与 Vite 构建通过。桌面 Chromium 的 11 项实际页面检查覆盖连续输入、慢请求中再编辑和回改、跨行队列、失败重导、重复导出、下载地址替换,并核对了实际 TXT 下载。导出刚开始和 flush 等待时,用原生输入事件、临时绕过 disabled 属性,单独检查同步 ref 保护;还实际卸载组件,检查等待中的导出不生成文件、已有 Blob URL 被释放。另有 390px 桌面视口的无整页横向溢出检查,不代表移动端兼容性。截图从这次运行的 main 元素截取,只去掉页面外围空白。
单元测试用可控 Promise 和假定时器安排先后顺序,重点检查连续输入、保存中再次编辑、回改旧内容、重复 commit、失败重试、flush 与 dispose;新增 A 失败而 B 仍在途的两种重试顺序,以及同步异常和晚到拒绝后的导出解锁。浏览器再检查真实 React 页面与下载内容。两类验证分别记录,不用模拟请求的测试证明业务系统端到端正确。
本次复用了已安装依赖,没有从空环境验证 npm install。Safari、移动设备、真实网络断连、任意时点的卸载、关闭浏览器后的持久化、多标签页、多设备及服务端版本机制、Vimorph 业务端到端没有验证。平台上传后的图片和移动端文章预览也仍需人工确认。
接入现有编辑器时,还要明确片段筛选、拆分和任务切换如何处理草稿。当前业务的 drain 只等待调用时的队列尾项,Provider.flush 处理已登记处理器的快照,不是跨页面或跨设备的事务。这篇示例保留固定编辑上下文,用来把保存流程的职责说明清楚。
把保存和后续操作连接起来
防抖决定何时开始提交,队列协调当前实例的写入顺序,草稿状态决定是否还有内容待确认。导出、切换等操作需要显式等待保存结果,而不是只等待计时器结束或检查按钮有没有转圈。
这个流程的核心是明确"当前编辑到哪一版、已经确认到哪一版"。遇到失败,保留草稿并让调用方决定重试或停下,后续操作才有可靠的输入。
关于 Vimorph
本文的工程背景来自 Vimorph AI 音视频创作平台的字幕编辑模块。后续会持续分享音视频创作工具中的前端交互、异步任务与工程实践。
项目官网:Vimorph