Предложение по стандартизации сигналов для TC39

В августе прошлого года я решил начать работу над потенциальным стандартом для сигналов для TC39 (Technical Committee № 39  —  технический комитет ECMA, отвечающий за стандартизацию языка JavaScript в соответствии с спецификацией ECMAScript). Сегодня рад поделиться черновой версией этого предложения (v0), которое находится в открытом доступе, а также полифилом, соответствующим спецификации.

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

Сигнал  —  это тип данных, который обеспечивает односторонний поток данных, моделируя ячейки состояний и вычислений, полученных из других состояний/вычислений. Состояние и вычисления образуют ациклический граф, в котором каждый узел имеет другие узлы, получающие состояние из его значения (sinks  —  стоки) и/или вносящие состояние в его значение (sources  —  источники). Узел также может быть отслежен как “чистый” или “грязный”.

Но что все это значит? Рассмотрим простой пример.

Допустим, у нас есть счетчик (counter), который надо отслеживать. Представим его в виде состояния:

const counter = new Signal.State(0);

Прочитать текущее значение поможет функция get():

console.log(counter.get()); // 0

А функция set() поможет изменить текущее значение:

counter.set(1);
console.log(counter.get()); // 1

Теперь представим, что нам потребовался еще один сигнал, определяющий, содержит ли счетчик четное число (even).

const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);

Вычисления нельзя записать, но всегда можно прочитать их последнее значение:

console.log(isEven.get()); // false
counter.set(2);
console.log(isEven.get()); // true

В приведенном выше примере isEven является стоком counter, а counter  —  источником isEven.

Можно добавить еще одно вычисление, которое обеспечивает четность счетчика:

const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");

Теперь у нас есть isEven как источник parity (четности) и isEven как сток parity. Можно изменить исходный counter, и состояние будет однонаправленно перетекать в parity.

counter.set(3);
console.log(parity.get()); // нечетное (odd)

Все вышеописанные действия, похоже, можно выполнить с помощью обычной композиции функций. Но если реализовать это таким образом, то без сигналов не будет графа источников/стоков. А зачем нужен этот граф? В чем его роль?

Как я упоминал, сигналы могут быть “чистыми” или “грязными”. Когда вы изменяете значения counter, он становится грязным. Поскольку у нас графовые отношения, можно пометить все стоки counter (потенциально) грязными, а также все их стоки, и так далее.

Здесь необходимо понять одну важную деталь. Сигнальный алгоритм не является push-моделью (моделью передачи сигналов). Изменение counter не приводит к обновлению значения isEven, а затем через граф  —  к обновлению parity. Здесь также речь не идет о чистой pull-модели (модели извлечения сигналов). Чтение значения parity не всегда вычисляет значение parity или isEven. Скорее, когда counter изменяется, он передает через граф только изменение грязного флага. Любое потенциальное повторное вычисление откладывается до тех пор, пока значение конкретного сигнала не будет явно извлечено.

Это так называемая модель “push then pull” (передать, потом извлекать). Грязные флаги обновляются активно (push), а вычисления оцениваются пассивно (pull).

Сочетание структуры данных ациклического графа с алгоритмом “push then pull” имеет ряд преимуществ. Вот некоторые из них:

  • Signal.Computed автоматически мемоизируется. Если исходные значения не изменились, то повторное вычисление не нужно.
  • Ненужные значения не вычисляются повторно даже при изменении источников. Если вычисление грязное, но ничто не считывает его значение, то повторное вычисление не происходит.
  • Можно избежать ложного или “избыточного обновления”. Например, если вы измените counter с 2 на 4, то да, он будет грязным. Но когда извлечете значение parity, его вычисления не потребуется запускать заново, потому что isEven, будучи извлеченным, вернет тот же результат для 4, что и для 2.
  • Можно получать уведомления о том, что сигналы становятся грязными, и выбирать способы реагирования на них.

Эти характеристики оказываются очень важными при эффективном обновлении пользовательских интерфейсов. Чтобы понять, как это сделать, можно ввести фиктивную функцию effect, которая будет вызывать некое действие, когда один из источников станет грязным. Можно, например, обновить текстовый узел в DOM с помощью parity:

effect(() => node.textContent = parity.get());
// Выполняется обратный вызов effect, и текст узла обновляется на "odd".
// Effect следит за источником обратного вызова (parity) на предмет грязных изменений.

counter.set(2);
// Counter загрязняет свои стоки, что приводит к тому, что функция effect
// помечается как потенциально грязная, поэтому планируется операция pull (извлечение).
// Планировщик начинает повторную оценку обратного вызова effect, извлекая parity.
// parity начинает оцениваться, извлекая isEven.
// isEven извлекает counter, в результате чего значение isEven изменяется.
// Поскольку значение isEven изменилось, parity нужно вычислить заново.
// Поскольку значение parity изменилось, запускается effect, а текст обновляется на "odd".

counter.set(4);
// Счетчик (counter) загрязняет свои стоки, что приводит к тому, что функция effect
// помечается как потенциально грязная, поэтому планируется операция pull.
// Планировщик начинает повторную оценку обратного вызова effect, извлекая parity.
// parity начинает оцениваться, извлекая isEven.
// isEven извлекает счетчик, в результате чего значение isEven становится таким же, как и раньше.
// isEven помечается как чистый.
// Поскольку isEven чист, parity отмечается как чистая.
// Поскольку parity чистая, effect не запускается, и текст не обновляется.

Надеюсь, вы поняли назначение сигналов и комбинации ациклического графа источников/стоков с алгоритмом “push then pull”.

Кто работал над этим?

В конце 2023 года я вместе с Дэниелом Эринбергом, Беном Лешем и Домиником Ганнавеем попытался собрать как можно больше авторов библиотек сигналов и специалистов по сопровождению фронтенд-фреймворков. Все, кто проявил интерес, были привлечены к исследованию возможности использования сигналов в качестве стандарта.

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

В течение последних 6–7 месяцев мы прорабатывали деталь за деталью, пытаясь перейти от общих точек соприкосновения к конкретике по структурам данных, алгоритмам и исходному API. Вы можете ознакомиться с рядом библиотек и фреймворков, которые в разное время вносили свой вклад в разработку на протяжении всего процесса: Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz и многие другие.

Это довольно большой список! Оглядываясь на собственную работу в области веб-стандартов за последние десять лет, могу ответственно заявить: это одно из самых удивительных совместных мероприятий с моим участием. Оно собрало уникальную группу людей, обладающих именно тем коллективным опытом, который необходим для дальнейшего развития веб-разработки.

Что входит в предложение по сигналам?

Предложение, которое можно найти на GitHub, включает:

  • Предысторию, мотивацию, цели разработки и FAQ.
  • Предлагаемый API для создания сигналов состояния и вычислений.
  • Предлагаемый API для просмотра сигналов.
  • Различные дополнительные предлагаемые API, например, для интроспекции.
  • Подробное описание различных алгоритмов сигналов.
  • Соответствующий спецификации полифил, охватывающий все предложенные API.

Предложение по сигналам не включает API effect, поскольку такие API часто глубоко интегрированы со стратегиями рендеринга и пакетной обработки, которые сильно зависят от фреймворка/библиотеки. Тем не менее проект стремится определить набор примитивов и утилит, которые авторы библиотек могут использовать для реализации собственных функций effect.

В связи с этим проект разработан таким образом, чтобы признать, что существует две широкие категории пользователей сигналов:

  • разработчики приложений;
  • разработчики библиотек/фреймворков/инфраструктуры.

API, предназначенные для использования разработчиками приложений, открываются непосредственно из пространства имен Signal. К ним относятся Signal.State() и Signal.Computed(). API, которые редко или вообще никогда не должны использоваться в коде приложений и скорее связаны с тонкой обработкой, обычно на уровне инфраструктуры, открываются через пространство имен Signal.subtle. К ним относятся Signal.subtle.Watcher, Signal.subtle.untrack() и API интроспекции.

Как разработчику приложений использовать сигналы?

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

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

Авторы библиотек смогут писать код с использованием сигналов, который будет работать с любым компонентом или библиотекой рендеринга, поддерживающими этот стандарт, что уменьшит фрагментацию в веб-экосистеме. Разработчики приложений смогут создавать слои модели/состояния без привязки к текущей технологии рендеринга, что даст им большую архитектурную гибкость, а также возможность экспериментировать и развивать слой представления, не переписывая все приложение.

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

Вернемся к начатому выше разговору об Signal.State() и Signal.Computed(). Это два основных API, которые использует разработчик приложения, если не применяет их косвенно через API фреймворка. Они могут использоваться сами по себе для представления отдельных реактивных состояний и вычислений или в сочетании с другими конструкциями JavaScript, например классами. Вот класс Counter, который использует сигнал для представления своего внутреннего состояния:

export class Counter {
#value = new Signal.State(0);

get value() {
return this.#value.get();
}

increment() {
this.#value.set(this.#value.get() + 1);
}

decrement() {
if (this.#value.get() > 0) {
this.#value.set(this.#value.get() - 1);
}
}
}

const c = new Counter();
c.increment();
console.log(c.value);

Один из особенно удобных способов использования сигналов  —  их сочетание с декораторами. Можно создать декоратор @signal, который превращает аксессор в сигнал следующим образом:

export function signal(target) {
const { get } = target;

return {
get() {
return get.call(this).get();
},

set(value) {
get.call(this).set(value);
},

init(value) {
return new Signal.State(value);
},
};
}

Можно также использовать его, чтобы уменьшить количество шаблонного кода и улучшить читаемость класса Counter, например так:

export class Counter {
@signal accessor #value = 0;

get value() {
return this.#value;
}

increment() {
this.#value++;
}

decrement() {
if (this.#value > 0) {
this.#value--;
}
}
}

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

Попутное замечание: некоторые пользователи определенных библиотек сигналов могут не одобрить применение в приведенном выше примере следующего кода: this.#value.set(this.#value.get() + 1). В отдельных случаях реализации сигналов это может привести к бесконечному циклу при использовании внутри свойства computed или effect (хотя не вызывает проблем ни в текущем предложении по свойству computed, ни в примере функции effect, показанном ниже). Должен ли он вызвать цикл? Или должен выбрасывать исключение? Каким будет поведение? Это пример множества деталей, которые необходимо проработать, чтобы стандартизировать API такого рода.

Как разработчику библиотеки/инфраструктуры интегрировать сигналы?

Надеемся, что с интеграцией этого предложения будут экспериментировать специалисты по сопровождению библиотек представлений и компонентов, а также разработчики библиотек управления состоянием и библиотек, связанных с данными. Первым шагом интеграции будет обновление сигналов библиотеки для использования Signal.State() и Signal.Computed() внутри библиотеки вместо их текущей специфичной для библиотеки реализации. Конечно, этого недостаточно. Общим следующим шагом будет обновление effect или эквивалентной инфраструктуры. Как я уже говорил, предложение не предусматривает реализацию effect. Наши исследования показали, что эта функция слишком тесно связана с нюансами рендеринга и пакетной обработки, чтобы стандартизировать ее на данном этапе. Скорее, пространство имен Signal.subtle будет предоставлять примитивы, которые фреймворк может использовать для создания собственных функций effect. 

Рассмотрим реализацию простой функции effect, которая пакетно обрабатывает обновления очереди микрозадач.

let needsEnqueue = true;

const w = new Signal.subtle.Watcher(() => {
if (needsEnqueue) {
needsEnqueue = false;
queueMicrotask(processPending);
}
});

function processPending() {
needsEnqueue = true;

for (const s of w.getPending()) {
s.get();
}

w.watch();
}

export function effect(callback) {
let cleanup;

const computed = new Signal.Computed(() => {
typeof cleanup === "function" && cleanup();
cleanup = callback();
});

w.watch(computed);
computed.get();

return () => {
w.unwatch(computed);
typeof cleanup === "function" && cleanup();
};
}

Функция effect начинает с создания Signal.Computed() из предоставленного пользователем обратного вызова. Затем она может использовать Signal.subtle.Watcher для просмотра источников computed. Чтобы Watcher (наблюдатель) мог “видеть” источники, нужно выполнить computed хотя бы один раз, что и делаем, вызывая get(). Данная реализация effect также поддерживает базовый механизм обратных вызовов для обеспечения функций очистки, а также способ прекратить наблюдение с помощью возвращаемой функции.

Посмотрим на создание Signal.subtle.Watcher. Конструктор принимает обратный вызов, который будет вызываться синхронно каждый раз, когда любой из наблюдаемых сигналов станет грязным. Поскольку Watcher может следить за любым количеством сигналов, планируем обработку всех грязных сигналов в очереди микрозадач. Определенная базовая логика защиты гарантирует, что планирование произойдет только один раз, пока не будут обработаны ожидающие сигналы.

В функции processPending() проходим по всем сигналам, которые Watcher отследил как ожидающие, и заново оцениваем их, вызывая get(). Затем даем команду Watcher возобновить наблюдение за всеми отслеженными сигналами.

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

Другие API

Еще один API, который, скорее всего, будет использоваться в инфраструктуре,  —  помощник Signal.subtle.untrack(). Эта функция принимает обратный вызов для выполнения и гарантирует, что сигналы, прочитанные в рамках обратного вызова, не будут отслеживаться.

Считаю необходимым напомнить: пространство имен Signal.subtle обозначает API, которые нужно использовать с осторожностью, причем в основном это касается авторов фреймворков или инфраструктур. Неправильное или небрежное применение чего-то вроде Signal.subtle.untrack() может испортить приложение так, что трудно будет найти источник ошибок.

С учетом сказанного, рассмотрим правильное использование этого API.

Многие фреймворки, ориентированные на представления, располагают способом рендеринга списка элементов. Как правило, вы передаете фреймворку массив и “шаблон” или фрагмент HTML, который он должен отобразить для каждого элемента в массиве. Разработчику приложения нужно, чтобы любое взаимодействие с этим массивом отслеживалось системой реактивности, чтобы вывод списка синхронизировался с данными. 

А как же сам фреймворк? Он должен получить доступ к массиву, чтобы сделать его рендеринг. Если бы доступ фреймворка к массиву отслеживался системой зависимостей, это создало бы всевозможные ненужные связи в графе, что привело бы к ложным или избыточным обновлениям, не говоря уже о вероятности возникновения проблем с производительностью и странных ошибок. API Signal.subtle.untrack() предоставляет автору библиотеки простое решение. В качестве примера рассмотрим небольшой фрагмент кода из SolidJS, который рендерит массивы и который я немного изменил.

export function mapArray(list, mapFn, options = {}) {
let items = [],
mapped = [],
len = 0,
/* ...другие переменные исключены... */;

// ...исключено...

return () => {
let newItems = list() || [], // Доступ к массиву отслеживается
i,
j;

// Доступ к длине отслеживается
let newLen = newItems.length;

// В следующем обратном вызове ничего не будет отслеживаться. Нам не нужно, чтобы
// рендеринг фреймворка повлиял на граф сигналов!
return Signal.subtle.untrack(() => {
let newIndices,
/* ...другие переменные исключены... */;

// быстрый путь для пустых массивов
if (newLen === 0) {
// ... считывание из массива, не отслеживается ...
}
// быстрый путь для создания нового
else if (len === 0) {
// ... считывание из массива, не отслеживается ...
} else {
// ... считывание из массива, не отслеживается...
}

return mapped;
});
};
}

Несмотря на то, что я скрыл основную часть алгоритма Solid, вы видите, что основная часть работы выполняется в неотслеживаемом блоке кода, который обращается к массиву.

В пространстве имен Signal.subtle есть дополнительные API, которые можно изучить на досуге. Надеемся, приведенные выше примеры помогут продемонстрировать, для каких сценариев предназначена эта часть предложения.

Как можно принять участие в разработке предложения?

Просто присоединяйтесь! Все есть на GitHub. Проект находится в корне репозитория, а полифил  —  в папке packages

Вот несколько идей по внесению вклада в разработку предложения:

  • Опробуйте сигналы в своем фреймворке или приложении.
  • Улучшайте документацию/обучающие материалы по сигналам.
  • Документируйте сценарии использования (независимо от того, поддерживает ли их API или нет).
  • Пишите больше тестов, например перенося их из других реализаций сигналов.
  • Переносите другие реализации сигналов на этот API.
  • Пишите бенчмарки для сигналов  —  как для синтетических, так и для реальных приложений.
  • Сообщайте об ошибках в полифиле, своих мыслях по поводу дизайна и т. д.
  • Попробуйте разработать реактивные структуры данных/абстракции управления состоянием на базе сигналов.
  • Реализуйте сигналы нативно в JS-движке (за флагом/в PR, не отправляется!).

Кроме того, уже есть возможность отслеживания текущих дискуссий, потенциальных изменений в алгоритмах, дополнительных API, сценариев использования, ошибок в полифилах и т. д. Ознакомьтесь с ними и посмотрите, по каким аспектам вы можете высказать свое мнение. Если вы являетесь автором фреймворка или библиотеки, нам бы хотелось узнать ваше мнение по любым сценариям или случаям использования, которые, по вашему мнению, могут представлять проблему для текущего предложения.

Что дальше?

Мы пока находимся в самом начале этой работы. В ближайшие несколько недель Дэниел Эринберг (Bloomberg) и Джатин Раманатан (Google/Wiz) представят проект в TC39, чтобы пройти стадию 1 обработки предложения. На стадии 1 предложение находится на рассмотрении. Сейчас мы еще не дошли даже до этого. Можно считать, что сигналы находятся на стадии 0  —  самой ранней из ранних. После презентации на собрании TC39 мы продолжим развивать проект, основываясь на отзывах, полученных на этой встрече, и комментариях людей, которые участвуют в проекте через GitHub.

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

Считаю, что стандарт сигналов имеет огромный потенциал для JavaScript и для веб-разработки. Было интересно работать с таким большим количеством людей из индустрии, которые глубоко заинтересованы в реактивных системах. Желаю всем развития дальнейшего сотрудничества и успешных веб-разработок.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи EisenbergEffect: A TC39 Proposal for Signals

Предыдущая статья10 советов по созданию чистого кода для мобильной разработки на Kotlin в 2024 году
Следующая статьяСоздание приложения-чата с LangChain, большими языковыми моделями и Streamlit для взаимодействия со сложной базой данных SQL. Часть 2