Предыдущие главы объясняли каждую часть в нескольких строках. Модель приложения больше: функциональные части в отдельных модулях, хранилища (store), читающие друг друга, эффекты (effect), обращающиеся к серверу. Эта глава — об устройстве модели, которое согласуется с правилами ядра.
Состояние приложений для разбора
План курса называет приложения для подробного разбора: сначала примеры самого курса, затем модели echd-os, edu-island-kan и analytics — каждое после своего первого этапа миграции. На коммите, которым обоснована эта страница, ни одно из них ещё не импортирует @fiberfx/hypergraph; все три продолжают импортировать предшествующий пакет. Поэтому в репозитории пока нет модели приложения для пошагового разбора, и глава её не выдумывает.
Что уже есть:
- Собственная модель пакета: тест публичного API строит типизированную модель из экспортов пакета и запускает её. На ней основан итоговый пример.
- Проверка примеров сайта: каждый блок TypeScript или JavaScript на страницах Hypergraph собирается как модуль временного приложения mx командой
mx build --bundle. Имя, которое пакет не экспортирует, приводит к ошибке сборки.
После миграции трёх приложений их модели станут примерами этой главы. Пока правила ниже опираются на ядро, его тесты и векторы.
Модуль на каждую функциональную часть
Объявляйте единицы Hypergraph (unit) каждой функциональной части на верхнем уровне её собственного модуля. Единица принадлежит поколению кода, которое её создало. Все модули приложения загружаются в одно поколение, поэтому единицы разных модулей свободно связываются друг с другом. Единицы двух разных поколений связывать нельзя. Это существенно только при горячей замене: код каждого поколения связывает собственные единицы.
// cart.ts
import { event, store } from "@fiberfx/hypergraph";
export const itemAdded = event<{ id: string; price: number }>();
export const cleared = event();
export const $items = store<{ id: string; price: number }[]>([], { sid: "cart/items" })
.on(itemAdded, (items, item) => [...items, item])
.reset(cleared);
export const $total = $items.map((items) => items.reduce((sum, item) => sum + item.price, 0));
События на входе, производные хранилища на выходе
Интерфейс функциональной части — события (event), которые другой код может вызывать, и хранилища, которые он может читать. Типы уже задают эту границу:
EventCallable<T>можно вызвать; у производногоEvent<T>, полученного через.mapили.filter, нет сигнатуры вызова.- У
StoreWritable<T>есть.onи.reset; уStore<T>, типа любого производного хранилища, нет ни того, ни другого.
Собственный тест типов пакета помечает оба неправильных действия как ошибки компиляции: вызов хранилища и .on у производного хранилища. Остальное ядро отклоняет во время выполнения: вызов производной единицы выбрасывает derived-call, а указание её как target — derived-target.
Функциональная часть может выдавать свои изменяемые хранилища под более узким типом, чтобы другие части читали их, а запись оставалась внутри:
// cart-public.ts
import type { Store } from "@fiberfx/hypergraph";
import { $items, $total } from "./cart";
export const items: Store<{ id: string; price: number }[]> = $items;
export const total: Store<number> = $total;
Эта граница действует при компиляции, а не во время выполнения: дескриптор остаётся тем же объектом.
Идентичность в большой модели
Sid сохраняет идентичность хранилища между сервером и клиентом и между правками. Предполагается, что его задаёт компилятор; sid в конфигурации позволяет автору указать его явно. Курс задаёт его вручную, чтобы ключи кадров состояния (frame) были видны. Для множества модулей действуют три правила:
- Один sid — одно хранилище во всём поколении. Загрузка модуля, чей sid уже использует другая единица того же поколения, отклоняется с указанием обеих единиц независимо от того, находятся они в одном модуле или в двух. Следующее поколение может повторно использовать sid: правило действует внутри поколения, а не между ними.
- Замена графа тоже отклоняет дубликат с
duplicate-sidи сохраняет загруженное поколение действующим. - Единицы, созданные обычными вызовами, не проходят эту проверку загрузки. Если явно заданный sid повторяется там, код кадра области находит по sid первое использующее его хранилище. Ни тест, ни вектор не задают, что происходит со значением второго хранилища, поэтому на это нельзя полагаться. Префикс функциональной части в sid (
cart/items) помогает избежать совпадений.
Эффекты на границах
Помещайте вызовы, выходящие за пределы модели, — запросы, таймеры, сокеты — в эффекты, а всё остальное выражайте чистыми связями:
- Тест или сервер задаёт эффекту другой обработчик для конкретной изолированной области состояния (scope) через
fork({handlers}). Поэтому функциональной части, у которой все побочные действия оформлены эффектами, не нужна подмена модулей. attach({effect, source, mapParams})строит параметры эффекта из состояния, поэтому место вызова передаёт только действие пользователя.- Обработчик помечает свой эффект в графе модели как доступный только в JavaScript. Объявленная спецификация (
{request},{channel}или{timer}) — данные, которые могла бы исполнить другая среда; её исполнители имеют уровень готовности Preview.
// checkout.ts
import { attach, effect, sample } from "@fiberfx/hypergraph";
import { $items, cleared } from "./cart";
const postOrder = effect(async (items: { id: string; price: number }[]) => {
const response = await fetch("/api/orders", { method: "POST", body: JSON.stringify(items) });
return (await response.json()) as { orderId: string };
});
export const checkout = attach({ effect: postOrder, source: $items, mapParams: (_: void, items) => items });
sample({ clock: checkout.done, target: cleared });
Связи между функциональными частями
Одна часть читает другую через её экспортируемые хранилища и реагирует на её экспортируемые события: sample с событием одной части как clock и хранилищем другой как source, combine для хранилищ разных частей, merge для событий нескольких частей. Всё это преобразуется в рёбра единого графа. Поэтому запуск, пересекающий границы частей, остаётся одним запуском: он доходит до состояния покоя без промежуточных несогласованных значений.
Что передавать
Каждое изменяемое хранилище отвечает на один вопрос: должно ли его значение передаваться кадром клиенту и при замене графа?
| Хранилище | Что ему задать |
|---|---|
| состояние, которое клиент или следующая правка должны сохранить | уникальный sid |
значение, не представимое обычным JSON, например Date |
sid и serialize: {write, read} |
| секрет, кеш или временное рабочее состояние | serialize: 'ignore' |
| производное хранилище | ничего: оно вычисляется заново там, где применяется кадр |
Именно эти хранилища — изменяемые, с sid, не исключённые из сериализации — горячая замена сохраняет либо сбрасывает с указанием причины (горячая замена).
Что проверить в модели
- По модулю на функциональную часть; единицы на его верхнем уровне.
- Вызываемые события и производные хранилища образуют интерфейс части; изменяемые хранилища записываются только внутри неё.
- Уникальный
sidс префиксом функциональной части у каждого передаваемого хранилища;serialize: 'ignore'у каждого непередаваемого. - Каждый вызов за пределы модели находится внутри эффекта, чтобы область могла его заменить.
- Тест каждой части в собственной области и один тест передачи и восстановления кадра всей модели, как в итоговом примере.