Перейти к содержанию
Mercury
Русский
Дизайн книга
Меню
Русский

Свойства и потоки: патчи по сети

При первом рендеринге передаются все свойства. Затем сервер отправляет только изменения в компактном формате JSON Patch, а потоки LiveView приходят как операции вставки или обновления, удаления и ограничения размера, адресуемые по DOM id каждого элемента.

В LM1 мы увидели, что свойства острова следуют за assigns его LiveView. Этот урок разбирает передачу данных: что сервер записывает в элемент при каждом рендеринге, как клиент это читает и какие два расширения JSON Patch нужны потокам LiveView.

Три атрибута

LiveMercury.mercury/1 записывает данные острова в три атрибута, а четвёртый указывает клиенту, какой из них читать:

Атрибут Содержимое Когда записывается
data-props все свойства в компактном JSON при первом и неподключённом рендеринге, а также при любом рендеринге с выключенным diff
data-props-diff изменения свойств в виде компактного патча при каждом последующем рендеринге с включённым diff
data-streams-diff изменения потоков в виде компактного патча при каждом рендеринге, в котором назначен поток
data-use-diff "true" или "false" — настройка diff в HTML каждого рендеринга; не входит в assigns, которые mercury/1 помечает изменёнными

«Когда записывается» означает, когда LiveView отправляет атрибут. mercury/1 помечает собственные вычисляемые assigns изменёнными, а LiveView отправляет только помеченные атрибуты. Поэтому повторный рендеринг при активном подключении и включённом diff отправляет data-props-diff, а data-props сохраняет снимок первого рендеринга. Тест проверяет именно такое поведение отслеживания изменений LiveView — как с assign socket, так и без него.

На клиенте updated() применяет data-props-diff, если data-use-diff равен "true", а в остальных случаях заново читает весь data-props. data-streams-diff применяется каждый раз.

Изменения свойств

При включённом diff — это значение по умолчанию (config :live_mercury, :enable_props_diff, читается при компиляции) — mercury/1 берёт только assigns, которые LiveView пометил изменёнными. Для каждого из них:

  • старого значения нет (карта изменений LiveView содержит true): одна операция replace по пути /<key> с новым значением;
  • старое значение есть: Jsonpatch.diff(old, new) под путём /<key>, поэтому передаются только изменившиеся листья. Структуры сначала проходят через LiveMercury.Encoder, а элементы списков сопоставляются по полю id.

Тесты показывают форму изменений. Свойство-карта, в котором изменились два поля:

assigns = %{user: %{name: "John", age: 30}, __changed__: %{}}
assigns = assign(assigns, :user, %{name: "Alice", age: 25})
# props_diff: replace /user/age 25, replace /user/name "Alice"

И список, в котором изменилось имя одного элемента, сопоставленного по id:

assigns = %{items: [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}], __changed__: %{}}
assigns = assign(assigns, :items, [%{id: 1, name: "Alice"}, %{id: 2, name: "New Bob"}])
# props_diff: replace /items/1/name "New Bob"

Каждая разница начинается с операции test в корне, содержащей случайное число. LiveMercury.Patch записывает её как nonce — n<digits>. Он нужен лишь для изменения текста атрибута, чтобы LiveView отправил его. Декодер клиента его пропускает.

Передайте острову diff={false}, чтобы при каждом рендеринге отправлять все свойства. Первый рендеринг всегда отправляет все свойства, а его разница пуста.

Потоки

Assign со значением Phoenix.LiveView.LiveStream — это поток, а не обычное свойство. Его изменения попадают в data-streams-diff; на клиенте каждый поток становится массивом под собственным ключом в свойствах компонента.

mercury/1 превращает ожидающие изменения потока в операции по следующим правилам:

Изменение потока Операция
сброс replace /<name> со значением []
удаление remove /<name>/$$<dom_id>
вставка в конец (at: -1) upsert /<name>/-
вставка по индексу upsert /<name>/<at>
только обновление replace /<name>/$$<dom_id>
вставка с ограничением размера limit /<name> со значением ограничения

Каждый элемент кодируется через LiveMercury.Encoder и получает поле __dom_id — идентификатор, назначенный LiveView. Первый и неподключённый рендеринг сначала заменяют каждый поток на [], затем применяют вставки, чтобы клиент начинал с пустого состояния. Последующие рендеринги вместо этого начинаются с nonce.

Первый рендеринг потока из двух пользователей:

stream = LiveStream.new(:users, make_ref(), users, [])
# streams_diff:
#   replace /users []
#   upsert /users/- %{"__dom_id" => "users-1", "id" => 1, "name" => "Alice", "age" => 30}
#   upsert /users/- %{"__dom_id" => "users-2", "id" => 2, "name" => "Bob", "age" => 25}

Последующее удаление Bob — это remove /users/$$users-2.

Формат передачи

LiveMercury.Patch.serialize/1 записывает операции в одну строку, а decodeCompactPatch на клиенте читает её обратно. Каждая операция состоит из однобуквенного кода, пути с префиксом длины и значения с тегом типа (кроме remove, у которой значения нет):

<op><path_len>:<path><value>
Код Операция Тег значения Значение
a add z null
d remove b0, b1 false, true
r replace n<len>:<number> число
u upsert s<len>:<string> строка
l limit J<len>:<json> компактный JSON
n<digits> nonce, пропускается

Длина измеряется как длина строки JavaScript — в кодовых единицах UTF-16, а не в байтах. Поэтому путь или значение может содержать любой символ без экранирования: длина zażółć равна 6, а 🚀 — 2.

Компактный JSON — это JSON, в котором каждый " записан как ^, чтобы поместить его в HTML-атрибут без экранирования сущностями. Буквальный ^ превращается в ~^, а буквальный ~ — в ~~. data-props использует то же кодирование, а decodeCompactJson выполняет обратное преобразование.

Пример из клиентских тестов:

import { decodeCompactPatch } from "@fiberfx/live-mercury";

decodeCompactPatch("n123r6:/countn1:6a8:/items/3s1:dd8:/items/0");
// [
//   { op: "replace", path: "/count", value: 6 },
//   { op: "add", path: "/items/3", value: "d" },
//   { op: "remove", path: "/items/0" },
// ]

decodeCompactPatch(null) и decodeCompactPatch("") возвращают []. Неизвестный код операции, неизвестный тег значения или некорректный префикс длины вызывает исключение.

Применение патча

applyPatch(document, patch) применяет массив операций, а applyOperation(document, operation) — одну операцию. Это JSON Patch (RFC 6902) с расширениями Live Mercury:

import { applyPatch, decodeCompactPatch } from "@fiberfx/live-mercury";

const next = applyPatch(props, decodeCompactPatch(payload));

applyPatch использует копирование при записи ниже корня. Когда пакет операций впервые затрагивает контейнер на пути операции, функция клонирует его. Поэтому вложенное значение, сохранённое предыдущим рендерингом, не меняется у него за спиной, а контейнер на незатронутом пути сохраняет идентичность. Сам корень изменяется на месте и возвращается: тест проверяет, что applyPatch(original, …) — это original. Поэтому хук при каждой отрисовке разворачивает свойства, потоки и функции в новый объект: компонент получает новый объект верхнего уровня при каждом обновлении.

Операции test принимаются, но ничего не делают: значения не сравниваются. getValueByPointer(document, pointer) читает одно значение по JSON Pointer, где - означает последний элемент массива, а $$<id> — элемент по его __dom_id.

upsert, limit и пути $$

Эти три расширения нужны для потоков.

  • Сегменты пути $$<id> адресуют элемент массива по его __dom_id, а не по индексу. Сравнение нестрогое, поэтому числовой __dom_id совпадает со своей строковой формой. Если элемента с таким id нет, операция пропускается с предупреждением в консоли.
  • upsert со значением, содержащим __dom_id, заменяет элемент массива с тем же __dom_id, если он есть, а иначе вставляет значение по индексу из пути (- — конец массива). Значение без __dom_id вставляется.
  • limit при n >= 0 оставляет первые n элементов, а при n < 0 — последние |n|. Путь операции указывает на массив.
import { applyOperation } from "@fiberfx/live-mercury";

const doc = { rows: [{ __dom_id: "a", v: 1 }, { __dom_id: "b", v: 2 }] };
applyOperation(doc, { op: "upsert", path: "/rows/-", value: { __dom_id: "a", v: 99 } });
// doc.rows: [{ __dom_id: "a", v: 99 }, { __dom_id: "b", v: 2 }]
applyOperation(doc, { op: "remove", path: "/rows/$$a" });
// doc.rows: [{ __dom_id: "b", v: 2 }]

После переподключения хук заново строит каждый поток из патча повторного монтирования, который начинается со сброса, вместо слияния с сохранёнными потоками. Этот обратный вызов разобран в LM1.