Перейти к содержанию

Сигналы

Этот пакет входит в семейство экспериментальных пакетов Lit Labs. Как использовать пакеты Labs в продакшене, описано на странице Lit Labs.

Обзор

Что такое сигналы?

Сигналы — это структуры данных для наблюдаемого состояния.

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

Сигналы удобны для общего наблюдаемого состояния — состояния, которое читают и меняют разные компоненты. Когда сигнал обновляется, обновляется каждый компонент, который наблюдает этот сигнал или любой сигнал, зависящий от него.

Сигналы — общая идея. В библиотеках и фреймворках JavaScript есть много реализаций. Сейчас в TC39 идёт предложение стандартизировать сигналы как часть JavaScript.

В API сигналов обычно три понятия:

  • Сигналы состояния хранят одно значение.
  • Вычисляемые сигналы оборачивают вычисление, которое может зависеть от других сигналов.
  • Наблюдатели, или эффекты, запускают код с побочными эффектами, когда значения сигналов меняются.

Пример

Пример на предлагаемом стандартном API сигналов JavaScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//
// Код, которым разработчик описывает состояние на сигналах...
//

// Сигналы состояния хранят значения:
const count = new Signal.State(0);

// Вычисляемые сигналы оборачивают вычисления, которые читают другие сигналы:
const doubleCount = new Signal.Computed(() => count.get() * 2);

//
// Более низкоуровневый код, который обычно живёт внутри фреймворков
// и библиотек, потребляющих сигналы...
//

// Наблюдатели узнают, когда меняются сигналы, за которыми они следят:
const watcher = new Signal.subtle.Watcher(async () => {
    // В колбэке уведомления нельзя синхронно читать сигналы
    await 0;
    console.log('doubleCount is', doubleCount);
    // После срабатывания наблюдателя его нужно включить снова:
    watcher.watch();
});
watcher.watch(doubleCount);

// Вычисляемые сигналы ленивые: чтобы запустить вычисление
// и, возможно, уведомить наблюдателей, значение нужно прочитать:
doubleCount.get();

Библиотеки сигналов

На JavaScript написано много реализаций сигналов. Часть из них встроена во фреймворки и доступна только из них, часть — отдельные библиотеки, которые можно вызывать из любого кода.

Конкретные API отличаются, но устроены похоже.

Библиотека сигналов Preact, @preact/signals, — отдельный пакет, относительно быстрый и небольшой. Первая интеграция Lit Labs была построена вокруг неё: @lit-labs/preact-signals.

Предложение сигналов для JavaScript

API сигналов похожи друг на друга, фреймворки всё чаще строят на них реактивность, и системам нужно взаимодействовать. Поэтому в TC39 идёт стандартизация: https://github.com/tc39/proposal-signals.

Пакет @lit-labs/signals интегрирует Lit с официальным полифилом этого предложения.

Для экосистемы веб-компонентов это важно. Если библиотеки и фреймворки примут стандарт, их сигналы будут совместимы: разным веб-компонентам не нужна одна и та же библиотека, чтобы читать и создавать сигналы.

Сигналы могут стать основой самых разных систем управления состоянием и библиотек наблюдаемости, новых и уже существующих. Сейчас MobX, Redux и похожие библиотеки требуют отдельный адаптер, чтобы удобно встроиться в жизненный цикл Lit. После стандартизации может хватить одного адаптера Lit, а когда поддержка сигналов войдёт в ядро Lit — и его не понадобится.

Сигналы и Lit

Сейчас есть два пакета интеграции:

Предложение TC39 задумано как тот API, к которому сойдутся системы на JavaScript, поэтому в этом документе разбирается именно оно.

Установка

Установите @lit-labs/signals из npm:

1
npm i @lit-labs/signals

Использование

Из @lit-labs/signals нужны три вещи:

  • Миксин SignalWatcher для классов, которые читают сигналы.
  • Директива шаблона watch(), чтобы следить за отдельными сигналами и обновлять точечно.
  • Тег шаблона html, который сам применяет watch() к привязкам.

Импорт:

1
import { SignalWatcher, watch, signal } from '@lit-labs/signals';

@lit-labs/signals также экспортирует часть API полифила сигналов и фабрику тега withWatch(), чтобы к собственному тегу шаблона можно было добавить наблюдение за сигналами.

Автоматическое наблюдение через SignalWatcher

Проще всего применить миксин SignalWatcher к классу пользовательского элемента. После этого сигналы можно читать в методах жизненного цикла Lit, например в render(): изменение этих сигналов само запустит обновление. Записывать сигналы можно там, где это уместно, например в обработчиках событий.

В примере SharedCounterComponent читает и записывает общий сигнал. Все экземпляры показывают одно значение и обновляются, когда оно меняется.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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);
    }
}
1
2
3
<!-- Оба элемента покажут одно и то же значение счётчика -->
<shared-counter></shared-counter>
<shared-counter></shared-counter>

Точечные обновления через watch()

Сигналами можно обновлять отдельные привязки, а не весь компонент. Для этого за сигналом следят директивой watch().

Обновления от watch() собираются в пакет и всё равно проходят реактивный жизненный цикл Lit. Если конкретное обновление Lit запустили только директивы watch(), пересчитываются лишь привязки с изменившимися сигналами. Остальные привязки шаблона пропускаются.

Тот же пример, но при изменении сигнала count обновляется только привязка ${watch(count)}:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 экспортирует особую версию тега html: она сама применяет watch() к любому сигналу в привязке.

Так не нужно писать директиву watch() или вызывать signal.get() там, где watch() нет.

Если импортировать html из @lit-labs/signals, а не из lit, привязки начнут следить за сигналами сами:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 установлен один раз:

1
npm ls signal-polyfill

Если в списке больше одной строки signal-polyfill и рядом нет пометки deduped, в дереве лежат дубликаты.

Обычно это лечится так:

1
npm dedupe

Если не помогло, обновите зависимости, пока во всём дереве не останется одна совместимая версия signal-polyfill.

Чего пока нет

@lit-labs/signals ещё не полон. Планируются возможности, которые сделают работу с сигналами в Lit удобнее и быстрее:

  • [ ] Директива repeat(), понимающая сигналы. Инкрементальные обновления массивов станут эффективнее.
  • [ ] Декоратор @property(), который хранит значение в сигнале и объединяет реактивные свойства с сигналами. Тогда общие утилиты сигналов проще применять к реактивным свойствам Lit.
  • [ ] Декоратор @computed() для методов-вычисляемых сигналов. Вычисляемые сигналы мемоизируются, это помогает с дорогими расчётами.
  • [ ] Декоратор @effect() для методов-эффектов. Это удобнее, чем отдельная утилита.

Полезные материалы

signal-utils

Пакет signal-utils содержит утилиты для предложения сигналов TC39:

  • Наблюдаемые коллекции на сигналах: Array, Map, Set, WeakMap, WeakSet и Object.
  • Декораторы для классов с полями на сигналах.
  • Эффекты и реакции.

Коллекции и декораторы нужны, когда модель данных сложнее примитива.

Коллекции

Наблюдаемый массив:

1
2
3
import { SignalArray } from 'signal-utils/array';

const numbers = new SignalArray([1, 2, 3]);

Чтение массива — обход или свойство .length — учитывается как доступ к сигналу. Мутации вроде .push() и .pop() уведомляют наблюдателей.

Декораторы

Декораторы описывают класс с наблюдаемыми полями, похоже на LitElement:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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]);
    }
}

Классы с SignalWatcher, которые читают экземпляр GameState, будут за ним следить и обновляться, когда состояние игры меняется.

Статус и обратная связь

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

Пакет также зависит от предложения и полифила, которые сами ещё не стабильны. По мере движения предложения API может меняться, и эти изменения попадут в полифил.

Пакет можно пробовать, чтобы накопить опыт и оставить отзыв об интеграции с Lit. Зависимости стоит фиксировать внимательно и проверять обновления, чтобы неожиданные ломающие изменения не застали врасплох.

Отзывы об интеграции — в обсуждении @lit-labs/signals. Ошибки можно завести в репозитории Lit.

Отзывы о предложении сигналов — в репозитории предложения. Ошибки полифила — здесь.

Комментарии