
Фронтенд давно упёрся в потолок оптимизаций. Мы научились грамотно мемоизировать, батчить обновления, выносить тяжёлые вычисления в воркеры, но каждый раз сталкиваемся с одной и той же проблемой: компоненты всё ещё перерисовываются чаще, чем нужно.
React стал символом этой модели. Благодаря Fiber и Concurrent Mode он действительно ускорился, но его архитектура по‑прежнему опирается на дерево компонентов и диффинг виртуального DOM. Даже с умным планировщиком React всё ещё «пересчитывает дерево», а не конкретные зависимости данных.
На этом фоне появилась Signals — архитектура, которая предлагает другой путь: не оптимизировать старую модель, а избавиться от неё, сделав обновления атомарными и точечными. Без VDOM, ререндеров и догадок.
В этой статье мы разберём, чем «сигнальная реактивность» отличается от компонентной, и на реальных примерах из Solid.js и Angular Signals посмотрим, где именно проходит граница между «умным диффом» и «fine‑grained реактивностью».
Зачем нам вообще Signals
Фронтенд переживает очередной виток эволюции. Когда-то мы отказались от ручного управления DOM в пользу декларативного Virtual DOM, что дало нам выразительный синтаксис и избавило от микроменеджмента узлов. Но со временем выяснилось, что даже оптимизированный Virtual DOM тратит слишком много ресурсов, обновляя больше, чем действительно нужно.
Современные метрики производительности, такие как INP (Interaction to Next Paint) и TTI (Time to Interactive), требуют, чтобы JavaScript вмешивался в работу интерфейса как можно реже. Каждое лишнее вычисление или обновление DOM приводит к существенным задержкам для пользователя.
Именно поэтому фреймворки начинают двигаться в сторону атомарной реактивности — модели, при которой обновляется не компонент, а конкретное значение.
Чтобы понять, почему вообще понадобилось искать альтернативу Virtual DOM, посмотрим, как эволюционировал в последние десять лет сам React, главный двигатель этой парадигмы.
Эволюция React: VDOM, Fiber и планировщик
Классический VDOM + Diff (Ранний React)
Изначально React создавал виртуальное дерево, и при изменении состояния строил новое VDOM, сравнивал (диффил) его с предыдущим и применял патчи к реальному DOM. Это был синхронный и часто затратный процесс, но он дал разработчикам удобство декларативного описания интерфейса.
Fiber (React 16): новая внутренняя машина
Fiber не заменил VDOM, но стал совершенно новой архитектурой (реконсилером). Он разбил работу на мелкие, прерываемые единицы — «файберы».
Это позволило:
Приостанавливать и возобновлять рендеринг, не блокируя основной поток UI
Планировать приоритеты (например, пользовательский ввод важнее загрузки данных)
Fiber сделал React асинхронным и отзывчивым, но идея VDOM‑диффинга при рендере компонента сохранилась.
React 18 (Concurrent Features): умнее, но всё ещё дерево
React 18 принес такие концепции, как Concurrent Rendering и Transitions (startTransition). Эти механизмы сделали интерфейс более отзывчивым: теперь React способен откладывать менее приоритетные обновления, чтобы не блокировать взаимодействие с пользователем.
Но при всей этой «умности» важно понимать: единицей реактивности в React всё ещё остаётся компонент, а не конкретное значение. Когда меняется состояние, React вызывает повторный рендер всей функции компонента, даже если в DOM изменится только один текстовый узел. Fiber и VDOM Diff лишь помогают сократить последствия этой избыточности, но не устраняют её.
И в текущем современном виде он остаётся фреймворком, в котором обновления описываются через дерево компонентов, а не через прямые зависимости между данными и представлением.
Именно здесь на сцену выходит Signals — архитектура, предлагающая иной взгляд на саму природу обновлений в интерфейсе: не «дерево, которое нужно пересчитать», а граф зависимостей, в котором обновляется только то, что изменилось.
Архитектура на основе сигналов
Сигнал (Signal) — это не просто переменная. Это реактивный примитив, который инкапсулирует значение и представляет собой миниатюрный, самодостаточный граф зависимостей.
Особенность | VDOM (React 18) | Signals (Solid/Angular) |
Единица обновления | Компонент | Атомарное значение (Signal) |
Механизм обновления | VDOM Diff & Reconciliation | Прямое обновление DOM-узла |
Модель зависимостей | Неявная (через props/state) | Явная (зависимости трекаются при чтении) |
Runtime Overhead | Выше (VDOM, Fiber) | Значительно ниже (нет VDOM) |
Как работает атомарная реактивность
Работа Signals строится на трёх ключевых концепциях:
Writable Signal (записываемый сигнал). Это сама переменная, имеющая два метода:
Чтение (
.get()илиcount()): когда код читает значение сигнала, фреймворк «автоматически регистрирует» эту операцию, и читатель становится подписчиком (Subscriber)Запись (
.set()): когда значение меняется, сигнал уведомляет всех зарегистрированных потребителей
Automatic Dependency Tracking (автоматический трекинг зависимостей). Это «магия» Signals. Фреймворк поддерживает глобальный контекст, следящий за тем, какая функция (или какой DOM‑эффект) выполняется в данный момент. Когда эта функция читает сигнал, она добавляется в список подписчиков этого сигнала.
Пример: функция, которая обновляет DOM‑текст, читает
count(). При первом запуске Signal трекает эту функцию и DOM-узел становится прямым подписчиком.Execution/Side-Effect (исполнение/побочный эффект). Это конечный потребитель сигнала. При изменении сигнала выполняется только та часть кода, которая на него подписана.
В React изменение состояния родительского компонента приводит к «перезапуску функции рендера» всего компонента. Затем VDOM Diff находит, что обновился только один текст.
В Signals изменение сигнала
countприводит к выполнению только того DOM‑эффекта, который связан с отображением этого числа. Функция самого компонента остаётся не затронутой и не расходует ресурсы процессора на перезапуск.
На практике это означает, что Signals позволяют строить интерфейсы, в которых каждый узел напрямую знает, от какого состояния он зависит, без лишних проверок и пересчётов.
Чтобы не оставаться на уровне теории, посмотрим, как этот принцип реализован в реальных фреймворках: Solid.js (чистый fine‑grained подход) и Angular Signals (интеграция сигнальной модели в зрелую экосистему).
Пример Solid.js
В Solid компоненты‑функции выполняются только один раз при инициализации. Реактивность обеспечивают createSignal и автоматический трекинг:
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
// count() читается и связывается с textNode
return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}
// При setCount(1), обновляется ТОЛЬКО текст в кнопке. Функция Counter не запускается.Пример Angular Signals
Angular, традиционно использующий Zone.js для обнаружения изменений, теперь активно интегрирует Signals. Это позволяет фреймворку обнаруживать изменения точечно, минуя проверку всего дерева:
import { Component, signal } from '@angular/core';
@Component({
template: `<button (click)="increment()">{{ count() }}</button>`
})
export class CountSignal {
count = signal(0);
increment() {
this.count.update(v => v + 1);
}
}
// При increment() обновляется ТОЛЬКО DOM-узел, связанный с count().
// Вся иерархия компонентов не проверяется, если они не зависят от сигнала.Micro-benchmarks: реальное сравнение React и Signals
Ниже приведён код, который использовался при эксперименте. Вы можете провести его самостоятельно.
Angular
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; `
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule],
template: `
<button (click)="updateRandom()">Update Random (Angular)</button>
<div class="grid">
@for(value of values; track value) {
<div class="item" >{{ value() }}</div>
}
</div>,
styles: `
.grid { display: grid; grid-template-columns: repeat(20, 1fr); }
.item { padding: 2px; text-align: center; } `
})
export class App {
values = Array.from({ length: 1000 }, () => signal(0));
updateRandom() {
const i = Math.floor(Math.random() * 1000);
this.values[i].update(value => value + 1);
}
}React
import React, { useState, Profiler, memo } from 'react';
const renderCount = new Set();
const Item = memo(function Item({ value }: { key: number; value: number; }) {
renderCount.add(Item);
return `<div className="item">{value}</div>`;
});
function App() {
const [data, setData] = useState(Array.from({ length: 1000 }, () => 0));
const updateRandom = () => {
const i = Math.floor(Math.random() * 1000);
setData(prev => {
const copy = [...prev];
copy[i] += 1;
return copy;
});
};
const onRender = (id, phase, actualDuration) => {
// Здесь мы увидим, что ререндерится только 1 компонент (тот, что изменился)
console.log(`React Profiler: Phase - ${phase}, Time - ${actualDuration.toFixed(2)}ms. Rerendered components: ${renderCount.size}`);
renderCount.clear();
};
return (
<Profiler id="App" onRender={onRender}>
<button onClick={updateRandom}>Update Random (React)</button>
<div className="grid">
{data.map((value, i) => <Item key={i} value={value} />)}
</div>
</Profiler>
);
}
export default App;Solid
import { createSignal, For } from "solid-js";
function Item(props) {
// Этот console.log отработает только 1 раз при создании компонента
console.log('Solid Item component function executed');
return <div class="item">{props.value()}</div>;
}
function App() {
const [items, setItems] = createSignal(
Array.from({ length: 1000 }, () => createSignal(0))
);
const updateRandom = () => {
const i = Math.floor(Math.random() * 1000);
const [get, set] = items()[i];
set(get() + 1);
};
return (
<>
<button onClick={updateRandom}>Update Random (Solid)</button>
<div class="grid">
<For each={items()}>
{([value]) => <Item value={value} />}
</For>
</div>
</>
);
}
export default App;Рубрика «Эксперименты»

После написания всего тестового кода я провёл замеры на классическом сценарии update‑heavy: при каждом клике из тысячи элементов обновлялся один случайный счётчик. Для анализа использовался Chrome DevTools (вкладка Performance), в частности, поле Scripting Time, отражающее время выполнения JavaScript на CPU.
React не оптимизированный (без использования memo)

Изначально не оптимизированный React тратит 403 мс, что неудивительно, поскольку он перерисовывает все 1 тыс. компонентов.
Оптимизированный React (с использованием memo)

Однако даже после оптимизации с помощью React.memo длительность скриптинга React сократилась лишь до 205 мс.
React Profiler

При этом наш React Profiler показал, что фактическая длительность активного рендеринга (Actual Duration) составляла всего около 40 мс.
Angular Perfomance

Solid Perfomance

Как видите, в Angular и Solid длительность Scripting значительно меньше.
Почему оптимизированный React (205 мс) всё равно в 15—68 раз медленнее, чем Signals (3—14 мс)
Разница примерно в 191 мс (если брать значение Angular — 14 мс) — это накладные расходы (Overhead), которые Signals полностью исключают:
Проверка memo. Даже при использовании
React.memoфреймворк всё равно вынужден пройтись по всему дереву компонентов: вызватьApp, обойти все 1 тыс.<Item>и для каждого сравнить свойства (prevProps.value === nextProps.value). Это тысячи проверок, которые почти никогда ничего не меняют, но тем не менее отнимают время процессора.VDOM/Fiber Overhead. Остальные миллисекунды уходят на внутренние процессы React: планирование задач, работу Fiber-цикла и поддержание структур Virtual DOM. Всё это приводит к задержке, даже если обновился только один элемент.
В результате даже оптимизированный React «платит за проверку» всего дерева, потому что мы 1 тыс. раз проверяем текст и один раз обновляем его. А сигналы «платят только за обновление» (один раз обновляем текст). В этом и заключается фундаментальная разница и главный выигрыш fine‑grained реактивности.
Является ли Signals серебряной пулей
Несмотря на очевидные преимущества в производительности, Signals не являются панацеей. У них есть свои компромиссы, особенно в сравнении с огромной экосистемой React:
Новая ментальная модель (DX): разработчики, привыкшие к императивным хукам React (useState, useEffect), должны привыкать к явному трекингу зависимостей Signals. Неправильное чтение сигнала вне контекста трекинга может не создать подписку, что приведёт к неожиданному поведению.
Экосистема и Tooling: React обладает самой большой и зрелой экосистемой (DevTools, хуки для оптимизации, готовые библиотеки), что критически важно для больших команд. Signal-фреймворки активно развиваются, но пока проигрывают в зрелости.
SSR, гидратация и «возобновляемость» (Resumability): реактивность — это не только обновления на клиенте. Современные приложения всё чаще требуют эффективной работы и на серверной стороне, особенно при рендеринге и загрузке.
Когда мы говорим о Server‑Side Rendering (SSR), мы имеем дело с проблемой гидратации.
Гидратация (Hydration) — процесс, при котором браузер получает от сервера готовый HTML‑код (чтобы показать контент быстро), а затем загружает весь JavaScript, чтобы «оживить» этот HTML, привязать к нему обработчики событий и восстановить состояние. Пока JavaScript не загружен и гидратация не завершена, пользовательский интерфейс может быть неинтерактивным. Гидратация — процесс дорогой, требующий времени CPU.
Решение от VDOM (React): React использует гидратацию. Он загружает весь код, строит VDOM-дерево в браузере и сверяет его с HTML от сервера.
Решение от Signals (Qwik, Solid): хотя Solid и Angular демонстрируют преимущество Signals в runtime, другие фреймворки пошли ещё дальше. Например, Qwik использует ту же идею реактивных сигналов, но применяет её на уровне загрузки и восстановления состояния приложения (Resumability).
Суть в том, что на серверной стороне фреймворк не только генерирует HTML, но и сериализует состояние приложения и местонахождение всех подписчиков (сигналов) прямо в HTML.
Что даёт Resumability
Браузеру не нужно запускать весь JavaScript, чтобы «оживить» приложение. Он загружает только минимально необходимый код: маленький загрузчик, позволяющий возобновить работу с того места, где остановился сервер. Компоненты при этом загружаются лениво, только при первом взаимодействии пользователя (например, при клике на кнопку). Это устраняет «простой» CPU и значительно улучшает метрику TTI (Time To Interactive).
Стоит отметить, что миграция на React Server Components (RSC) тоже может быть непростой, особенно если вы планируете использовать компоненты на основе сигналов.
Эти примеры показывают, что сигнальная архитектура — не очередная оптимизация, а новый фундаментальный слой реактивности. Помимо производительности, она меняет подход к проектированию интерфейсов: заставляет думать не о дереве компонентов, а о прямых связях между состоянием и интерфейсом.
Когда стоит внедрять Signals
Реактивность на основе сигналов стоит рассматривать в проектах, где производительность интерфейса является критическим фактором:
Приложения с множеством интерактивных элементов (дашборды, графики, таблицы)
Сценарии в реальном времени, в которых обновления происходят десятки раз в секунду
Интерфейсы с ограниченными ресурсами (мобильные, embedded, SmartTV)
В этих случаях fine-grained реактивность даёт ощутимый выигрыш без сложных оптимизаций и ручной настройки.
Если же в проекте важнее стабильность экосистемы, поддержка tooling и большая команда, то React по-прежнему остаётся более предсказуемым и зрелым выбором.