Интеграция Yjs для real-time collaboration в мобильном приложении
Y.js — CRDT-библиотека на JavaScript, которую всё чаще тащат в React Native-проекты, рассчитывая получить Google Docs-опыт в мобильном приложении. Реальность сложнее: Y.js проектировался под браузерное окружение, у него нет официального Flutter SDK, а Hermes на старых RN-версиях встречает WASM-бинарник @automerge/automerge с паникой при инициализации. Разберём, где настоящие грабли.
Как устроена синхронизация в Y.js
Каждый Y.Doc содержит внутренний стейт-вектор — Map<clientId, maxClock>. При подключении двух клиентов они обмениваются своими стейт-векторами и запрашивают только дельту: Y.encodeStateAsUpdateV2(doc, remoteStateVector). Это дифференциальный протокол — при реконнекте не нужно передавать весь документ.
Транспортный уровень реализован через провайдеры:
| Провайдер | Транспорт | Особенности |
|---|---|---|
y-websocket |
WebSocket | Официальный, есть серверная часть |
y-webrtc |
WebRTC DataChannel | P2P, нет в RN без полифила |
y-indexeddb |
IndexedDB | Только браузер |
| Кастомный | SQLite / AsyncStorage | Нужна ручная реализация для RN |
Для React Native: y-websocket на транспортном уровне работает через react-native-get-random-values + нативный WebSocket. Персистентность — кастомный провайдер поверх react-native-sqlite-storage или op-sqlite.
Персистентность через SQLite в React Native
Готового y-sqlite-провайдера для RN нет. Минимальная реализация:
import * as Y from 'yjs';
import { openDatabase } from 'react-native-sqlite-storage';
const db = openDatabase({ name: 'collab.db' });
// Инициализация таблицы
db.transaction(tx => {
tx.executeSql(
'CREATE TABLE IF NOT EXISTS ydocs (id TEXT PRIMARY KEY, update BLOB, ts INTEGER)'
);
});
// Сохранение при каждом изменении
ydoc.on('updateV2', (update: Uint8Array, origin: unknown) => {
if (origin === 'sqlite-load') return; // не зацикливаемся
const encoded = Buffer.from(update).toString('base64');
db.transaction(tx => {
tx.executeSql(
'INSERT OR REPLACE INTO ydocs (id, update, ts) VALUES (?, ?, ?)',
[docId, encoded, Date.now()]
);
});
});
// Загрузка при открытии
db.transaction(tx => {
tx.executeSql('SELECT update FROM ydocs WHERE id = ?', [docId], (_, result) => {
if (result.rows.length > 0) {
const raw = Buffer.from(result.rows.item(0).update, 'base64');
Y.applyUpdateV2(ydoc, new Uint8Array(raw), 'sqlite-load');
}
});
});
Проблема с этим подходом: при частом редактировании (типпинг в реальном времени) updateV2 триггерится при каждом символе. Батчинг обязателен — debounce на 300–500 мс или накопление через Y.mergeUpdatesV2. Без этого в продакшене у вас быстро закончится место на устройстве, а транзакции в SQLite начнут блокировать JS-тред.
Серверная часть: y-websocket vs собственный сервер
Официальный y-websocket сервер минималистичен — хранит документы в памяти. Для продакшена нужно:
-
Персистентность — сохранение
Y.encodeStateAsUpdateV2()при отключении последнего клиента. Подходит LevelDB (пакетy-leveldb) или PostgreSQL с BYTEA-колонкой. -
Авторизация —
y-websocketне проверяет токены. Нужен middleware, перехватывающий Upgrade-запрос и проверяющий JWT до апгрейда соединения. - Масштабирование — один процесс y-websocket не знает о других. При горизонтальном масштабировании — Redis PubSub как шина между узлами.
Альтернатива: Hocuspocus (надстройка над y-websocket с авторизацией, хуками и готовой персистентностью). Для большинства проектов Hocuspocus закрывает 90% серверных потребностей без написания custom-сервера.
Типичные ошибки при интеграции в React Native
clientID Y.js генерируется случайно при создании Y.Doc. Если создавать новый Y.Doc при каждом маунте компонента — клиент будет иметь новый ID после каждого размонтирования, и стейт-вектор сервера будет накапливать мёртвые записи. Фикс: хранить ydoc в ref или глобальном стейте, не пересоздавать.
Awareness (курсоры, онлайн-статус) через y-protocols/awareness требует активного WebSocket. При переходе приложения в фоновый режим на iOS WebSocket может быть убит через 30–60 секунд. awareness.setLocalState(null) нужно вызывать в обработчике AppState.change → background, иначе пользователь будет висеть в списке онлайн-участников после сворачивания приложения.
Flutter: Y.js через JS runtime
Для Flutter нативного порта Y.js нет. Варианты:
-
flutter_js— запускает V8/QuickJS, весит ~5 МБ. Y.js работает, но производительность на больших документах оставляет желать. - Нативный Dart CRDT:
crdtпакет от Cachapa — реализует LWW-CRDT, не совместим с Y.js по протоколу. - Rust FFI через
yrs(Rust-реализация Y.js) +flutter_rust_bridge— наиболее производительный путь, но 4–6 недель только на биндинги.
Оценка
React Native + Y.js + кастомный SQLite-провайдер + Hocuspocus бэкенд: 6–10 недель. Flutter через yrs FFI: 10–16 недель. Включает: персистентность, awareness, reconnect-логику с exponential backoff, тесты на конфликты при одновременном редактировании. Стоимость рассчитывается индивидуально после анализа требований.







