Реализация совместного редактирования (Collaborative Editing) на сайте
Collaborative editing — это синхронизация состояния документа между несколькими пользователями в реальном времени без конфликтов. Задача сложнее, чем «просто WebSocket». Ключевая проблема: два пользователя одновременно редактируют один документ — чьё изменение победит и как не потерять ни одно из них?
Два подхода к синхронизации: OT vs CRDT
Operational Transformation (OT) — классический подход (Google Docs). Операции трансформируются относительно конкурирующих операций. Требует центрального сервера для сериализации.
CRDT (Conflict-free Replicated Data Types) — структуры данных, которые математически гарантируют eventual consistency без координатора. Yjs, Automerge — основные реализации.
| OT | CRDT (Yjs) | |
|---|---|---|
| Центральный сервер | Обязателен | Опционально (P2P возможен) |
| Офлайн-редактирование | Сложно | Встроено |
| Производительность | Высокая | Высокая (Yjs — очень эффективен) |
| Сложность реализации | Высокая | Низкая (библиотека берёт на себя) |
| Популярные библиотеки | ShareDB, ot.js | Yjs, Automerge |
Для большинства новых проектов выбор — Yjs.
Yjs: архитектура
Yjs предоставляет shared types: Y.Text, Y.Map, Y.Array, Y.XmlFragment. Изменения в этих типах автоматически синхронизируются между всеми участниками через провайдер.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
// Документ Y — корень всего
const ydoc = new Y.Doc();
// Провайдер — транспорт синхронизации
const provider = new WebsocketProvider(
'wss://your-yjs-server.com',
'document-room-id',
ydoc,
{ connect: true }
);
// Shared text тип
const ytext = ydoc.getText('quill-content');
// Связываем с редактором
const editor = new Quill('#editor', { theme: 'snow' });
const binding = new QuillBinding(ytext, editor, provider.awareness);
// Awareness — присутствие, курсоры
provider.awareness.setLocalStateField('user', {
name: 'Иван',
color: '#ff6b6b',
});
Сервер синхронизации
y-websocket — стандартный сервер для Yjs. Может персистировать состояние в Redis или LevelDB:
// server.js
const { setupWSConnection } = require('y-websocket/bin/utils');
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end();
});
const wss = new WebSocket.Server({ server });
wss.on('connection', (conn, req) => {
setupWSConnection(conn, req, {
docName: getDocNameFromUrl(req.url),
gc: true, // garbage collect deleted content
});
});
server.listen(1234);
Для production — Hocuspocus (официальный сервер для TipTap/Yjs с авторизацией, персистентностью, хуками):
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
const server = Server.configure({
port: 1234,
extensions: [
new Database({
fetch: async ({ documentName }) => {
return await db.getDocument(documentName);
},
store: async ({ documentName, state }) => {
await db.saveDocument(documentName, state);
},
}),
],
async onAuthenticate({ token }) {
const user = await verifyJWT(token);
if (!user) throw new Error('Unauthorized');
return { user };
},
async onLoadDocument({ documentName, context }) {
if (!canRead(context.user, documentName)) {
throw new Error('Forbidden');
}
},
});
server.listen();
Интеграция с TipTap
TipTap — наиболее зрелый rich-text редактор с нативной поддержкой Yjs через @tiptap/extension-collaboration:
import { Editor } from '@tiptap/core';
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';
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: 'wss://your-server.com',
name: `document-${docId}`,
document: ydoc,
token: authToken,
});
const editor = new Editor({
extensions: [
StarterKit.configure({ history: false }), // history отключаем — Yjs управляет undo
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: currentUser.name, color: generateColor(currentUser.id) },
}),
],
});
Курсоры и awareness
Awareness — это эфемерное состояние (не сохраняется в документе): курсоры, выделения, статус онлайн. Обновляется через CRDT-like механизм Yjs:
// Подписка на изменения awareness
provider.awareness.on('change', ({ added, updated, removed }) => {
const states = provider.awareness.getStates();
renderRemoteCursors(states);
});
// Установка позиции курсора
editor.on('selectionUpdate', ({ editor }) => {
const { from, to } = editor.state.selection;
provider.awareness.setLocalStateField('cursor', {
anchor: Y.createRelativePositionFromTypeIndex(ytext, from),
head: Y.createRelativePositionFromTypeIndex(ytext, to),
});
});
Относительные позиции (RelativePosition) важны: они указывают на символ в документе, а не на индекс. При вставке текста перед курсором — позиция автоматически корректируется.
Офлайн и локальное персистирование
y-indexeddb сохраняет состояние документа в IndexedDB — пользователь может работать офлайн, изменения синхронизируются при восстановлении соединения:
import { IndexeddbPersistence } from 'y-indexeddb';
const persistence = new IndexeddbPersistence(`doc-${docId}`, ydoc);
persistence.on('synced', () => {
console.log('Локальное содержимое загружено из IndexedDB');
});
// Состояние автоматически сохраняется при каждом изменении
// При переподключении WebsocketProvider — Yjs мержит локальные и серверные изменения
Контроль версий и история
Yjs хранит всю историю операций. Для UI «история версий»:
import { UndoManager } from 'yjs';
const undoManager = new UndoManager(ytext, {
captureTimeout: 500, // объединять изменения в течение 500 мс
trackedOrigins: new Set([provider.awareness.clientID]),
});
// Undo/Redo
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') undoManager.undo();
if (e.ctrlKey && e.key === 'y') undoManager.redo();
});
Для снепшотов (named versions):
import * as Y from 'yjs';
// Создание снепшота
const snapshot = Y.snapshot(ydoc);
const snapshotBinary = Y.encodeSnapshot(snapshot);
await saveSnapshot(docId, snapshotBinary);
// Восстановление для просмотра
const restoredDoc = Y.createDocFromSnapshot(ydoc, snapshot);
Права доступа на уровне документа
Hocuspocus позволяет дифференцировать read/write:
async onAuthenticate({ token, documentName }) {
const user = await verifyToken(token);
const doc = await getDocumentMeta(documentName);
if (doc.ownerId === user.id) return { user, role: 'owner' };
if (doc.editors.includes(user.id)) return { user, role: 'editor' };
if (doc.viewers.includes(user.id)) return { user, role: 'viewer' };
throw new Error('Access denied');
},
async onChange({ context, update }) {
if (context.role === 'viewer') {
throw new Error('Read-only access');
}
}
Структурированный контент: Y.Map и Y.Array
Совместное редактирование не ограничивается текстом. Для форм, канбан-досок, презентаций:
// Доска задач
const ytasks = ydoc.getArray('tasks');
// Добавление задачи (синхронизируется всем)
const task = new Y.Map();
task.set('id', generateId());
task.set('title', 'Новая задача');
task.set('status', 'todo');
task.set('assignee', userId);
ytasks.push([task]);
// Перемещение задачи (автоматический merge если два пользователя перемещают одновременно)
ytasks.observe(event => {
renderBoard(ytasks.toArray());
});
Масштабирование и кластеризация
При нескольких инстансах сервера нужен общий бэкенд для Yjs-документов. Hocuspocus поддерживает Redis адаптер:
import { Redis } from '@hocuspocus/extension-redis';
const server = Server.configure({
extensions: [
new Redis({
host: 'redis://your-redis:6379',
// Все инстансы синхронизируются через Redis pub/sub
}),
],
});
Метрики производительности
Yjs оптимизирован для больших документов. Бенчмарки показывают обработку 1000+ одновременных операций без деградации. Для мониторинга:
- Размер Y.Doc в памяти:
Y.encodeStateAsUpdate(ydoc).byteLength - Количество операций GC: метрика из hocuspocus stats
- Задержка синхронизации: разница между
Date.now()при отправке и получении awareness-события
Сроки
- Базовый совместный текстовый редактор (TipTap + Hocuspocus + PostgreSQL) — 5–7 дней
- Курсоры других пользователей, presence-индикаторы — плюс 2–3 дня
- Офлайн-режим (IndexedDB persistence) — плюс 1–2 дня
- История версий с UI — плюс 3–4 дня
- Права доступа read/write на уровне документа — плюс 2–3 дня
- Совместное редактирование структурированного контента (доска, формы) — отдельная оценка по задаче







