Обзор
Continuum — UI-фреймворк, в котором компоненты выполняются один раз, а состояние само держит DOM в синхроне.
Как он сравнивается
Continuum — класса Solid по скорости и памяти, но в разы меньше по весу. Весь счётчик ниже — фреймворк, реактивность, рендерер и код приложения — это 5.6 кБ gzip-JavaScript.
Размер бандла (gzip, полное приложение-счётчик)
| Continuum | Solid | React + ReactDOM |
|---|---|---|
| 5.6 кБ ✅ | ~7 кБ | ~45 кБ (~8×) |
Память — 10 000 строк, heap после GC
Замер на таблице js-framework-benchmark (npm run bench:mem, Chromium, медиана из 3):
| Heap после GC | Continuum | Solid |
|---|---|---|
| 10 000 строк | 9.7 МБ ✅ | 14.1 МБ |
| после очистки | 1.96 МБ | 1.94 МБ |
| DOM-слушатели | 27 (делегированы) | 27 |
−32 % кучи на 10k строк, а очистка возвращается к базовой линии — без утечек, паритет с Solid.
Скорость — чистый JS на операцию (script ms, меньше — лучше)
Та же таблица, вывод нашего компилятора против Solid (npm run bench, медиана из 10):
| Операция | Continuum | Solid | |
|---|---|---|---|
| создать 1 000 строк | 4.2 | 3.4 | |
| создать 10 000 строк | 32.9 | 27.8 | ~1.2× |
| выбрать строку | 0.10 | 0.20 | 2× быстрее ✅ |
| поменять местами | 0.80 | 0.70 | |
| удалить строку | 0.85 | 0.60 | |
| очистить 1 000 | 2.65 | 2.85 | быстрее ✅ |
| мусор GC, создать 10k | 8.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 секунд
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 />);Три движения — и это вся основная модель:
- Создать состояние —
state(0)даёт State — реактивное значение с методом.set. КакuseState, только компонент никогда не перезапускается. - Вывести —
count.map(n => n * 2)— значение, вычисленное из другого. Без массива зависимостей:doubleзависит отcount, потому что построен из него. - Привязать — значение в JSX (
{count},class={cls}) подключает к нему ровно этот текстовый узел или атрибут. Меняется значение — обновляется только этот узел.
Ни хуков, ни ре-рендеров, ни мемоизации, ни виртуального DOM.
Часть, которая делает его Continuum
Точечный рендеринг можно получить и в других местах. Чего в других местах не получить: здесь само изменение — это значение. Stream — это история того, что происходит: клики, отправленные формы, ответы сервера, — а состояние это свёртка этой истории:
const actions = stream<Action>();
const todos = actions.accum([], reduce); // состояние = всё, что случилось, свёрнутоеЗачем это нужно? Потому что когда каждое изменение проходит через один поток, фичи, на которые обычно берут по библиотеке, стоят по одной дополнительной свёртке:
- Undo/redo — сверните те же действия в историю (рецепт);
- Персист — зеркальте свёрнутое значение в сторадж (рецепт);
- Синхронизация вкладок — событие
storageэто просто ещё один диспетчер в тот же редьюсер; - Поиск без гонок — запросы это поток, поэтому «последний запрос побеждает» решено один раз, в библиотеке (рецепт).
Где это окупается
Две фичи целиком — каждый сниппет и есть вся фича, а не выдержка из неё.
Undo в десять строк. Сверните те же действия не в значение, а в историю; редьюсер не меняется ни на букву:
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 — устаревший ответ физически не может перезаписать свежий, потому что «последний запрос побеждает» решено один раз, внутри библиотеки:
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:tsxbatch(() => { price.set(99); currency.set("EUR"); }); // каждая формула над обоими обновится один раз — «99 USD» не увидеть никогдаЧестная асинхронность. Результаты и ошибки IO возвращаются обычными данными; гонки ответов решены один раз, в библиотеке (
resource, last-request-wins).Детерминированная уборка. Значения — формулы, а состояние и эффекты принадлежат скоупу: всё, что компонент создал, уничтожается вместе с его поддеревом — подписки, таймеры, порталы.
Теория не нужна, чтобы пользоваться фреймворком — она рядом, когда захочется понять, почему это работает: глубокое погружение.
Пакеты
| Пакет | Что внутри | Размер (brotli) |
|---|---|---|
@continuum-js/frp | реактивные значения и потоки, движок обновлений | ~2.3 kB |
@continuum-js/dom | JSX-рендерер, Show/Each, жизненный цикл, контекст | ~4.5 kB с frp |
@continuum-js/std | debounce, resource, persist, … | ~2.6 kB с frp |
@continuum-js/router | вложенные роуты, guards, ленивый код-сплиттинг | ~4.6 kB со всем |
@continuum-js/test | render/fire/type/flush для vitest | только dev |
Куда дальше
- Впервые слышите про реактивность? Что такое FRP — на пальцах.
- Быстрый старт — работающее приложение одной командой.
- Примеры — маленькие законченные приложения прямо в браузере.
- Понятия — по одной идее на страницу.
- Из React — карта переноса, конструкция за конструкцией.
- Когда станет интересно, как устроен движок: Мышление в States и Streams.
Незнакомый термин? Вся терминология этой документации объяснена в глоссарии.