SVeditor 文档
数据持久化与同步

自动保存

自动保存指南 — Next.js 接入、API 约定、元数据与配置参数。

自动保存

自动保存负责把编辑器正文从你的应用存储中拉出来,并在用户编辑后写回去。最常见的接入方式是:

  1. 在服务端提供一个文档 API,例如 GET /api/docs/:docIdPOST /api/docs/:docId
  2. 在客户端编辑器组件里把 extensionsOptions.autoSave 传给 SVeditor.create()
  3. 如果你要显示标题、Emoji、封面等文档信息,再单独配置 extensionsOptions.meta
  4. onStatusChangeonFetchStatusChange 把加载、保存状态显示到你的页面上。

下面的示例假设你已有 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.onSaveMetaextensionsOptions.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保存或加载初始空闲状态。
fetchingFetchStatus正在执行 onFetch
successFetchStatus初始正文加载成功。
savingSaveStatus正在执行 onSaveContent
savedSaveStatus最近一次正文保存成功。
error保存或加载对应回调抛错或超时。

后端接口要求

自动保存不限制你用什么后端,但建议遵守这些接口约定:

要求说明
幂等相同内容多次 POST 不应产生额外副作用。
增量更新{ content } 只更新正文,{ meta } 只更新元数据。
响应足够快建议正文保存目标低于 500ms,否则用户会长时间看到 saving
保存失败要抛错onSaveContentonSaveMeta 中遇到非 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(),或在服务端另行生成展示用内容。

注意事项

  1. onFetch 只在编辑器挂载时执行一次。切换 docId 时,建议销毁并重建编辑器实例。
  2. onSaveMeta 没有防抖。若你的标题输入会高频触发保存,请在应用层或接口层做节流。
  3. 保存失败不会自动无限重试。进入 error 后,下一次内容变化会触发新的保存尝试。
  4. 常见防抖建议:普通文档 1000ms,高频输入 1500ms2000ms,强实时反馈 500ms
  5. 在 React 或 Next.js 中,不要内联创建 options。用 useMemo 保证引用稳定。