Сигналы
Обзор
Что такое сигналы?
Сигналы — это структуры данных для управления наблюдаемым состоянием.
Сигнал может содержать одно значение или вычисляемое значение, зависящее от других сигналов. Сигналы являются наблюдаемыми, поэтому потребитель может получать уведомления об их изменении. Поскольку они образуют граф зависимостей, вычисляемые сигналы будут пересчитываться и уведомлять потребителей при изменении своих зависимостей.
Сигналы очень полезны для моделирования и управления общим наблюдаемым состоянием — состоянием, к которому могут обращаться и/или которое могут изменять многие компоненты. При обновлении сигнала обновляется каждый компонент, который использует и отслеживает этот сигнал или любые зависящие от него сигналы.
Сигналы — это общее понятие, имеющее множество различных реализаций и вариантов в библиотеках и фреймворках JavaScript. Кроме того, в TC39 сейчас рассматривается предложение по стандартизации сигналов как части JavaScript.
В API сигналов обычно есть три основных понятия:
- Сигналы состояния, содержащие одно значение
- Вычисляемые сигналы, оборачивающие вычисление, которое может зависеть от других сигналов
- Наблюдатели или эффекты, выполняющие код с побочными эффектами при изменении значений сигналов
Пример
Вот пример использования сигналов с предлагаемым стандартным API сигналов JavaScript:
//
// Code developers might write to build their signals-based state...
//
// State signals hold values:
const count = new Signal.State(0);
// Computed signals wrap computations that use other signals:
const doubleCount = new Signal.Computed(() => count.get() * 2);
//
// Lower-level code of the sort that will typically be inside frameworks and
// signal-consuming libraries...
//
// Watchers are notified when signals that they watch change:
const watcher = new Signal.subtle.Watcher(async () => {
// Notify callbacks are not allowed to access signals synchronously
await 0;
console.log('doubleCount is', doubleCount);
// Watchers have to be re-enabled after they run:
watcher.watch();
});
watcher.watch(doubleCount);
// Computed signals are lazy, so we need to read it to run the computation and
// potentially notify watchers:
doubleCount.get();
Библиотеки сигналов
В JavaScript реализовано множество вариантов сигналов. Многие из них тесно интегрированы с фреймворками и доступны только в рамках этих фреймворков, а некоторые представляют собой автономные библиотеки, которые можно использовать с любым другим кодом.
Хотя API конкретных реализаций сигналов различаются, они довольно похожи.
Библиотека сигналов Preact, @preact/signals, — это автономная, относительно быстрая и компактная библиотека. Поэтому наш первый пакет интеграции сигналов Lit Labs был создан на её основе: @lit-labs/preact-signals.
Предложение по добавлению сигналов в JavaScript
Учитывая значительное сходство API сигналов, всё более широкое применение сигналов для реализации реактивности во фреймворках и стремление к взаимодействию систем, использующих сигналы, в TC39 началась работа над предложением по стандартизации сигналов: https://github.com/tc39/proposal-signals.
Для интеграции с официальным полифилом этого предложения Lit предоставляет пакет @lit-labs/signals.
Это предложение открывает большие перспективы для экосистемы веб-компонентов. Поскольку все библиотеки и фреймворки, поддерживающие стандарт, будут создавать совместимые сигналы, разным веб-компонентам не потребуется использовать одну и ту же библиотеку для совместного потребления и создания сигналов.
Кроме того, сигналы могут стать основой для широкого спектра новых и существующих систем управления состоянием и библиотек наблюдаемости. Сейчас каждой такой библиотеке, например MobX или Redux, требуется отдельный адаптер для удобной интеграции с жизненным циклом Lit. Стандартизация сигналов может в итоге позволить нам обойтись одним адаптером Lit (или вовсе без адаптера, если поддержка сигналов будет встроена в основную библиотеку Lit).
Сигналы и Lit
Сейчас Lit предоставляет два пакета интеграции сигналов: @lit-labs/signals для интеграции с предложением TC39 по сигналам и @lit-labs/preact-signals для интеграции с Preact Signals.
Поскольку предложение TC39 по сигналам обещает стать тем единственным API сигналов, к которому придут системы JavaScript, мы рекомендуем использовать его и сосредоточимся на этом варианте в данном документе.
Установка
Установите @lit-labs/signals из npm:
npm i @lit-labs/signals
Использование
@lit-labs/signals предоставляет три основных экспорта:
- Миксин
SignalWatcherдля применения ко всем классам, использующим сигналы - Директива шаблона
watch()для отслеживания отдельных сигналов с точечными обновлениями - Тег шаблона
htmlдля автоматического применения директивы watch к привязкам шаблона
Импортируйте их следующим образом:
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
Пакет @lit-labs/signals также для удобства экспортирует часть API полифила сигналов и фабрику тегов шаблона withWatch(), чтобы разработчики, которым нужны собственные теги шаблонов, могли легко добавить отслеживание сигналов.
Автоматическое отслеживание с помощью SignalWatcher
Самый простой способ использовать сигналы — применить миксин SignalWatcher при определении класса пользовательского элемента. Применив этот миксин, вы сможете считывать сигналы в методах жизненного цикла Lit (например, render()); любые изменения значений этих сигналов будут автоматически запускать обновление. Записывать значения сигналов можно там, где это уместно, например в обработчиках событий.
В этом примере SharedCounterComponent считывает и записывает общий сигнал. Все экземпляры компонента будут отображать одно и то же значение и обновятся при его изменении.
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';
import {SignalWatcher, signal} from '@lit-labs/signals';
const count = signal(0);
@customElement('shared-counter')
export class SharedCounterComponent extends SignalWatcher(LitElement) {
static styles = css`
:host {
display: block;
}
`;
render() {
return html`
<p>The count is ${count.get()}</p>
<button @click=${this.#onClick}>Increment</button>
`;
}
#onClick() {
count.set(count.get() + 1);
}
}
<!-- Both of these elements will show the same counter value --> <shared-counter></shared-counter> <shared-counter></shared-counter>
Точечные обновления с помощью watch()
Сигналы также можно использовать для точечных обновлений DOM отдельных привязок вместо обновления всего компонента. Для этого нужно отдельно отслеживать сигналы с помощью директивы watch().
Для согласованной работы обновления, запускаемые директивой watch(), объединяются в пакеты и по-прежнему участвуют в реактивном цикле обновления Lit. Однако, если обновление Lit было запущено исключительно директивами watch(), обновляются только привязки с изменившимися сигналами; остальные привязки шаблона пропускаются.
Этот пример совпадает с предыдущим, но при изменении сигнала count обновляется только привязка ${watch(count)}:
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
const count = signal(0);
@customElement('shared-counter')
export class SharedCounterComponent extends SignalWatcher(LitElement) {
static styles = css`
:host {
display: block;
}
`;
render() {
return html`
<p>The count is ${watch(count)}</p>
<button @click=${this.#onClick}>Increment</button>
`;
}
#onClick() {
count.set(count.get() + 1);
}
}
Обратите внимание, что благодаря такому точечному обновлению удаётся избежать совсем небольшого объёма работы: пропускается только проверка идентичности шаблона, возвращённого render(), и проверка значения привязки @click. Обе операции выполняются быстро.
На самом деле в большинстве случаев watch() не обеспечивает значительного прироста производительности по сравнению с обычной отрисовкой шаблонов Lit. Это объясняется тем, что Lit и без того обновляет в DOM только привязки, значения которых изменились.
Экономия производительности при использовании watch() обычно увеличивается пропорционально объёму логики шаблона и количеству привязок, которые можно пропустить при обновлении. Поэтому она будет заметнее в шаблонах с большим количеством логики и привязок.
В @lit-labs/signals пока нет директивы repeat() с поддержкой сигналов. До её появления изменения содержимого массивов будут приводить к полной повторной отрисовке.
Автоматические точечные обновления с помощью тега шаблона сигналов html
@lit-labs/signals также экспортирует специальную версию тега шаблона Lit html, которая автоматически применяет директиву watch() к любому значению сигнала, переданному в привязку.
Это может быть удобно, если нужно обойтись без дополнительных символов директивы watch() или вызовов signal.get(), необходимых без watch().
Если импортировать html из @lit-labs/signals, а не из lit, будет доступна функция автоматического отслеживания:
import {LitElement} from 'lit';
import {SignalWatcher, html, signal} from '@lit-labs/signals';
// SharedCounterComponent ...
render() {
return html`
<p>The count is ${count}</p>
<button @click=${this.#onClick}>Increment</button>
`;
}
Тег сигналов html пока плохо работает с lit-analyzer. Анализатор будет сообщать об ошибках типов для привязок, использующих сигналы, поскольку видит присваивание Signal<T> в T.
Правильная установка полифила
@lit-labs/signals включает пакет signal-polyfill в качестве зависимости, поэтому для начала работы с сигналами не нужно устанавливать что-либо ещё вручную.
Но поскольку сигналы используют общую глобальную структуру данных (граф зависимостей сигналов), крайне важно правильно установить полифил: на странице или в приложении может быть только одна копия пакета полифила.
Если установлено несколько копий полифила (из-за несовместимых версий или других проблем с npm), граф сигналов может разделиться на части: некоторые наблюдатели перестанут работать с некоторыми сигналами или некоторые сигналы перестанут отслеживаться как зависимости других.
Чтобы этого избежать, убедитесь, что пакет signal-polyfill установлен только один раз. Для этого используйте команду npm ls:
npm ls signal-polyfill
Если в списке пакета signal-polyfill больше одной записи без deduped рядом со строкой, значит, установлено несколько копий полифила.
Обычно эту проблему можно исправить, выполнив команду:
npm dedupe
Если это не поможет, возможно, придётся обновить зависимости, чтобы во всей установке пакетов осталась одна совместимая версия signal-polyfill.
Отсутствующие возможности
@lit-labs/signals пока не реализует все возможности. Есть несколько запланированных функций, которые сделают работу с сигналами в Lit более удобной и производительной:
- [ ] Директива
repeat()с поддержкой сигналов. Она позволит эффективнее выполнять инкрементальные обновления массивов. - [ ] Декоратор
@property(), использующий сигналы для хранения данных и объединяющий реактивные свойства с сигналами. Благодаря ему универсальные утилиты для работы с сигналами будет проще использовать с реактивными свойствами Lit. - [ ] Декоратор
@computed()для обозначения методов как вычисляемых сигналов. Поскольку вычисляемые сигналы мемоизируются, это может помочь при дорогостоящих вычислениях. - [ ] Декоратор
@effect()для обозначения методов как эффектов. Это может быть более удобным способом запуска эффектов, чем использование отдельной утилиты.
Полезные ресурсы
signal-utils
Пакет npm signal-utils содержит ряд утилит для работы с предложением TC39 по сигналам, в том числе:
- Наблюдаемые коллекции на основе сигналов, например
Array,Map,Set,WeakMap,WeakSetиObject - Декораторы для создания классов с полями на основе сигналов
- Эффекты и реакции
Эти коллекции и декораторы полезны для создания наблюдаемых моделей данных на основе сигналов, в которых часто требуется управлять более сложными значениями, чем примитивные типы.
Коллекции
Например, можно создать наблюдаемый массив:
import {SignalArray} from 'signal-utils/array';
const numbers = new SignalArray([1, 2, 3]);
Чтение данных из массива, например при переборе или чтении .length, будет отслеживаться как обращение к сигналу, а изменения массива, например с помощью .push() или .pop(), будут отправлять уведомления наблюдателям.
Декораторы
Декораторы позволяют моделировать класс с наблюдаемыми полями, подобно LitElement:
import {signal} from 'signal-utils';
class GameState {
@signal
accessor playerOneTotal = 0;
@signal
accessor playerTwoTotal = 0;
@signal
accessor over = false;
readonly rounds = new SignalArray();
recordRound(playerOneScore, playerTwoScore) {
this.playerOneTotal += playerOneScore;
this.playerTwoTotal += playerTwoScore;
this.rounds.push([playerOneScore, playerTwoScore]);
}
}
Экземпляры этого класса GameState будут отслеживаться классами SignalWatcher, которые к ним обращаются, и обновляться при изменении состояния игры.
Статус и обратная связь
Этот пакет входит в семейство экспериментальных пакетов Lit Labs и активно разрабатывается. В нём могут отсутствовать некоторые функции, встречаться серьёзные ошибки реализации, а несовместимые изменения могут выходить чаще, чем в основных библиотеках Lit.
Кроме того, этот пакет зависит от предложения и полифила, которые сами по себе пока нестабильны. По мере развития предложения по сигналам в предлагаемый API могут вноситься несовместимые изменения, которые затем появятся и в полифиле.
Мы рекомендуем использовать пакет с осторожностью, чтобы получить опыт работы со слоем интеграции Lit и собрать отзывы. Внимательно управляйте зависимостями и тщательно тестируйте приложение, чтобы свести к минимуму неожиданные несовместимые изменения.
Оставляйте отзывы в обсуждении пакета @lit-labs/signals и сообщайте о проблемах, с которыми столкнулись.
Отзывы о предложении по сигналам можно оставить в репозитории предложения по сигналам. О проблемах с полифилом можно сообщить здесь.
© Google LLC
Licensed under the Creative Commons Attribution 3.0 Unported License.
https://lit.dev/docs/data/signals/