Мышление в States и Streams
Машинное отделение
Эта страница не нужна, чтобы строить на Continuum — для повседневной работы хватает Понятий. Читайте её, когда захотите понять, почему фреймворк может обещать «без багов обновления»: модель под капотом — классический FRP, и она по-настоящему элегантна.
Continuum — фреймворк классического FRP. Это не «реактивность» в смысле сигналов или прокси-обёрток: это две точно определённые абстракции времени и жёсткие гарантии их композиции. Туториал учит думать в этой модели; операционные детали (ранги, фазы транзакции, устройство flatten) разобраны в FRP-MODEL.
Проблема: состояние как синхронизация
В обработчико-центричном коде состояние — это переменные, которые обработчики синхронизируют между собой:
let count = 0;
button.addEventListener("click", () => {
count++;
label.textContent = String(count); // не забыть!
submit.disabled = count === 0; // не забыть и это!
});Каждое новое место, зависящее от count, — ещё одна строчка в каждом обработчике, который его меняет. Забыли одну — интерфейс рассинхронизировался. React решает это перезапуском: «поменялось состояние — перерисуем всё поддерево и сравним». Continuum решает иначе: зависимость объявляется один раз, при построении, и дальше поддерживается сетью.
Два типа времени
Вся модель — два типа:
State<A>(в FRP-литературе — Behavior) — значение, которое существует в каждый момент времени. ДенотационноTime → A: у него нельзя спросить «пришло ли», у него можно только спросить «какое сейчас». Позиция мыши, текст поля ввода, текущий счётчик, URL.Stream<A>— дискретные вхождения: в какие-то моменты происходит что-то со значениемA, в остальные — не происходит ничего. Клик, нажатие клавиши, ответ сервера, тик таймера.
Первый вопрос при проектировании любой фичи: это значение или событие?
| Спрашиваете себя | Тип |
|---|---|
| «Что сейчас введено / выбрано / загружено?» | State |
| «Произошёл ли клик / сабмит / ответ?» | Stream |
Ошибка в выборе всегда мстит: событие, засунутое в State («флажок justClicked»), требует ручного сброса; значение, размазанное по событиям, требует ручной синхронизации — мы вернулись к тому, от чего уходили.
Сеть строится, а не выполняется
Компонент в Continuum — обычная функция, которая выполняется один раз и строит две вещи: FRP-сеть и привязанный к ней DOM.
import { stream } from "@continuum-js/frp";
function Counter() {
const clicks = stream<MouseEvent>();
const count = clicks.accum(0, (_e, n) => n + 1);
const parity = count.map((n) => (n % 2 === 0 ? "чёт" : "нечет"));
return (
<button onClick={clicks.fire}>
count: {count} ({parity})
</button>
);
}Прочитайте это не как «код, который выполнится при клике», а как схему: clicks → accum → count → map → parity, и два текстовых узла, подключённых к count и parity. Клик прокатывается по схеме; функция Counter больше не вызывается никогда. (Состояние — accum — привязано к скоупу компонента и умрёт вместе с ним; чистые производные вроде parity — формулы, живущие по спросу.)
Зависимости здесь не перечисляются в массиве — они и есть структура выражения. parity построен из count, значит зависит от него. Забыть зависимость синтаксически невозможно.
Несколько источников комбинируются явно — данные вперёд, функция последней:
import { combine } from "@continuum-js/frp";
const total = combine(priceW, qtyW, (price, qty) => price * qty);Момент времени: одновременность честная
Все обновления происходят в транзакциях — атомарных моментах логического времени. Если из одного события выведены две ветки, а из них — общий потомок («ромб»), потомок пересчитается один раз, увидев обе ветки уже обновлёнными:
const doubled = count.map((n) => n * 2);
const squared = count.map((n) => n * n);
const sum = combine(doubled, squared, (d, s) => d + s);
// при count: 2 → 3 sum перейдёт 8 → 15 одним шагом,
// промежуточного «6 + 4» не существует ни в один моментЭто и называется «отсутствие глитчей»: полуобновлённое состояние не просто редко наблюдается — его не существует в модели.
Два события в одном моменте — тоже честная одновременность. Stream.merge принимает функцию коалесинга и сворачивает одновременные вхождения в одно:
const either = Stream.merge(left, right, (a, b) => a + b);Почему hold задержан: прошлое доступно, настоящее — нет
e.hold(init) превращает событие в State: «последнее значение e». Ключевое правило: State обновляется на границе момента. Внутри того же момента, в котором пришло событие, hold ещё показывает старое значение.
Зачем так? Чтобы «текущее значение» было определено всегда, даже когда вы смотрите на него из события, которое его же и меняет:
const submit = stream<void>();
const draft = input.hold(""); // текст поля
// какой текст отправляем? тот, что был НА МОМЕНТ сабмита:
const submitted = draft.at(submit);Если бы hold обновлялся мгновенно, ответ на вопрос «а что видит at, когда input и submit пришли одновременно?» зависел бы от порядка подписок — классическая гонка. С задержанным hold ответ определён математически: at видит значение до этого момента. Прошлое стабильно; настоящее ещё формируется.
at вместо «прочитать в обработчике»
Привычка из обработчико-центричного мира — «в колбэке дочитаю, что нужно, из переменных». FRP-эквивалент — at: событие захватывает значение State в свой момент:
// «при сабмите взять текст и очистить поле»
const submitted = draft.at(submitClicks);Разница с чтением в колбэке — та же, что выше: у at есть точная семантика одновременности, у «дочитаю в обработчике» — порядок исполнения.
Рендеринг: DOM подключён к сети
State в JSX — это привязка, а не значение:
{count}в детях — текстовый узел, который патчится при изменении;class={cls}с State — атрибут, который обновляется сам;bindInput(text)— двусторонняя привязка поля (ячейка сама знает свойset).
Пока меняются только значения, структура DOM неподвижна. Когда должна измениться структура, это объявляется явно:
import { Show, Each } from "@continuum-js/dom";
<Show when={user} fallback={() => <Login />}>
{(u) => <Profile user={u} />}
</Show>
<Each each={todos} by={(t) => t.id}>
{(t) => <TodoRow todo={t} />}
</Each>Show перестраивает поддерево только при смене истинности условия. Each переиспользует строки по ключу и переставляет их минимальным числом перемещений; строка перерендеривается только если её ключ исчез и появился. Granularity правило простое: значение — привязка, структура — Show/Each/dyn.
Владение: кто построил, тот и уберёт
Всё, что компонент создаёт — подписки, таймеры, состояние (hold/accum), эффекты (perform), — регистрируется в дереве владения. Уходит поддерево (переключился Show, удалилась строка Each, размонтировалось приложение) — каскадно выполняются его onCleanup, а процессы состояния отцепляются от источников:
import { onCleanup, onMount } from "@continuum-js/dom";
import { stream } from "@continuum-js/frp";
function Clock() {
const tick = stream<number>();
const id = setInterval(() => tick.fire(Date.now()), 1000);
onCleanup(() => clearInterval(id));
const input = (<input />) as HTMLInputElement;
onMount(() => input.focus()); // узлы уже в документе
return (
<div>
{tick.hold(Date.now()).map((t) => new Date(t).toLocaleTimeString())}
{input}
</div>
);
}onMount — зеркальный onCleanup: выполняется один раз, когда узлы скоупа вставлены в DOM (фокус, измерения, сторонние библиотеки). Дочерние скоупы монтируются раньше родительских. И это обычные функции — никаких правил хуков: можно звать в условиях, циклах, хелперах. Единственное правило — у состояния должен быть владелец: внутри компонента он есть автоматически, на уровне модуля объявите его явно — root(() => …).
Асинхронность: IO как данные
Сеть чистая; мир входит в неё через явные люки. perform превращает событие запросов в событие результатов, где ошибка — значение, а не исключение:
import { perform, type Result } from "@continuum-js/frp";
const results = perform(queries, async (q): Promise<Page> => fetchPage(q));
// Stream<Result<unknown, Page>> — { ok: true, value } | { ok: false, error }Для типичного «загрузи и покажи» есть resource из @continuum-js/std: State с состояниями idle | loading | ok | error, которые рендерятся обычным dyn — никакого Suspense-механизма, это просто данные.
Куда дальше
- FRP-MODEL — операционный разбор: фазы транзакции, ранги,
flatten, непрерывное время, с отсылками к книге Functional Reactive Programming (Blackheath & Jones). - Из React — таблица соответствий и парные примеры.
npm create continuum-js@latest— заготовка проекта, чтобы пробовать по ходу чтения.
Незнакомый термин? Вся терминология этой документации объяснена в глоссарии.