Совместное редактирование представляет собой механизм синхронизации изменений между несколькими клиентами в режиме реального времени. Браузерные приложения подключаются к брокеру сообщений через WebSocket и обмениваются событиями изменения документа.
Типичная схема взаимодействия:
Пользователь A
↓
STOMP.js Client
↓
WebSocket
↓
Message Broker
↓
Другие подписчики
↓
Пользователь B, C, D
Каждое изменение документа передаётся как отдельное сообщение. Остальные клиенты получают событие и обновляют локальное состояние интерфейса.
Для организации совместного редактирования обычно используются:
Через npm:
npm install @stomp/stompjs
Подключение:
import { Client } from '@stomp/stompjs';
Базовая конфигурация:
import { Client } from '@stomp/stompjs';
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000
});
client.activate();
Ключевые параметры:
| Параметр | Назначение |
|---|---|
| brokerURL | Адрес WebSocket-сервера |
| reconnectDelay | Автопереподключение |
| heartbeatIncoming | Проверка активности сервера |
| heartbeatOutgoing | Проверка активности клиента |
Изменение документа обычно представляется в виде операции:
{
type: 'ins ert',
position: 15,
val ue: 'Hello'
}
Либо:
{
type: 'delete',
start: 10,
end: 20
}
Расширенная структура:
{
documentId: 'doc-1',
userId: 'user-15',
version: 42,
operation: {
type: 'ins ert',
position: 120,
val ue: 'Text'
},
timestamp: Date.now()
}
После соединения клиент подписывается на канал документа.
client.onConn ect = () => {
client.subscribe('/topic/document/1', (message) => {
const payload = JSON.parse(message.body);
applyRemoteOperation(payload);
});
};
Топики могут формироваться динамически:
const topic = `/topic/document/${documentId}`;
Каждое локальное изменение отправляется через
publish.
function sendOperation(operation) {
client.publish({
destination: '/app/document/edit',
body: JSON.stringify(operation)
});
}
Пример:
sendOperation({
documentId: '1',
type: 'ins ert',
position: 5,
val ue: 'A'
});
Простейшая реализация:
const editor = document.querySelector('#editor');
editor.addEventListener('input', () => {
const operation = {
documentId: '1',
content: editor.value
};
client.publish({
destination: '/app/document/update',
body: JSON.stringify(operation)
});
});
Получение обновлений:
client.subscribe('/topic/document/1', (message) => {
const data = JSON.parse(message.body);
editor.value = data.content;
});
Подобный подход подходит только для небольших демонстрационных проектов, поскольку при каждом изменении передаётся весь документ.
Гораздо эффективнее передавать только операции изменения.
Пример вставки:
{
type: 'ins ert',
position: 10,
text: 'Hello'
}
Пример удаления:
{
type: 'remove',
start: 5,
end: 15
}
Преимущества:
Функция вставки:
function applyInsert(textarea, operation) {
const val ue = textarea.value;
textarea.value =
value.slice(0, operation.position) +
operation.text +
value.slice(operation.position);
}
Удаление:
function applyDelete(textarea, operation) {
const value = textarea.value;
textarea.value =
value.slice(0, operation.start) +
value.slice(operation.end);
}
Обработка операций:
function applyRemoteOperation(operation) {
switch (operation.type) {
case 'ins ert':
applyInsert(editor, operation);
break;
case 'remove':
applyDelete(editor, operation);
break;
}
}
Без дополнительной логики клиент может повторно применять собственные изменения.
Решение — использовать идентификатор пользователя.
const currentUserId = crypto.randomUUID();
Отправка:
client.publish({
destination: '/app/document/edit',
body: JSON.stringify({
userId: currentUserId,
operation
})
});
Фильтрация:
client.subscribe('/topic/document/1', (message) => {
const data = JSON.parse(message.body);
if (data.userId === currentUserId) {
return;
}
applyRemoteOperation(data.operation);
});
Главная проблема совместного редактирования — конфликты изменений.
Пример:
Пользователь A вставляет символ на позиции 5
Пользователь B удаляет символ на позиции 5
Возможные решения:
OT трансформирует операции относительно других операций.
Пример:
A: insert(5, "X")
B: delete(5)
После трансформации:
delete(6)
STOMP.js не реализует OT самостоятельно, но отлично подходит как транспортный слой.
Схема:
Редактор → OT Engine → STOMP.js → Сервер
Популярные OT-движки:
Каждая операция содержит номер версии.
{
version: 18,
operation: {
type: 'insert',
position: 25,
text: 'A'
}
}
Сервер проверяет:
clientVersion === serverVersion
Если версии различаются:
При рассинхронизации клиент может запросить полный документ.
client.publish({
destination: '/app/document/sync',
body: JSON.stringify({
documentId: '1'
})
});
Ответ:
client.subscribe('/user/queue/document-sync', (message) => {
const data = JSON.parse(message.body);
editor.val ue = data.content;
});
STOMP поддерживает персональные каналы.
Пример:
client.subscribe('/user/queue/errors', callback);
Использование:
Для совместного редактирования важна информация о присутствии участников.
Отправка heartbeat-событий:
setInterval(() => {
client.publish({
destination: '/app/presence',
body: JSON.stringify({
userId: currentUserId,
documentId: '1'
})
});
}, 5000);
Подписка:
client.subscribe('/topic/presence/1', (message) => {
const user = JSON.parse(message.body);
updatePresence(user);
});
Передача позиции курсора:
editor.addEventListener('keyup', () => {
client.publish({
destination: '/app/cursor',
body: JSON.stringify({
userId: currentUserId,
position: editor.selectionStart
})
});
});
Получение:
client.subscribe('/topic/cursor/1', (message) => {
const data = JSON.parse(message.body);
renderCursor(data);
});
Нельзя отправлять сообщение на каждый символ без ограничений.
Используется debounce:
function debounce(callback, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
callback(...args);
}, delay);
};
}
Применение:
const sendChanges = debounce((operation) => {
client.publish({
destination: '/app/document/edit',
body: JSON.stringify(operation)
});
}, 100);
Вместо отдельных сообщений:
ins ert
insert
insert
Можно отправлять массив:
{
operations: [
{ type: 'insert', text: 'H' },
{ type: 'insert', text: 'e' },
{ type: 'insert', text: 'y' }
]
}
Преимущества:
Для критически важных изменений используется подтверждение доставки.
Подписка:
client.subscribe('/topic/document/1', callback, {
ack: 'client'
});
Подтверждение:
client.subscribe('/topic/document/1', (message) => {
const operation = JSON.parse(message.body);
applyRemoteOperation(operation);
message.ack();
}, {
ack: 'client'
});
STOMP поддерживает транзакции.
const tx = client.begin();
Отправка:
client.publish({
destination: '/app/document/edit',
body: JSON.stringify(operation),
transaction: tx.id
});
Подтверждение:
tx.commit();
Отмена:
tx.abort();
Транзакции полезны при:
Потеря WebSocket-соединения — обычная ситуация.
Автоматическое восстановление:
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
reconnectDelay: 3000
});
Дополнительная логика:
client.onWebSocketCl ose = () => {
showOfflineIndicator();
};
После восстановления:
client.onConn ect = () => {
requestFullSync();
};
Во время отсутствия соединения операции могут накапливаться локально.
const pendingOperations = [];
Добавление:
function queueOperation(operation) {
pendingOperations.push(operation);
}
Повторная отправка:
function flushQueue() {
while (pendingOperations.length) {
const operation = pendingOperations.shift();
client.publish({
destination: '/app/document/edit',
body: JSON.stringify(operation)
});
}
}
Ключевые методы оптимизации:
Плохо:
{
content: entireDocument
}
Хорошо:
{
type: 'insert',
position: 10,
text: 'A'
}
Можно использовать:
Используются:
Сначала операция применяется локально:
applyLocalOperation(operation);
И только затем отправляется:
sendOperation(operation);
Это уменьшает ощущение задержки интерфейса.
Совместное редактирование требует строгой авторизации.
Передача токена:
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
connectHeaders: {
Authorization: 'Bearer token'
}
});
На сервере проверяются:
Нельзя доверять входящим сообщениям.
Пример проверки:
function validateOperation(operation) {
if (typeof operation.position !== 'number') {
return false;
}
if (typeof operation.text !== 'string') {
return false;
}
return true;
}
Совместное редактирование часто требует аудита.
Пример события:
{
userId: '15',
documentId: '1',
operation: 'insert',
timestamp: 1710000000
}
Использование:
Операции можно хранить в стеке.
const undoStack = [];
const redoStack = [];
Добавление:
undoStack.push(operation);
Отмена:
const operation = undoStack.pop();
Инверсия операции:
function invert(operation) {
if (operation.type === 'insert') {
return {
type: 'remove',
start: operation.position,
end: operation.position + operation.text.length
};
}
}
При большом количестве пользователей появляются дополнительные задачи:
Плохо:
/topic/all-documents
Хорошо:
/topic/document/1
/topic/document/2
/topic/document/3
Это уменьшает объём ненужного трафика.
Сервер может объединять операции:
insert A
insert B
insert C
В одно сообщение:
insert ABC
Подобная оптимизация особенно важна при высокой скорости печати.
STOMP.js часто используется вместе с:
Пример для CodeMirror:
editor.on('change', (instance, change) => {
client.publish({
destination: '/app/document/edit',
body: JSON.stringify(change)
});
});
Операции должны быть компактными.
Хорошо:
{
t: 'i',
p: 10,
v: 'A'
}
Плохо:
{
operationType: 'insert',
insertionPosition: 10,
insertedVal ue: 'A'
}
STOMP.js предоставляет callback-события.
Ошибки STOMP:
client.onStompEr ror = (frame) => {
console.error(frame.headers.message);
};
Ошибки WebSocket:
client.onWebSocketEr ror = (event) => {
console.error(event);
};
Типичная схема production-решения:
Browser
↓
STOMP.js
↓
WebSocket Gateway
↓
Broker Cluster
↓
Collaboration Service
↓
Database
Дополнительно используются:
Причины:
Решение:
{
operationId: crypto.randomUUID()
}
Решение:
Решение:
let suppressChanges = false;
Пример:
if (suppressChanges) {
return;
}
Причины:
Решения:
{
operationId: 'op-100',
documentId: 'doc-1',
userId: 'user-15',
version: 42,
type: 'insert',
position: 120,
text: 'Hello',
timestamp: Date.now()
}
Подобная структура обеспечивает: