Клавиша / esc

Signals

Автоматическое отслеживание зависимостей, раскраска графа, ленивый пересчёт и почему эффектов нет в стандарте.

Время чтения: 15 мин

Кратко

Скопировано

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

Сигналы есть почти в каждом современном фронтенд-инструменте: в 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() возвращает то же самое «🔴 Стоп», но мы всё равно пересчитываем canGo(), message() и заново трогаем DOM. А если где-то ещё в приложении нужно знать только canGo, например чтобы включить предупреждающий сигнал, сделать это без переписывания текущего кода нельзя.

Первое, что приходит в голову — прикрутить pub/sub, чтобы light рассылал уведомления всем, кто на него подписан. Проблема расползается дальше: render теперь должен знать, что нужно подписаться именно на light, хотя по смыслу зависит только от message. Подписаться на canGo или message отдельно от light всё ещё нельзя без ручной прокладки. А самое неприятное: для каждого нового значения светофора приходится заново городить подписки, отписки и следить, чтобы нигде не потекла память.

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

Как пишется

Скопировано

В основе лежат три сущности.

Signal.State — ячейка с состоянием, которую можно читать и записывать. Например:

        
          
          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.Computed — формула, вычисленная из других сигналов.

Эффект — сторонний эффект, который нужно перезапускать при изменении зависимостей. В стандарте его нет, он собирается поверх Signal.State и Signal.Computed силами фреймворка, но для примера предположим, что функция 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 сам разберётся, что зависит от canGo, canGo, в свою очередь, от light, а эффект зависит от message. Обновление всей цепочки происходит само.

Авторы специально выбрали .get() и .set() вместо привычных по многим библиотекам .value или вызова signal(): они не стали копировать ни один существующий API, чтобы не создавать ложное ощущение полной совместимости. Заодно так дешевле по памяти: состояние умещается в один объект, а не в пару [значение, сеттер] с двумя замыканиями на каждый сигнал.

Как сигнал узнаёт о своих зависимостях

Скопировано

Остаётся понять, откуда canGo вообще знает, что зависит от light. Ответа два: вручную объявить зависимости или отследить их автоматически во время вычисления. Сигналы выбирают второй путь. Устроен он так.

У движка сигналов есть скрытое глобальное состояние (по спецификации, на уровне треда):

  • computing — какой вычисляемый сигнал сейчас выполняется, либо null;
  • frozen — запрещено ли сейчас читать и писать сигналы;
  • generation — счётчик поколений для сборки мусора.

Когда Signal.Computed пересчитывается, движок кладёт его в computing. Метод чтения Signal.State.prototype.get() выглядит примерно так:

  1. Если computing не пуст, добавить себя в список источников (sources) текущего вычисляемого сигнала.
  2. Вернуть значение.

Больше ничего не нужно. Любое чтение внутри Signal.Computed автоматически регистрирует зависимость, никакого 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()}`
})

        
        
          
        
      

Пока showDetails равен false, label читает только name, про details он ничего не знает и не будет пересчитываться при его изменении. Стоит переключить showDetails в true, и details попадёт в список источников уже при следующем чтении. Такое поведение называют динамическими зависимостями: набор источников каждый раз актуализируется по факту прочитанного, а не объявляется заранее.

При вложенных вычисляемых сигналах все прочитанные сигналы записываются на самый внутренний: если Computed A вызывает Computed B, а тот читает light, то зависимость на light появится у B, а не у A.

Раскраска графа

Скопировано

Автотрекинг объясняет, откуда берутся связи. Но остаётся вопрос поважнее: как система понимает, что именно нужно пересчитать при изменении, и как не считает лишнего? Здесь в игру вступает раскраска графа (graph coloring), основной механизм, вокруг которого построена вся спецификация.

У вычисляемого сигнала есть четыре состояния.

Состояние Что значит
clean значение есть и точно свежее
checked какой-то непрямой источник изменился, значение может быть устаревшим
computing колбэк сигнала выполняется прямо сейчас
dirty значение точно устарело либо сигнал ещё ни разу не читали

Работа идёт в два такта — push при записи и pull при чтении.

Push: что происходит внутри set()

Скопировано

Когда вызывается light.set(newValue), синхронно, ещё до возврата из set(), происходит следующее:

  1. Если новое значение равно старому по equals, ничего не делаем, изменение гасится на месте.
  2. Иначе прямые потребители (sinks) красятся в dirty.
  3. Все остальные потребители дальше по цепочке переходят в checked. Если какой-то сигнал уже был dirty, состояние checked его не понижает.
  4. Затронутые низкоуровневые наблюдатели (watchers, о них дальше) переходят в pending, и им синхронно вызывается колбэк notify.

Сама запись не запускает ни одного пересчёта. Она только помечает часть графа как потенциально устаревшую.

Pull: что происходит внутри get()

Скопировано

Пересчёт откладывается до момента, когда кто-то реально читает значение. Внутри Signal.Computed.prototype.get():

  1. Если сигнал уже clean, вернуть закэшированное значение.
  2. Если dirty или checked, рекурсивно найти самый глубокий и самый левый (то есть раньше всех прочитанный) источник в состоянии dirty. Поиск обрывается, как только встречается clean-сигнал: туда лезть не нужно, он точно свежий.
  3. Пересчитать найденный сигнал, сравнить новое значение со старым через equals.
  4. Если значение не изменилось, потребители, которые были checked, возвращаются в clean, дальше по графу пересчёт не идёт.
  5. Если изменилось, потребители красятся в dirty, и шаги 2–4 повторяются, пока весь путь до исходного сигнала не станет clean.

Состояние checked значит «не знаю, поменялось что-то у меня в глубине или нет, надо проверить», в отличие от dirty, которое значит «точно поменялось». Поэтому изменение, которое гасится где-то в середине графа (equals вернул true), не долетает до конца: лишнего пересчёта не происходит нигде.

Это и называют «глитч-фри» (glitch-free) вычислением: неправильное промежуточное значение не успевает появиться, потому что пересчёт откладывается до момента, пока не станет понятно, что именно нужно посчитать.

Разница особенно заметна там, где два сигнала читают одно и то же значение, а третий зависит от обоих сразу. Возьмём тот же светофор: cars и pedestrians оба зависят от light, а табло перекрёстка board зависит сразу от cars и pedestrians:

Открыть демо в новой вкладке

В наивном pub/sub board пересчитывается дважды: по разу на каждое обновление cars и pedestrians. У сигналов он читается один раз, потому что раскраска графа заранее говорит: «оба родителя устарели, пересчитывай board только когда его реально спросят, и только один раз».

Сравнение значений и кэш

Скопировано

По умолчанию сигналы сравнивают значения через Object.is(). Сравнение выполняется один раз, в момент записи источника или пересчёта вычисляемого сигнала, а не при каждом обращении потребителя. Поэтому дорогое сравнение объектов не размножается по графу.

Компаратор можно переопределить через 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.Computed бросает исключение, оно сохраняется и перебрасывается при каждом следующем чтении, до тех пор пока какая-нибудь из зависимостей не изменится и не запустит пересчёт заново.

Специфика вычисляемых сигналов накладывает и жёсткие ограничения:

  • рекурсивное чтение сигнала изнутри его же вычисления считается ошибкой;
  • внутри колбэка notify у наблюдателя нельзя ни читать, ни писать ни один сигнал: Signal.subtle.untrack() от этого не спасает, потому что движок в этот момент помечен как frozen.

Почему в стандарте нет эффектов

Скопировано

Реактивные библиотеки почти всегда предлагают готовый effect(). В спецификации сигналов такой функции нет, есть только низкоуровневый Signal.subtle.Watcher, поверх которого эффект можно собрать самостоятельно:

        
          
          // Этот код обычно живёт в библиотеке, а не в// прикладном коде. Планирование здесь упрощено// до предела, для продакшена так не пишут.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 не может сразу что-то пересчитать: он может только запланировать работу на потом, например через queueMicrotask(). Для этого и нужен getPending() — он возвращает актуальный список сигналов, которые всё ещё нужно пересчитать, на момент, когда это уже безопасно делать.

После обработки очереди наблюдателя нужно снова «включить» вызовом watcher.watch() без аргументов, иначе он не заметит следующее изменение.

Причина, по которой планирование оставили фреймворкам, простая: единого правильного расписания не существует. React привязан к фазам рендера, Angular отслеживает изменения без зон (zone-less change detection), а Solid ориентируется на свой планировщик. Стандарт даёт синхронный, предсказуемый механизм уведомления, а решение «когда именно перезапускать эффект» каждый фреймворк принимает сам.

Signal.subtle: для авторов фреймворков

Скопировано

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

Signal.subtle.untrack(callback) — выполняет код без отслеживания зависимостей. Например:

        
          
          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)
  }
})

        
        
          
        
      

Пока ни один эффект не следит за windowWidth, обработчик resize не висит впустую.

ИнтроспекцияintrospectSources(), introspectSinks(), hasSources(), hasSinks(), currentComputed(). Эти функции не нужны в обычном прикладном коде, зато на них опираются девтулы: можно построить граф зависимостей целиком, найти, какой сигнал держит другой живым, или отследить цепочку вычислений, которая привела к ошибке.

Как это выглядит во фреймворках

Скопировано
Фреймворк Состояние Вычисление Эффект
Angular signal() computed() effect()
Vue ref() / reactive() computed() watchEffect()
Solid createSignal() createMemo() createEffect()
Svelte 5 $state() $derived() $effect()
Preact signal() computed() effect()
Qwik useSignal() useComputed$() useTask$()

Синтаксис и планировщик у каждого свои, но алгоритм в основе почти везде тот же, что описан выше: автотрекинг через глобальную переменную вычисления, ленивый пересчёт, раскраска графа. Показательный пример — библиотека alien-signals. Её алгоритм лёг в основу signal-polyfill, официального полифила предложения TC39, а заодно и в ядро Vue начиная с 3.6. Общая теория начала перетекать обратно в реализации фреймворков.

Что часто путают с сигналами

Скопировано

События и pub/sub. Для одиночного уведомления «что-то произошло» (клик, сообщение по сокету) обычная подписка проще и не требует графа зависимостей.

Proxy-реактивность. Реактивные прокси (reactive() во Vue, аналогичные механизмы в MobX) отвечают на вопрос «как заметить запись в поле объекта», а сигналы, в свою очередь, на вопрос «как распространить уже замеченное изменение». Это разные уровни одной системы, а не конкуренты.

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

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

AbortSignal. Название совпадает случайно: AbortSignal из Web API не имеет отношения к реактивным сигналам, обсуждаемым здесь.

Статус предложения

Скопировано

Предложение находится в TC39 на Stage 1 с апреля 2024 года. Команда авторов сознательно не торопится: прежде чем предлагать Stage 2, они хотят получить несколько production-полифилов, реально работающих в разных фреймворках, и убедиться, что API не потребует переделки.

За скобками первой версии осталось:

  • асинхронность — сигналы всегда синхронно доступны, состояние загрузки предлагается моделировать через исключения;
  • транзакции — одновременное существование «старого» и «нового» состояния графа для плавных переходов между экранами;
  • удобные методы (вроде укороченных обёрток над Computed) — сознательно не включены, чтобы не зафиксировать в стандарте то, по чему ещё нет согласия между фреймворками.

Обсуждается и более далёкая перспектива: интеграция с DOM Parts и декларативным шаблонированием в HTML, но это отдельное предложение, а не часть текущего.

На практике

Скопировано

Денис Русаков советует

Скопировано

🛠 Когда разбираюсь в незнакомой реактивной библиотеке, я не читаю документацию по API, а ищу в исходниках четыре состояния сигнала: что-то вроде clean, dirty, checked и computing. Если библиотека построена на графе, эти состояния почти всегда называются похоже, и через них понятно, где в коде происходит пометка изменений (push), а где реальный пересчёт (pull). Экономит часы на чтении чужого кода.

🛠 Если пишете своё маленькое хранилище состояния и тянетесь за pub/sub, сначала прикиньте на бумаге граф зависимостей. Если в нём есть развилка, которая потом снова сходится в одну точку, наивные подписки почти гарантированно пересчитают конечную точку несколько раз за одно изменение. Это не всегда страшно, но если пересчёт дорогой, например там сетевой запрос или тяжёлая сортировка, лучше сразу закладывать ленивый пересчёт по чтению, а не по записи.