В 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.