Постановка проблемы

Императивная модель

Итак, рассмотрим простейший код:

let a = 6
let b = 7
let c = a + b     // c = 13

Вопрос знатокам: каким будет значение c при изменении значения a?

a = 67

Подвоха тут, конечно, никакого нет. Очевидно, что значение c так и останется равным 13, ведь оно было вычислено лишь единожды. План был следующим:

  1. Объявить переменную a со значением 6
  2. Объявить переменную b со значением 7
  3. Объявить переменную c со значением a + b
    1. Получить значение a => 6
    2. Получить значение b => 7
    3. Вычислить значение a + b => 6 + 7 => 13
  4. Изменить значение переменной a на 67

С точки зрения императивного программирования, такое поведение ожидаемо: переменная a вычислилась один раз и более не связана с породившим её выражением. Однако при разработке пользовательских интерфейсов мы сталкиваемся с принципиально иной задачей: состояние приложения и его представление должны быть синхронизированы. Изменение данных должно автоматически влечь обновление тех частей интерфейса, которые от этих данных зависят.

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

Это стало одним из спусковых крючков к появлению понятия реактивности.

Реактивная модель

| Реактивность — это способность программной системы автоматически поддерживать согласованность между зависимыми сущностями при изменении исходных данных.

Довольно формальное определение. Однако в сущности это означает, что вместо привычной императивной парадигмы:

let a = 6
let b = 7
let c = a + b     // c = 13
let a = 67        // c = 13

мы получаем подобное поведение:

let a = 6
let b = 7
let c = a + b     // c = 13
let a = 67        // c = 74

Конечно, в контексте работы с двумя переменными это имеет мало смысла. Тем не менее, верю, что читателю довольно очевидна важность отзывчивости фронтенда посредством реактивности в реальном приложении.

Нетрудно выделить ключевые свойства, прямо вытекающие из определения:

  • Направленность. Реактивная связь между состояниями всегда однонаправлена. Иными словами, если изменение состояния AA реактивно влечёт изменение состояния BB (условимся обозначать это как A→BA \to B), то из этого никак не следует, что B→AB \to A.
  • Транзитивность. A→BA \to B и B→CB \to C   ⟹  \implies A→CA \to C.

Модели реактивных систем

Существует три основных модели построения реактивных систем.

Pull-модель

Это самая примитивная модель реактивности.

В pull-модели зависимая сущность активно запрашивает актуальное состояние источника в определённые моменты времени. Источник изменений пассивен — он только хранит данные, но не инициирует уведомления.

Концептуально это может быть реализовано как некоторый цикл, который с некоторой частотой вызывает условную булеву функцию check(), возвращающую true, если состояние изменилось по сравнению с предыдущем.

Эффективность такой системы обратно пропорциональна частоте проверок и прямо пропорциональна количеству отслеживаемых переменных.

Push-модель

В push-модели источник изменений активно уведомляет зависимые сущности о факте изменения. Потребители пассивны до момента получения уведомления — они реагируют только тогда, когда изменение действительно произошло.

Классическая реализация push-модели — паттерн «Наблюдатель» (Observer). Источник (subject) ведёт список подписчиков (observers) и при изменении своего состояния рассылает уведомления всем подписанным объектам.

Push-Pull-модель

На практике чистые модели встречаются редко. Большинство современных систем используют гибридные стратегии, сочетающие преимущества обоих подходов.

Push-Pull гибриды работают следующим образом:

  1. На этапе уведомления используется push: источник сообщает, что данные изменились.
  2. На этапе получения актуального значения используется pull: подписчик сам запрашивает новое значение.

Такой подход позволяет, с одной стороны, избежать холостых проверок (благодаря push-уведомлениям), а с другой — не навязывать потребителю, в каком виде передавать данные (благодаря pull-запросу).

Далее мы рассмотрим ряд примеров конкретных реализаций реактивности в различных современных фреймворках.

Механизм «грязных проверок»

Исторически первым широко распространённым фреймворком, предложившим декларативный подход к построению интерфейсов, стал AngularJS (2010). В момент своего появления он решал принципиально важную задачу: избавить разработчика от ручного манипулирования DOM и синхронизации данных. Механизм, которым AngularJS обеспечивал реактивность, получил название «грязной проверки» (dirty checking).

| Грязная проверка (dirty checking) — это алгоритм обнаружения изменений, основанный на циклическом сравнении текущих значений отслеживаемых выражений с их предыдущими состояниями. Если значение изменилось (стало «грязным»), запускаются соответствующие обработчики и обновления представления.

Архитектура в AngularJS

В AngularJS центральными понятиями являются:

  • Scope ($scope) — объект-контекст, к которому привязываются данные и выражения.
  • Watch ($watch) — регистрация выражения для отслеживания.
  • Digest cycle ($digest) — цикл обхода всех зарегистрированных $watch с проверкой изменений.
  • Apply ($apply) — механизм входа в контекст AngularJS из внешнего кода.

При создании связи с представлением AngularJS неявно регистрирует $watch для соответствующего выражения. В результате на каждом scope формируется список наблюдений ($$watchers). Цикл $digest итеративно проходит по всем $$watchers и сравнивает результаты вычисления выражений.

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

Для понимания сути механизма достаточно следующей минимальной реализации:

class Scope {
  constructor() {
    // Хранилище всех зарегистрированных наблюдений (watchers)
    this.$$watchers = [];
  }

  // Регистрация наблюдения за выражением
  $watch(watchFn, listenerFn) {
    this.$$watchers.push({
      watchFn: watchFn,       // как вычислить текущее значение
      listenerFn: listenerFn, // что делать при изменении
      lastValue: undefined    // последнее вычисленное значение
    });
  }

  // Основной цикл проверки изменений
  $digest() {
    // Флаг, указывающий, были ли изменения в текущем проходе
    // Начинаем с true, чтобы гарантировать хотя бы один проход
    let dirty = true;

    // Счётчик для защиты от бесконечного цикла
    // В реальном AngularJS это значение равно 10
    let loopCount = 0;
    const maxLoops = 10;

    while (dirty && loopCount < maxLoops) {
      dirty = false;

      for (const watcher of this.$$watchers) {
        const newValue = watcher.watchFn(this);
        const oldValue = watcher.lastValue;

        // В случае несоответствия значений вызовем
        // соответствующий метод.
        // Попутно установим флаг для повторной итерации.
        if (newValue !== oldValue) {
          watcher.listenerFn(newValue, oldValue, this);
          watcher.lastValue = newValue;
          dirty = true;
        }
      }

      loopCount++;
    }
  }
}

В реальном коде AngularJS, конечно, присутствуют многочисленные оптимизации и нюансы, включая глубокое сравнение объектов, отслеживание по ссылке и коллекциям, но базовая логика остаётся именно такой.

Достоинства и недостатки подхода

Конечно, неоспоримым достоинством системы «грязных проверок» является простота понимания — механизм очень просто объясняется, не использует метапрограммирование. Также он довольно предсказуем — любые изменения данных будут обнаружены, независимо от того, как они были произведены.

Из недостатков очевидно выделяется вычислительная сложность — алгоритм имеет асимптотическую сложность O(n) на каждый цикл проверки. При росте числа наблюдателей производительность падает линейно.

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

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

Место в классификации реактивности

С точки зрения введённого ранее различения push/pull-моделей, dirty checking является чистой pull-моделью:

  • Источник данных пассивен и не уведомляет об изменениях;
  • Потребитель (цикл $digest) активен и сам опрашивает все источники;
  • Факт изменения обнаруживается только в момент проверки.

Интересно, что в AngularJS триггером запуска цикла $digest служат события, которые потенциально могли изменить данные (пользовательский ввод, ответы от сервера, таймеры). Однако внутри цикла всё равно выполняется полная проверка всех наблюдений, независимо от того, какое именно событие произошло. Это и есть характерная черта pull-модели — реакция на событие как на сигнал к опросу, а не как на прямое уведомление об изменении конкретного значения.

Паттерн «Наблюдатель»

Грязная проверка, рассмотренная нами ранее, решала задачу обнаружения изменений, но делала это неэффективно — через активный опрос всех источников. Возникает естественный вопрос: а можно ли построить систему, в которой источник сам уведомляет зависимые сущности о факте изменения? Именно эту задачу решает паттерн «Наблюдатель» (Observer).

| Наблюдатель (Observer) — это поведенческий паттерн проектирования, в котором субъект ведёт список зависящих от него объектов (наблюдателей) и автоматически уведомляет их о любых изменениях своего состояния, обычно вызывая один из их методов.

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

| Сигнал (signal) — это примитив реактивности, который хранит значение и позволяет подписаться на его изменения.

В терминах паттерна «Наблюдатель»:

  • Субъект — сигнал, хранящий текущее значение и список подписчиков.
  • Наблюдатели — эффекты, вычисляемые значения или компоненты, которые зависят от данного сигнала.
  • Метод уведомления — при изменении значения сигнал обходит своих подписчиков и вызывает их.

Архитектура в Solid

В Solid сигналы создаются функцией createSignal, которая возвращает кортеж из двух элементов: геттера и сеттера:

import { createSignal } from 'solid-js';

const [state, setState] = createSignal(0);

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

Реактивность сигналов достигается, например, при помощи побочных эффектов:

import { createSignal, createEffect } from 'solid-js';

const [count, setCount] = createSignal(0);

createEffect(() => {
  console.log(`Current: ${count()}`);
});

setCount(1); // Сигнал уведомляет эффект, и он выполняется снова

Устройство сигналов

Реализуем минимальный аналог сигнала, демонстрирующий паттерн «Наблюдатель»:

Функция createSignal содержит множество наблюдателей, которые могут подписываться, читать и отписываться от сигнала:

function createSignal(initialValue) {
  let value = initialValue;
  const observers = new Set();  // список наблюдателей (эффекты)

  // Геттер - читает значение и регистрирует наблюдателя
  const read = () => {
    if (currentEffect) {
      observers.add(currentEffect);
    }
    return value;
  };

  // Сеттер - обновляет значение и уведомляет наблюдателей
  const write = (newValue) => {
    value = newValue;
    for (const observer of observers) {
      observer();
    }
  };

  return [read, write];
}

Реализация же эффектов работает так: в глобальном эффекте описывается связь между текущим эффектом и сигналом. Функция createEffect обновляет её и вызывает функцию эффекта:

let currentEffect = null;   // текущий выполняемый эффект

function createEffect(fn) {
  const effect = () => {
    currentEffect = effect;
    fn();
    currentEffect = null;
  };

  effect();
}

Важное преимущество такой реализации — автоматическое управление подписками. При повторном выполнении эффекта:

  1. Эффект запускается.
  2. Все сигналы, которые он читает, добавляют его в свои списки наблюдателей.
  3. Старые подписки (от предыдущего запуска эффекта) автоматически удаляются, так как эффект каждый раз регистрируется заново.

Это решает классическую проблему паттерна Observer — необходимость вручную отписываться от источников, когда наблюдатель больше не нужен.

Примечательно, что для корректной работы здесь обязательно использование замыкания. Для подробностей можно обратиться к фрагменту кода на Github Gist.

Достоинства и недостатки подхода

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

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

Конечно, нельзя сказать, что в Solid используется классический «Наблюдатель», описанный Бандой четверых. Однако основные концепции действительно сохранены.

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

Паттерн «Издатель-Подписчик»

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

Паттерн «Издатель-Подписчик» (Publisher-Subscriber, PubSub) принципиально отличается от «Наблюдателя» наличием посредника — брокера сообщений. Издатели и подписчики не имеют прямых ссылок друг на друга; всё взаимодействие осуществляется через центральный компонент, который принимает сообщения и доставляет их заинтересованным сторонам.

В React реализация этого паттерна представлена во внутреннем механизме планирования обновлений. Не будем глубоко вдаваться в детали и далее этот механизм будем именовать брокером.

Архитектура в React

Рассмотрим React с точки зрения паттернов коммуникации. Когда разработчик вызывает setState (или dispatch в useReducer), происходит следующий процесс:

  1. Публикация сообщения. Вызов setState генерирует событие «состояние компонента изменилось». Издатель (компонент) не указывает, кто должен на это отреагировать.
  2. Брокер — принимает все сообщения об изменениях, ставит их в очередь, выполняет дедупликацию, определяет приоритеты и принимает решение о том, какие компоненты должны быть обновлены и в каком порядке.
  3. Доставка подписчикам. Компоненты, которые должны быть перерендерены (подписчики), получают уведомление через механизм рендеринга. При этом компонент-подписчик не знает, какой именно издатель инициировал обновление.

Таким образом, React реализует PubSub-архитектуру, где система очередей выступает центральным брокером, а компоненты могут быть как издателями, так и подписчиками.

Устройство системы очередей в React

Для понимания механизма полезно рассмотреть упрощённую схему того, как React обрабатывает обновления:

class ReactScheduler {
  constructor() {
    this.updateQueue = [];
    this.pendingUpdates = new Set();
    this.isProcessing = false;
  }

  // Публикация события (например при вызове setState)
  scheduleUpdate(component) {
    // Дедупликация
    if (!this.pendingUpdates.has(component)) {
      this.pendingUpdates.add(component);
      this.updateQueue.push(component);
    }

    // Асинхронная обработка очереди
    if (!this.isProcessing) {
      Promise.resolve().then(() => this.processQueue());
    }
  }

  // Обработчик очереди, доставляющий обновления
  processQueue() {
    this.isProcessing = true;

    // Копируем очередь и очищаем
    // для возможности новых публикаций
    const componentsToUpdate = [...this.updateQueue];
    this.updateQueue = [];
    this.pendingUpdates.clear();

    // Доставка обновлений
    for (const component of componentsToUpdate) {
      component.render();
      // в реальности React ещё и определит какие
      // дочерние элементы нужно обновить
    }

    this.isProcessing = false;
  }
}

И в данной реализации компонент может быть как издателем, так и подписчиком:

class ReactComponent {
  constructor(scheduler) {
    this.scheduler = scheduler;
    this.state = {};
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.scheduler.scheduleUpdate(this);
  }

  render() {
    // реализация рендера
  }
}

Особенности брокера в React

  1. Асинхронность и пакетирование (batching). Одно из важнейших свойств системы очередей — группировка множественных обновлений. Если в одном синхронном блоке кода несколько раз вызвать setState, React опубликует только одно событие обновления.
  2. Дедупликация. Брокер React отслеживает, какие компоненты уже находятся в очереди на обновление. Если компонент дважды запросил обновление до того, как очередь была обработана, он будет добавлен только один раз.
  3. Приоритезация. Современный React (начиная с 16 версии) использует концепцию приоритетов обновлений. Срочные обновления (например, ввод текста) обрабатываются раньше, чем фоновые (например, загрузка данных). Это ещё одна функция брокера, невозможная при прямых связях «Наблюдателя».
  4. Отсутствие прямых связей. Компонент, вызывающий setState, не знает и не должен знать, какие другие компоненты будут перерендерены в результате. Это может быть:
    • сам компонент;
    • его дочерние компоненты (через свойства);
    • компоненты, использующие этот же контекст.

Достоинства и недостатки подхода

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

Из недостатков, конечно, следует отметить общую сложность подхода. Вероятно, тяжело придумать что-то сложнее. Более того, в силу косвенности взаимодействия мы получаем дополнительные накладные расходы на планирование и очереди.

Следует также отметить, что в классической реализации PubSub брокер обычно хранит список подписчиков для каждого типа события. В React система очередей не имеет явной таблицы подписок. Вместо этого он полагается на структуру компонентного дерева и механизм распространения пропсов и контекста. Однако, как в случае с «Наблюдателем» и Solid, это не сильно меняет сути: коммуникация остаётся опосредованной, с централизованным планированием и отсутствием прямых ссылок между издателем и подписчиком.

Перехват доступа. Proxy

И Observer (Solid), и PubSub (React) требуют явного вызова функций для изменения состояния: setCount(1) или setState({ count: 1 }). Это создаёт определённый когнитивный и синтаксический барьер — разработчик вынужден помнить о необходимости использовать специальный API вместо естественного для JavaScript присваивания.

Отличным решением с точки зрения разработчика была бы возможность писать императивный код с обычными присваиваниями, но получать реактивное поведение автоматически. То есть, возвращаясь к примеру из постановки проблемы, мы стремимся получить нечто подобное:

let a = 6
let b = 7
let c = a + b     // c = 13
let a = 67        // c = 74

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

Итак, нам нужно перехватить поведение присваивания на объекте по умолчанию и изменить его. Эта задача решается через метапрограммирование, позволяющее выполнять дополнительную логику при чтении и записи свойств.

Механизм Proxy

| Proxy — встроенный в JavaScript (начиная с ES6) механизм, позволяющий создавать объект-обёртку, перехватывающий и переопределяющий фундаментальные операции с объектом: чтение свойств, запись, удаление, вызов функций и другие.

В контексте реактивности Proxy предоставляет две критические возможности:

  • Перехват записи (set) — позволяет выполнить дополнительный код при присваивании значения свойству.
  • Перехват чтения (get) — позволяет отследить, какие именно части кода читают данное свойство (сбор зависимостей).

Именно эти возможности легли в основу реактивной системы Vue 3.

Следует отметить, что до третьей версии Proxy не использовался, так как Vue 2 был разработан в эпоху более ранних спецификаций ES. Вместо Proxy использовались манипуляции с объектом через Object.defineProperty, на чём мы не будем останавливаться в рамках данной лекции.

Рассмотрим минимальный пример работы с Proxy:

const original = { count: 0 };

const proxy = new Proxy(original, {
  // Перехват чтения свойства
  get(target, property, receiver) {
    console.log(`Читаю ${property}`)
    return Reflect.get(target, property, receiver);
  },

  // Перехват записи свойства
  set(target, property, value, receiver) {
    console.log(`Записываю ${property} = ${value}`)
    return Reflect.set(target, property, value, receiver);
  }
});

proxy.count;      // Читаю count
proxy.count = 5;  // Записываю count = 5

Краткое отступление про Reflect

Особенно внимательный читатель мог бы задаться вопросом: почему вместо Reflect.get и Reflect.set нельзя использовать обычные аксессоры target[property]?

Ответ содержится в аргументе receiver. Этот аргумент определяет контекст выполнения. Действительно, ведь при использовании target[property] мы теряем информацию о том, что работаем не в контексте объекта не исходного, а проксированного. Именно receiver позволяет нам реализовывать реактивность подобным образом.

Архитектура в Vue 3

В Vue 3 реактивность строится вокруг функции reactive, которая принимает обычный объект и возвращает его Proxy-обёртку:

import { reactive } from 'vue';

const state = reactive({
  count: 0,
  user: {
    name: 'Иван'
  }
});

state.count = 5;
state.user.name = 'Пётр';

Устройство reactive

Для понимания сути достаточно реализовать минимальную версию reactive, демонстрирующую ключевые механизмы:

// Хранилище зависимостей: слабая карта, где
// ключ     - объект,
// значение - карта свойств с множествами эффектов
const targetMap = new WeakMap();

// Текущий эффект, который выполняется (для сбора зависимостей)
let activeEffect = null;


// Функция для регистрации эффекта
function effect(fn) {
  activeEffect = fn;
  fn(); // При выполнении собираются зависимости через get
  activeEffect = null;
}


// Функция для отслеживания зависимости (вызывается в get)
function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  dep.add(activeEffect);
}


// Функция для запуска эффектов при изменении (вызывается в set)
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}


// Основная функция reactive
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);

      // Сбор зависимости
      track(target, key);

      // Автоматическая глубокая реактивность
      if (typeof result === 'object' && result !== null) {
        return reactive(result);
      }

      return result;
    },

    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);

      // Если значение изменилось, уведомляем зависимости
      if (oldValue !== value) {
        trigger(target, key);
      }

      return result;
    }
  });
}

// Пример использования
const state = reactive({ count: 0 });

effect(() => {
  console.log(`Эффект выполняется: count = ${state.count}`);
  // Чтение state.count вызывает track и регистрирует эффект
});

state.count = 5; // Вызывает trigger и повторно запускает эффект

Особенности реализации

Следует в первую очередь отметить, что в Vue используется двусторонний перехват состояний:

  • get — собирает зависимости, запоминая, какие эффекты используют данное свойство;
  • set — при изменении значения находит все зависимости, собранные для этого свойства, и запускает их.

Теперь к оптимизациям. Proxy создаются только при обращении к вложенным объектам. Если глубокое свойство никогда не читалось, оно не будет обёрнуто в Proxy, что экономит память. А использование WeakMap гарантирует, что если объект больше не используется и сборщик мусора удаляет его, соответствующие записи о зависимостях также будут удалены автоматически, предотвращая утечки памяти.

Достоинства и недостатки подхода

Итак, мы получаем естественный синтаксис работы с состояниями. Получаем динамичность — новые свойства автоматически становятся состояниями. Реализация свободна от дополнительных функций и методов.

Из неочевидных подводных камней — проблема целостности оригинального объекта. Proxy работает только с объектом-обёрткой. Если сохранить ссылку на оригинальный объект и изменять его напрямую, реактивность не сработает.

Реактивность на этапе компиляции

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

Но есть в семье фреймворков и библиотек довольно молодое (2019 год) решение. Svelte предлагает принципиально иную парадигму: реактивность реализуется на этапе компиляции, а не во время выполнения. Фреймворк выступает компилятором, преобразующим декларативный код в императивные инструкции по прямому манипулированию DOM.

Итак, вместо того чтобы интерпретировать реактивность в рантайме, Svelte анализирует код на этапе сборки и генерирует оптимальный императивный код для каждого обновления состояния.

Архитектура в Svelte

В Svelte реактивность триггерится самим фактом присваивания. Компилятор анализирует код и там, где встречается оператор =, добавляет необходимый код для обновления зависимых частей DOM.

Рассмотрим простой компонент Svelte:

<script>
  let count = 0;

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  Кликов: {count}
</button>

При компиляции Svelte преобразует этот код в нечто приближённое к следующему:

function createComponent() {
  let count = 0;

  const button = document.createElement('button');
  const text = document.createTextNode('Кликов: ');
  const countNode = document.createTextNode(count);

  button.appendChild(text);
  button.appendChild(countNode);

  // Функция обновления интерфейса
  function updateCount(newValue) {
    countNode.data = newValue;
  }

  // Обработчик клика
  button.addEventListener('click', () => {
    count += 1;         // обновили состояние
    updateCount(count); // обновили представление
  });

  return button;
}

Итак, Svelte анализирует, какие переменные используются в шаблоне, и при каждом присваивании этим переменным добавляет прямой код обновления соответствующих DOM-узлов.

Реактивные декларации

Svelte предоставляет специальный синтаксис для создания производных значений, которые автоматически пересчитываются при изменении зависимостей:

<script>
  let width = 10;
  let height = 20;

  // Реактивная декларация
  $: area = width * height;

  // Реактивный блок
  $: {
    console.log(`Размеры изменились: ${width} x ${height}`);
  }
</script>

<div>Площадь: {area}</div>

Метка $: сообщает компилятору: «следующее выражение зависит от переменных, которые в нём используются». Компилятор анализирует эти зависимости и при каждом присваивании соответствующим переменным добавляет код для пересчёта и обновления.

Достоинства и недостатки подхода

Довольно очевидно, что при работе с реактивными состояниями на этапе компиляции достигается невероятный уровень оптимизации, недоступный ни одному из решений, описанных ранее. У Svelte кратно меньше размер сборки и приложение работает на порядок быстрее.

Автор мог бы назвать ряд недостатков, лежащих на поверхности — сложная работа с состояниями-объектами и прочее. Однако почти все они были решены в версии Svelte 5 с введением обратно-совместимого «рунического синтаксиса». В частности, были добавлены «руны» $state() и $derived(), которые расширяют возможности и без того мощной системы.

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

Заключение

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

Каждое решение балансирует между несколькими осями:

  • Производительность — накладные расходы на отслеживание изменений.
  • Гранулярность — точность определения того, что именно нужно обновить.
  • Синтаксис — насколько естественно пишется код.
  • Масштабируемость — как ведёт себя система при росте приложения.
  • Размер бандла — сколько кода нужно доставить пользователю.

Ни одно решение из представленных не является абсолютно лучшим — выбор зависит от приоритетов проекта.

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