リッチテキストエディターの自動保存:編集内容を失わずにサーバーに保存する

JavaScript のリッチテキストエディターにサーバーへの自動保存を追加し、書き込みリクエストの重複や、保存完了前の「Saved」表示を防ぎます。このガイドでは、テスト済みの保存キュー、HTTP のインターフェース仕様、低速ネットワークや競合を確認するためのチェックリストを紹介します。
ブラウザー内のローカル下書きとサーバーへの保存は異なります。ローカルだけで保存する執筆アプリについては、Markdown の下書き保存とダウンロードのガイドを参照してください。このガイドでは、サーバーがドキュメントとそのバージョンを管理します。
最初に保存のインターフェース仕様を定義する
ログイン済みユーザーのアクセス権限を確認してから、ドキュメントを読み込みます。HTML と、内部構造をクライアントが解釈しないバージョン識別子を返し、識別子は ETag レスポンスヘッダーに設定します。更新のたびに、クライアントはその値を If-Match で送信します。サーバーは、バージョンの比較と HTML およびバージョンの更新を、単一のアトミックな操作として実行する必要があります。読み取り後に同時更新への保護なしで書き込むだけでは不十分です。
- 書き込みがコミットされた後にのみ、新しい
ETagを返します。 - 古いバージョンには HTTP 412 を返し、別のタブでの編集内容を上書きしないでください。
- すべてのリクエストで、リクエストボディのサイズとドキュメントへの権限を検証します。Cookie 認証を使う書き込みには、アプリの CSRF 対策を適用してください。
- 信頼できない HTML を別の場所で表示する前に、サーバー側でサニタイズします。エディターはアプリのセキュリティ境界にはなりません。
保存リクエストは一度に 1 つだけ実行する
アプリに保存キューモジュールをダウンロードします。このモジュールは、入力が 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 での画像アップロードと画像を含むリッチテキストの保存を参照してください。
失敗時の動作をテストする
- 保存を遅延させ、A、B、C の順に入力します。リクエストが重複せず、最終的に保存されたドキュメントが C であることを確認してください。
- HTTP 500 を一度返します。エラー表示が残り、再試行で最新の内容が送信されることを確認してください。
- 同じバージョンを 2 つのタブで開きます。2 回目の古いバージョンでの保存が HTTP 412 を返し、ローカルの編集内容が保持されることを確認してください。
- 保存自体は成功させ、
ETagを返さないようにします。UI が成功と表示しないことを確認してください。 - 長いドキュメント、画像アップロード、再読み込み、モバイルでのバックグラウンド移行、ログインセッションの期限切れをテストしてください。
続いて、フォームの検証、保存と読み込みのデモ、自動保存の設計上のトレードオフを参照してください。