React 自动保存怎么设计?防抖、串行请求与导出前等待

字幕文本刚改完,输入框还没有失焦,用户就点击了导出。此时页面显示的是新文本,服务端保存的可能还是上一版。导出函数本身即使正确,也可能拿到旧内容。

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

相关推荐
JavaGuide1 小时前
推荐一个好用 DeepSeek Harness 插件,直接把 Agent 上下文给可视化了!
前端·后端
开心就好20251 小时前
在 Xcode 中运行和调试单元测试的完整指南
后端·ios
橙子家1 小时前
Exception 简介以及都有哪些类型【C# 基础】
后端
flash俊杰1 小时前
agent 节点、人工授权竞速邮箱与四态 golden 评测
后端
三十而立洋1 小时前
TypeScript 7.0 深度解析:新特性、Go 原生编译器及与 TypeScript 6.0 的区别
前端·typescript·前端工程化
用户8356290780511 小时前
如何使用 Python 将 PDF 转换为 PPTX 文件
后端·python
King禅1 小时前
第 7 课:方法(函数)
后端
梦里寻码2 小时前
给 AI Agent 搭围栏:Monorepo 是被低估的 Agent 基础设施
前端·后端·github
高晶2 小时前
Android 电子词典开发实战指南
后端·程序员