Проект ЦИТадель
Названия и интерфейсы фронтенд-фреймворков меняются быстрее, чем их основные задачи. Одна из них — сохранять соответствие между состоянием приложения и DOM, когда пользователь, сеть и фоновые процессы изменяют данные. Для этого применяются декларативные представления, компоненты, согласование деревьев, реактивные зависимости и разные способы первоначального рендеринга. Статья рассматривает эти механизмы без выбора одного продукта и показывает, за какую сложность отвечает каждый из них.
Сначала разберём задачу на примере списка дел и представим интерфейс как функцию состояния. Затем рассмотрим компоненты, виртуальный DOM и мелкозернистую реактивность, размещение состояния, серверный рендеринг и гидратацию. В практикуме будет построено небольшое реактивное ядро, на котором видны и основной принцип, и ограничения такой реализации.
Возьмём список дел со счётчиком незавершённых пунктов, фильтром и кнопкой «Удалить завершённые», которая видна только при наличии таких пунктов. Одна отметка должна изменить флажок, счётчик, видимость кнопки и, при активном фильтре, сам список. При непосредственной работе с DOM обработчик может обновить каждое место явно. Для небольшого виджета это приемлемо, но с ростом числа состояний и представлений появляется риск пропустить одну связь.
Особенно трудно сопровождать интерфейс, если один и тот же факт хранится одновременно в переменной и в DOM — например, в свойстве checked или классе видимости. Тогда изменение одного представления не обязательно достигает другого. Задача фреймворка не в том, чтобы запретить DOM API, а в том, чтобы задать единый источник данных и воспроизводимый способ получить из него все зависимые части интерфейса.
Полезная модель декларативного интерфейса записывается как UI = f(состояние). Разработчик описывает, какой результат соответствует текущим данным, а обработчики событий изменяют данные. Механизм рендеринга обновляет DOM. Для списка дел источниками служат массив и выбранный фильтр; число незавершённых пунктов и видимость кнопки являются производными значениями, которые не нужно хранить отдельно.
Это похоже на согласование желаемого и фактического состояния из статьи «Kubernetes: идея и устройство», хотя масштабы и гарантии у систем различны. Во фронтенде согласование может означать повторное вычисление декларации, отслеживание прочитанных значений либо сочетание этих подходов. Формула описывает зависимость результата от данных, но не предписывает единственную реализацию.
Компонент объединяет фрагмент представления, входные свойства, локальное состояние и поведение. Приложение складывается в дерево или граф компонентов, а не в одну функцию. Хорошая граница компонента соответствует самостоятельному элементу предметной области или интерфейса и скрывает детали его разметки.
Многие модели требуют, чтобы фаза рендеринга была чистым вычислением: одинаковые входные свойства, состояние и контекст дают одинаковую декларацию и не изменяют внешний мир. Запросы, таймеры и непосредственные операции с DOM выполняются в предусмотренной фазе эффектов. Это правило облегчает повторный рендеринг и планирование работы, но не означает, что компонент целиком является чистой функцией одних свойств: у него могут быть локальное состояние и контекст. Способ обмена также зависит от системы; свойства вниз и события вверх — полезная дисциплина, а не универсальное ограничение платформы.
Одна семья реализаций строит виртуальное дерево — объекты, описывающие желаемые элементы, свойства и дочерние узлы. После изменения состояния система снова вычисляет затронутую декларацию, сопоставляет её с предыдущей и в фазе фиксации выполняет необходимые операции над DOM. Это сохраняет узлы, ввод пользователя и фокус там, где элементы признаны теми же. Связь с раскладкой и отрисовкой браузера подробно разобрана в статье «Как устроен браузер».
Сопоставление основано на эвристиках, потому что поиск произвольной минимальной разницы между деревьями был бы слишком дорог. Тип элемента, положение и стабильный key помогают определить идентичность. Ключ нужен прежде всего для корректного сохранения состояния элемента при вставке, удалении и перестановке; случайный ключ или индекс изменяющегося списка способен не только замедлить обновление, но и связать локальное состояние не с той строкой.
Повторный вызов компонентов и создание виртуальных узлов имеют стоимость, однако она зависит от реализации, компилятора и границ обновления. Мемоизация тоже не бесплатна: сравнение входов расходует время и может усложнить код. Её вводят после измерения конкретного узкого места, а не как обязательное дополнение к каждому компоненту.
Другая семья решений запоминает зависимости во время вычисления. Сигнал хранит значение и множество подписчиков. Когда эффект или вычисляемое значение читает сигнал в отслеживаемом контексте, зависимость регистрируется; запись уведомляет нужных подписчиков. Мелкозернистая система может связать эффект непосредственно с текстовым узлом DOM и обновить его без повторного вызова всего компонента.
Сигнал сам по себе не определяет способ рендеринга. Одни системы используют такие зависимости для прямых обновлений DOM, другие — чтобы точнее назначить повторный рендеринг, третьи сочетают реактивность с виртуальным деревом и компиляцией шаблонов. Поэтому виртуальный DOM и сигналы — не взаимоисключающие продукты, а разные части возможной реализации.
За точность приходится поддерживать граф: перед повторным запуском удалять прежние подписки, учитывать условные чтения, освобождать завершившиеся эффекты, пакетировать серию записей и соблюдать порядок вычислений. Асинхронное чтение после завершения отслеживаемой функции обычно уже не регистрируется автоматически. Эти детали проявятся в практикуме.
Состояние следует хранить в минимальном виде: массив дел и фильтр являются исходными данными, а счётчик вычисляется из них. Дублирование производного значения требует синхронизации и создаёт ещё один источник расхождения. Локальное состояние помещают в ближайший компонент, которому оно принадлежит; если несколько соседних компонентов должны менять один факт, владельца поднимают к их общему предку или выделяют отдельное хранилище.
Однонаправленный поток — свойства вниз, намерения изменить данные вверх — делает владельца и путь изменения видимыми. Некоторые фреймворки предоставляют двустороннее связывание формы, но и оно должно разворачиваться в определённые чтение и запись, а не создавать второй независимый источник данных. Контекст и общее хранилище снимают необходимость передавать свойства через каждый уровень, однако не отменяют вопрос о владельце.
Ответ сервера тоже является состоянием, но с особым жизненным циклом: это локальная копия удалённых данных, у которой есть свежесть, повторный запрос, конкурирующие изменения и инвалидация. Её полезно отделять от краткоживущего состояния интерфейса — например, раскрытой панели. Сетевые аспекты такого кэша рассмотрены в статье «Надёжность поверх ненадёжной сети».
Первое представление можно построить в браузере или на сервере. Клиентский рендеринг обычно доставляет HTML-оболочку, данные и JavaScript; содержимое появляется после загрузки и исполнения необходимых ресурсов. Серверный рендеринг отправляет HTML с уже построенным представлением. Это может ускорить появление содержимого, но результат зависит от времени ответа сервера, потоковой передачи, кэширования, размера HTML и последующей работы клиента.
Чтобы серверная разметка стала интерактивной, клиент выполняет гидратацию: связывает компоненты и обработчики с существующим DOM и проверяет соответствие ожидаемой структуры. Для этого всё равно нужны код и данные; несовпадение серверного и клиентского результата может привести к предупреждениям или замене части DOM. Цена гидратации измеряется временем процессора и объёмом JavaScript, а не выводится из одного размера приложения.
Потоковый SSR, острова интерактивности, частичная или отложенная гидратация и серверные компоненты распределяют работу по-разному. У каждого варианта есть требования к инфраструктуре, кэшированию, навигации и модели программирования. Выбор следует проверять на пользовательских сценариях методами из статьи «Веб-производительность».
Статья, справочник или небольшая форма могут не иметь сложного изменяемого состояния. Семантический HTML, CSS и несколько обработчиков дают им меньше зависимостей, меньше клиентского кода и более простую модель отказа. Прогрессивное улучшение оставляет основное содержимое и действие доступными до загрузки сценария, если это допускает задача.
Фреймворк приносит пользу приложению с большим деревом компонентов, общей навигацией, сложными формами и множеством переходов состояния. Вместе с ним приходят среда исполнения, обновления, соглашения и сборка. Между крайними вариантами находятся веб-компоненты, небольшие реактивные библиотеки и интерактивные острова. Решение принимают по сложности состояния, компетенциям команды, сроку жизни, требованиям к первому экрану и результатам измерений, а не по числу элементов на странице.
Соберём сигналы и привязку к DOM в одном HTML-файле. Это учебная синхронная реализация: в ней нет планировщика, обработки асинхронных зависимостей и интеграции с жизненным циклом DOM.
let activeEffect = null;
function signal(initialValue) {
let value = initialValue;
const subscribers = new Set();
return {
get() {
if (activeEffect) {
subscribers.add(activeEffect);
activeEffect.dependencies.add(subscribers);
}
return value;
},
set(nextValue) {
if (Object.is(value, nextValue)) return;
value = nextValue;
for (const run of [...subscribers]) run();
}
};
}
function effect(fn) {
function run() {
for (const set of run.dependencies) set.delete(run);
run.dependencies.clear();
const previous = activeEffect;
activeEffect = run;
try {
fn();
} finally {
activeEffect = previous;
}
}
run.dependencies = new Set();
run();
return () => {
for (const set of run.dependencies) set.delete(run);
run.dependencies.clear();
};
}
Задание 1. Счётчик. Создайте const count = signal(0), кнопку и текстовый узел. Эффект должен присваивать узлу count.get(), а обработчик — вызывать count.set(count.get() + 1). Проследите регистрацию функции в subscribers во время чтения.
Задание 2. Вычисляемое значение. Реализуйте computed(fn) через внутренний сигнал и эффект. Проверьте цепочку «задачи → число незавершённых → текст». Объясните, почему проверка Object.is предотвращает уведомление при записи того же значения.
Задание 3. Список дел. Поместите массив и фильтр в сигналы, а счётчик и видимость кнопки сделайте вычисляемыми. Обработчики должны менять данные, а эффекты — только соответствующие узлы DOM. Не изменяйте массив на месте: создавайте новое значение, иначе сеттер не увидит изменения ссылки.
Задание 4. Ромб зависимостей. Пусть два вычисляемых значения читают один сигнал, а итоговый эффект читает оба. Посчитайте его запуски после одной записи. Добавьте простейшую очередь, которая объединяет повторные запуски до ближайшей микрозадачи, и снова измерьте результат.
Задание 5. Условие и очистка. Создайте эффект, читающий сигнал b только при истинном a. Временно уберите две строки очистки в начале run и покажите лишний запуск после выключения a. Верните очистку. Вызовите функцию освобождения эффекта и убедитесь, что дальнейшие записи его не запускают.
Задание 6*. Сопоставление подходов. Реализуйте учебное виртуальное дерево и полную замену его DOM-представления без алгоритма сравнения. Сопоставьте время изменения одного элемента в списке с точечным эффектом, но не делайте вывод о производственных фреймворках: для него нужны их реальные оптимизированные реализации, одинаковая разметка и отдельные измерения начальной загрузки, обновления и памяти.