自动保存
自动保存指南 — Next.js 接入、API 约定、元数据与配置参数。
自动保存
自动保存负责把编辑器正文从你的应用存储中拉出来,并在用户编辑后写回去。最常见的接入方式是:
- 在服务端提供一个文档 API,例如
GET /api/docs/:docId和POST /api/docs/:docId。 - 在客户端编辑器组件里把
extensionsOptions.autoSave传给SVeditor.create()。 - 如果你要显示标题、Emoji、封面等文档信息,再单独配置
extensionsOptions.meta。 - 用
onStatusChange和onFetchStatusChange把加载、保存状态显示到你的页面上。
下面的示例假设你已有 Next.js App Router 项目并已安装 @wztlink1013/sveditor。不包含从零创建 Next.js 项目的步骤。
在 Next.js 项目中接入
1. 约定文档数据结构
自动保存把正文和元数据分开处理:
interface DocRecord {
content: string;
meta: {
name?: string;
description?: string;
emojiInfo?: string;
coverImage?: string;
coverBlock?: string;
coverPos?: string;
};
}content是编辑器正文。推荐保存onSaveContent收到的 Tiptap JSON 字符串。meta是标题、Emoji、封面等文档信息。它通过extensionsOptions.meta单独读取和保存。- 新文档可以返回空字符串
""作为正文,元数据返回{}或一个默认标题。
2. 实现文档 API
在新项目里可以先用内存存储跑通流程,真实业务再把 Map 换成数据库、对象存储或你的后端服务。下面是 app/api/docs/[docId]/route.ts 的完整形状:
import { NextResponse } from "next/server";
const metaFields = [
"name",
"description",
"contentStr",
"emojiInfo",
"coverImage",
"coverBlock",
"coverPos",
] as const;
type MetaField = (typeof metaFields)[number];
type DocMeta = Partial<Record<MetaField, string>>;
interface DocRecord {
content: string;
meta: DocMeta;
}
const docs = new Map<string, DocRecord>();
function getDoc(docId: string): DocRecord {
const existing = docs.get(docId);
if (existing) {
return existing;
}
const created = {
content: "",
meta: { name: "Untitled document" },
} satisfies DocRecord;
docs.set(docId, created);
return created;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function pickMetaPatch(value: unknown): DocMeta {
if (!isRecord(value)) {
return {};
}
const patch: DocMeta = {};
for (const field of metaFields) {
if (typeof value[field] === "string") {
patch[field] = value[field];
}
}
return patch;
}
export async function GET(
_request: Request,
context: { params: Promise<{ docId: string }> },
) {
const { docId } = await context.params;
return NextResponse.json(getDoc(docId));
}
export async function POST(
request: Request,
context: { params: Promise<{ docId: string }> },
) {
const { docId } = await context.params;
const body = (await request.json().catch(() => ({}))) as {
content?: unknown;
meta?: unknown;
};
const current = getDoc(docId);
const metaPatch = pickMetaPatch(body.meta);
const next: DocRecord = {
content: typeof body.content === "string" ? body.content : current.content,
meta:
Object.keys(metaPatch).length > 0
? { ...current.meta, ...metaPatch }
: current.meta,
};
docs.set(docId, next);
return NextResponse.json({ success: true });
}生产环境里建议保持这个接口语义不变:
| 方法 | 用途 | 返回或请求 |
|---|---|---|
GET /api/docs/:docId | 拉取初始正文和元数据 | { content, meta } |
POST /api/docs/:docId | 保存正文或元数据补丁 | { content }、{ meta } 或二者同时存在 |
3. 创建客户端编辑器组件
SVeditor 只能在浏览器中运行,所以 Next.js 组件需要使用 "use client"。关键点是:用 useMemo 稳定 options,否则 React 每次渲染都会创建新对象,可能导致编辑器被销毁并重建。
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { SVeditor } from "@wztlink1013/sveditor";
import type {
EditorOptions,
FetchMetaObject,
FetchStatus,
SaveStatus,
} from "@wztlink1013/sveditor";
import "@wztlink1013/sveditor/style.css";
type EditorInitOptions = Omit<EditorOptions, "el">;
interface DocEditorProps {
docId: string;
}
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return (await response.json()) as T;
}
export function DocEditor({ docId }: DocEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [fetchStatus, setFetchStatus] = useState<FetchStatus>("idle");
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const options = useMemo<EditorInitOptions>(
() => ({
extensionsOptions: {
autoSave: {
onFetch: async () => {
const data = await readJson<{ content?: string }>(
await fetch(`/api/docs/${docId}`, { cache: "no-store" }),
);
return { content: data.content ?? "" };
},
onSaveContent: async (json: string) => {
await readJson(
await fetch(`/api/docs/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: json }),
}),
);
},
debounceMs: 1000,
fetchTimeoutMs: 10000,
onFetchStatusChange: setFetchStatus,
onStatusChange: setSaveStatus,
},
meta: {
onFetchMeta: async () => {
const data = await readJson<{ meta?: FetchMetaObject | null }>(
await fetch(`/api/docs/${docId}`, { cache: "no-store" }),
);
return data.meta ?? {};
},
onSaveMeta: async (meta: FetchMetaObject) => {
await readJson(
await fetch(`/api/docs/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ meta }),
}),
);
},
},
},
}),
[docId],
);
useEffect(() => {
if (!containerRef.current) {
return;
}
const editor = SVeditor.create({
el: containerRef.current,
...options,
});
return () => editor.destroy();
}, [options]);
return (
<section className="doc-editor">
<div className="doc-editor__status">
{fetchStatus === "fetching" && "加载文档中"}
{fetchStatus === "error" && "文档加载失败"}
{fetchStatus !== "fetching" && saveStatus === "saving" && "保存中"}
{fetchStatus !== "fetching" && saveStatus === "saved" && "已保存"}
{fetchStatus !== "fetching" && saveStatus === "error" && "保存失败"}
</div>
<div ref={containerRef} style={{ minHeight: 640 }} />
</section>
);
}4. 在页面里使用
DocEditor 已经是客户端组件,页面只需要把路由参数传进去:
import { DocEditor } from "@/components/doc-editor";
export default async function DocPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const { docId } = await params;
return <DocEditor docId={docId} />;
}到这里,用户打开 /docs/my-doc 时会先调用 onFetch 拉取正文;编辑内容后,SDK 会等待 debounceMs,再调用 onSaveContent 把最新 JSON 写回 /api/docs/my-doc。
常见接入模式
只保存正文,不显示标题栏
如果你只需要保存正文,不需要内置标题、Emoji、封面等元数据 UI,只配置 autoSave 即可:
SVeditor.create({
el: "#editor",
extensionsOptions: {
autoSave: {
onFetch: async () => {
const data = await fetch(`/api/docs/${docId}`).then((res) =>
res.json(),
);
return { content: data.content ?? "" };
},
onSaveContent: async (json) => {
await fetch(`/api/docs/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: json }),
});
},
},
},
});已有初始内容,只需要保存变化
如果内容已经在页面数据里,不需要编辑器自己拉取,跳过 onFetch,直接传入 content:
SVeditor.create({
el: "#editor",
content: initialContentFromPage,
extensionsOptions: {
autoSave: {
onSaveContent: async (json) => {
await saveDoc(docId, { content: json });
},
debounceMs: 1000,
},
},
});这种模式适合你已经在 Server Component 里预取了文档,或者从父应用把内容直接注入编辑器。
只在本地浏览器试用
没有后端时,可以先用 localStorage 模拟持久化:
const storageKey = `draft:${docId}`;
SVeditor.create({
el: "#editor",
extensionsOptions: {
autoSave: {
onFetch: async () => ({
content: localStorage.getItem(storageKey) ?? "",
}),
onSaveContent: async (json) => {
localStorage.setItem(storageKey, json);
},
debounceMs: 800,
},
meta: {
onFetchMeta: async () => ({
name: localStorage.getItem(`${storageKey}:title`) ?? "Untitled",
}),
onSaveMeta: async (meta) => {
if (meta.name) {
localStorage.setItem(`${storageKey}:title`, meta.name);
}
},
},
},
});同时启用元数据
配置 extensionsOptions.meta.onSaveMeta 或 extensionsOptions.meta.onMetaChange 后,编辑器会显示内置标题和 Emoji 区域。正文仍然由 autoSave.onSaveContent 保存,元数据由 meta.onSaveMeta 保存:
SVeditor.create({
el: "#editor",
extensionsOptions: {
autoSave: {
onFetch: () => fetchDocContent(docId),
onSaveContent: (json) => saveDoc(docId, { content: json }),
},
meta: {
onFetchMeta: () => fetchDocMeta(docId),
onSaveMeta: (meta) => saveDoc(docId, { meta }),
onMetaChange: (meta) => setPreviewTitle(meta.name ?? ""),
},
},
});onMetaChange 只适合同步宿主界面状态,例如外部标题预览。它不是落库回调,不能替代 onSaveMeta。
搭配版本历史
版本历史是独立扩展,可以和自动保存同时启用。自动保存负责持续写入当前正文,版本历史负责创建快照:
SVeditor.create({
el: "#editor",
extensionsOptions: {
autoSave: {
onFetch: () => fetchDocContent(docId),
onSaveContent: (json) => saveDoc(docId, { content: json }),
debounceMs: 1000,
},
versionHistory: {
enabled: true,
autoSnapshot: {
enabled: true,
debounceMs: 3000,
},
},
},
});参数配置
extensionsOptions.autoSave
autoSave 只负责正文内容,不负责标题、Emoji、封面等元数据。
interface AutoSaveOptions {
onFetch?: () => Promise<{ content: string }>;
onSaveContent?: (json: string) => Promise<void>;
debounceMs?: number;
fetchTimeoutMs?: number;
onFetchError?: (error: Error, retry: () => void) => void;
onStatusChange?: (status: SaveStatus) => void;
onFetchStatusChange?: (status: FetchStatus) => void;
}| 参数 | 什么时候用 | 说明 |
|---|---|---|
onFetch | 需要编辑器自己从服务端加载初始正文 | 挂载时执行一次。返回 { content },成功后才启用自动保存。 |
onSaveContent | 需要保存用户编辑后的正文 | 防抖结束后调用,参数是 Tiptap JSON 字符串。 |
debounceMs | 控制保存频率 | 默认 1000。越小越及时,越大越省请求。 |
fetchTimeoutMs | 初始加载可能卡住时 | 超时后视为拉取失败,并触发 onFetchError 或错误状态。 |
onFetchError | 想提供重试按钮或自定义错误 UI | 第二个参数 retry 可以重新执行 onFetch。 |
onStatusChange | 页面要显示“保存中 / 已保存 / 失败” | 接收 SaveStatus。 |
onFetchStatusChange | 页面要显示“加载中 / 加载失败” | 接收 FetchStatus。 |
extensionsOptions.meta
元数据配置和正文保存分离:
interface MetaOptions {
onFetchMeta?: () => Promise<FetchMetaObject>;
onSaveMeta?: (meta: FetchMetaObject) => Promise<void>;
onMetaChange?: (meta: FetchMetaObject) => void;
}| 参数 | 什么时候用 | 说明 |
|---|---|---|
onFetchMeta | 初始进入编辑器时需要标题、Emoji、封面 | 与 autoSave.onFetch 并行执行。 |
onSaveMeta | 用户在内置元数据区域保存时需要落库 | 立即调用,不经过 autoSave.debounceMs。 |
onMetaChange | 宿主应用要监听任意元数据变化 | UI 修改或 editor.setMeta() 都会触发;只做同步,不负责保存。 |
FetchMetaObject
interface FetchMetaObject {
name?: string;
description?: string;
contentStr?: string;
emojiInfo?: string;
coverImage?: string;
coverBlock?: string;
coverPos?: string;
}运行时可通过 editor.getMeta() 读取当前元数据,也可用 editor.setMeta({ ... }) 合并更新部分字段。详见 EditorInstance。
状态类型
type SaveStatus = "idle" | "saving" | "saved" | "error";
type FetchStatus = "idle" | "fetching" | "success" | "error";| 状态 | 来源 | 含义 |
|---|---|---|
idle | 保存或加载 | 初始空闲状态。 |
fetching | FetchStatus | 正在执行 onFetch。 |
success | FetchStatus | 初始正文加载成功。 |
saving | SaveStatus | 正在执行 onSaveContent。 |
saved | SaveStatus | 最近一次正文保存成功。 |
error | 保存或加载 | 对应回调抛错或超时。 |
后端接口要求
自动保存不限制你用什么后端,但建议遵守这些接口约定:
| 要求 | 说明 |
|---|---|
| 幂等 | 相同内容多次 POST 不应产生额外副作用。 |
| 增量更新 | { content } 只更新正文,{ meta } 只更新元数据。 |
| 响应足够快 | 建议正文保存目标低于 500ms,否则用户会长时间看到 saving。 |
| 保存失败要抛错 | onSaveContent 或 onSaveMeta 中遇到非 2xx 响应时应 throw,这样 SDK 才能进入 error 状态。 |
不要在 onFetch 里混入元数据 | onFetch 返回 { content };标题、Emoji、封面走 onFetchMeta。 |
工作原理
初始化流程
配置 onFetch 后,编辑器会先进入加载状态。只有正文拉取成功后,编辑器才会启用自动保存,避免还没拿到远端内容时就把空文档写回服务端。
组件挂载
|
|-- 未配置 onFetch
| 使用传入的 content 初始化
| 自动保存可立即响应后续编辑
|
|-- 配置了 onFetch
onFetchStatusChange("fetching")
并行执行 onFetch() 与 onFetchMeta()
|
|-- 成功
| 设置正文和元数据
| onFetchStatusChange("success")
| 后续编辑可触发自动保存
|
|-- 失败或超时
onFetchStatusChange("error")
触发通知或 onFetchError
未成功加载前不会保存空内容保存队列
正文保存使用“最新内容覆盖待保存内容”的队列。如果一次保存还没结束,用户又继续输入,SDK 不会并发调用 onSaveContent,而是只保留最新 JSON,等当前请求结束后再保存一次最新状态。
用户编辑
|
|-- debounceMs 内继续编辑
| 重置防抖计时器
|
|-- debounceMs 到期
onStatusChange("saving")
await onSaveContent(json)
|
|-- 成功
| onStatusChange("saved")
| 如果期间有新内容,继续保存最新内容
|
|-- 失败
onStatusChange("error")
下一次内容变化会触发新的保存尝试内容格式
onSaveContent 收到的是 JSON.stringify(editor.getJSON()) 的结果,不是 HTML。这样服务端可以保存完整的 Tiptap 文档结构。若业务还需要 HTML,可以在应用层自行调用 editor.getHTML(),或在服务端另行生成展示用内容。
注意事项
onFetch只在编辑器挂载时执行一次。切换docId时,建议销毁并重建编辑器实例。onSaveMeta没有防抖。若你的标题输入会高频触发保存,请在应用层或接口层做节流。- 保存失败不会自动无限重试。进入
error后,下一次内容变化会触发新的保存尝试。 - 常见防抖建议:普通文档
1000ms,高频输入1500ms到2000ms,强实时反馈500ms。 - 在 React 或 Next.js 中,不要内联创建
options。用useMemo保证引用稳定。