如果每次更新都将整个文档发给服务器,并保存,那对于大文档以及频繁更新的文档,这就太浪费了。既然Tiptap是以节点来标识文档,那做增量更新是容易的事情,具体有两种方式:
Implementing Efficient Updates in Tiptap: Sending Only Changes to the Server
Tiptap, built on ProseMirror, doesn't natively provide a built-in mechanism to automatically compute and serialize only the "delta" (changes) between document states for server-side patching. The standard approach via the onUpdate event sends the full document JSON on every change, which works for small documents but can be inefficient for large ones due to bandwidth and server load.
To send only changes, you have two main paths:
- Simple diffing of JSON states (client-side computation, suitable for non-collaborative, single-user editors). This involves tracking previous and current document JSON, computing a diff, and sending the delta to the server.
- Collaboration infrastructure with Y.js (recommended for robustness, especially if you might add real-time features later). Y.js handles operational transformation (OT) or conflict-free replicated data types (CRDTs) to track and sync changes natively, sending only deltas via a WebSocket server.
I'll outline both approaches with code examples. Assume you're using React (common for Tiptap), but these adapt easily to Vue/Svelte. You'll need to implement server-side logic to apply the changes (e.g., merge JSON diffs or apply Y.js updates).
Prerequisites
- Install Tiptap:
npm install @tiptap/react @tiptap/pm(core) + extensions as needed. - For diffing:
npm install json-diff(lightweight JSON delta library). - For Y.js:
npm install yjs y-websocket(client); server needs a WebSocket provider likey-websocket.
Approach 1: Client-Side JSON Diffing (Simple, No External Libs for Sync)
Track the previous document state and compute differences on update. Use json-diff to generate a patch (delta) object, which you send to the server via a PATCH request. The server reapplies the patch to its stored document.
This is lightweight but assumes single-user editing (concurrent changes could conflict; handle with versioning or locks).
Client-Side Code (React Example):
jsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { useEffect, useRef } from 'react';
import diff from 'json-diff'; // For computing deltas
const TiptapEditor = ({ documentId }) => {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Initial content</p>', // Load initial from server
onUpdate: ({ editor }) => {
const currentJSON = editor.getJSON();
const prevJSONRef = prevJSON.current;
if (prevJSONRef) {
// Compute diff (delta) between prev and current
const delta = diff.diff(prevJSONRef, currentJSON);
// Send only the delta to server (debounce if needed, e.g., via lodash.throttle)
if (delta) {
fetch(`/api/documents/${documentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: delta }), // Delta is an array of { op: 'replace'|'add'|'remove', path: [...], value: ... }
}).catch(console.error);
}
}
// Update previous state
prevJSON.current = currentJSON;
},
});
const prevJSON = useRef(null);
// Load initial document on mount
useEffect(() => {
fetch(`/api/documents/${documentId}`)
.then(res => res.json())
.then(data => {
editor.commands.setContent(data.content); // Assuming server returns { content: JSON }
prevJSON.current = data.content;
});
}, [documentId, editor]);
if (!editor) return null;
return <EditorContent editor={editor} />;
};
Key Notes:
-
onUpdatefires after every transaction (change). It provideseditorforgetJSON(). -
json-diffoutputs a structured delta (e.g.,[{ op: 'replace', path: ['content', 0, 'text'], value: 'new text' }]). Send this as the payload. -
Debouncing: Wrap the fetch in a throttled function (e.g., 500ms delay) to avoid spamming on typing: Use
lodash.throttleor a custom hook. -
Edge Cases: Diffing nested ProseMirror JSON can be complex (e.g., node attributes). Test with your schema. For ranges, use ProseMirror's
getChangedRanges(tr)from the transaction (onUpdateprovides{ transaction }). -
Server-Side (Node.js Example with json-patch):
jsconst express = require('express'); const jsonPatch = require('fast-json-patch'); const app = express(); // Assume documents stored in DB as JSON let docStore = { /* { id: { content: JSON } } */ }; app.patch('/api/documents/:id', express.json(), (req, res) => { const id = req.params.id; const { changes } = req.body; // Delta from client const doc = docStore[id]; if (!doc) return res.status(404).send('Not found'); try { // Apply patch (json-diff deltas are compatible with json-patch) const patched = jsonPatch.applyPatch(doc.content, changes).newDocument; docStore[id].content = patched; // Save to DB res.send({ success: true }); } catch (err) { res.status(400).send('Invalid patch'); } });Install:
npm install json-patch fast-json-patch.
Pros/Cons: Easy to implement, no WebSockets. But manual conflict resolution; not great for large docs or concurrency.
Approach 2: Y.js for Change Tracking and OT/CRDT Sync (Robust, Handles Concurrency)
Y.js is Tiptap's official collaboration backend. It treats the document as a stream of changes (not snapshots), so you naturally send only deltas. Updates are applied via operational transformation (OT) or CRDTs, merging concurrent edits automatically.
This requires a WebSocket server (e.g., Hocuspocus for Tiptap) but offloads diffing/sync to Y.js. Ideal if you plan multi-user support.
Setup:
- Client:
npm install @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor yjs y-websocket. - Server: Use Hocuspocus (
npm install @hocuspocus/server) for a simple WebSocket server that broadcasts Y.js updates.
Client-Side Code (React Example):
jsx
import { useEditor, EditorContent } from '@tiptap/react';
import { Collaboration } from '@tiptap/extension-collaboration';
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'; // Optional for cursors
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import StarterKit from '@tiptap/starter-kit';
const TiptapEditor = ({ documentId, user }) => {
const ydoc = new Y.Doc();
const wsUrl = 'ws://localhost:1234'; // Your Hocuspocus server
const connector = new WebsocketProvider(wsUrl, `document-${documentId}`, ydoc);
const editor = useEditor({
extensions: [
StarterKit,
// Bind Y.js as the source of truth
Collaboration.configure({
document: ydoc,
}),
// Optional: Show user cursors
CollaborationCursor.configure({
provider: connector,
user: { name: user.name, color: user.color },
}),
],
content: '', // Y.js loads initial state
});
// Optional: Manual snapshot save (Y.js handles deltas automatically)
useEffect(() => {
const interval = setInterval(() => {
// Get Y.js snapshot as JSON for backup/full save
const json = Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ydoc)); // Binary delta since last sync
fetch(`/api/documents/${documentId}/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: json, // Send binary update (only changes)
});
}, 30000); // Every 30s
return () => clearInterval(interval);
}, [documentId, ydoc]);
if (!editor) return null;
return <EditorContent editor={editor} />;
};
Key Notes:
-
Y.js automatically syncs changes over WebSocket---only deltas are sent (binary updates via
Y.encodeStateAsUpdate). -
On first load, fetch the initial Y.js state from server (as binary or JSON via
@hocuspocus/transformer). -
onUpdate: Still available if needed, but Y.js handles propagation. Use
editor.getJSON()for occasional full exports. -
Server (Hocuspocus Example):
jsconst { Hocuspocus } = require('@hocuspocus/server'); const server = Hocuspocus.createServer({ port: 1234, // Persist Y.documents to DB for durability onStoreDocument: async (yDoc, documentName) => { // Save binary snapshot to DB }, onLoadDocument: async (yDoc, documentName) => { // Load from DB }, }); server.listen();See Tiptap docs for full persistence setup.
Pros/Cons: Handles concurrency/OT out-of-the-box, efficient for large docs. Requires server setup.
Additional Tips
- Versioning: Pair with Tiptap's Snapshot extension (
@tiptap-pro/extension-snapshot) for manual snapshots and diffs (e.g.,editor.commands.createSnapshot()). - Performance: For high-frequency updates, debounce with
requestIdleCallbackor libraries likeuse-debounce. - Testing Changes: Use ProseMirror's
transactioninonUpdateto get raw steps:{ editor, transaction }---transaction.stepsare the atomic changes. - Docs References: Check Tiptap's Events for
onUpdateand Collaboration for Y.js.
If your setup involves collaboration or custom nodes, go with Y.js. For simple cases, JSON diffing suffices. Provide more details (e.g., framework, schema) for tailored code!