Двоичные обновления и слияние
Мы научились изменять общие типы и различать локальные источники транзакций. Теперь рассмотрим, как другая реплика узнаёт об этих изменениях. У Yjs есть двоичные updates: они содержат операции и необходимую структуру для согласования документа. В этом уроке передадим состояние второй копии «Черновика», добавим там пункт и применим результат обратно, не вводя пока сетевой сервер.
Состояние lesson-04 продолжает документ markdown и добавляет кнопку «Передать копии». Независимая реплика создаётся в памяти, получает текущие данные, дописывает строку «Проверить сроки» и отдаёт свой update исходному документу. Такой локальный опыт отделяет правила слияния от задержки сети, права комнаты и долговременного хранения. Все эти свойства будут добавлены позднее отдельными слоями.
Состояние в двоичном виде
encodeStateAsUpdate кодирует известное состояние документа как Uint8Array. Это не UTF-8-файл Markdown: вместе с видимыми символами в пакете присутствует информация о структуре операций. applyUpdate применяет такое представление к другой реплике. Получателю не обязательно знать, какой именно пользователь нажал кнопку или через какой транспорт пришли байты, чтобы согласовать структуру.
Не пытайтесь заменить update результатом text.toString. Строка удобна для экспорта документа пользователю, но теряет информацию, которая позволяет согласовывать параллельные изменения. Если две стороны независимо заменят своё состояние чужой строкой, получится стратегия последней полной записи. Она не равнозначна обмену CRDT-операциями и не сохраняет тот же механизм объединения.
Ниже показан точный фрагмент кнопки из src/app.js; определения doc и text уже существуют в openEditor. Полная программа находится в каталоге урока. Значение источника transfer назначено только для локального различения импорта и ввода, а не для подтверждения личности другой стороны.
$('exchange').onclick=()=>{const copy=new Y.Doc();Y.applyUpdate(copy,Y.encodeStateAsUpdate(doc),'transfer');copy.getText('markdown').insert(copy.getText('markdown').length,'\n- Проверить сроки');Y.applyUpdate(doc,Y.encodeStateAsUpdate(copy),'transfer');$('report').textContent=copy.getText('markdown').toString();copy.destroy();};
После самостоятельного выполнения команда должна добавить в исходный черновик новый пункт и показать содержимое второй копии в блоке отчёта. Затем временная копия уничтожается. Повторное нажатие создаст ещё одну независимую операцию добавления пункта: одинаковая видимая строка, введённая разными действиями, не считается одним и тем же update. Это ожидаемая семантика листинга, а не проведённый эксперимент.
Повторная доставка
Двоичные обновления рассчитаны на повторную доставку и согласование в различном порядке. Повторное применение одного и того же пакета не должно повторно вставлять уже известные структурные элементы. Но создание нового документа и новая вставка такой же фразы порождают другую операцию. Поэтому нужно различать повторную передачу байтов и повторное выполнение пользовательской команды.
Это различие важно при восстановлении сети. Если транспорт не уверен, получил ли сервер пакет, он может передать тот же update снова. Однако нельзя заново интерпретировать сохранённый текст как очередную команду вставки. Тогда повторный запрос уже создаёт новые данные. На уровне продукта аналогичная проблема возникает с кнопкой ревизии: одинаковый label не означает автоматически одну серверную ревизию.
CRDT обеспечивает согласование доставленных операций по правилам своих типов. Он не угадывает идеальную литературную формулировку. Когда Анна и Борис одновременно дописывают один пункт, результат может содержать обе вставки в порядке, установленном структурой документа. Пользователи всё равно должны прочитать объединённый текст и при необходимости отредактировать его смысл. «Без конфликтов структуры» не означает «без редакторских разногласий».
Вектор состояния
Полное состояние удобно для первого объяснения, но при повторном соединении передавать всё содержимое не обязательно. Вектор состояния описывает, какие диапазоны операций известны реплике. Получив такой вектор, другая сторона может вызвать encodeStateAsUpdate с ним и подготовить недостающую часть. Вектор не является текстовой версией документа, номером ревизии или отметкой времени последней правки.
Рассмотрим ситуацию: Борис некоторое время работал без сети, а Анна в это время меняла общий черновик. При возвращении обе стороны имеют часть новых данных. Односторонней загрузки с сервера недостаточно, поскольку локальные правки Бориса тоже нужно передать. Полезно обменяться сведениями о состоянии и затем недостающими updates в обоих направлениях. Эту процедуру provider получит в уроке о reconnect.
Устройство вектора объясняет, почему сравнение только строк не является полноценной проверкой синхронизации. Два документа могут показывать одинаковый текст, но отличаться внутренней известной историей операций. И наоборот, после получения недостающих структур текущая строка может не измениться, например если пакет относится к уже удалённому содержимому. Для транспорта это всё равно существенная работа.
Байты и транспорт
Браузерный update можно передавать в двоичном WebSocket-кадре. Наш будущий учебный протокол использует JSON с base64, чтобы легче читать типы сообщений и служебные поля. Base64 увеличивает объём и требует аккуратного преобразования Uint8Array; оно не шифрует содержимое. Нельзя считать документ секретным только потому, что его пакет выглядит набором непонятных символов.
Сервер должен ограничивать размер входящих сообщений и обрабатывать ошибочные пакеты. Публично доступный обработчик applyUpdate без предварительной проверки доступа предоставляет канал изменения документа любому подключившемуся клиенту. Сам формат update не содержит решения «пользователь имеет право редактировать draft-42». Поэтому сетевой шлюз сначала проверяет комнату и сессию, затем принимает допустимые сообщения.
Отдельная граница — хранение. Получив update и применив его в памяти, сервер ещё не гарантирует восстановление после остановки процесса. Подтверждение «получено» относится к транспорту, а подтверждение «записано» должно отправляться после успешной записи выбранного хранилища. Позднее мы введём различимые статусы, чтобы интерфейс не смешивал эти события одним зелёным индикатором.
Для самостоятельного анализа сохраните один и тот же пакет в переменной, примените его к новой копии дважды и сравните этот сценарий с двумя отдельными вставками одинакового текста. Предполагаемые результаты различаются именно из-за идентичности операций. После этого объясните, какие данные нужны для экспорта Markdown, какие для синхронизации, а какие для истории продукта. Следующий урок добавит состояние присутствия, которому долговременная CRDT-история не требуется.
Оглавление серии · Исходные состояния проекта.
API сверены с первичной документацией 11 октября 2026 года. Пример имеет статус not_run: фактический запуск и наблюдение результата читателю предстоит выполнить самостоятельно.