Представьте: у вас есть переменная count, и где-то на странице отображается ее значение. Пользователь нажимает кнопку, count увеличивается на единицу. Вопрос: кто и когда обновит DOM-дерево, чтобы пользователь увидел новое число?
В нативном js просто подписываешься и случаешь событие клика, когда событие происходит, меняешь переменную, потом вручную находишь нужный элемент в DOM и меняешь его значение на новое. При росте проекта это превращается в кошмар из запутанных callback-ов, где непонятно, кто за какой кусок DOM отвечает.
Под реактивностью в пользовательских интерфейсах понимают модель, в которой представление (view) остается согласованным с состоянием приложения (state): разработчик описывает, что должно отображаться при текущих данных, а система сама обновляет DOM, когда состояние меняется. Звучит просто, но индустрия перепробовала множество принципиально разных способов реализовать эту синхронизацию. Сегодня хочется поговорить про эти способы и их преимущества и недостатки.
Проверяем все на каждый чихНачалось все с AngularJS — первого фреймворка, который сделал реактивность мейнстримом фронтенда. Идея была революционной для своего времени: пишешь {{ count }} в шаблоне, и фреймворк сам разберется, когда обновить DOM. Сейчас это звучит привычно, но тогда это было настоящим прорывом, задавшим саму постановку задачи для всех, кто пришел после.
Внутри все работало весьма прямолинейно. AngularJS использовал механизм под названием dirty checking. Суть проста: фреймворк не знает, что именно изменилось. Вместо этого он хранит список всех привязок данных (так называемых $$watchers) и периодически проходится по каждой, сравнивая текущее значение с предыдущим. Если значение изменилось, то фреймворк обновляет DOM.
// Упрощенная схема того, что делал AngularJS при каждом изменении
function digestCycle(watchers) {
let dirty = true;
// Идем по циклу, пока хоть что-то меняется
while (dirty) {
dirty = false;
for (const watcher of watchers) {
const newValue = watcher.getValue();
if (newValue !== watcher.lastValue) {
watcher.lastValue = newValue;
watcher.callback(newValue);
dirty = true; // Что-то изменилось — нужен еще один проход
}
}
}
}
Обратите внимание на while (dirty). Один watcher может изменить данные, от которых зависит другой watcher. Поэтому AngularJS прогонял весь массив повторно, пока ни одно значение не менялось. Если за 10 проходов стабильности достичь не удавалось, фреймворк бросал ошибку 10 $digest() iterations reached.
Когда запускался этот цикл? При каждом «интересном» событии: клик, ввод текста, ответ сервера, таймер. AngularJS оборачивал стандартные API браузера (вроде setTimeout или XMLHttpRequest) используя паттерн monkey-patching. Далее в коде вы использовали эти обертки, чтобы после каждого такого события автоматически запускать $digest. Если вы меняли данные вне контролируемого контекста (например, в нативном addEventListener), приходилось вручную вызывать $scope.$apply(), чтобы инициировать цикл проверки.
У такого подхода есть очевидная проблема производительности. Если на странице много привязок, каждый клик запускает полный перебор их всех. Причем несколько раз подряд, пока все не стабилизируется. На медленных устройствах ожидаемо такой интерфейс будет работать заметно медленно и даже терять отклик.
Наблюдение за виртуальными деревьямиНа смену dirty checking пришел React с совсем другой идеей. Вместо того чтобы точечно отслеживать, какие данные изменились, React предложил на каждое изменение строить новое дерево и затем сравнивать его с текущим DOM-деревом, заменяя те части, которые изменились.
Как это работаетПерерисовка всего DOM-дерева достаточно дорогая операция. Поэтому React ввел промежуточный слой (Virtual DOM). Это обычные JavaScript-объекты, описывающие структуру интерфейса.
// Вот так примерно выглядит виртуальный узел
const vnode = {
type: 'div',
props: { className: 'counter' },
children: [
{ type: 'span', props: {}, children: ['Счетчик: '] },
{ type: 'span', props: {}, children: ['42'] }
]
};
Такое дерево дешево построить заново целиком ведь это просто объекты в памяти, без обращения к реальному DOM. А вот применять его к странице напрямую, стирая и создавая узлы заново при каждом изменении, слишком дорого: браузеру пришлось бы каждый раз пересчитывать layout и "перекрашивать кнопки". Поэтому вместо прямой перерисовки React сравнивает новое дерево со старым и вычисляет разницу. Этот шаг и называется реконсиляция. В DOM летит не все дерево, а только конкретные изменения: заменить текст, добавить атрибут, вставить узел.
Отсюда возникает вопрос: как понять, когда вообще нужно строить новое дерево? Тут React предлагает компонентную модель, которая разделяет ответственность, добавляет изоляцию изменений, а также композицию и переиспользование кода. Каждый компонент отвечает за свое состояние и жизненный цикл. В классовых компонентах, предложенных React, когда мы вызываем setState, React заново вызывает функцию компонента (или метод render у классовых компонентов), получает новое виртуальное дерево и запускает алгоритм реконсиляцию. Этот алгоритм находит минимальный набор изменений и применяет только их к настоящему DOM.
class Counter extends React.Component {
state = { count: 0 };
increment = () => {
// При вызове setState React:
// 1. Вызовет render() заново
// 2. Получит новый виртуальный DOM
// 3. Сравнит его со старым
// 4. Обновит только текст внутри <span>
this.setState({ count: this.state.count + 1 });
};
render() {
// Это не HTML, а JSX (синтаксический сахар),
// который компилируется в React.createElement(...) и возвращает
// обычный JS-объект (тот самый vnode), а не разметку для браузера
return (
<div className="counter">
<span>Счетчик: {this.state.count}</span>
<button onClick={this.increment}>+1</button>
</div>
);
}
}
Ключевое отличие React от AngularJS в том, что он не пытается узнать, какие именно данные изменились. Он просто перевызывает всю функцию компонента, создает новый снимок виртуального дерева и сравнивает его с предыдущим. Это элегантно, потому что не нужно никакой подписки и специальных оберток.
ПроблемыЗа эту простоту приходится платить. Если у вас дерево из тысячи компонентов и изменилось одно значение на самом верху, React по умолчанию перевызовет все дочерние компоненты, чтобы получить новые виртуальные деревья, даже если их результат не изменится. Оптимизировать это можно через React.memo, useMemo и useCallback, но это ручная работа, которую разработчик должен делать сам. По сути, разработчик берет на себя ответственность подсказать фреймворку, что именно можно не пересчитывать.
Отсюда, кстати, растет целая ветка инструментов: React Compiler (бывший React Forget), который пытается автоматически расставлять эти мемоизации за вас на этапе сборки. Тот факт, что для этого потребовался отдельный компилятор, хорошо показывает масштаб проблемы.
Перехватываем доступ к данным через ProxyVue пошел третьим путем. Идея звучала так: а что если мы сделаем сами данные «умными»? Пусть объект с данными знает, кто его читает, и при изменении уведомляет только тех, кто от него зависит.
Как это работаетVue 2 реализовывал это через Object.defineProperty, перехватывая геттеры и сеттеры каждого свойства. Но у этого подхода были серьезные ограничения: нельзя было отследить добавление новых свойств или изменение элементов массива по индексу. Отсюда появились костыли вроде Vue.set() и Vue.delete().
Vue 3 переехал на Proxy, специальный встроенный в JavaScript объект, выступающий в роли обертки над другим объектом и позволяющий перехватить любую операцию над ним.
// Упрощенная реактивная система в стиле Vue 3
let activeEffect = null;
const targetMap = new WeakMap();
function reactive(target) {
return new Proxy(target, {
get(obj, key) {
// Запоминаем, кто нас читает
track(obj, key);
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
// Уведомляем всех, кто от нас зависит
trigger(obj, key);
return true;
}
});
}
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let deps = depsMap.get(key);
if (!deps) depsMap.set(key, (deps = new Set()));
deps.add(activeEffect);
}
function trigger(target, key) {
const deps = targetMap.get(target)?.get(key);
if (deps) deps.forEach(effect => effect());
}
Когда Vue рендерит компонент, он запускает функцию рендера как эффект. Во время выполнения функция читает реактивные свойства, и Proxy автоматически запоминает зависимости. Позже, когда свойство меняется, Proxy точно знает, какие компоненты нужно перерендерить, а остальные не трогает.
Это называют моделью с вытягиванием зависимостей (фаза сбора зависимостей — вытягивание, а фаза уведомления об изменении — проталкивание). В отличие от React, здесь не нужно ничего мемоизировать: система сама знает граф зависимостей.
ПроблемыProxy работает только с объектами. Примитивные значения (числа, строки, булевы) нельзя обернуть в Proxy, поэтому Vue 3 ввел отдельную обертку ref() для примитивов, и теперь приходится везде писать .value. Это создает дополнительную когнитивную нагрузку: Когда нужен ref, а когда reactive? Почему в шаблоне .value не нужен, а в скрипте нужен?
Кроме того, деструктуризация ломает реактивность. Если написать const { name } = reactive({ name: 'Паша' }), переменная name станет обычной строкой, потерявшей связь с Proxy. Для этого придумали toRefs() и нужно помнить эту особенность при разработке.
Четвертым свой путь предложил Svelte. Начиная с третьей версии, он задал провокационный вопрос: а зачем вообще таскать реактивную систему в браузер? Что если решить все на этапе компиляции?
Как это работаетВ Svelte вы пишете, казалось бы, обычный JavaScript:
<script>
let count = 0;
function increment() {
count += 1;
}
</script>
<button on:click={increment}>
Счетчик: {count}
</button>
Но этот код никогда не попадет в браузер в таком виде. Компилятор Svelte анализирует его и генерирует примерно следующее:
// Псевдокод того, что генерирует компилятор
function increment() {
count += 1;
$$invalidate('count', count); // <- компилятор добавил это сам
}
// ...
// Компилятор также генерирует прямое обновление DOM:
if (dirty & COUNT_FLAG) {
set_data(text_node, count);
}
Компилятор точно знает, какие переменные используются в шаблоне, и генерирует точные инструкции по обновлению конкретных DOM-узлов. Никакого виртуального DOM, никаких Proxy, никакого сравнения деревьев. Просто if и прямое присваивание.
В Svelte 5 модель реактивности обновилась. Появились руны, представление из себя специальные функции вроде $state и $derived, которые делают реактивность более явной:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<p>{count} × 2 = {doubled}</p>
Компилятор по-прежнему генерирует оптимальный код обновления DOM, но теперь он лучше справляется со сложными сценариями и вложенными структурами данных.
ПроблемыSvelte-код — это не совсем JavaScript. Он выглядит как JavaScript, но семантика другая: обычное присваивание count += 1 неожиданно становится триггером обновления. Это означает, что отладка иногда требует заглядывания в сгенерированный код, а знания нативного JS не всегда переносятся напрямую. Кроме того, вся экосистема завязана на компилятор и использовать реактивность Svelte отдельно от Svelte нельзя.
Следующий виток эволюции реактивности это signals(сигналы), самый заметный тренд последних лет. Их реализовали в Preact, SolidJS, Angular (начиная с версии 16), Qwik, и даже есть черновик предложения в TC39 для включения сигналов в сам стандарт JavaScript.
Как это работаетПо сути, сигнал — это контейнер для значения с автоматическим отслеживанием зависимостей. Очень похоже на ref() из Vue о котором мы говорили ранее, но с важным отличием: сигналы спроектированы как примитив, не привязанный к конкретному фреймворку.
// Упрощенная реализация сигналов
let currentSubscriber = null;
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
function read() {
// Если кто-то сейчас отслеживает зависимости — подпишем его
if (currentSubscriber) {
subscribers.add(currentSubscriber);
}
return value;
}
function write(newValue) {
value = newValue;
// Уведомляем всех подписчиков
for (const sub of subscribers) {
sub();
}
}
return [read, write];
}
Код createSignal выше специально написан похоже на reactive() из раздела про Vue: у обоих есть подписчики и функция, которая их уведомляет. Но механизм подписки принципиально другой. У Vue подписка происходит незаметно: вы просто читаете обычное свойство объекта (state.count), а Proxy сам перехватывает это чтение через get-ловушку и молча запоминает, кто читал. С сигналом все проще. Чтобы подписаться, нужно явно вызвать сигнал как функцию (count()). Именно этот вызов и есть точка подписки, а не побочный эффект чтения свойства. Из-за этого сигналы работают с любым значением, включая примитивы, и не нуждаются во всем объекте-обертке. Однако это и не позволяют деструктурировать состояние так, будто это обычный JS-объект.
Ключевая разница с Virtual DOM: сигналы обновляют только то, что реально зависит от измененных данных. Не нужно перевызывать всю функцию компонента и сравнивать деревья. Зависимость устанавливается на уровне конкретного DOM-узла или вычисления.
Вот как это выглядит в SolidJS:
import { createSignal, createEffect } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
// Этот эффект перезапустится ТОЛЬКО при изменении count
createEffect(() => {
console.log('Счетчик:', count());
});
// Функция Counter() вызывается только ОДИН раз
return (
<div>
<span>Счетчик: {count()}</span>
<button onClick={() => setCount(count() + 1)}>+1</button>
</div>
);
}
Обратите внимание: в отличие от React, функция компонента в SolidJS выполняется не при каждом обновлении, а ровно один раз. Реактивные привязки устанавливаются внутри JSX, и при изменении сигнала обновляется только текст внутри <span>, без пересоздания виртуального дерева и reconciliation.
Отдельные сигналы редко живут поодиночке — почти всегда нужны значения, вычисленные из других сигналов. Для этого есть createMemo (в SolidJS), computed (в Vue и Angular) или $derived (в Svelte 5):
const [firstName, setFirstName] = createSignal('Иван');
const [lastName, setLastName] = createSignal('Иванов');
// Пересчитается только если firstName или lastName реально изменились
const fullName = createMemo(() => `${firstName()} ${lastName()}`);
Но как только производных значений становится несколько и они начинают зависеть друг от друга, вылезает подвох. Представьте, что fullName зависит от firstName, а еще один производный сигнал greeting зависит сразу и от firstName, и от fullName. Если при изменении firstName система уведомляет подписчиков в том порядке, в каком они попадаются, greeting может пересчитаться дважды: сначала со старым значением fullName, потом с новым. На мгновение пользователь увидит на экране несогласованные данные. Граф зависимостей в этом месте по форме напоминает ромб. Отсюда такой эффект получил название: «проблема алмаза».
Некоторые реализации сигналов решают эту проблему следующим образом. Сначала выстраивают все зависимости в правильном порядке (от источников к тем, кто от них зависит), а уже потом пересчитывают производные значения именно в этом порядке и одним пакетом, а не по одному. Все сигналы, которые затронуло изменение, сперва помечаются «грязными», и только когда система точно знает финальное состояние всех источников, начинается пересчет. Это гарантирует что на выходе не бывает промежуточных, несогласованных версий данных.
ПроблемыВызов сигнала как функции (count() вместо count) поначалу кажется неудобным. Также нужна дисциплина при работе с производными значениями: если забыть обернуть вычисление в createMemo или его аналог, можно получить избыточные пересчеты. Упорядоченный пересчет из предыдущего раздела тоже не бесплатный: он требует, чтобы библиотека честно строила и поддерживала граф зависимостей, а в не самой аккуратной реализации на долю секунды вполне может проскочить устаревшее производное значение. Ну и главная практическая проблема: сигналы пока не стандартизированы, и каждый фреймворк реализует их немного по-своему.
Пройдя путь от dirty checking до сигналов, можно заметить, что оптимизации движутся к более точечной реактивности. Чем точнее система знает, что именно изменилось и кого это затрагивает, тем меньше лишней работы делает браузер.
Но думаю было бы ошибкой считать, что Virtual DOM это прошлый век, а сигналы — серебряная пуля. React выбрал модель, в которой компонент является чистой функцией от данных. Это упрощает рассуждение о коде и делает его более предсказуемым. За это приходится платить производительностью при ререндерах, но для большинства приложений эта цена посильна.
А еще стоит помнить, что в реальных проектах узкое место обычно не реактивная система фреймворка. Большинство проблем как правило это неоптимизированные запросы к API, тяжелые изображения, CSS-анимации и рендер тысяч DOM-узлов, которых можно было бы избежать. На мой взгляд на практике архитектурные решения и культура оптимизации в команде значат куда больше, чем разница между фреймворками.