Реализация Real-Time совместного редактирования на сайте (Yjs/Liveblocks)
Совместное редактирование позволяет нескольким пользователям одновременно работать с одним документом, видя изменения друг друга в реальном времени. Google Docs-подобная функциональность для веб-приложений.
Выбор подхода
Yjs — open-source CRDT (Conflict-free Replicated Data Type) библиотека. Самостоятельно управляете сервером (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} />;
}
Conflict Resolution через 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 недели







