JavaScript Signals 標準提案ドラフト
- JavaScriptにおけるシグナル(signals)の初期的な共通方向性を説明する文書で、ES2015でTC39によって標準化されたPromises以前のPromises/A+の取り組みに似ている。
- この取り組みはJavaScriptエコシステムの足並みをそろえることに重点を置いており、この調整が成功すれば、その経験をもとに標準が登場する可能性がある。
- 複数のフレームワーク作者が、リアクティビティコアを支えられる共通モデルについて協力している。
- 現在のドラフトは、Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wizなどの作者・メンテナーからの設計インプットに基づいている。
背景: なぜシグナルなのか?
- 複雑なユーザーインターフェース(UI)を開発するために、JavaScriptアプリケーション開発者は、状態を保存し、計算し、無効化し、同期し、効率的な方法でアプリケーションのビューレイヤーへ反映する必要がある。
- UIは単なる値管理だけでなく、他の値や状態に依存する計算済み状態をレンダリングすることをしばしば含む。
- シグナルの目標は、このようなアプリケーション状態を管理するためのインフラを提供し、開発者が反復的な細部ではなくビジネスロジックに集中できるようにすることにある。
例 - VanillaJSカウンター
counterという変数があり、この変数が変更されるたびにDOMへカウンターの偶奇を更新したいとする。
- Vanilla JSでは、次のようなコードになる可能性がある。
let counter = 0;
const setCounter = (value) => {
counter = value;
render();
};
const isEven = () => (counter & 1) == 0;
const parity = () => isEven() ? "even" : "odd";
const render = () => element.innerText = parity();
// Simulate external updates to counter...
setInterval(() => setCounter(counter + 1), 1000);
- このコードにはいくつかの問題がある。
counterの設定はノイズが多く、ボイラープレートも多い。
counterの状態がレンダリングシステムと密結合している。
counterは変更されたがparityは変更されない場合(例: 2から4への変更)でも、不必要な計算とレンダリングを行ってしまう。
- UIの別の部分が、
counter更新時だけレンダリングしたい場合がある。
isEvenやparityのみに依存するUIの別の部分は、counterと直接相互作用しなければ更新できない。
シグナルの紹介
- モデルとビュー間のデータバインディング抽象化は、JSやWebプラットフォームにそのような仕組みが組み込まれていないにもかかわらず、長らくUIフレームワークの中核だった。
- JSフレームワークやライブラリの中では、このバインディングを表現するさまざまな方法について多くの実験が行われており、「Signals」とよく呼ばれる、状態や他のデータから導出された計算を表す第一級のリアクティブ値アプローチの強みが実証されてきた。
- シグナルAPIを使って上の例を再構成すると、次のようになる。
const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);
effect(() => element.innerText = parity.get());
// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);
シグナル標準化の動機
相互運用性
- 各シグナル実装は独自の自動追跡メカニズムを持っているため、異なるフレームワーク間でモデル、コンポーネント、ライブラリを共有しにくい。
- この提案の目標は、リアクティブモデルをレンダリングビューから完全に分離し、開発者が新しいレンダリング技術へ移行する際に非UIコードを書き直さなくて済むようにすること、または別の文脈で配布可能な共有リアクティブモデルをJSで開発できるようにすることにある。
パフォーマンス / メモリ使用量
- 一般に、よく使われるライブラリが組み込みになることで送信コード量が減るのは常に小さな潜在的性能向上をもたらしうるが、シグナル実装は通常かなり小さいため、この効果が非常に大きいとは期待されていない。
開発者ツール
- 既存のJS言語向けシグナルライブラリを使う場合、計算済みシグナルの連鎖を通るコールスタックや、シグナル間の参照グラフなどを追跡するのは難しい。
- 組み込みシグナルがあれば、JSランタイムと開発者ツールがシグナルを検査するための改善されたサポートを提供できる。
副次的な利点
標準ライブラリの利点
- 一般的にJavaScriptはかなり最小限の標準ライブラリしか持ってこなかったが、TC39の流れは、JSを高品質な組み込み機能セットを備えた「バッテリー同梱」言語にしていくことにある。
HTML/DOM統合(将来の可能性)
- W3Cとブラウザ実装者は現在、HTMLにネイティブテンプレートを導入するための作業を進めている。
- こうした目標を達成するには、最終的にHTMLにリアクティブなプリミティブ値が必要になる。
シグナル設計目標
- 既存のシグナルライブラリは、中核部分ではそれほど大きく異ならない。
- この提案は、多くのライブラリの重要な特性を実装することで、それらの成功の上に築こうとしている。
中核機能
- 状態を表すSignal型、つまり書き込み可能なSignal。
- 他のシグナルに依存し、遅延評価されキャッシュされる計算 / メモ / 派生Signal型。
- JSフレームワークが独自にスケジューリングできるようにすること。
APIスケッチ
- 初期のシグナルAPIのアイデアは以下の通り。これはあくまで初期ドラフトであり、今後変更されることが想定されている。
namespace Signal {
// A read-write Signal
class State<T> implements Signal<T> {
// Create a state Signal starting with the value t
constructor(t: T, options?: SignalOptions<T>);
// Get the value of the signal
get(): T;
// Set the state Signal value to t
set(t: T): void;
}
// A Signal which is a formula based on other Signals
class Computed<T> implements Signal<T> {
// Create a Signal which evaluates to the value returned by the callback.
// Callback is called with this signal as the this value.
constructor(cb: (this: Computed<T>) => T, options?: SignalOptions<T>);
// Get the value of the signal
get(): T;
}
// This namespace includes "advanced" features that are better to
// leave for framework authors rather than application developers.
// Analogous to `crypto.subtle`
namespace subtle {
// Run a callback with all tracking disabled (even for nested computed).
function untrack<T>(cb: () => T): T;
// Get the current computed signal which is tracking any signal reads, if any
function currentComputed(): Computed | null;
// Returns ordered list of all signals which this one referenced
// during the last time it was evaluated.
// For a Watcher, lists the set of signals which it is watching.
function introspectSources(s: Computed | Watcher): (State | Computed)[];
// Returns the Watchers that this signal is contained in, plus any
// Computed signals which read this signal last time they were evaluated,
// if that computed signal is (recursively) watched.
function introspectSinks(s: State | Computed): (Computed | Watcher)[];
// True if this signal is "live", in that it is watched by a Watcher,
// or it is read by a Computed signal which is (recursively) live.
function hasSinks(s: State | Computed): boolean;
// True if this element is "reactive", in that it depends
// on some other signal. A Computed where hasSources is false
// will always return the same constant.
function hasSources(s: Computed | Watcher): boolean;
class Watcher {
// When a (recursive) source of Watcher is written to, call this callback,
// if it hasn't already been called since the last `watch` call.
// No signals may be read or written during the notify.
constructor(notify: (this: Watcher) => void);
// Add these signals to the Watcher's set, and set the watcher to run its
// notify callback next time any signal in the set (or one of its dependencies) changes.
// Can be called with no arguments just to reset the "notified" state, so that
// the notify callback will be invoked again.
watch(...s: Signal[]): void;
// Remove these signals from the watched set (e.g., for an effect which is disposed)
unwatch(...s: Signal[]): void;
// Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal
// with a source which is dirty or pending and hasn't yet been re-evaluated
getPending(): Signal[];
}
// Hooks to observe being watched or no longer watched
var watched: Symbol;
var unwatched: Symbol;
}
interface Options<T> {
// Custom comparison function between old and new value. Default: Object.is.
// The signal is passed in as the this value for context.
equals?: (this: Signal<T>, t: T, t2: T) => boolean;
// Callback called when isWatched becomes true, if it was previously false
[Signal.subtle.watched]?: (this: Signal<T>) => void;
// Callback called whenever isWatched becomes false, if it was previously true
[Signal.subtle.unwatched]?: (this: Signal<T>) => void;
}
}
シグナルアルゴリズム
- JavaScriptに公開される各APIについて、実装アルゴリズムを説明している。
- これは初期仕様とみなすことができ、非常にオープンな変更に対して可能な限り意味論の集合を明確化することを目指している。
GN⁺の意見
- JavaScript Signals標準提案は、フレームワーク間の相互運用性を高め、開発者がリアクティブプログラミングをより容易に実装できるようにすることを目指している。
- この提案は、既存の複数のシグナルライブラリの中核機能を標準化しようとする試みであり、開発者に一貫したプログラミングモデルを提供しうる。
- シグナルの概念はUI開発だけでなく非UIコンテキストでも有用に適用でき、とくにビルドシステムで不要な再ビルドを避けるのに役立つ可能性がある。
- 提案されたAPIはフレームワーク開発者に有用なツールを提供し、これによってより良いパフォーマンスとメモリ管理を達成できることが期待される。
- ただし、この技術が広く採用されるためには、さらなるプロトタイピングとコミュニティからのフィードバックが必要であり、実際のアプリケーションに統合されてその効果が実証される必要がある。
- 現在、React、Vue、Svelteのようなフレームワークはすでに独自のリアクティブシステムを持っており、これらのフレームワークとの互換性や統合戦略も重要な検討事項となる。
1件のコメント
Hacker Newsの意見
Vanilla JS vs. Signals の例
isEvenや parity にだけ依存している場合、アプローチ全体を変える必要があるかもしれない。Promises と JavaScript の変化
new Promiseを頻繁に書く必要があるのではと心配したが、実際にはほとんど使わなかった。.thenを多用するようになり、これはさまざまなサードパーティライブラリとのインターフェースを単純化した。言語の一部としての Signals
アプリケーションでのイベント使用
window.dispatchEventとwindow.addEventListenerを通じてイベントを発火し、購読している。DOM の状態管理と更新の難しさ
Promises と非同期プログラミング
S.js と Signals
MobX に似た Signals
標準ライブラリへのフレームワーク追加
Signal 提案への理解と問題点
effect関数がどのように parity の変更を検知するのか、どのシグナル変更でもこのラムダを呼ぶのか、といった疑問がある。