富文本编辑器自动保存:将内容保存到服务器,避免编辑丢失

RichTextEditor,显示完整工具栏和可编辑文档

为 JavaScript 富文本编辑器添加服务器自动保存功能,避免写入请求重叠,也避免过早显示“Saved”。本指南提供经过测试的保存队列、HTTP 接口约定,以及慢速网络和冲突场景的检查清单。

浏览器本地草稿与服务器保存不同。如果要构建仅在本地保存内容的写作应用,请参阅Markdown 草稿与下载指南。本指南中的文档及其版本由服务器管理。

先定义保存接口约定

只有在确认已登录用户具有访问权限后,才能加载文档。返回文档的 HTML,并在 ETag 响应头中返回一个不透明的版本标识。每次更新时,客户端通过 If-Match 发送该值。服务器必须以原子操作比较版本,并同时更新 HTML 和版本。先读取再进行没有并发保护的写入并不够。

  • 仅在写入提交后返回新的 ETag。
  • 对于过期版本,返回 HTTP 412;不要覆盖另一个标签页中的编辑内容。
  • 每次请求都要验证请求体大小和文档权限。对于通过 Cookie 进行身份验证的写入请求,应使用应用的 CSRF 防护机制。
  • 在其他位置渲染不可信 HTML 之前,先在服务器端进行净化。编辑器不是应用的安全边界。

每次只发送一个保存请求

将保存队列模块下载到应用中。它会在输入暂停 800ms 后合并编辑内容;如果没有正在处理的请求,持续输入时也会在五秒内启动保存;保存期间则保留最新草稿。即使保存很慢,请求也始终串行执行。保存失败时,草稿仍处于待保存状态;发生冲突时,后续写入会被阻止,直到应用解决冲突。

队列会调用您提供的 save 函数,但不提供服务器、身份验证或协作合并功能。请使用实际加载的 HTML 初始化队列,而不是占位内容。

将 RichTextEditor 连接到您的接口

首先参阅编辑器快速入门。以下模块假定已有初始化完成的 editor,/api/documents/123 处存在文档,且页面上已有状态显示和重试控件。下方的接口是需要用您自己的服务器端语言实现的接口约定,并非 RichTextEditor 提供的公共服务。

<p id="save-status" role="status" aria-live="polite"></p>
<button id="retry-save" type="button">Retry save</button>
<script type="module">
import { createDraftSaver } from "/js/draft-autosave.mjs";
// Your initialized RichTextEditor instance:
const editor = window.editor;
const status = document.getElementById("save-status");
const retry = document.getElementById("retry-save");
const endpoint = "/api/documents/123";
const loaded = await fetch(endpoint, { credentials: "same-origin" });
if (!loaded.ok) throw new Error("Document could not be loaded");
let version = loaded.headers.get("ETag");
if (!version) throw new Error("Server must return an ETag");
const documentData = await loaded.json();
editor.setHTMLCode(documentData.html);
const messages = {
  unsaved: "Unsaved changes", saving: "Saving…", saved: "Saved",
  error: "Not saved. Keep this tab open and retry.",
  conflict: "Another version exists. Copy your work before reloading."
};
const saver = createDraftSaver({
  initialHTML: editor.getHTMLCode(),
  onStatus(state) {
    status.textContent = messages[state];
    retry.disabled = state !== "error";
  },
  async save(html) {
    const response = await fetch(endpoint, {
      method: "PUT", credentials: "same-origin",
      headers: { "Content-Type": "application/json", "If-Match": version },
      body: JSON.stringify({ html })
      // Add your application's CSRF token/header here.
    });
    if (response.status === 412 || response.status === 409) {
      throw Object.assign(new Error("Version conflict"), { code: "CONFLICT" });
    }
    if (!response.ok) throw new Error("Save failed: " + response.status);
    const nextVersion = response.headers.get("ETag");
    if (!nextVersion) throw new Error("Save response is missing its ETag");
    version = nextVersion;
  }
});
retry.disabled = true;
editor.attachEvent("change", () => saver.schedule(editor.getHTMLCode()));
retry.addEventListener("click", () => { void saver.flush().catch(() => {}); });
window.addEventListener("beforeunload", event => {
  if (saver.pending) { event.preventDefault(); event.returnValue = ""; }
});
</script>

明确显示失败并支持恢复

“Saved”表示队列中最新的 HTML 已得到保存确认,而不仅仅是请求已发出。请求失败时,最新草稿仍保留在内存中。对于网络错误和服务器错误,应提供重试操作;发生版本冲突后,应让用户先复制或下载内容,再重新加载。不要自动使用保存响应替换编辑器内容,否则可能导致光标移动并破坏撤销历史。

此示例不提供离线持久化。关闭标签页或浏览器崩溃都可能导致未保存的内容丢失。离开页面时的警告只能尽力提示,在移动设备上无法保证可靠显示。不要依赖 pagehide 期间发出的最后一次网络请求。如果应用需要恢复功能,请另外添加浏览器本地恢复草稿,说明其隐私限制,并且绝不要将该副本视为已保存到服务器的证明。

为图片保存持久有效的 URL

不要将上传过程中的临时占位内容持久化为已完成的文档。将自动保存与上传工作流协调起来,只有当文档中的图片都具有持久有效的 URL 时,才将文档标记为完全保存。请参阅Node.js 图片上传和保存包含图片的富文本。

测试失败场景

  1. 延迟保存,依次输入 A、B、C,确认请求始终不重叠,最终保存的文档内容为 C。
  2. 让服务器返回一次 HTTP 500。确认错误提示持续显示,且重试发送的是最新内容。
  3. 在两个标签页中打开同一版本。确认第二次使用过期版本保存时返回 HTTP 412,并保留本地编辑内容。
  4. 让一次保存成功,但不返回 ETag。确认界面不会声称保存成功。
  5. 测试长文档、图片上传、重新加载、移动设备切到后台以及登录会话过期等情况。

接下来可参阅表单验证、保存与加载演示和自动保存的设计权衡。

延伸阅读