Skip to content

Обзор

Continuum — UI-фреймворк, в котором компоненты выполняются один раз, а состояние само держит DOM в синхроне.

Как он сравнивается

Continuum — класса Solid по скорости и памяти, но в разы меньше по весу. Весь счётчик ниже — фреймворк, реактивность, рендерер и код приложения — это 5.6 кБ gzip-JavaScript.

Размер бандла (gzip, полное приложение-счётчик)

ContinuumSolidReact + ReactDOM
5.6 кБ~7 кБ~45 кБ (~8×)

Память — 10 000 строк, heap после GC

Замер на таблице js-framework-benchmark (npm run bench:mem, Chromium, медиана из 3):

Heap после GCContinuumSolid
10 000 строк9.7 МБ14.1 МБ
после очистки1.96 МБ1.94 МБ
DOM-слушатели27 (делегированы)27

−32 % кучи на 10k строк, а очистка возвращается к базовой линии — без утечек, паритет с Solid.

Скорость — чистый JS на операцию (script ms, меньше — лучше)

Та же таблица, вывод нашего компилятора против Solid (npm run bench, медиана из 10):

ОперацияContinuumSolid
создать 1 000 строк4.23.4
создать 10 000 строк32.927.8~1.2×
выбрать строку0.100.202× быстрее ✅
поменять местами0.800.70
удалить строку0.850.60
очистить 1 0002.652.85быстрее ✅
мусор GC, создать 10k8.4 МБ13.8 МБ−39 % ✅

Continuum на равных с Solid в операциях, которые доминируют в реальных приложениях — обновления, выбор, перестановка, очистка — при этом создаёт ~40 % меньше мусора, и отстаёт лишь в ~1.2× на массовом создании строк. React — совсем другой класс: виртуальный DOM, который ре-рендерит и делает diff, в разы медленнее фреймворков класса Solid на той же таблице (см. js-framework-benchmark) и ~8× по весу.

Стоимость движка, на операцию

Транзакционные гарантии практически бесплатны. Замер в изолированных Node-процессах (npm run bench:core, @continuum-js/frp):

ОперацияВремяМусор
чтение state (.sample())0.5 нс0 Б
обновление state (.set())~35 нс~1 Б
происшествие в потоке (fire)~23 нс~2 Б
батч из 5 слитых обновлений~295 нс~6 Б

Один .set() — это полная транзакция (слияние одновременных, propagation по рангам, граница hold) за ~35 наносекунд и около одного байта мусора. Чтение — обычное обращение к полю.

Проверьте сами

npm run bench (скорость), npm run bench:mem (память), npm run bench:core (нс/оп движка), npm run size (байты) — одна машина, один харнесс, все фреймворки через один замер.

И Continuum даёт то, чего нет ни у одного из них: само изменение — это значение — undo, поиск без гонок и синхронизация между вкладками становятся одним свёртыванием каждое, а не библиотекой каждое.

Версия за 30 секунд

tsx
import { state } from "@continuum-js/frp";
import { mount } from "@continuum-js/dom";

function Counter() {
  const count = state(0);
  const double = count.map((n) => n * 2);
  return (
    <div>
      <button onClick={() => count.update((n) => n + 1)}>+1</button>
      <p>
        count: {count}, double: {double}
      </p>
    </div>
  );
}

mount(document.getElementById("app")!, () => <Counter />);

Три движения — и это вся основная модель:

  1. Создать состояниеstate(0) даёт State — реактивное значение с методом .set. Как useState, только компонент никогда не перезапускается.
  2. Вывестиcount.map(n => n * 2) — значение, вычисленное из другого. Без массива зависимостей: double зависит от count, потому что построен из него.
  3. Привязать — значение в JSX ({count}, class={cls}) подключает к нему ровно этот текстовый узел или атрибут. Меняется значение — обновляется только этот узел.

Ни хуков, ни ре-рендеров, ни мемоизации, ни виртуального DOM.

Часть, которая делает его Continuum

Точечный рендеринг можно получить и в других местах. Чего в других местах не получить: здесь само изменение — это значение. Stream — это история того, что происходит: клики, отправленные формы, ответы сервера, — а состояние это свёртка этой истории:

tsx
const actions = stream<Action>();
const todos = actions.accum([], reduce); // состояние = всё, что случилось, свёрнутое

Зачем это нужно? Потому что когда каждое изменение проходит через один поток, фичи, на которые обычно берут по библиотеке, стоят по одной дополнительной свёртке:

  • Undo/redo — сверните те же действия в историю (рецепт);
  • Персист — зеркальте свёрнутое значение в сторадж (рецепт);
  • Синхронизация вкладок — событие storage это просто ещё один диспетчер в тот же редьюсер;
  • Поиск без гонок — запросы это поток, поэтому «последний запрос побеждает» решено один раз, в библиотеке (рецепт).

Где это окупается

Две фичи целиком — каждый сниппет и есть вся фича, а не выдержка из неё.

Undo в десять строк. Сверните те же действия не в значение, а в историю; редьюсер не меняется ни на букву:

tsx
import { stream } from "@continuum-js/frp";

type Hist = { past: Todo[][]; present: Todo[] };

const actions = stream<Action>();
const history = actions.accum<Hist>({ past: [], present: [] }, (a, h) =>
  a.type === "undo"
    ? h.past.length
      ? { past: h.past.slice(0, -1), present: h.past.at(-1)! }
      : h
    : { past: [...h.past, h.present], present: reduce(a, h.present) },
);
const todos = history.map((h) => h.present);
const canUndo = history.map((h) => h.past.length > 0);

Поиск, который не умеет гоняться. Нажатия клавиш — поток: пропустите его через debounce и отдайте resource — устаревший ответ физически не может перезаписать свежий, потому что «последний запрос побеждает» решено один раз, внутри библиотеки:

tsx
import { state } from "@continuum-js/frp";
import { debounce, resource } from "@continuum-js/std";

const query = state("");
const results = resource(debounce(query.updates, 300), (q) =>
  fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json()),
);
// State<Async<T>>: idle → loading → ok | error

Правило, каким инструментом пользоваться: нет истории — state; история стоит того, чтобы её хранить — поток. Сам state — это сахар над stream + hold: идеален для полей формы, тогглов и всего, что просто перезаписывают. Как только ловите себя на вопросе «а как это значение сюда попало» — undo, аудит, синхронизация — потоковая форма это то же состояние, но с приложенной историей.

Почему этому можно верить

Под простой поверхностью — строгий движок: классический FRP (дискретная ветвь в духе Sodium). Что это даёт на практике:

  • Атомарные обновления. Когда одно изменение расходится по многим производным значениям, всё обновляется одним шагом. Производное значение не может увидеть полуобновлённое состояние — целый класс тонких UI-багов невозможен, а не просто маловероятен. Несколько источников, меняющихся вместе, — это один batch:

    tsx
    batch(() => {
      price.set(99);
      currency.set("EUR");
    });
    // каждая формула над обоими обновится один раз — «99 USD» не увидеть никогда
  • Честная асинхронность. Результаты и ошибки IO возвращаются обычными данными; гонки ответов решены один раз, в библиотеке (resource, last-request-wins).

  • Детерминированная уборка. Значения — формулы, а состояние и эффекты принадлежат скоупу: всё, что компонент создал, уничтожается вместе с его поддеревом — подписки, таймеры, порталы.

Теория не нужна, чтобы пользоваться фреймворком — она рядом, когда захочется понять, почему это работает: глубокое погружение.

Пакеты

ПакетЧто внутриРазмер (brotli)
@continuum-js/frpреактивные значения и потоки, движок обновлений~2.3 kB
@continuum-js/domJSX-рендерер, Show/Each, жизненный цикл, контекст~4.5 kB с frp
@continuum-js/stddebounce, resource, persist, …~2.6 kB с frp
@continuum-js/routerвложенные роуты, guards, ленивый код-сплиттинг~4.6 kB со всем
@continuum-js/testrender/fire/type/flush для vitestтолько dev

Куда дальше

  1. Впервые слышите про реактивность? Что такое FRP — на пальцах.
  2. Быстрый старт — работающее приложение одной командой.
  3. Примеры — маленькие законченные приложения прямо в браузере.
  4. Понятия — по одной идее на страницу.
  5. Из React — карта переноса, конструкция за конструкцией.
  6. Когда станет интересно, как устроен движок: Мышление в States и Streams.

Незнакомый термин? Вся терминология этой документации объяснена в глоссарии.