2026 г.

Как устроен фронтенд-фреймворк

Проект ЦИТадель

Названия и интерфейсы фронтенд-фреймворков меняются быстрее, чем их основные задачи. Одна из них — сохранять соответствие между состоянием приложения и DOM, когда пользователь, сеть и фоновые процессы изменяют данные. Для этого применяются декларативные представления, компоненты, согласование деревьев, реактивные зависимости и разные способы первоначального рендеринга. Статья рассматривает эти механизмы без выбора одного продукта и показывает, за какую сложность отвечает каждый из них.

Сначала разберём задачу на примере списка дел и представим интерфейс как функцию состояния. Затем рассмотрим компоненты, виртуальный DOM и мелкозернистую реактивность, размещение состояния, серверный рендеринг и гидратацию. В практикуме будет построено небольшое реактивное ядро, на котором видны и основной принцип, и ограничения такой реализации.

1. Задача: одно состояние, много отражений

Возьмём список дел со счётчиком незавершённых пунктов, фильтром и кнопкой «Удалить завершённые», которая видна только при наличии таких пунктов. Одна отметка должна изменить флажок, счётчик, видимость кнопки и, при активном фильтре, сам список. При непосредственной работе с DOM обработчик может обновить каждое место явно. Для небольшого виджета это приемлемо, но с ростом числа состояний и представлений появляется риск пропустить одну связь.

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

2. Центральная формула: интерфейс — функция состояния

Полезная модель декларативного интерфейса записывается как UI = f(состояние). Разработчик описывает, какой результат соответствует текущим данным, а обработчики событий изменяют данные. Механизм рендеринга обновляет DOM. Для списка дел источниками служат массив и выбранный фильтр; число незавершённых пунктов и видимость кнопки являются производными значениями, которые не нужно хранить отдельно.

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

3. Компонент: единица декларации

Компонент объединяет фрагмент представления, входные свойства, локальное состояние и поведение. Приложение складывается в дерево или граф компонентов, а не в одну функцию. Хорошая граница компонента соответствует самостоятельному элементу предметной области или интерфейса и скрывает детали его разметки.

Многие модели требуют, чтобы фаза рендеринга была чистым вычислением: одинаковые входные свойства, состояние и контекст дают одинаковую декларацию и не изменяют внешний мир. Запросы, таймеры и непосредственные операции с DOM выполняются в предусмотренной фазе эффектов. Это правило облегчает повторный рендеринг и планирование работы, но не означает, что компонент целиком является чистой функцией одних свойств: у него могут быть локальное состояние и контекст. Способ обмена также зависит от системы; свойства вниз и события вверх — полезная дисциплина, а не универсальное ограничение платформы.

4. Механика первая: виртуальный DOM

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

Сопоставление основано на эвристиках, потому что поиск произвольной минимальной разницы между деревьями был бы слишком дорог. Тип элемента, положение и стабильный key помогают определить идентичность. Ключ нужен прежде всего для корректного сохранения состояния элемента при вставке, удалении и перестановке; случайный ключ или индекс изменяющегося списка способен не только замедлить обновление, но и связать локальное состояние не с той строкой.

Повторный вызов компонентов и создание виртуальных узлов имеют стоимость, однако она зависит от реализации, компилятора и границ обновления. Мемоизация тоже не бесплатна: сравнение входов расходует время и может усложнить код. Её вводят после измерения конкретного узкого места, а не как обязательное дополнение к каждому компоненту.

5. Механика вторая: сигналы

Другая семья решений запоминает зависимости во время вычисления. Сигнал хранит значение и множество подписчиков. Когда эффект или вычисляемое значение читает сигнал в отслеживаемом контексте, зависимость регистрируется; запись уведомляет нужных подписчиков. Мелкозернистая система может связать эффект непосредственно с текстовым узлом DOM и обновить его без повторного вызова всего компонента.

Сигнал сам по себе не определяет способ рендеринга. Одни системы используют такие зависимости для прямых обновлений DOM, другие — чтобы точнее назначить повторный рендеринг, третьи сочетают реактивность с виртуальным деревом и компиляцией шаблонов. Поэтому виртуальный DOM и сигналы — не взаимоисключающие продукты, а разные части возможной реализации.

За точность приходится поддерживать граф: перед повторным запуском удалять прежние подписки, учитывать условные чтения, освобождать завершившиеся эффекты, пакетировать серию записей и соблюдать порядок вычислений. Асинхронное чтение после завершения отслеживаемой функции обычно уже не регистрируется автоматически. Эти детали проявятся в практикуме.

6. Данные текут вниз, события — вверх

Состояние следует хранить в минимальном виде: массив дел и фильтр являются исходными данными, а счётчик вычисляется из них. Дублирование производного значения требует синхронизации и создаёт ещё один источник расхождения. Локальное состояние помещают в ближайший компонент, которому оно принадлежит; если несколько соседних компонентов должны менять один факт, владельца поднимают к их общему предку или выделяют отдельное хранилище.

Однонаправленный поток — свойства вниз, намерения изменить данные вверх — делает владельца и путь изменения видимыми. Некоторые фреймворки предоставляют двустороннее связывание формы, но и оно должно разворачиваться в определённые чтение и запись, а не создавать второй независимый источник данных. Контекст и общее хранилище снимают необходимость передавать свойства через каждый уровень, однако не отменяют вопрос о владельце.

Ответ сервера тоже является состоянием, но с особым жизненным циклом: это локальная копия удалённых данных, у которой есть свежесть, повторный запрос, конкурирующие изменения и инвалидация. Её полезно отделять от краткоживущего состояния интерфейса — например, раскрытой панели. Сетевые аспекты такого кэша рассмотрены в статье «Надёжность поверх ненадёжной сети».

7. Сервер: SPA, SSR и цена гидратации

Первое представление можно построить в браузере или на сервере. Клиентский рендеринг обычно доставляет HTML-оболочку, данные и JavaScript; содержимое появляется после загрузки и исполнения необходимых ресурсов. Серверный рендеринг отправляет HTML с уже построенным представлением. Это может ускорить появление содержимого, но результат зависит от времени ответа сервера, потоковой передачи, кэширования, размера HTML и последующей работы клиента.

Чтобы серверная разметка стала интерактивной, клиент выполняет гидратацию: связывает компоненты и обработчики с существующим DOM и проверяет соответствие ожидаемой структуры. Для этого всё равно нужны код и данные; несовпадение серверного и клиентского результата может привести к предупреждениям или замене части DOM. Цена гидратации измеряется временем процессора и объёмом JavaScript, а не выводится из одного размера приложения.

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

8. Когда фреймворк не нужен

Статья, справочник или небольшая форма могут не иметь сложного изменяемого состояния. Семантический HTML, CSS и несколько обработчиков дают им меньше зависимостей, меньше клиентского кода и более простую модель отказа. Прогрессивное улучшение оставляет основное содержимое и действие доступными до загрузки сценария, если это допускает задача.

Фреймворк приносит пользу приложению с большим деревом компонентов, общей навигацией, сложными формами и множеством переходов состояния. Вместе с ним приходят среда исполнения, обновления, соглашения и сборка. Между крайними вариантами находятся веб-компоненты, небольшие реактивные библиотеки и интерактивные острова. Решение принимают по сложности состояния, компетенциям команды, сроку жизни, требованиям к первому экрану и результатам измерений, а не по числу элементов на странице.

9. Практикум: небольшое реактивное ядро

Соберём сигналы и привязку к 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-представления без алгоритма сравнения. Сопоставьте время изменения одного элемента в списке с точечным эффектом, но не делайте вывод о производственных фреймворках: для него нужны их реальные оптимизированные реализации, одинаковая разметка и отдельные измерения начальной загрузки, обновления и памяти.

Итоги

  • Декларативная модель выводит несколько представлений из одного минимального состояния и уменьшает число ручных связей с DOM.
  • Компонент задаёт границу представления и поведения. Чистая фаза рендеринга отделяется от запросов, таймеров и других эффектов.
  • Виртуальное дерево позволяет вычислить и зафиксировать изменения DOM; стабильные ключи задают идентичность элементов списка. Мемоизация оправдана измеренным узким местом.
  • Сигналы регистрируют зависимости при чтении. Мелкозернистая реактивность может обновлять точные привязки, но требует очистки подписок, пакетирования и управления жизненным циклом.
  • Однонаправленный поток делает владельца изменения видимым. У локального интерфейса и кэша удалённых данных разные сроки жизни и правила согласования.
  • Клиентский и серверный рендеринг, гидратация и острова по-разному распределяют сетевую и вычислительную работу; вариант выбирают по измерениям.
  • Для документа или небольшого виджета достаточно возможностей платформы. Фреймворк окупает свою стоимость при соответствующей сложности приложения.

Литература

  1. React: Render and Commit и Rendering Lists — повторный рендеринг, фиксация изменений и роль ключей.
  2. Vue.js: Rendering Mechanism и Reactivity in Depth — виртуальное дерево, компиляция шаблонов и реактивные зависимости в одной системе.
  3. SolidJS: Fine-grained reactivity — сигналы, эффекты и построение небольшого реактивного ядра.
  4. Rendering on the Web — клиентский и серверный рендеринг и гидратация.
  5. Оглавление раздела «Веб-технологии»; другие статьи цикла: «Как устроен браузер», «JSON и дизайн API», «Современный JavaScript и TypeScript» и «Веб-производительность».
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией