Реактивность вместо ручной работы с DOM: те же задачи на чистом JS и на Micra

·

На чистом JS интерактив делается руками: находишь элемент и меняешь его — переключаешь класс, ставишь атрибут, вписываешь текст. Для одной кнопки так и надо.

Тяжесть такого кода — в состоянии. «Открыто ли меню», «что сейчас в фильтре», «какие задачи отмечены» — всё это нигде не записано явно. Оно разбросано по классам, атрибутам и переменным в замыканиях, и ты вручную следишь, чтобы DOM ему соответствовал. Чем больше таких состояний и чем чаще они пересекаются, тем больше слежки и тем легче что-нибудь рассогласовать.

Реактивность меняет направление. Ты не проталкиваешь изменения в DOM по шагам — ты описываешь, что от чего зависит, и трогаешь только состояние. DOM подстраивается сам. Micra — небольшая библиотека ровно про это, поверх готового серверного HTML. Она ничего не заменяет и ни на что не претендует, просто закрывает нишу, где сервер уже отдал разметку, а на клиенте нужно немного интерактива.

Ниже — несколько одинаковых задач, каждая дважды: на чистом JS и на Micra. Разница в строчках будет, но интереснее другое — момент, когда ручная слежка за DOM перестаёт окупаться.

Подключение, чтобы примеры запускались:

<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>

Тоггл: здесь и менять нечего

Меню открыть-закрыть. Бит состояния, который читается в одном месте.

const btn = document.querySelector(".burger");
const menu = document.querySelector(".menu");
btn.addEventListener("click", () => menu.classList.toggle("open"));

Три строки. На Micra пришлось бы завести компонент с полем open и методом — длиннее и без всякой пользы. Если на странице ровно один такой переключатель, реактивность ему не нужна.

Но во фронтенде только дай что-нибудь усложнить. Поэтому, давайте посмотрим на случай, когда состояние читается в нескольких местах.

Одно состояние, несколько отражений

«Показать ещё»: по клику меняются видимость блока, текст кнопки и aria-expanded.

const btn = document.querySelector(".more-btn");
const extra = document.querySelector(".extra");
let expanded = false;
btn.addEventListener("click", () => {
expanded = !expanded;
extra.hidden = !expanded;
btn.textContent = expanded ? "Свернуть" : "Показать ещё";
btn.setAttribute("aria-expanded", String(expanded));
});

Тут хорошо видно императивность: на каждый клик ты перечисляешь, что в DOM поменять. Три обновления вручную, и expanded обязан совпадать со всеми тремя. Захочешь ещё и иконку повернуть — четвёртая строка здесь же, и забыть её ничего не стоит.

Реактивный взгляд переворачивает вопрос: вместо «что поменять по клику» — «что от чего зависит».

<div data-component="more">
<div class="extra" data-show="expanded"><!-- … --></div>
<button
@click="toggle"
data-text="expanded ? 'Свернуть' : 'Показать ещё'"
data-bind="aria-expanded:expanded ? 'true' : 'false'"
></button>
</div>
<script>
Micra.define("more", {
state: { expanded: false },
toggle() {
this.state.expanded = !this.state.expanded;
},
});
Micra.start();
</script>

Видимость, текст и атрибут объявлены как функции от expanded. Метод toggle меняет один бит, остальное Micra пересчитывает — DOM ты не трогаешь вообще. Новое отражение состояния это ещё один data-* рядом, а не строка в обработчике, которую надо помнить синхронизировать.

Несколько одинаковых блоков: FAQ-аккордеон

Теперь не один блок, а десяток — обычный FAQ, где каждый вопрос раскрывается. На vanilla тут обычно перебирают все блоки и вешают обработчик на каждый:

document.querySelectorAll(".faq").forEach((faq) => {
const btn = faq.querySelector("button");
const panel = faq.querySelector(".answer");
btn.addEventListener("click", () => {
const willOpen = panel.hidden;
panel.hidden = !willOpen;
btn.setAttribute("aria-expanded", String(willOpen));
});
});

Тоже немного. Но состояние «открыт ли блок» снова живёт в DOM — ты читаешь его из panel.hidden. DOM стал источником правды, и как только внутри блока появится что-то ещё, зависящее от «открыт», вернёшься к ручной синхронизации из прошлого примера, только теперь в цикле по всем блокам.

В Micra компонент описывается один раз, а на странице встречается сколько угодно раз:

<div data-component="collapse" class="faq">
<button @click="toggle" data-bind="aria-expanded:open ? 'true' : 'false'">
Сколько стоит?
</button>
<div class="answer" data-show="open">От 50 000 ₽.</div>
</div>
<div data-component="collapse" class="faq">
<button @click="toggle" data-bind="aria-expanded:open ? 'true' : 'false'">
Какие сроки?
</button>
<div class="answer" data-show="open">Две-три недели.</div>
</div>
<!-- …столько блоков, сколько нужно… -->
<script>
Micra.define("collapse", {
state: { open: false },
toggle() {
this.state.open = !this.state.open;
},
});
Micra.start();
</script>

Каждый блок с data-component="collapse" — это отдельный экземпляр со своим open, и Micra.start() поднимает их все разом. Десять блоков превращаются в десять копий разметки (которые обычно и так генерит сервер в цикле) и тот же один define. Ни общего обработчика, ни реестра «кто сейчас открыт»: состояние у каждого своё и лежит в нём, а не в DOM.

Отдельный случай — аккордеон, где открыт может быть только один блок. Это уже общее состояние между блоками, и оно одинаково всплывает в обоих подходах: на vanilla дописываешь «закрыть остальные», в Micra поднимаешь состояние в родительский компонент, который держит openId. Момент «состояние стало общим, у него должен быть один владелец» универсален, и его полезно замечать рано.

Список из данных

Поиск по списку услуг.

const items = [
{ id: 1, name: "Дизайн" },
{ id: 2, name: "Разработка" } /* … */,
];
const input = document.querySelector("#q");
const ul = document.querySelector("#list");
function render() {
const q = input.value.trim().toLowerCase();
ul.innerHTML = items
.filter((i) => i.name.toLowerCase().includes(q))
.map((i) => `<li>${i.name}</li>`)
.join("");
}
input.addEventListener("input", render);
render();

Здесь подход другой: HTML собирается строкой и вставляется целиком. Отсюда три знакомые проблемы: ${i.name} в innerHTML — это XSS, если имена приходят не из вашего кода; пересборка на каждый символ сбрасывает фокус и состояние всего, что окажется внутри <li>; а сделать аккуратно (экранировать, обновлять точечно) — это уже не пять строк.

Реактивно список описывается как функция от данных:

<div data-component="services">
<input data-model="q" placeholder="Поиск услуги" />
<p data-text="summary()"></p>
<ul>
<template data-each="visible()" data-key="id">
<li data-text="item.name"></li>
</template>
</ul>
</div>
<script>
Micra.define("services", {
state: {
q: "",
items: [
{ id: 1, name: "Дизайн" },
{ id: 2, name: "Разработка" } /* … */,
],
},
visible() {
const q = this.state.q.trim().toLowerCase();
return this.state.items.filter((i) => i.name.toLowerCase().includes(q));
},
summary() {
return `Найдено: ${this.visible().length}`;
},
});
Micra.start();
</script>

data-text пишет текст, а не HTML, поэтому экранирование тут по умолчанию и той XSS-дыры просто нет. data-each с data-key сверяет список по ключу и трогает только изменившиеся строки — фокус и состояние внутри <li> переживают фильтрацию. visible() и summary() — методы: производные значения в Micra не держат в состоянии, а считают от него, поэтому фильтр и счётчик не разойдутся со списком.

Если HTML всё-таки нужен — скажем, в названии есть размеченный фрагмент, — для этого есть data-html. По умолчанию он пишет значение как есть, но санитайзинг включается одной строкой:

import DOMPurify from "dompurify";
Micra.config({ sanitize: DOMPurify.sanitize });

После этого каждое значение data-html прогоняется через очистку перед вставкой в DOM. Сам санитайзер Micra не тащит — его вес и выбор остаются за вами.

Когда частей много: vanilla дорастает до фреймворка

Соберём то, на чём обычно и принимают решение: задачи с добавлением, отметкой, удалением, счётчиком и сохранением. Несколько источников событий, общее состояние, список из данных. Нормальный vanilla-код к этому моменту выглядит примерно так:

let tasks = load(); // из localStorage
const root = document.querySelector("#app");
function render() {
root.querySelector(".list").innerHTML = tasks
.map(
(t) => `
<li data-id="${t.id}" class="${t.done ? "done" : ""}">
<button class="toggle">${escapeHtml(t.text)}</button>
<button class="del">×</button>
</li>`,
)
.join("");
root.querySelector(".count").textContent =
`${tasks.filter((t) => t.done).length} из ${tasks.length}`;
}
root.querySelector(".list").addEventListener("click", (e) => {
const li = e.target.closest("li");
if (!li) return;
const id = Number(li.dataset.id);
if (e.target.matches(".del")) tasks = tasks.filter((t) => t.id !== id);
else if (e.target.matches(".toggle"))
tasks = tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
save(tasks);
render();
});
render();

Это не плохой код. Но посмотрите, во что он сложился. Чтобы ничего не рассогласовать, состояние вынесли в tasks, а DOM собирают одной функцией render() — это единый источник правды и перерисовка. Чтобы не вешать обработчик на каждую кнопку, один слушатель на контейнере с разбором e.target — делегирование. Чтобы ${t.text} не стал дырой — escapeHtml. По сути это маленький самописный фреймворк: стейт, рендер, делегирование, экранирование. Только менее проверенный, и поддерживать его теперь вам.

И дальше предсказуемо тяжелее. render() через innerHTML пересобирает список целиком; появится поле ввода внутри строки — оно будет терять фокус, и придётся обновлять точечно или сравнивать старое с новым. Это реактивность, дописанная вручную.

Те же задачи на Micra — это та же структура, но рендер и делегирование уже не ваши:

<div data-component="tasks">
<form @submit.prevent="add">
<input data-model="draft" placeholder="Новая задача" />
</form>
<p data-text="summary()"></p>
<ul>
<template data-each="tasks" data-key="id">
<li data-class="done:item.done">
<button
class="toggle"
@click="toggle"
data-bind="data-id:item.id"
data-text="item.text"
></button>
<button class="del" @click="remove" data-bind="data-id:item.id">
×
</button>
</li>
</template>
</ul>
</div>
<script>
Micra.define("tasks", {
state: { draft: "", tasks: load() },
summary() {
const done = this.state.tasks.filter((t) => t.done).length;
return `${done} из ${this.state.tasks.length}`;
},
add() {
const text = this.state.draft.trim();
if (!text) return;
this.state.tasks = [
...this.state.tasks,
{ id: Date.now(), text, done: false },
];
this.state.draft = "";
save(this.state.tasks);
},
toggle(e) {
const id = Number(e.currentTarget.dataset.id);
this.state.tasks = this.state.tasks.map((t) =>
t.id === id ? { ...t, done: !t.done } : t,
);
save(this.state.tasks);
},
remove(e) {
const id = Number(e.currentTarget.dataset.id);
this.state.tasks = this.state.tasks.filter((t) => t.id !== id);
save(this.state.tasks);
},
});
Micra.start();
</script>

Функции render() нет — что показывать, описано в разметке и пересобирается само при изменении state. Делегирования нет — @click Micra навесит и снимет сама, id берётся из data-id. Экранирование даёт data-text. Обновления — это замена tasks целиком (.map, .filter, spread), а не правка полей внутри; data-key следит, чтобы перерисовались только изменившиеся строки. Я специально старался, чтобы тут не было ощущения магии: это те же стейт, рендер и делегирование, просто вынесенные из вашего проекта в библиотеку и сделанные один раз.

Предел возможностей Micra

У реактивности тоже есть граница, и она недалеко за этим примером. Когда из страницы вырастает приложение — клиентский роутинг, много экранов, общее состояние между ними, оптимистичные обновления — Micra становится мала: реактивность у неё неглубокая, роутера нет. Тогда уместен React, Vue или Svelte, и ставить туда крошечную библиотеку так же странно, как подключать большой фреймворк ради тоггла из первого примера.

Если совсем грубо: один изолированный интерактив — чистый JS; несколько таких, чьё состояние пересекается, плюс списки из данных — тот момент, когда state и render() ты всё равно напишешь, и проще взять готовое; полноценное приложение — большой фреймворк.

Код примеров и ещё несколько готовых компонентов лежат на micrajs.dev.