Micra.js под капотом: как устроен реактивный фреймворк

·

Это длинный текст про то, как изнутри устроена Micra.js — маленькая библиотека, которая добавляет реактивность к HTML, отрендеренному на сервере. Я написал её, чтобы закрыть конкретную нишу: страницы и админки, где сервер уже отдаёт готовую разметку, а на клиенте нужно лишь немного интерактивности — переключить вкладку, открыть модалку, отфильтровать таблицу, сходить за данными. Раньше для этого тянулись к jQuery, потом к Alpine. Micra — это попытка дать тот же «сахар», но предсказуемо, типизированно и с учётом Content-Security-Policy.

В этой статье я хочу рассказать про инженерию: что происходит под капотом, почему именно так, и какие концепции из «большого» программирования прячутся за каждым модулем. По дороге я постараюсь объяснить вещи вроде микрозадач, абстрактного синтаксического дерева и алгоритма наибольшей возрастающей подпоследовательности.

Главный принцип: оживить разметку

Прежде чем смотреть на код, нужно понять одно решение, из которого вытекает всё остальное. Micra не строит DOM. Она его находит. Вы пишете обычный HTML — руками или шаблонизатором на сервере — и расставляете в нём атрибуты-директивы:

<div data-component="counter">
<button @click="decrement"></button>
<strong data-text="count"></strong>
<button @click="increment">+</button>
</div>

Когда страница загрузилась, вы вызываете Micra.start(). Он сканирует документ, находит элементы с data-component, и для каждого создаёт «экземпляр» — объект с состоянием и методами, связанный с этим куском разметки. Дальше Micra следит за состоянием и обновляет только те узлы, которые этого требуют.

React и подобные ему фреймворки идут от противоположного: разметка — это результат выполнения JavaScript, дерево живёт в памяти как виртуальный DOM, а HTML — лишь его проекция. Это мощно, но дорого, потому что нужен сборщик, нужен рантайм на десятки килобайт, нужно, чтобы весь интерфейс прошёл через JS. Такое решение оправдано приложения-SPA. Но для небольших сайтов, простых SaaS или админок это совершенно излишне.

То есть задача Micra — «оживить серверный HTML», добавив немного динамических изменений. Представьте, что вы делаете поиск на своем блоге, добавляете скрывающуюся панель навигации или модальное окно. Для этих случаев часто хочется выбрать что-то полегче React.

Из принципа «оживляем серверный HTML» следует и бюджет размера. Изначально я пытался уместить все решения в 5 килобайт. Однако в ходе разработки пришлось подвигуться в сторону 7 килобайт в gzip. Почти каждое решение ниже принималось с оглядкой на байты и я очень старался чтобы понятия «маленькое» и
«правильное» совпадали.

Карта местности

Micra — это несколько крошечных модулей, каждый делает одну вещь:

  • реактивное состояниеProxy, который замечает записи в state;
  • планировщик — собирает пачку изменений в один перерисовочный проход;
  • сканер — один обход дерева, который находит все директивы и кэширует их;
  • директивы — функции, которые читают состояние и пишут в DOM;
  • вычислитель выражений — превращает строки вроде count > 0 в значения без eval;
  • рендер списков — keyed-diff для data-each;
  • события — привязка @click, модификаторы, автоматическая очистка;
  • шина событий — общение между компонентами;
  • fetch, жизненный цикл, типы — обвязка вокруг ядра.

Когда вы пишете this.state.count++, по цепочке происходит вот что: Proxy ловит запись, говорит планировщику «нужен рендер», тот ставит одну микрозадачу; в микрозадаче запускается render(), который берёт закэшированный результат сканирования, прогоняет по нему директивы, те вычисляют выражения и обновляют DOM. Разберём каждое звено.

Реактивность: почему Proxy и почему «неглубокий»

Реактивность — это когда вы меняете данные, а интерфейс обновляется сам. Чтобы это работало, библиотеке нужно узнать о том, что данные изменились. Есть два пути. Можно вызывать специальную функцию (setState, this.set), — тогда библиотека узнаёт об изменении в момент вызова. А можно перехватывать сами присваивания, чтобы this.state.count = 5 работало как обычный JavaScript, но втайне дёргало нужный механизм. Micra идёт вторым путём, и инструмент для этого —
Proxy.

Proxy — это объект-обёртка вокруг другого объекта. Вы задаёте «ловушки» (traps) на операции: чтение свойства, запись, удаление. Когда кто-то обращается к обёртке, вместо обычного поведения вызывается ваша ловушка. Весь реактивный слой Micra — это буквально несколько строк:

export function createReactiveState(obj, schedule, onKey) {
return new Proxy(obj, {
set(target, key, value) {
target[key] = value; // сделать обычную запись
onKey?.(key); // запомнить, какой ключ изменился
schedule(); // попросить перерисовку
return true;
},
});
}

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

Ловушка стоит только на верхнем уровне объекта. Тот самый неглубокий (shallow). Это значит, что вот так интерфейс обновится:

this.state.user = { ...this.state.user, name: "Ана" }; // запись в state.user — ловушка сработала

а вот так — нет:

this.state.user.name = "Ана"; // запись в user.name, а не в state.user — ловушка молчит

Во втором случае мы пишем во вложенный объект user, а его-то Proxy не оборачивал
— оборачивали только state.

Почему не сделать глубокую реактивность, чтобы отслеживалось всё на любой вложенности? Потому что это дорого. Во-первых, по размеру и скорости: пришлось бы рекурсивно оборачивать каждый вложенный объект в свой Proxy, лениво создавать их при доступе, хранить связи. Во-вторых, по предсказуемости: глубокие прокси порождают баги, когда один и тот же объект лежит в двух местах состояния, или когда вы передаёте кусок состояния во внешнюю функцию и теряете реактивность. Неглубокая модель честнее: «реактивны только записи в верхний уровень state; хочешь обновить вложенное — замени верхний ключ целиком». Это та же дисциплина иммутабельности, к которой пришёл и React со своим setState({ ...prev }).

Чтобы убрать боль от ручного «расплющивания» вложенных объектов на каждый инпут формы, есть синтаксический сахар — this.set('user.name', x) и data-model="user.name". Под ним — функция setPath, которая делает ровно то, что вы написали бы руками: пересобирает цепочку вложенных объектов и переприсваивает верхний ключ.

export function setPath(state, path, value) {
const parts = path.split("."); // 'user.address.city' → ['user','address','city']
const top = parts[0];
if (parts.length === 1) {
state[top] = value;
return;
} // плоский путь — обычная запись
const root = { ...state[top] }; // копируем верхний объект
let cur = root;
for (let i = 1; i < parts.length - 1; i++) {
cur = cur[parts[i]] = { ...cur[parts[i]] }; // копируем каждый промежуточный уровень
}
cur[parts[parts.length - 1]] = value; // пишем в самый глубокий
state[top] = root; // ← вот эта запись будит Proxy
}

Заметьте: последней строкой мы пишем в state[top] — верхний уровень. Именно она запускает один-единственный рендер. Это синтаксических сахар над неглубокой реактивностью. Разница принципиальная: мы не платим за отслеживание всего дерева, мы лишь автоматизируем правильный иммутабельный апдейт.

Планировщик: почему один рендер на пачку изменений

Допустим, в обработчике вы пишете три раза подряд:

this.state.loading = false;
this.state.page = 2;
this.state.items = nextItems;

Наивная реакция — перерисовать интерфейс три раза. Это и медленно, и приводит к видимым «мерцаниям» промежуточных состояний. Правильно — собрать все синхронные изменения в одну пачку и перерисовать один раз, когда стек вызовов опустеет. Для этого нужен планировщик, и тут на сцену выходят микрозадачи.

Браузерный событийный цикл устроен так: есть макрозадачи (обработчик клика, таймер, сетевой ответ) и микрозадачи (то, что ставит Promise.then и queueMicrotask). После того как очередная макрозадача досчитает свой синхронный код до конца, браузер полностью опустошает очередь микрозадач — и только потом, возможно, перерисовывает экран. То есть микрозадача — это идеальное место для «отложи до конца текущего кода, но успей до отрисовки».

Планировщик Micra пользуется этим напрямую:

export function createScheduler(render) {
let pending = false;
const flush = () => {
pending = false;
render();
};
return function schedule() {
if (pending) return; // рендер уже запланирован — выходим
pending = true;
queueMicrotask(flush); // одна микрозадача на всю пачку
};
}

Первая запись в состояние ставит флаг pending и заводит микрозадачу. Вторая и третья видят поднятый флаг и просто выходят. Когда синхронный код обработчика закончится, браузер выполнит единственную микрозадачу, flush сбросит флаг и вызовет render() один раз. Три записи — один рендер. Без таймеров, лишних аллокаций промисов, регистрации и смс.

Три синхронные записи в состояние сходятся в один schedule(), который ставит одну микрозадачу flush() и вызывает render() ровно один раз

Сканер: один обход вместо десяти

Когда приходит время рендерить, нужно знать, где в разметке живут директивы — какие элементы имеют data-text, какие data-if, какие @click. Простое решение — querySelectorAll('[data-text]'), querySelectorAll('[data-if]') и так далее: с десяток обходов дерева на каждый рендер. Однако такое решение расточительно, особенно если рендеров много.

Micra обходит поддерево компонента ровно один раз — через TreeWalker, низкоуровневый браузерный итератор по узлам. За один проход функция classify смотрит атрибуты каждого элемента и раскладывает его по «корзинам»: текстовые привязки в одну, условия в другую, события в третью. Результат — объект ScanIndex — кэшируется прямо на корневом элементе компонента (el.__micraScan). Первый рендер платит за обход; все последующие берут готовый индекс и не трогают DOM-дерево вовсе.

В классификации спрятаны две приятные мелочи. Первая — проверка по коду первого символа атрибута:

const first = name.charCodeAt(0);
if (first === 64 /* '@' */) {
/* это событие */
}
if (first === 100 /* 'd' */ && name.charCodeAt(4) === 45 /* '-' */) {
/* это data-* */
}

Большинство атрибутов (id, class, href) — не наши. Сравнение одного числа отсекает их без накладных расходов на сравнение строк.

Вторая — TreeWalker настроен возвращать FILTER_REJECT на любом вложенном data-component. Это значит, что родительский компонент даже не заходит в поддеревья дочерних: каждый компонент владеет только своими директивами. В результате, получаем своего рода бесплатную изоляцию.

Кроме того, TreeWalker по умолчанию не заходит в содержимое <template> — а это ровно то, что нужно, потому что строки списка живут в template.content и обрабатываются отдельным модулем.

Слева — десяток отдельных проходов querySelectorAll по дереву на каждый рендер; справа — один проход TreeWalker, раскладывающий директивы по корзинам ScanIndex, который кэшируется на элементе

Директивы: чтение состояния, запись в DOM

Директива — это маленькая чистая функция «прочитай состояние, обнови один аспект DOM». data-text ставит textContent, data-show переключает style.display, data-bind — атрибуты, data-class — классы. Все они тривиальны, кроме двух мест, где прячется характерное решение.

Первое — data-if. Он не прячет элемент через display: none, а физически удаляет его из дерева, оставляя на его месте пустой комментарий-заглушку; когда условие снова становится истинным, элемент возвращается на место заглушки. Очевидно, что скрытый через CSS элемент всё ещё в дереве доступности: скринридеры его видят, фокус по табу может в него попасть, его дочерние компоненты живут и тратят ресурсы. data-if убирает узел по-настоящему. А когда нужно именно дешёвое визуальное переключение без удаления — есть data-show.

Второе — семантика data-bind. Тут важно различать типы значений. Если выражение вернуло булево true/false, атрибут трактуется как присутствующий/отсутствующий. Так работают настоящие булевы атрибуты HTML вроде disabled и hidden. Если возвращается строка, то она ставится как есть. Проблема в том, что ARIA-состояния — это не булевы атрибуты HTML: aria-expanded обязан содержать буквальную строку "true" или "false", а пустое aria-expanded="" или отсутствие атрибута для скринридера значат другое. Поэтому голое булево тут баг:

<!-- Баг: open === true → aria-expanded="" ; open === false → атрибут удалён -->
<button data-bind="aria-expanded: open"></button>
<!-- всегда строка: "true" или "false" -->
<button data-bind="aria-expanded: open ? 'true' : 'false'"></button>

Вычислитель выражений без eval

В директивах вы пишете JavaScript-подобные выражения: data-if="count > 0", data-text="formatDate(user.createdAt)", data-class="active: tab === 'home'". Кто-то должен превратить эти строки в значения. Самый простой способ — new Function('count', 'return count > 0') или eval. Именно так делает, например, Alpine. И именно поэтому Alpine не работает под строгой Content-Security-Policy.

CSP — это заголовок, которым сервер говорит браузеру, какой код тот имеет право исполнять. Строгая политика default-src 'self' без 'unsafe-eval' запрещает eval и new Function целиком: любая попытка превратить строку в исполняемый код падает с ошибкой. Значит, eval отпадает. А раз отпадает — нужно написать собственный вычислитель выражений. Это самый сложный модуль в библиотеке, и он состоит из трёх классических этапов любого интерпретатора: токенизатор, парсер, интерпретатор.

Токенизатор: строка → поток лексем

Токенизатор (он же лексер) разбивает строку на «лексемы» — неделимые кусочки: числа, строки, идентификаторы, знаки операций. Выражение price * qty превращается в поток [id "price", punct "*", id "qty"]. Это скучная, но необходимая работа: дальше парсеру удобнее работать с потоком осмысленных токенов, чем с символами. Токенизатор Micra — это один цикл while по символам строки: увидел кавычку — собирай строку до закрывающей; увидел цифру — собирай число; увидел букву — собирай идентификатор; иначе ищи самый длинный подходящий знак операции.

Абстрактное синтаксическое дерево

Прежде чем говорить о парсере, нужно объяснить, что он строит — абстрактное синтаксическое дерево (Abstract Syntax Tree, AST). Это представление выражения в виде дерева, где каждый узел — операция или значение, а связи отражают структуру и приоритеты. Возьмём price * qty + 10. Мы со школы знаем, что умножение «сильнее» сложения, поэтому выражение означает (price * qty) + 10. AST кодирует это явно:

Дерево разбора price * qty + 10: в корне сложение, его левый ребёнок — умножение с листьями price и qty, правый ребёнок — литерал 10

Корень дерева — сложение, потому что оно выполняется последним (на самом верхнем уровне). Его левый ребёнок — умножение, правый — литерал 10. Когда дерево построено, вычислить выражение легко: достаточно рекурсивно обойди его снизу вверх. Никаких приоритетов больше помнить не надо — они уже «сохранены» в форму дерева.

Поэтому парсинг и вычисление — это два разных этапа: парсер один раз разбирается с приоритетами и строит дерево, а интерпретатор потом сколько угодно раз его обходит. В Micra узлы дерева описаны компактным типом: литерал, идентификатор, доступ к свойству (mem), вызов (call), унарная операция (un), бинарная (bin), тернарный оператор (tern).

Парсер: почему именно Pratt

Парсер превращает плоский поток токенов в дерево, правильно расставляя приоритеты.

Наивный способ — рекурсивный спуск с одной функцией на каждый уровень приоритета: parseTernary зовёт parseOr, тот parseAnd, тот parseEquality, тот parseComparison, тот parseAddition, тот parseMultiplication, тот parseUnary. Семь уровней приоритета — семь почти одинаковых функций, каждая из которых отличается от соседней только списком операторов и тем, кого она зовёт ниже. Это работает, но тут будет много почти дублирующегося кода. Поэтому велик риск выйти за рамки бюджета 7кб.

Pratt-парсинг (в варианте «карабкания по приоритетам», precedence climbing) сворачивает все эти уровни в одну функцию, параметризованную минимальным приоритетом. Идея в таблице: каждому бинарному оператору присвоено число — его «сила связывания».

const BIN_PREC = {
"||": 1,
"&&": 2,
"==": 3,
"!=": 3,
"===": 3,
"!==": 3,
"<": 4,
"<=": 4,
">": 4,
">=": 4,
"+": 5,
"-": 5,
"*": 6,
"/": 6,
"%": 6,
};

А вся логика разбора бинарных операций умещается в один цикл:

function parseBin(minPrec) {
let left = parseUnary();
for (;;) {
const t = peek();
const prec = t ? BIN_PREC[t.v] : undefined;
if (prec === undefined || prec < minPrec) break; // оператор слишком «слабый» — стоп
next();
const right = parseBin(prec + 1); // правую часть парсим с приоритетом выше
left = { k: "bin", op: t.v, l: left, r: right };
}
return left;
}

Прочитайте этот цикл медленно — в нём вся суть. Мы разбираем левый операнд, затем смотрим на следующий оператор. Если его приоритет ниже планки minPrec — мы останавливаемся и отдаём то, что собрали, наверх (там оператор послабее заберёт нас себе левым операндом). Если выше или равен — мы «съедаем» оператор и рекурсивно разбираем правую часть, но уже с планкой prec + 1. Это + 1 — то, что делает операторы левоассоциативными: a - b - c собирается как (a - b) - c, а не a - (b - c). Один цикл с таблицей заменяет семь функций, легко расширяется (добавить оператор — это строчка в таблице), и весит он считанные байты.

Интерпретатор и модель безопасности

Когда дерево построено, интерпретатор обходит его рекурсивно: литерал возвращает своё значение, бинарный узел вычисляет левую и правую ветви и применяет операцию, тернарный выбирает ветку, и так далее. Логические && и || сделаны с коротким замыканием — как в настоящем JS, они возвращают значение операнда.

Но интереснее не что интерпретатор умеет, а чего он принципиально не может. Когда выражение упоминает голый идентификатор — скажем, count — интерпретатор ищет его в строго определённом порядке: сперва в состоянии компонента, потом в белом списке безопасных глобалов (Math, JSON, Date, String, Number…). Если имени нет ни там, ни там — оно превращается в undefined. Это значит, что window, document, fetch, eval недостижимы по построению. То есть мы их не спрятали или заслонили, а в области видимости выражения их просто нет. Нет переменной — нет доступа.

Отдельно закрыт классический трюк побега через прототип: item.constructor.constructor("…")() — цепочка, которой в старых eval-based движках можно было добраться до конструктора Function и в обход всего исполнить произвольный код. При доступе к свойству интерпретатор отказывает именам __proto__, constructor, prototype. Стоит подчеркнуть честно: вызовы методов по-прежнему исполняют настоящий JS — если ваш метод трогает window, он его тронет. Шаблоны директив — это доверенный код, который пишете вы; защита здесь от того, чтобы выражение в разметке случайно или злонамеренно не дотянулось до опасного глобала, а не песочница для чужого кода.

Двухуровневый кэш

Разбор строки в дерево — не бесплатная операция, и делать её на каждый рендер было бы глупо. Поэтому результат кэшируется по самой строке выражения. Но есть и более тонкая оптимизация: подавляющее большинство выражений в реальных шаблонах — это простые пути вроде count, user.name, item.email. Для них незачем гонять токенизатор и парсер. Регулярка распознаёт «простой путь», и тогда выражение хранится просто как массив частей ['user', 'name'], а вычисление — это проход по объекту. Полноценный AST строится только для настоящих выражений с операциями. Так горячий путь остаётся дешёвым, а сложный аппарат включается только когда он действительно нужен.

Как выражения видят и состояние, и методы

Производные значения — счётчики, суммы, отфильтрованные подмножества — в Micra не хранятся в состоянии, а вычисляются методами. data-text="totalCount()" вызывает метод totalCount. Чтобы это работало, выражение должно видеть не только state, но и методы компонента. За это отвечает exprStateProxy, который при чтении сперва отдаёт собственные ключи состояния, а если их нет — отдаёт метод компонента, предварительно привязав ему this (чтобы внутри метода this.state работал, как ожидается). Привязанные копии методов мемоизируются, чтобы повторные чтения были дешёвыми. Здесь же закрыт ещё один периметр: имена с Object.prototype (constructor, toString и прочие) через выражение недостижимы — вернётся undefined, а не утечёт прототип.

Почему производные значения — методы, а не поля состояния? Потому что поле — это второй источник правды, который рано или поздно разойдётся с первым. Если хранить и todos, и todosCount, то любой код, забывший обновить счётчик, порождает баг. Метод же вычисляется на чтение и всегда консистентен. А кэш выражений и пакетный рендер делают повторные вызовы достаточно дешёвыми, чтобы за эту чистоту не было стыдно платить.

data-each: keyed-diff и зачем тут целый алгоритм

Списки — самая нагруженная часть любого UI. data-each рендерит список из
<template>:

<template data-each="rows()" data-key="id">
<tr>
<td data-text="item.name"></td>
</tr>
</template>

Простейшая реализация — на каждое изменение стереть все строки и нарисовать заново. Но это убивает состояние DOM: теряется фокус в инпуте внутри строки, прерываются CSS-анимации, браузер делает лишнюю работу. Правильно — сопоставить старые узлы с новыми и тронуть только то, что реально изменилось. Это и есть keyed-diff, и ключ к нему — атрибут data-key, который даёт каждой строке устойчивую идентичность.

Имея ключи, Micra переиспользует существующие DOM-узлы там, где ключ совпал, создаёт узлы только для новых ключей и удаляет узлы исчезнувших. Но самое тонкое — это переупорядочивание. Допустим, список переставили. Сколько узлов нужно физически подвигать в DOM? Наивно — все. Оптимально — как можно меньше. И тут включается алгоритм наибольшей возрастающей подпоследовательности (longest increasing subsequence, LIS).

Сопоставим каждому узлу в новом порядке его позицию в старом порядке — получим массив чисел. Если найти в нём самую длинную подпоследовательность строго возрастающих чисел, то эти узлы уже стоят в правильном относительном порядке друг к другу — их можно вообще не трогать. Двигать нужно только остальные, вставляя их относительно «неподвижного скелета». Для перестановки двух соседних строк это означает два перемещения вместо перерисовки всего списка. Micra считает LIS методом «пасьянса» (patience sorting) за O(n log n) и делает ровно столько операций с DOM, сколько узлов реально сменили позицию.

Список A B C D E переставлен в A C D E B. Наибольшая возрастающая подпоследовательность — A C D E — остаётся неподвижным скелетом, физически перемещается только узел B: одна операция с DOM вместо перерисовки пяти

Строка шаблона может содержать один корневой элемент (<tr>) или несколько узлов. Если корень один — он и становится узлом строки. Если несколько — они оборачиваются в служебный <micra-each-item style="display:contents">, чтобы строка всегда соответствовала одному стабильному узлу DOM (а display:contents делает обёртку визуально прозрачной). Тонкость в том, что определение «один корень» обязано игнорировать пробельные текстовые узлы — иначе красиво отформатированный <template> с переносами строк вокруг <tr> посчитался бы многокорневым, и обёртка <micra-each-item> поехала бы внутрь <tbody>, ломая селекторы tbody > tr. И каждой строке достаётся своё «состояние строки» через Object.create(state) — объект, который своими полями держит item и index, а через цепочку прототипов дотягивается до общего состояния и методов компонента. Поэтому в строке работают и item.name, и вызовы методов.

События, модификаторы и почему важна очистка

@click="increment" и data-on="click:save" навешивают обработчики. Под капотом есть две формы записи: голое имя метода (increment — вызовется instance.increment) и выражение-вызов (select(item.id) — вычислится против области видимости строки, с доступом к item и $event). Вторая форма проходит через тот же вычислитель выражений, что и директивы.

Модификаторы — @keydown.enter, @click.ctrl.prevent, @click.self — это маленький конвейер проверок перед запуском обработчика. И тут есть неочевидная тонкость: проверки идут в порядке записи и прерываются на первой несостоявшейся. Поэтому @keydown.enter.prevent означает «если клавиша Enter — тогда preventDefault и обработчик», а не «всегда preventDefault». Сначала идёт страж клавиши, и только если он прошёл, выполняется prevent. Если бы порядок был обратный, prevent срабатывал бы на каждое нажатие. Поэтому тут порядок важен.

Каждый навешанный обработчик записывается в список на экземпляре компонента. Когда компонент уничтожается, destroy() проходит по списку и снимает все слушатели. Это лечит целый класс утечек, типичных для «ручного» jQuery-кода: там обработчик, навешанный через addEventListener и забытый, продолжает держать ссылку на узел и замыкание после того, как элемент логически исчез. В Micra жизненный цикл слушателя привязан к жизненному циклу компонента — навесил декларативно, забыл, оно само снимется.

Шина событий: как компоненты разговаривают

Компоненты изолированы — у каждого своё состояние. Но иногда им нужно общаться: дропдаун выбрал значение, таблица должна перефильтроваться. Прямые ссылки между компонентами создали бы хрупкую связность, поэтому общение идёт через шину — Map, где ключ это имя события, а значение — Set обработчиков. emit пробегает по подписчикам и зовёт каждого, оборачивая вызов в try/catch, чтобы ошибка в одном подписчике не уронила остальных. Подписка через this.on(...) автоматически регистрируется для очистки на destroy() — та же дисциплина, что и с DOM-слушателями. Объявив в интерфейсе MicraEvents: 'cart:updated': { count: number }, вы получаете проверку типов на emit и on во всём проекте.

Жизненный цикл: что делает mount от и до

Всё это связывается в mount — функции, которая превращает определение компонента в живой экземпляр. Она копирует методы из определения на экземпляр; навешивает помощники (prop, set, fetch, emit, on); оборачивает состояние в реактивный Proxy, соединённый с планировщиком; строит exprState; и определяет render() — функцию, которая берёт (закэшированный) результат сканирования, прогоняет директивы, рендерит списки, привязывает события и собирает рефы. Затем первый рендер, и вызов onCreate в микрозадаче (поэтому в onCreate уже доступны рефы и безопасно ходить за данными). Симметрично, onDestroy вызывается при уничтожении, после того как сняты все слушатели и отписаны все подписки шины.

В render спрятана ещё одна защита: флаг isRendering. Если внутри вычисления выражения вы случайно мутируете состояние, это запустило бы рендер во время рендера — бесконечную или просто неожиданную рекурсию. Флаг ловит такую ситуация, гасит вложенный рендер и один раз предупреждает в консоль: «перенеси запись состояния в метод».

Дисциплина размера и чего внутри нет

Я уже сказал про бюджет 7 килобайт. Я бы очень хотел уместить всё-всё и еще немного сверху. Но очевидно, что в этом случае библиотека сильно разрастется. Во время сборки измеряется gzip-размер итогового бандла и роняет билд, если он превысил 7168 байт.

Именно из-за бюджета, кстати, помощник resource() для загрузки данных — это рецепт для копирования, а не встроенная функция: он полезен, но не настолько, чтобы тратить на него драгоценные байты ядра.

В Micra нет роутера, нет глубокой реактивности, нет собственной системы веб-компонентов, нет растущего «языка» внутри директив (туда сознательно не пускают литералы объектов и массивов — логика живёт в методах, а не в разметке). Каждая такая фича разменяла бы ровно то, ради чего Micra создавалась: предсказуемость, небольшой размер и отсутствие сборки (вообще она конечно есть, но абсолютно необязательна). Дисциплина останавливаться — едва ли не самое сложное в разработке инструмента, потому что добавить фичу всегда выглядит как улучшение, а отказаться — как слабость. Но именно граница превращает библиотеку из «ещё одного фреймворка» в понятный выбор для конкретной задачи.

Когда Micra — именно ваш инструмент

У вас Rails, Laravel, Django, Phoenix или просто сервер на чём угодно, который уже отдаёт готовый HTML. Вам не нужен SPA, а совсем наоборот — нужна горстка интерактива поверх страниц: переключить вкладку, открыть модалку, отфильтровать таблицу, сходить за данными и показать состояние загрузки. Вы не хотите тащить сборщик, node_modules на сотни мегабайт и рантайм, который весит больше вашей бизнес-логики. Если это про вас — Micra была написана буквально для вас, и всё, что вы прочитали выше, работает на то, чтобы в вашем случае «просто работало» и не выросло в монстра.

Если же вы строите полноценное одностраничное приложение с клиентским роутингом, сложным клиентским состоянием и богатыми виджетами уровня дашборда трейдера — берите React, Vue или Svelte. Это их работа, и пытаться догнать их на семи килобайтах было бы глупостью, а не доблестью. Micra намеренно отказывается от этой битвы.

Самый честный способ проверить, ваш ли это инструмент, — попробовать его на чём-то маленьком уже на этой неделе. Возьмите один экран в ближайшем проекте — форму настроек, таблицу пользователей, выпадающий фильтр. Добавьте на страницу один <script>, напишите Micra.define(...) и Micra.start(). Без сборки, без конфигов, без церемоний — от пустого файла до реактивного компонента проходит минута. А дальше вы почувствуете то, ради чего всё это и затевалось: каково это, когда инструмент ровно по размеру задачи — ни больше, ни меньше.

Спасибо, что дочитали :) А теперь, самое время скачать и попробовать Micra.js в действии!