Кратко
СкопированоСигналы — примитив для хранения значения, которое умеет само сообщать о своих изменениях. Значение меняется, и обновление само добирается до всех, кто на него смотрит: до других вычисляемых значений и до интерфейса, без ручных подписок и подписной обвязки.
Сигналы есть почти в каждом современном фронтенд-инструменте: в Angular, Vue, Solid, Svelte, Preact, Qwik, MobX, и в каждом под своим именем и со своим движком внутри. TC39 сейчас пытается собрать из этого зоопарка единый примитив, предложение Signals.
Зачем нужны сигналы
СкопированоПредставим светофор, для которого нужно решить, можно ли сейчас ехать. Без сигналов это выглядит так:
let light = 'red'const setLight = (value) => { light = value render()}const canGo = () => light === 'green'const message = () => canGo() ? '🟢 Можно ехать' : '🔴 Стоп'const render = () => { element.innerText = message()}
let light = 'red'
const setLight = (value) => {
light = value
render()
}
const canGo = () => light === 'green'
const message = () => canGo() ? '🟢 Можно ехать' : '🔴 Стоп'
const render = () => {
element.innerText = message()
}
Работать будет, но с проблемами. Светофор жёстко склеен с рендером, по-другому обновить интерфейс нельзя. Если light меняется с 'yellow' на 'red', message возвращает то же самое «🔴 Стоп», но мы всё равно пересчитываем can, message и заново трогаем DOM. А если где-то ещё в приложении нужно знать только can, например чтобы включить предупреждающий сигнал, сделать это без переписывания текущего кода нельзя.
Первое, что приходит в голову — прикрутить pub/sub, чтобы light рассылал уведомления всем, кто на него подписан. Проблема расползается дальше: render теперь должен знать, что нужно подписаться именно на light, хотя по смыслу зависит только от message. Подписаться на can или message отдельно от light всё ещё нельзя без ручной прокладки. А самое неприятное: для каждого нового значения светофора приходится заново городить подписки, отписки и следить, чтобы нигде не потекла память.
Сигналы решают именно это: как связать разные куски состояния между собой, чтобы изменение автоматически добиралось до всех, кому это нужно, без ручного бухгалтерского учёта подписок.
Как пишется
СкопированоВ основе лежат три сущности.
Signal — ячейка с состоянием, которую можно читать и записывать. Например:
const light = new Signal.State('red')light.get() // 'red'light.set('green')light.get() // 'green'
const light = new Signal.State('red')
light.get() // 'red'
light.set('green')
light.get() // 'green'
Signal — формула, вычисленная из других сигналов.
Эффект — сторонний эффект, который нужно перезапускать при изменении зависимостей. В стандарте его нет, он собирается поверх Signal и Signal силами фреймворка, но для примера предположим, что функция effect уже есть:
const canGo = new Signal.Computed(() => light.get() === 'green')const message = new Signal.Computed(() => { return canGo.get() ? '🟢 Можно ехать' : '🔴 Стоп'})effect(() => { element.innerText = message.get()})light.set('yellow')
const canGo = new Signal.Computed(() => light.get() === 'green')
const message = new Signal.Computed(() => {
return canGo.get() ? '🟢 Можно ехать' : '🔴 Стоп'
})
effect(() => {
element.innerText = message.get()
})
light.set('yellow')
Никаких ручных подписок. message сам разберётся, что зависит от can, can, в свою очередь, от light, а эффект зависит от message. Обновление всей цепочки происходит само.
Авторы специально выбрали .get и .set вместо привычных по многим библиотекам .value или вызова signal: они не стали копировать ни один существующий API, чтобы не создавать ложное ощущение полной совместимости. Заодно так дешевле по памяти: состояние умещается в один объект, а не в пару [значение с двумя замыканиями на каждый сигнал.
Как сигнал узнаёт о своих зависимостях
СкопированоОстаётся понять, откуда can вообще знает, что зависит от light. Ответа два: вручную объявить зависимости или отследить их автоматически во время вычисления. Сигналы выбирают второй путь. Устроен он так.
У движка сигналов есть скрытое глобальное состояние (по спецификации, на уровне треда):
computing— какой вычисляемый сигнал сейчас выполняется, либоnull;frozen— запрещено ли сейчас читать и писать сигналы;generation— счётчик поколений для сборки мусора.
Когда Signal пересчитывается, движок кладёт его в computing. Метод чтения Signal выглядит примерно так:
- Если
computingне пуст, добавить себя в список источников (sources) текущего вычисляемого сигнала. - Вернуть значение.
Больше ничего не нужно. Любое чтение внутри Signal автоматически регистрирует зависимость, никакого subscribe руками.
Список зависимостей у вычисляемого сигнала не фиксированный, а собирается заново при каждом пересчёте:
const showDetails = new Signal.State(false)const name = new Signal.State('Тыква')const details = new Signal.State('Хэллоуинская классика')const label = new Signal.Computed(() => { if (!showDetails.get()) return name.get() return `${name.get()}: ${details.get()}`})
const showDetails = new Signal.State(false)
const name = new Signal.State('Тыква')
const details = new Signal.State('Хэллоуинская классика')
const label = new Signal.Computed(() => {
if (!showDetails.get()) return name.get()
return `${name.get()}: ${details.get()}`
})
Пока show равен false, label читает только name, про details он ничего не знает и не будет пересчитываться при его изменении. Стоит переключить show в true, и details попадёт в список источников уже при следующем чтении. Такое поведение называют динамическими зависимостями: набор источников каждый раз актуализируется по факту прочитанного, а не объявляется заранее.
При вложенных вычисляемых сигналах все прочитанные сигналы записываются на самый внутренний: если Computed A вызывает Computed B, а тот читает light, то зависимость на light появится у B, а не у A.
Раскраска графа
СкопированоАвтотрекинг объясняет, откуда берутся связи. Но остаётся вопрос поважнее: как система понимает, что именно нужно пересчитать при изменении, и как не считает лишнего? Здесь в игру вступает раскраска графа (graph coloring), основной механизм, вокруг которого построена вся спецификация.
У вычисляемого сигнала есть четыре состояния.
| Состояние | Что значит |
|---|---|
clean | значение есть и точно свежее |
checked | какой-то непрямой источник изменился, значение может быть устаревшим |
computing | колбэк сигнала выполняется прямо сейчас |
dirty | значение точно устарело либо сигнал ещё ни разу не читали |
Работа идёт в два такта — push при записи и pull при чтении.
Push: что происходит внутри set()
СкопированоКогда вызывается light, синхронно, ещё до возврата из set, происходит следующее:
- Если новое значение равно старому по
equals, ничего не делаем, изменение гасится на месте. - Иначе прямые потребители (
sinks) красятся вdirty. - Все остальные потребители дальше по цепочке переходят в
checked. Если какой-то сигнал уже былdirty, состояниеcheckedего не понижает. - Затронутые низкоуровневые наблюдатели (watchers, о них дальше) переходят в
pending, и им синхронно вызывается колбэкnotify.
Сама запись не запускает ни одного пересчёта. Она только помечает часть графа как потенциально устаревшую.
Pull: что происходит внутри get()
СкопированоПересчёт откладывается до момента, когда кто-то реально читает значение. Внутри Signal:
- Если сигнал уже
clean, вернуть закэшированное значение. - Если
dirtyилиchecked, рекурсивно найти самый глубокий и самый левый (то есть раньше всех прочитанный) источник в состоянииdirty. Поиск обрывается, как только встречаетсяclean-сигнал: туда лезть не нужно, он точно свежий. - Пересчитать найденный сигнал, сравнить новое значение со старым через
equals. - Если значение не изменилось, потребители, которые были
checked, возвращаются вclean, дальше по графу пересчёт не идёт. - Если изменилось, потребители красятся в
dirty, и шаги 2–4 повторяются, пока весь путь до исходного сигнала не станетclean.
Состояние checked значит «не знаю, поменялось что-то у меня в глубине или нет, надо проверить», в отличие от dirty, которое значит «точно поменялось». Поэтому изменение, которое гасится где-то в середине графа (equals вернул true), не долетает до конца: лишнего пересчёта не происходит нигде.
Это и называют «глитч-фри» (glitch-free) вычислением: неправильное промежуточное значение не успевает появиться, потому что пересчёт откладывается до момента, пока не станет понятно, что именно нужно посчитать.
Разница особенно заметна там, где два сигнала читают одно и то же значение, а третий зависит от обоих сразу. Возьмём тот же светофор: cars и pedestrians оба зависят от light, а табло перекрёстка board зависит сразу от cars и pedestrians:
В наивном pub/sub board пересчитывается дважды: по разу на каждое обновление cars и pedestrians. У сигналов он читается один раз, потому что раскраска графа заранее говорит: «оба родителя устарели, пересчитывай board только когда его реально спросят, и только один раз».
Сравнение значений и кэш
СкопированоПо умолчанию сигналы сравнивают значения через Object. Сравнение выполняется один раз, в момент записи источника или пересчёта вычисляемого сигнала, а не при каждом обращении потребителя. Поэтому дорогое сравнение объектов не размножается по графу.
Компаратор можно переопределить через equals в опциях:
const list = new Signal.State([1, 2, 3], { equals: (previous, next) => previous.length === next.length})
const list = new Signal.State([1, 2, 3], {
equals: (previous, next) => previous.length === next.length
})
Здесь list будет считаться изменившимся, только если поменялась длина массива, даже если внутри поменялись элементы. Колбэк equals вызывается с самим сигналом как this, чтобы не заводить лишнее замыкание на каждый сигнал.
Ошибки кэшируются так же, как обычные значения. Если колбэк Signal бросает исключение, оно сохраняется и перебрасывается при каждом следующем чтении, до тех пор пока какая-нибудь из зависимостей не изменится и не запустит пересчёт заново.
Специфика вычисляемых сигналов накладывает и жёсткие ограничения:
- рекурсивное чтение сигнала изнутри его же вычисления считается ошибкой;
- внутри колбэка
notifyу наблюдателя нельзя ни читать, ни писать ни один сигнал:Signalот этого не спасает, потому что движок в этот момент помечен как. subtle . untrack ( ) frozen.
Почему в стандарте нет эффектов
СкопированоРеактивные библиотеки почти всегда предлагают готовый effect. В спецификации сигналов такой функции нет, есть только низкоуровневый Signal, поверх которого эффект можно собрать самостоятельно:
// Этот код обычно живёт в библиотеке, а не в// прикладном коде. Планирование здесь упрощено// до предела, для продакшена так не пишут.let pending = falseconst watcher = new Signal.subtle.Watcher(() => { if (!pending) { pending = true queueMicrotask(() => { pending = false for (const signal of watcher.getPending()) { signal.get() } watcher.watch() }) }})function effect(callback) { let cleanup const computed = new Signal.Computed(() => { cleanup?.() cleanup = callback() }) watcher.watch(computed) computed.get() return () => { cleanup?.() watcher.unwatch(computed) }}
// Этот код обычно живёт в библиотеке, а не в
// прикладном коде. Планирование здесь упрощено
// до предела, для продакшена так не пишут.
let pending = false
const watcher = new Signal.subtle.Watcher(() => {
if (!pending) {
pending = true
queueMicrotask(() => {
pending = false
for (const signal of watcher.getPending()) {
signal.get()
}
watcher.watch()
})
}
})
function effect(callback) {
let cleanup
const computed = new Signal.Computed(() => {
cleanup?.()
cleanup = callback()
})
watcher.watch(computed)
computed.get()
return () => {
cleanup?.()
watcher.unwatch(computed)
}
}
Колбэк notify, который передаётся в Watcher, вызывается синхронно во время set, прямо посреди чужого кода, который ещё не закончил свою работу. Поэтому notify не может сразу что-то пересчитать: он может только запланировать работу на потом, например через queue. Для этого и нужен get — он возвращает актуальный список сигналов, которые всё ещё нужно пересчитать, на момент, когда это уже безопасно делать.
После обработки очереди наблюдателя нужно снова «включить» вызовом watcher без аргументов, иначе он не заметит следующее изменение.
Причина, по которой планирование оставили фреймворкам, простая: единого правильного расписания не существует. React привязан к фазам рендера, Angular отслеживает изменения без зон (zone-less change detection), а Solid ориентируется на свой планировщик. Стандарт даёт синхронный, предсказуемый механизм уведомления, а решение «когда именно перезапускать эффект» каждый фреймворк принимает сам.
Signal.subtle : для авторов фреймворков
СкопированоПродвинутые возможности вынесены в отдельное пространство имён, по аналогии с crypto, чтобы визуально отделить API для написания фреймворков и девтулов от повседневного использования.
Signal — выполняет код без отслеживания зависимостей. Например:
const total = new Signal.Computed(() => { const items = list.get() // Не хотим пересчитывать total из-за debugMode if (Signal.subtle.untrack(() => debugMode.get())) { console.log('Пересчёт total') } return items.length})
const total = new Signal.Computed(() => {
const items = list.get()
// Не хотим пересчитывать total из-за debugMode
if (Signal.subtle.untrack(() => debugMode.get())) {
console.log('Пересчёт total')
}
return items.length
})
API специально называется «unsafe» в документации: если внутри untrack прочитать сигнал, от которого результат вычисления реально зависит, граф перестанет быть согласованным, и total не обновится, когда должен.
Символы watched и unwatched — самая полезная часть для прикладного кода. Они позволяют лениво подписываться на внешний источник данных ровно тогда, когда сигнал реально кем-то наблюдается. Например:
const windowWidth = new Signal.State(window.innerWidth, { [Signal.subtle.watched]() { window.addEventListener('resize', this.onResize) }, [Signal.subtle.unwatched]() { window.removeEventListener('resize', this.onResize) }})
const windowWidth = new Signal.State(window.innerWidth, {
[Signal.subtle.watched]() {
window.addEventListener('resize', this.onResize)
},
[Signal.subtle.unwatched]() {
window.removeEventListener('resize', this.onResize)
}
})
Пока ни один эффект не следит за window, обработчик resize не висит впустую.
Интроспекция — introspect, introspect, has, has, current. Эти функции не нужны в обычном прикладном коде, зато на них опираются девтулы: можно построить граф зависимостей целиком, найти, какой сигнал держит другой живым, или отследить цепочку вычислений, которая привела к ошибке.
Как это выглядит во фреймворках
Скопировано| Фреймворк | Состояние | Вычисление | Эффект |
|---|---|---|---|
| Angular | signal | computed | effect |
| Vue | ref / reactive | computed | watch |
| Solid | create | create | create |
| Svelte 5 | $state | $derived | $effect |
| Preact | signal | computed | effect |
| Qwik | use | use | use |
Синтаксис и планировщик у каждого свои, но алгоритм в основе почти везде тот же, что описан выше: автотрекинг через глобальную переменную вычисления, ленивый пересчёт, раскраска графа. Показательный пример — библиотека alien-signals. Её алгоритм лёг в основу signal, официального полифила предложения TC39, а заодно и в ядро Vue начиная с 3.6. Общая теория начала перетекать обратно в реализации фреймворков.
Что часто путают с сигналами
СкопированоСобытия и pub/sub. Для одиночного уведомления «что-то произошло» (клик, сообщение по сокету) обычная подписка проще и не требует графа зависимостей.
Proxy-реактивность. Реактивные прокси (reactive во Vue, аналогичные механизмы в MobX) отвечают на вопрос «как заметить запись в поле объекта», а сигналы, в свою очередь, на вопрос «как распространить уже замеченное изменение». Это разные уровни одной системы, а не конкуренты.
RxJS и потоки. В RxJS поток данных — это последовательность событий во времени, с операторами комбинирования, отменой и явной подпиской. Сигнал, в отличие от него, всегда одно синхронно доступное значение «здесь и сейчас». Совмещать несколько источников асинхронных событий во времени по-прежнему задача для потоков.
React с иммутабельностью и перерисовкой. React и связанные с ним подходы делают противоположную ставку: пересчитывают всё дерево целиком при каждом изменении, полагаясь на компилятор и мемоизацию, вместо точечного обновления того, что изменилось. Обе модели рабочие, это разные компромиссы.
Abort. Название совпадает случайно: Abort из Web API не имеет отношения к реактивным сигналам, обсуждаемым здесь.
Статус предложения
СкопированоПредложение находится в TC39 на Stage 1 с апреля 2024 года. Команда авторов сознательно не торопится: прежде чем предлагать Stage 2, они хотят получить несколько production-полифилов, реально работающих в разных фреймворках, и убедиться, что API не потребует переделки.
За скобками первой версии осталось:
- асинхронность — сигналы всегда синхронно доступны, состояние загрузки предлагается моделировать через исключения;
- транзакции — одновременное существование «старого» и «нового» состояния графа для плавных переходов между экранами;
- удобные методы (вроде укороченных обёрток над
Computed) — сознательно не включены, чтобы не зафиксировать в стандарте то, по чему ещё нет согласия между фреймворками.
Обсуждается и более далёкая перспектива: интеграция с DOM Parts и декларативным шаблонированием в HTML, но это отдельное предложение, а не часть текущего.
На практике
Скопированосоветует
Скопировано🛠 Когда разбираюсь в незнакомой реактивной библиотеке, я не читаю документацию по API, а ищу в исходниках четыре состояния сигнала: что-то вроде clean, dirty, checked и computing. Если библиотека построена на графе, эти состояния почти всегда называются похоже, и через них понятно, где в коде происходит пометка изменений (push), а где реальный пересчёт (pull). Экономит часы на чтении чужого кода.
🛠 Если пишете своё маленькое хранилище состояния и тянетесь за pub/sub, сначала прикиньте на бумаге граф зависимостей. Если в нём есть развилка, которая потом снова сходится в одну точку, наивные подписки почти гарантированно пересчитают конечную точку несколько раз за одно изменение. Это не всегда страшно, но если пересчёт дорогой, например там сетевой запрос или тяжёлая сортировка, лучше сразу закладывать ленивый пересчёт по чтению, а не по записи.