Реалізація Real-Time спільного редактування (Yjs/Liveblocks)
Спільне редагування дозволяє кільком користувачам одночасно працювати з одним документом, видячи зміни друг друга у реальному часі. Google Docs-подібна функціональність для веб-додатків.
Вибір підходу
Yjs — open-source CRDT (Conflict-free Replicated Data Types) бібліотека. Керуйте сервером самостійно (y-websocket або Hocuspocus).
Liveblocks — managed платформа поверх Yjs. Без інфраструктури, але платно від $0.
Automerge — альтернатива Yjs, від Ink & Switch.
Yjs + Hocuspocus (self-hosted)
Сервер:
npm install @hocuspocus/server @hocuspocus/extension-database
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { Logger } from '@hocuspocus/extension-logger';
const server = Server.configure({
port: 1234,
extensions: [
new Logger(),
new Database({
fetch: async ({ documentName }) => {
// Завантажити документ з БД при першому підключенні
const doc = await documentRepo.findByName(documentName);
return doc?.content ?? null; // Yjs binary state
},
store: async ({ documentName, state }) => {
// Зберігаємо стан документу в БД
await documentRepo.upsert(documentName, state);
}
})
],
async onAuthenticate({ token, documentName }) {
// Перевірка доступу
const payload = jwt.verify(token, process.env.JWT_SECRET);
const canAccess = await checkDocumentAccess(payload.sub, documentName);
if (!canAccess) {
throw new Error('Access denied');
}
return { userId: payload.sub };
},
async onConnect({ documentName, context }) {
console.log(`User ${context.userId} connected to ${documentName}`);
}
});
server.listen();
Клієнт — Tiptap редактор з Yjs:
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
function CollaborativeEditor({ documentId }) {
const doc = useMemo(() => new Y.Doc(), []);
const provider = useMemo(() => new HocuspocusProvider({
url: process.env.NEXT_PUBLIC_HOCUSPOCUS_URL,
name: `doc:${documentId}`,
document: doc,
token: getAuthToken(),
onStatus: ({ status }) => console.log('Provider status:', status)
}), [documentId, doc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc }),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.name,
color: generateUserColor(currentUser.id)
}
})
]
});
return (
<div className="editor-container">
<CollaboratorsAvatars provider={provider} />
<EditorContent editor={editor} />
</div>
);
}
// Аватари активних учасників
function CollaboratorsAvatars({ provider }) {
const [users, setUsers] = useState([]);
useEffect(() => {
const awareness = provider.awareness;
const updateUsers = () => {
const states = Array.from(awareness.getStates().values());
setUsers(states.filter(s => s.user).map(s => s.user));
};
awareness.on('change', updateUsers);
updateUsers();
return () => awareness.off('change', updateUsers);
}, [provider]);
return (
<div className="collaborators">
{users.map(user => (
<Avatar key={user.id} name={user.name}
color={user.color} title={`${user.name} зейчас редагує`} />
))}
</div>
);
}
Liveblocks (managed)
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';
import * as Y from 'yjs';
import { LiveblocksYjsProvider } from '@liveblocks/yjs';
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_KEY
});
const { RoomProvider, useRoom } = createRoomContext(client);
function EditorPage({ documentId }) {
return (
<RoomProvider id={`document-${documentId}`} initialPresence={{}}>
<CollaborativeEditorWithLiveblocks />
</RoomProvider>
);
}
function CollaborativeEditorWithLiveblocks() {
const room = useRoom();
const doc = useMemo(() => new Y.Doc(), []);
useEffect(() => {
const provider = new LiveblocksYjsProvider(room, doc);
return () => provider.destroy();
}, [room, doc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc })
]
});
return <EditorContent editor={editor} />;
}
Розв'язання конфліктів через CRDT
Yjs використовує CRDT — математично доказаний алгоритм злиття конфліктуючих змін без координації. Два користувачі можуть редагувати офлайн, і при синхронізації конфлікти розв'язуються детерміністично.
Персистентність: y-leveldb / PostgreSQL
// Зберігання Yjs-документів в PostgreSQL
const documentTable = `
CREATE TABLE IF NOT EXISTS documents (
name VARCHAR(255) PRIMARY KEY,
content BYTEA NOT NULL, -- Yjs binary state
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`;
// Інкрементальне збереження через y-protocols
import { encodeStateAsUpdate } from 'yjs';
const binaryState = encodeStateAsUpdate(doc);
await db.query(
'INSERT INTO documents (name, content) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET content = $2',
[docName, Buffer.from(binaryState)]
);
Часові рамки реалізації
- Hocuspocus сервер + Tiptap клієнт + базове collaborative editing: 2–3 тижні
- Курсори, аватари, персистентність в PostgreSQL: ще 1 тиждень
- Liveblocks інтеграція (без self-hosted сервера): 1–1.5 тижні







