Глава 10 Альтернативы React

Vue.js, Angular, Svelte, Solid, Qwik

Книга
React. К вершинам мастерства

Fluent React: Build Fast, Performant, and Intuitive Web Applications

Глубокое погружение во внутреннее устройство React: JSX и продвинутые паттерны, виртуальный DOM и реконциляция, серверный рендеринг, конкурентный режим и серверные компоненты.

Программа вечера

Антон Помазков
Артём Никифоров
Артём Никифоров
Артём Никифоров

Что такое реактивность?

Способность системы обновляться сама в ответ на изменение данных.

AB
1 2 = A1 * 10 20
2 5 = A1 + A2 7
3 итог = B1 + B2 27

Меняем A1 — и все формулы, которые на неё ссылаются, пересчитываются сами. Причём каскадом: B3 зависит не от A1 напрямую, а от B1 и B2.

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

Как реактивность решает React

  • useState состояние компонента и функция, которая просит перерисоватьофициальная документация ↗
  • Virtual DOM описание интерфейса в памяти — чтобы было с чем сравниватьофициальная документация ↗
  • Reconciliation сверка двух деревьев: ищем минимальное изменение для DOMофициальная документация ↗
Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

console.log('Компонент перерендеривается!');
return ( <div>
<p>Счёт: {count}</p>
<button onClick={() => setCount(count + 1)}> Увеличить </button>
</div> ); }
0 <Counter /> Счёт: 0
1 setState Состояние живёт в компоненте. Меняем его — React помечает компонент «грязным»
2 Перерендер Функция компонента выполняется заново и создаёт новый Virtual DOM
3 Сверка Reconciliation: новое виртуальное дерево сравнивается с предыдущим
4 Патч DOM В реальный DOM уходит минимальный набор изменений — одна цифра

React пересоздаёт всё виртуальное дерево компонента, даже если изменилась одна цифра: единица обновления — компонент, а не значение.

Развитие реактивности в React

Что окружает эту модель — и как она меняется к 2026 году.

Состояние и эффекты

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

  • useState — состояние компонента
  • useEffect — побочные эффекты: подписки, запросы, таймеры
  • useMemo, useCallback — мемоизация: не пересчитывать то, что не изменилось

Конкурентный рендеринг

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

  • useTransition, startTransition — помечают обновление неспешным
  • useDeferredValue — показывает прежнее значение, пока считается новое
  • Suspense — запасной интерфейс, пока грузятся данные или код

Компилятор

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

Vue.js

Прогрессивный JavaScript-фреймворк для создания интерфейсов. Эван Ю работал в Google над AngularJS: Vue вдохновлялся Angular, но хотел быть легче и проще.

Vue.js vuejs.org ↗
Эван Ю · Evan You
Эван Ю · Evan You создатель Vue и Vite
2012–2013 Pet-проект Эван Ю не нашёл готового решения для быстрого прототипирования сложных интерфейсов — и написал своё
27 июля 2013 Первый коммит Начало публичной истории Vue на GitHub
2014–2015 Релиз и рост Официальный релиз, а следом — быстрый набор популярности
сентябрь 2016 Vue 2 Реактивность на Object.defineProperty
сентябрь 2020 Vue 3 Прокси-реактивность вместо геттеров и сеттеров
2026 Vue 3.6 Vapor Mode и Alien Signals

Vue 2: реактивность через Object.defineProperty

Каждое свойство объекта превращается в пару геттер/сеттер: чтение записывает зависимость, запись уведомляет об изменении.

  • Object.defineProperty подменяет свойство парой геттер/сеттерофициальная документация ↗
  • track на чтении запоминает, кто зависит от свойстваофициальная документация ↗
  • trigger на записи уведомляет всех, кто запомнилсяофициальная документация ↗
vue2-reactivity.js
// Упрощённая реализация Vue 2
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
get() { track(key); // отслеживаем, что свойство прочитано return val; },
set(newVal) { if (newVal !== val) { val = newVal; trigger(key); // уведомляем об изменении } }
}); }
const data = { count: 0 }; defineReactive(data, 'count', 0);

Точный, но заранее размеченный

Vue 2 знает про зависимости больше React: обновляется не компонент, а конкретные эффекты. Но перехват ставится по одному свойству — и всё, что не разметили заранее, выпадает из реактивности.

Vue 2: где ломается

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

Vue разметил 3 свойства: у каждого свой геттер и сеттер.

Vue 3: реактивность через Proxy

Object.defineProperty заменили на ES6 Proxy: он оборачивает объект целиком и перехватывает любые операции — чтение, запись, добавление, удаление.

  • Proxy посредник, который стоит перед объектом целикомофициальная документация ↗
  • track на чтении подписывает того, кто читаетофициальная документация ↗
  • trigger на записи уведомляет подписавшихсяофициальная документация ↗
vue3-reactivity.js
// Упрощённая реализация Vue 3
function reactive(obj) {
return new Proxy(obj, {
get(target, key) { track(target, key); // отслеживаем зависимость return target[key]; },
set(target, key, value) { target[key] = value; trigger(target, key); // уведомляем об изменении return true; }
}); } const state = reactive({ count: 0 }); state.count = 1; // работает! state.newProp = 'hello'; // тоже работает — Proxy перехватывает

Что дал Proxy

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

Цена — Proxy есть только в ES6+: поддержку IE11 Vue 3 потерял.

Proxy перехватывает всё

Одна и та же операция над состоянием — и то, что о ней узнаёт Vue 3 с Proxy и Vue 2 с геттерами и сеттерами.

Vue 3 одна обёртка Proxy на весь объект

Ловушка одна на все операции с объектом

Vue 2 defineProperty на каждое свойство

Реактивны только свойства, размеченные при инициализации

Vue 2 размечал свойства по одному — новых, удалённых и записанных по индексу он просто не видел. Proxy стоит перед объектом целиком, поэтому Vue.set() и Vue.delete() больше не нужны.

Vue 2026 · реактивность

Слой «кто от кого зависит» переписан на библиотеку Alien Signals. Публичный API не изменился ни на строку — меняется только то, как Vue внутри проводит изменение по графу зависимостей.

  • Alien Signals движок графа: связи двусвязным списком вместо коллекцийофициальная документация ↗
  • ref, computed, watch публичный API — он не меняется ни на строкуофициальная документация ↗
  • push-pull запись помечает зависимых, чтение пересчитываетофициальная документация ↗
Alien Signals github.com/stackblitz/alien-signals ↗
Johnson Chu
Johnson Chu автор Alien Signals и Vue Language Tools
Counter.vue
<script setup>
import { ref, computed, watch } from 'vue'

// источник — обычный ref
const count = ref(1)

// производные — считаются от него
const doubled = computed(() => count.value * 2)
const total   = computed(() => doubled.value + 10)

watch(total, v => console.log(v))
</script>

<template>
  <p>{{ total }}</p>
</template>

Этот файл в Vue 3.6 не меняется. Alien Signals живёт под ref и computed, а не вместо них.

ref count 1
computed doubled 2
computed total 12
эффект <p> на экране 12
Граф в покое: у каждого значения записано, кто на него смотрит.

Vue 2026 · рендеринг

Vapor Mode — вторая половина: вместо виртуального дерева компилятор сразу пишет код, который меняет нужный узел DOM. Включается атрибутом vapor в теге <script setup>.

  • Vapor Mode режим компиляции без виртуального DOMофициальная документация ↗
  • компилятор шаблонов связывает выражение с конкретным узлом DOMофициальная документация ↗
изменение Virtual DOM diff патч DOM
изменение прямое обновление DOM
Каждая реактивная зависимость связана напрямую с конкретным DOM-узлом
Counter.vue
<script setup vapor>
import { ref, computed } from 'vue'
const count = ref(0) const doubled = computed(() => count.value * 2)
</script> <template>
<p>{{ count }} × 2 = {{ doubled }}</p>
<button @click="count++">Увеличить</button>
</template>

Компонент без виртуального дерева

Обычный компонент Vue на каждое изменение строит новое виртуальное дерево и сравнивает его с прошлым. В Vapor Mode компилятор заранее знает, какое выражение живёт в каком узле, и генерирует прямое присваивание.

Angular

Платформа для разработки веб-приложений, написанная на TypeScript. Первую версию Мишко Хевери написал в Google. Запомните имя: в конце доклада оно встретится снова.

Angular angular.dev ↗
Мишко Хевери · Miško Hevery
Мишко Хевери · Miško Hevery создатель Angular и Qwik
2009 AngularJS Мишко Хевери и Адам Абронс пишут первую версию как личный проект
2010–2015 Годы популярности AngularJS — один из главных фреймворков эпохи
сентябрь 2016 Angular 2+ Полная перезапись на TypeScript, обнаружение изменений на Zone.js
2022–2023 Сигналы Появляются экспериментально — как параллельная модель реактивности
2025–2026 Zoneless Переход без Zone.js: сигналы становятся основой

Angular: обнаружение изменений на Zone.js

Angular не следит за данными. Он держит список привязок шаблона, а Zone.js подсказывает момент, когда их пора перепроверить.

  • Привязка (binding) заводится на каждое выражение шаблона и помнит прошлое значениеофициальная документация ↗
  • Zone.js патчит асинхронные операции и сообщает, когда проверятьофициальная документация ↗
  • Change Detector у каждого компонента свой — сверяет свои привязкиофициальная документация ↗
counter.component.ts
@Component({
  selector: 'app-counter',
  template: `
<p>{{ count }}</p>
<button (click)="increment()">+1</button>
` }) export class CounterComponent {
count = 0;
increment() {
this.count++;
} }
1 Привязка На каждое выражение шаблона — своя привязка с прошлым значением
2 Zone.js Патчит асинхронные операции и сообщает о конце обработчика
3 Change Detector Обход дерева сверху вниз: у каждого компонента свой детектор
4 Сравнение Новое значение против запомненного — отличается, пишем в DOM
App
Header List Sidebar
Logo Counter Row Filters
Проверяются все компоненты — Angular не знает, какой изменился

Angular 2026

Сигнал сам знает, кто его читает. Значит, спрашивать «а вдруг что-то изменилось» больше не надо — и Zone.js становится не нужен.

  • Signal API signal, computed, effect: значение со списком своих читателейофициальная документация ↗
  • zoneless от Zone.js отказались — он не подключается вовсеофициальная документация ↗
  • provideZonelessChangeDetection() этим режим и включается при старте приложенияофициальная документация ↗
counter.component.ts
import { Component, signal, computed } from '@angular/core';

@Component({
  template: `
<p>Счёт: {{ count() }}</p> <p>Удвоено: {{ doubled() }}</p>
` }) export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() { this.count.update(v => v + 1); }
}
// zoneless: Zone.js не подключается вовсе bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] });
1 signal() Значение хранит список тех, кто его прочитал
2 Чтение count() в шаблоне записывает компонент в читатели
3 Запись update() помечает читателей — без пересчёта и без обхода
4 Обновление Проверяются только помеченные компоненты, Zone.js не нужен
App
Header List Sidebar
Logo Counter Row Filters
Обновится только тот компонент, который читает сигнал

Откуда Angular знает, что обновлять

Список читателей ведёт само значение. Прочитали сигнал — записались в список; изменили — прошлись по списку. Дерево компонентов при этом не обходится ни разу.

Svelte

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

Svelte svelte.dev ↗
Рич Харрис · Rich Harris
Рич Харрис · Rich Harris создатель Svelte
2013 Ractive.js Предшественник Svelte — уже с идеей «умного» шаблона
2016 Первый релиз Компилятор вместо рантайма как главная ставка
2019 Svelte 3 Упрощённый синтаксис и реактивность через $:
2021 Vercel Рич Харрис переходит в Vercel — работать над Svelte полный день
2023–2026 Svelte 5 Анонс рун, затем — новый стандарт

Svelte 4: как работает $:

Svelte — компилятор. То, что другие фреймворки делают в браузере, он делает один раз при сборке: читает файл, находит присваивания и сам дописывает вызовы обновления.

  • компилятор Svelte читает файл при сборке, а не следит за ним в браузереофициальная документация ↗
  • $: метка реактивного выражения — зависимости берутся из текстаофициальная документация ↗
  • $$invalidate вызов, который компилятор дописывает после присваиванияофициальная документация ↗
Counter.svelte — что пишете вы
<script>
let count = 0; let doubleCount = 0;
// пересчитывается при изменении count $: doubleCount = count * 2;
function increment() { count += 1; }
</script>
<button on:click={increment}> Нажато {count} раз (удвоено: {doubleCount}) </button>
Counter.js — что уезжает в браузер
// сгенерировано компилятором
function instance($$self, $$props, $$invalidate) {
let count = 0; let doubleCount = 0;
function increment() { $$invalidate(0, count += 1); }
$$self.$$.update = () => { if ($$self.$$.dirty & /* count */ 1) { $$invalidate(1, doubleCount = count * 2); } };
// и обновление конкретного текстового узла set_data(t1, /*count*/ ctx[0]);
}
Справа пока пусто: результат появляется на этапе сборки

Svelte 4: где ломается $:

Всё держится на том, что компилятор видит присваивание в тексте файла. Стоит вынести его за границу .svelte — и дописывать $$invalidate становится некуда.

Counter.svelte
<script>
  let count = 0;
  $: doubleCount = count * 2;

  // присваивание здесь — компилятор его видит
  function increment() {
    count += 1;
  }
</script>
count += 1 отслеживается doubleCount

Код в .svelte: компилятор видит присваивание и связывает его с $:

1

Реактивность работает только внутри .svelte-файлов

2

Не видно присваивания — не видно и зависимости: arr.push() тоже не сработает

3

По коду не понять, что и когда обновляется: решение приняли за вас при сборке

Svelte 5: руны

Руна — явный вызов вместо неявного правила. Компилятор превращает её в сигнал, а зависимости собираются во время выполнения — по факту чтения, а не по тексту файла.

  • $state, $derived, $effect явные вызовы вместо неявных правилофициальная документация ↗
  • .svelte.ts обычный файл, который тоже проходит через компилятор рунофициальная документация ↗
  • зависимости в рантайме подписка получается из самого факта чтенияофициальная документация ↗
counter.svelte.ts — обычный файл, не компонент
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => { console.log('счёт:', count, 'удвоено:', doubled); });
export function increment() { count++; } export function getCount() { return count; }
count++ отслеживается doubled
Тот же сценарий, что ломал $: — здесь связь остаётся

Что изменилось по сути

Зависимости больше не выводятся из текста файла. Их собирает сама программа во время работы: прочитал сигнал — попал к нему в читатели. Ровно та же модель, что у Solid и Vue 3.6.

Solid

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

Solid solidjs.com ↗
Райан Карниато · Ryan Carniato
Райан Карниато · Ryan Carniato создатель Solid
2018–2019 Начало Райан Карниато начинает разработку Solid
2020 Первый релиз Публичная версия с сигналами в основе
2021–2023 Рост Популярность приходит через производительность
2026 Эталон скорости Один из самых быстрых фреймворков — и образец для остальных

Solid: сигнал как пара [getter, setter]

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

  • createSignal пара [getter, setter]: читать и писать — разные функцииофициальная документация ↗
  • createMemo и createEffect производное значение и эффект — подписываются самиофициальная документация ↗
  • компиляция JSX сразу в создание DOM-узлов, без виртуального дереваофициальная документация ↗
Counter.jsx
import { createSignal, createMemo, createEffect } from 'solid-js';

function Counter() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => { console.log('Счёт:', count()); });
return ( <>
<div>{count()}</div> <div>{doubled()}</div>
<button onClick={() => setCount(c => c + 1)}> Увеличить </button>
</> ); }
<Counter /> Счёт: 0 Удвоено: 0
Перерендер всего компонента
Counter — выполнен 1 раз Счёт: 0 Удвоено: 0
Обновились только два текстовых узла

Компонент — это функция настройки

В React функция компонента вызывается на каждое изменение. В Solid она вызывается один раз: её задача — создать узлы и подписки. Дальше работают только сигналы, а на частых точечных обновлениях это даёт 50–70% выигрыша.

Qwik

Фреймворк, построенный на возобновляемости (resumability): вместо гидратации приложение продолжает работу с того места, где остановился сервер. Автор — тот же, что придумал Angular.

Qwik qwik.dev ↗
Мишко Хевери · Miško Hevery
Мишко Хевери · Miško Hevery создатель Qwik и Angular
май 2021 Анонс Хевери уходит из Google в Builder.io и представляет Qwik
2022–2024 Экосистема Qwik City, интеграции, серверные адаптеры
2026 Qwik 1.19 Улучшенная реактивность на сигналах

Автор Angular второй раз переосмысляет реактивность — и на этот раз спорит не с обновлением DOM, а с самой загрузкой приложения.

Qwik: интерактивность без ожидания

Гидратация — это повторный запуск всего приложения в браузере ради того, что сервер уже посчитал. Qwik её не делает.

  • component$ и onClick$ границы, по которым компилятор режет код на чанкиофициальная документация ↗
  • сериализация состояния состояние и подписки уезжают в HTML вместе с разметкойофициальная документация ↗
  • загрузчик ~1.6 КБ ловит событие и подгружает нужный чанк — больше ничегоофициальная документация ↗
что пришло в браузер
<button on:click="q-a8f2.js#s_0[0]" q:id="3"> Счёт: 0 </button>
<script type="qwik/json"> {"objs": [0], "subs": [["3", "count"]]} </script>
<script> /* загрузчик Qwik, ~1.6 КБ */ </script>
HTML весь JS гидратация интерактивно
всё это время кнопки не работают
HTML + состояние интерактивно чанк по клику
ждать нечего: ~1.6 КБ
Обе дорожки стартуют вместе — смотрите, когда загорится «интерактивно»

Как развивалась реактивность

2009–2013
2014–2016
2017–2019
2020–2022
2023–2024
2025–2026
React
2013Virtual DOM и setState: сверка деревьев
2019Хуки: useState, useEffect, useMemo
2022Конкурентный рендеринг, Suspense
2025React Compiler: мемоизация автоматом
Vue
2014Object.defineProperty: геттер и сеттер на свойство
2020Proxy: обёртка на весь объект
2026Alien Signals и Vapor Mode
Angular
2010AngularJS: грязная проверка $digest
2016Zone.js: обход всего дерева компонентов
2023Сигналы — сначала экспериментально
2026Zoneless по умолчанию, Zone.js не нужен
Svelte
2016Компилятор вместо рантайма
2019$: — зависимости из текста файла
2024Руны: $state и $derived — сигналы
Solid
2020createSignal: мелкозернистая реактивность
2026Модель, которую повторили все остальные
Qwik
2021Анонс: возобновляемость вместо гидратации
2023Сигналы и сериализация состояния в HTML
2026Qwik 1.19: ~1.6 КБ начального JS

Куда всё идёт · август 2026

За шесть лет на сигналы перешли все, кроме одного. Каждый пришёл своим путём и в свой год — но к одной и той же модели.

Solid 2020 createSignal — с самого рождения
Qwik 2021 useSignal и состояние прямо в HTML
Preact 2022 @preact/signals как отдельный пакет
Angular 2023 Signal API, с 2026 — zoneless по умолчанию
Svelte 2024 руны $state и $derived вместо магии $:
Vue 2026 Alien Signals под теми же ref и computed
React 2025 React Compiler: мемоизацию расставляет сборка. Модель прежняя — обновляется весь компонент

Реактивность перестала быть отличием фреймворков и стала общим местом. Соревнование переехало на время до интерактивности.

Что далее

Антон Помазков
Артём Никифоров
Артём Никифоров
Артём Никифоров