JavaScriptにSignalsを追加する提案
(github.com/proposal-signals)- TC39のJavaScript Signals提案は、UIの状態と計算された状態を効率的に追跡するためのリアクティブなプリミティブを標準化しようとする初期方針であり、現在はStage 1段階のドラフト
- この提案は、アプリケーション開発者が直接使う表面的なAPIよりも、フレームワーク間で共有できるSignalグラフの中核セマンティクスと自動追跡メカニズムに焦点を当てている
Signal.State、Signal.Computed、Signal.subtle.Watcherが中核APIであり、計算はlazy evaluation、キャッシュ、自動依存関係追跡、glitch-freeな実行を目指す- 組み込みSignalsの目的は、Angular、Ember、MobX、Preact、Qwik、Solid、Svelte、Vueなど複数のフレームワーク間の相互運用性を高め、DevToolsによるデバッグ・性能分析支援の可能性を開くこと
- 提案グループはStage 2以前に、複数のプロダクション品質のpolyfill、フレームワーク統合、大規模アプリケーションでの検証、性能ベンチマークを経ようとしており、標準化までは少なくとも2〜3年以上かかる可能性がある
JavaScript Signals提案の位置づけ
- JavaScript SignalsはTC39プロセスのStage 1提案として紹介されている
- 現在の文書は、ES2015で
Promiseが標準化される前に存在したPromises/A+と同様に、JavaScriptエコシステムの共通の方向性をそろえようとする試み - 直接試せるpolyfillが提供されている
- 提案のチャンピオンと原著者は、複数のフレームワーク・ライブラリからの設計インプットをもとに現在のドラフトを構成している
標準化が狙う課題
- 複雑なUIでは値を保存し、計算し、無効化し、同期し、ビュー層へプッシュする必要があり、Signalsはこうした反復作業のための状態管理インフラを提供しようとしている
- Vanilla JSの例では、
counter、isEven、parity、renderが直接絡み合い、次の問題が生じる- 状態とレンダリングシステムが強く結合する
counterが2から4に変わる場合のように、parityが変わらなくても不要な計算とレンダリングが発生する- 他のUI断片が
counter、isEven、parityの一部だけを購読しようとすると、手動の購読と解除の管理が複雑になる - pub/subを複数段階に追加すると、ボイラープレートと購読のbookkeepingが増え、メモリリークのリスクが生じる
- Signalsベースの例では、
Signal.StateとSignal.Computedを使って、値、計算、副作用を1つの方法で扱う- 手動の購読は不要
- 計算されたSignalが依存するSignalを自動的に見つける
- 計算は値が明示的に要求されたときだけ実行される
- 計算されたSignalは最後の値をキャッシュする
提案の中核API
Signal<T>はget(): Tを持つ読み取り可能な値として定義されるSignal.State<T>は書き込み可能なSignal- コンストラクタは初期値とオプションを受け取る
get()で値を読み、set(t)で値を変更する
Signal.Computed<T>は他のSignalに基づく計算Signal- コールバックが返す値によって計算される
- 依存関係を自動追跡する
- 値はlazyに計算され、キャッシュされる
Signal.subtleは、フレームワーク作者やDevTools実装に近い高度なAPIを含むuntrack(cb)は追跡なしでSignalを読めるようにするcurrentComputed()は現在追跡中のcomputed Signalを返すintrospectSources、introspectSinks、hasSinks、hasSourcesはグラフ観察用APIWatcherはSignalの変更を検知し、フレームワークレベルのeffectとスケジューリングを実装する基盤
SignalOptions<T>はユーザー定義の比較関数equalsとwatched/unwatchedフックをサポートする
動作方式と実行モデル
- Signalは時間とともに変化し得るデータセルを表し、
stateまたはcomputedに分かれる - 計算されたSignalは、実行中に読んだSignalを自動的に記録し、その後読まれるときに以前の依存関係が変わったか確認する
- 計算はpull-based
- 依存関係が変わっても即座には再計算しない
- 誰かが
.get()で読んだとき、必要なら再計算する
- State Signalへの書き込みは同期的に反映される
.set()の後、その値に依存するcomputed Signalを読むと、必要に応じて即座に再計算される- 組み込みのbatchingはない
- Watcherの
notifyコールバックは.set()中に同期的に実行されることがある- ただし
notify中はSignalを読んだり書いたりできない - 実際の読み書き処理はその後にスケジュールする必要がある
- ただし
- computed Signalのコールバックが例外を投げると、その例外も値のようにキャッシュされ、Signalを再度読むと再び投げられる
標準化の動機
- 各フレームワークのSignal実装は独自の自動追跡メカニズムを持っているため、モデル・コンポーネント・ライブラリをフレームワーク間で共有しにくい
- 提案の目標は、リアクティブモデルをレンダリングビューから分離すること
- 開発者がレンダリング技術を変更しても、非UIコードを書き直さなくて済むようにする
- 複数の文脈で共有できるリアクティブモデルをJavaScriptで作れるようにする
- 性能とメモリの面では、組み込み実装がJS実装より小さな定数係数ぶん効率的である可能性はあるが、エンジンが魔法のようにアルゴリズムを変えるわけではないと明記している
- DevToolsの面では、組み込みSignalsによって次の情報をよりよく表示できる可能性がある
- computed Signalチェーンのcall stack
- Signal間の参照グラフ
- メモリ使用量のデバッグに必要な依存関係
- 標準ライブラリに含まれる場合、バンドルサイズの削減、安定性と品質の向上、プロジェクト間での共通語彙の形成といった副次的効果も期待される
設計目標と制約
- 中核機能には、書き込み可能なSignal、計算されたSignal、dirty状態への反応、フレームワーク独自のスケジューリング、
untrack、複数コードベースの合成が含まれる - 計算されたSignalはglitch-freeを目標とする
- 不要な計算を避けるため、グラフ内の潜在的にdirtyな部分をトポロジカルソートして実行する
- 重複計算を排除しようとする
- フレームワークが独自にスケジューリングできるよう、Promiseスタイルの組み込み強制スケジューリングは入れない
- 同期的なリアクションコールバックの誤用を防ぐため、Watcherの
notify内ではSignalの読み書きを禁止する untrackは安全でない脱出口として扱われる- 追跡なしで読んだSignalが計算結果に影響する場合、そのSignalの変更時にcomputed Signalが更新されない可能性がある
- APIはフレームワーク実装の基盤になることを優先しており、一般のアプリ開発者にとって特別にergonomicに設計されたものではない
effectとWatcher
- 提案には
effect()のような組み込み関数はない - effectのスケジューリングは、フレームワークのレンダリングサイクル、disposal、所有権管理と絡み合うため、JavaScript標準APIが直接解決するものではない
- 代わりに
Signal.subtle.Watcherがeffect実装の低レベル基盤を提供する- watched Signalの依存関係が変更されると
notifyが呼ばれる getPending()でまだdirtyなSignalを確認できる- disposeが必要なeffectは
unwatchで片付ける必要がある
- watched Signalの依存関係が変更されると
- Watcherが見ているSignalは内部stateが到達可能であれば生き続けることがあるため、effectの片付け時には
Watcher.prototype.unwatchの呼び出しが必要
現在のドラフトで欠けている機能
- Asyncは現在のモデルに含まれていない
- Signalsは常に同期的に評価可能な値として扱われる
- loading状態を例外としてモデル化する方法は一部可能であり、改善議論はIssue #30にある
- Transactionsも含まれていない
- 画面遷移で「from」状態と「to」状態を同時に維持しようとすると、Signalグラフ状態をforkする問題が生じる
- 関連する議論はIssue #73にある
- 一部のconvenience methodsも現在のドラフトからは外れている
- 欠けている機能は、フレームワーク間の合意不足と上位層で回避可能である点から除外されたが、プロトタイプ後に再検討される可能性がある
開発計画と標準化スケジュール
- この提案は2024年4月のTC39 Stage 1議題に上がっており、文書上では現在Stage 0のように見られると説明している
- Stage 2を提案する前に、次の作業を計画している
- 複数のプロダクション品質のpolyfill実装の開発
- さまざまなフレームワークでのテストとtest262スタイルのテスト通過
- thorough signal/framework benchmark setによる性能検証
- 代表的な複数のJSフレームワークと一部の大規模アプリケーションへの提案API統合
- API拡張可能性の理解と、含めるかどうかの決定
- 提案グループは、誤った形のSignalsが早すぎる段階で標準化されることを避けるため、保守的に進めようとしている
- FAQでは、標準Signalsがブラウザ全般でpolyfillなしに使えるようになるまで、少なくとも2〜3年はかかると予想している
- 現在のpolyfillは利用できるが、レビュー過程でAPIが変わる可能性があるため、安定性に依存しないほうがよいと案内している
FAQで整理された利用モデル
- 組み込みSignalsはレンダリング技術と独立している
- VDOMを使うPreact、native DOMを使うSolid、混合方式を使うVueのような形態はいずれも可能だと説明している
- アプリ開発者は、おおむねフレームワークを通じてSignalsを使うのが適している
- フレームワークがWatcher、
untrack、ownership、disposal、DOMレンダリングのスケジューリングを管理する
- フレームワークがWatcher、
- SSR、hydration、resumabilityとともに利用できる
- QwikはSignalsをこれらの特性とともに使っており、提案側はQwikのresumable SignalsをStateとComputedの組み合わせでモデル化できると見ている
- Signalsと
Proxyは相互補完的- Proxyは浅いオブジェクト操作を横取りし、Signalsはデータセルの依存関係グラフを調整する
- Proxyの背後にSignalsを置けば、nested reactive structureをよりergonomicにできる
- Signalsはストリームではなく、現在値を表すセル
- State Signalに連続して2回書き込み、何もしなければ、最初の書き込みはcomputed Signalやeffectから見えない可能性がある
- 文書はこれをglitch-free実行の反対側にある性質と見なし、ストリームにはasync iterableやobservableのような別の構成のほうが適しているとしている
1件のコメント
Hacker News のコメント
素の JavaScript の例のほうが、むしろ読みやすく扱いやすいと感じるのは自分だけだろうか
「設定がノイズとボイラープレートだらけ」だというが、signals の例も同じくらい騒がしくボイラープレートが多く見え、初心者には理解しにくい新しい概念まで追加している
「counter が 2 から 4 に変わると parity は変わらないのに、不要な計算とレンダリングをする」というのは、早すぎるメモ化のように聞こえる
UI の別の部分が counter の更新に合わせてレンダリングしたいなら、その strawman の例が適切でないのは確かで、その場合は signals、イベント処理、中央状態ストア(Redux 系)など別の方法を使えばよい
UI の別の部分が isEven や parity だけに依存しているなら、アプリの中核構造であればアプローチを変えられるかもしれないが、たいていはそうではない。「parity だけに依存する render 関数が counter を購読しなければならないという事実を知っている必要がある」というのも、必ずしも不当な負担ではなく、純粋な計算関数には入力を把握しやすいという利点がある
UI 開発でますます使われるようになっている概念である signals の標準化を試みるのは称賛に値する。ボイラープレートがどの程度か、イベントシステムを自分で作る必要があるのかといった細部の議論はさておき、複数のフレームワークで signals が使われているなら理由があるのかもしれず、時間がかかっても標準化を試みる価値はある
https://preactjs.com/guide/v10/signals
Preact では、signal が props や context としてツリーを下っていくとき、signal の参照だけを渡し、コンポーネントは値ではなく signal を見るため、signal を更新してもコンポーネントを再レンダリングしないで済む場合がある。実際に
.valueにアクセスしているツリー内のコンポーネントへ直接行けるまた signal は、値がいつアクセスされ、いつ更新されるかを追跡し、Preact ではコンポーネント内で signal の
.valueにアクセスすると、その signal の値が変わったときにコンポーネントを自動的に再レンダリングする経験上の大きな利点は、リアクティブな状態をモジュール化できる点だ。命令型スタイルでは変化を追跡するために追加の状態が必要で、モジュール性は抽象化によって達成される。必要なときだけ使えばよい
単純でありながら適用可能な例を作るのはバランスの問題だ。リアクティビティが明確に有利な場合は概してより複雑なので、単純だが適用範囲の狭い例より示しにくい
JavaScript に Promises が追加されるときは、あちこちで
new Promiseを書かなければならないのではないかと抵抗感があった実際には、
new Promiseを直接書いた回数は両手で数えられるくらいだ。代わりに、特にサードパーティライブラリを扱うときに.thenをはるかによく使うようになった結局 Promise が JavaScript に追加された日常的な効果は、サードパーティライブラリが提供するさまざまな特殊な動作や機能に対して、かなり単純でおおむね堅牢、ほぼ普遍的なインターフェースを与えたことだ。ファイル読み込みであれ API リクエストであれビルド段階の出力であれ、
.then(res => …)を使えば、すでに動く何かに半分ほど近づいたような感覚になるこの Signal 提案が、リアクティブ UI フレームワークのカンブリア爆発の中で似た役割を果たしてくれるなら賛成だ。さらに、リアクティビティが UI の外へ広がる助けにもなり得る。UI 状態ではないもののための漸進的な再計算状態ツリーを、しばしば想像してきた
new Promiseを直接書くことはまれだ初期の Promise の
.thenはネストした delegate より大きな改善で、単純なチェーンにはよいが、条件によって異なる Promise をチェーンしたり、特定のチェーンごとにエラー処理が違ったり、早期 return が必要になったりすると、コードはずっと読みづらく扱いにくくなり得るasync/await を使えば、Promise ではないかのように呼び出しを書けるし、特定の Promise 呼び出しの周囲に try/catch を簡単に置け、早期 return も自然にできる
なぜこれが言語の一部になるべきなのか分からない。ライブラリで実現できるし、すでにそうしたライブラリもある。小さいのでコードに含めても大きな負担ではなく、言語に追加すること自体が目的になってはいけない
現在の JS UI ライブラリが signals をあまりにうまく設計しているから言語の一部になるべきだ、と考えるのは傲慢だ。signals には異なるトレードオフを持つ実装が多くあり、そのどれも JavaScript 仕様で特別な位置を占める資格はない
こうしたライブラリは signals を使う前は仮想 DOM を使っていた。幸い仮想 DOM は JS の一部にはならなかったが、signals は何が違うのか? 違わない。標準化すべきだという論拠は、仮想 DOM のときよりもさらに弱い
Web を壊さずに、もはや望まれない機能を削除する方法が事実上ないランタイムに、流行しているものをすべて積み込むつもりなのか? かなり近視眼的だ
リアクティブ UIは勝った。小さなアプリケーションでも状態管理をしていると複雑さが爆発することこそ、素の JS を使いにくくしている核心だ。自分にとっては、どんなリアクティブフレームワークでも素の JS よりましであり、そうだとすれば欠けている構成要素があるのかもしれない
そろそろ 10 年ほど経ったので、標準化できる境界点を考えてみる時期だ。Promise のようにきちんとできれば、非常によくあるユースケースの複雑さを下げられる
よりよい評価は「既存のリアクティブフレームワークはこの提案を使うだろうか?」と問うことだ。そうでないなら、なぜか、何が足りないのか、何が不要なのか、他言語の UI とリアクティビティから何を学べるのかを見るべきだ。散在した経験を精製する価値はある
その必要性は議論できるが、標準ライブラリを拡張するなら、人気のあるものを見るのは良いアプローチだと思う
signals は仮想 DOM の代替物ではない
アプリケーション全体に何かを通知したいときはイベントを使う
window.dispatchEvent(new Event('counterChange'));そして反応したいアプリケーションのどの部分でも、こう購読できる
window.addEventListener('counterChange', () => { ... do something ... });このやり方の何が問題なのか?
イベント処理はとても簡単に散らかる。さらに深く見るなら、イベントバブリングと伝播を見ればよい
大きなアプリケーションには堅牢なイベント処理が必要で、これが Angular や Vue のようなフレームワークの、今ではあまり表に出ない利点だ
フレームワークなしに標準のイベント処理 API をそのまま使いたいとは思わないだろう。多数の要素について追加、削除、複製、発火、除去、1 回だけの発火などを扱っていると、深刻な望ましくない副作用が起こり得る
signals との違いは、最終的な消費者が値を読むときにだけ結果値が計算される点にある。signal に実際に書き込む時点と非同期レンダー更新の予約を分離し、ウォッチャーが実行する計算チェーンはレンダー中に一度だけ実行される
signal で送られた中間値は消えるので、その中で興味深いことを多く行うのは難しく、実質的にはレンダリング周期を調整するための高度な抽象化レイヤーに近い
性能もより良い可能性がある。例えば 2 つの値に依存する計算があるとして、
result = a ? b : 0で a が偽なら、b が変わっても再計算する必要はない。signals ではこれが自動で行われるが、従来の発行/購読ではかなり多くのコードが必要になるすべてのリスナーがそうした連鎖トリガーを作らないと保証するのも難しい
何十年もの間、人々がなぜ状態の追跡と DOM 更新をそんなに難しがるのか理解しようとしてきた
もちろん多少の規律は必要だが、数年ごとに出てくる解決策よりはずっと単純に感じる。Backbone、Knockout、Angular、React、言語自体の修正などがそうで、おそらく自分の考え方が根本的に違うのだと思う
関数名にもそれが表れている。
innerTextを更新することを「render」と呼んでいるが、実際にレンダリングしているわけではない。せいぜいブラウザがレンダリングしているのであって、それはペイントに関わる他のすべての処理も同じだ。最も単純な DOM 関数の一つを複雑にしようとする必死の試みに見えて、本当に困惑するより複雑になると簡単ではない
プログラミングの進歩は、良い結果を得るために必要な儀式的で厳格な規律を取り除いていく過程と見ることができる
React が次の進化だという意味ではないが、signals は明らかに正しい方向への一歩だ
DOM をデータ状態と同期させること自体はそれほど難しくないが、それを60fpsで非常に高性能に行うのはものすごく難しい。特に、漏れがなく、かつ面倒すぎない API を作る場合はなおさらだ
変更を生きた DOM ツリーに変換して反映するより、正直なところゲームのように canvas にピクセルを描く方が簡単かもしれない
Promises は良い成功例だが、async/await がなければ必ずしも標準化する必要はなかった
現在の草案は Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz などの作者/メンテナーの設計を基にしているというが、既存ライブラリの作者たちがこの提案をどう見ているのか気になる。React がリストにないのも興味深い
Signals はチャンネルに少し似ているが、単一の受信者ではなくブロードキャストである点が違う。これを活用して Web Worker が
onMessageコールバックではなくチャンネルで通信できるようになると素晴らしそうだ。特に Go のように signals/channels/promises の上でselectできるなら、複数の同時メッセージング機構をコールバックで管理するより構文上の利点がある。たとえば signals をPromise.anyに含められるようにする、といった形だx instanceof Promiseは単純に機能しない。自分のライブラリのthenメソッドは catch コールバックを受け取るが、別のライブラリは受け取らないとしたら、静かに相互運用できず、検出する方法もない。finallyはいつ実行されるのか? コールバックがどの程度非同期に実行されると期待できるのか?標準がなければ、Promise を使うすべてのライブラリが独自のポリフィルを持ち込まなければならない。すでに存在するものを信用できないからだ。そして、他のライブラリの Promise も実際には消費できない。期待した通りに動作すると信じられないからだ
これは推測ではなく、何年もの間実際にそうで、多くの人が耐えなければならなかった地獄だった
ぼんやりした直感では、signals は一般化された
useEffect()にあまりにも似ていて、React に入るとレンダーサイクル中に何が起きるのかをさらに混乱させそうに思える。良くも悪くも React は signals とは異なる更新方式を選んだ。ただし、適用可能性については自分が間違っているかもしれないuseEffectは命令型の動作をきれいに隔離するこれは Ember のデータバインディングに非常によく似て見え、結局は命令型の悪夢になり得る。デフォルトの状態が「自分の足を撃つ銃」に近く、そうならないようにするには莫大な認知負荷とメタパターンが必要になる
https://nodejs.org/en/learn/asynchronous-work/the-nodejs-eve...
リンク先 README の例が理解できなかった
// A library or framework defines effects based on other Signal primitivesdeclare function effect(cb: () => void): (() => void);どのライブラリ?どのフレームワーク?ここで迷子になった。
effectとは何なのか?effect(() => element.innerText = parity.get());effectは、parity が変わるたびにこのラムダを呼び出すべきだとどうやって知るのか?signal が変わるたびにこのラムダを呼び出すのか?だとしたらキャッシュの話はなぜ出てくるのか?たぶんそうではないのだろう。いずれにせよ、著者たちが伝えようとしていたことを正しく理解できているなら、signal というアイデア自体は妥当に見える。ただ、このような分離アーキテクチャの大きな問題は、アプリケーションが十分に複雑になると、特定のイベントがなぜ発生しているのか追っているうちに迷子になることだ。理想的には、signals がスタックトレースを修正し、コールバックが呼び出されたときに、そもそも signal をトリガーしたコードのスタックトレースがすでに含まれているべきだ。
effectという関数をエクスポートするライブラリはいくつもあり、signal の更新に反応して任意のコードを実行できるようにしている。Preact ドキュメントの signals と effects の入門がよい: https://preactjs.com/guide/v10/signals#effectfn私の理解では、このような effect 関数はまずコールバックを一度実行し、その実行中にどの signal にアクセスしたかを確認して、そのコールバックが依存する signal が更新されるたびにコールバックを再度呼び出す。signal へのアクセスが同期的でシングルスレッドなら、コールバック実行中に signal にアクセスしたという事実だけで、そのコールバックがその signal を購読すべきだと分かる。
getter でも可能だ。effect 関数が getter メソッドで signal のどのプロパティにアクセスしたかを追跡する方式で、Vue 2 は以前この方式を使っていたと理解している。プロキシでオブジェクトアクセスを追跡することもできる。提案書の例には signal の値にアクセスするために呼び出す
getメソッドがあり、このメソッドの実行を通じて依存関係を追跡できる。[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
parity.get()の呼び出しが、effect()に渡された関数に対する依存関係を登録する。parityが更新されると、その関数が呼び出される。signal が変わるたびに呼び出すのではなく、依存している signal が変わったときだけ呼び出す。
この場合
parityはisEvenに依存し、isEvenはcounterに依存している。なのでcounterが更新されると依存関係チェーン全体が無効化され、parityが無効化されてコールバックが再実行される。effectのような追跡コンテキストでは、読み取りが signal の状態ノードと effect の計算ノードの間にエッジを作り、実質的に後者が前者の以後の書き込みを購読することで、いつ計算を再実行するかを決める。effectは呼び出したい任意の関数だ。signals では、依存関係追跡メカニズムがどの値を再計算すべきかを把握し、その結果としてシステムはどの関数を再呼び出しすべきかも分かるようになる。
effectの実装に必要になりそうだ。関連して S.js がある: https://github.com/adamhaile/s
signals は好きだ。UI を作るときは他のどんなプリミティブよりも好んで使うし、例外があるとすれば cassowary 制約アルゴリズムくらいかもしれない。趣味で使うあらゆる言語で signals をまねしてみようとしている。
しかし、JavaScript 言語そのものに入れるものではまったくないと思う。しばらく言語を放っておいてほしい。人々はすでについていくのに苦労しているし、TC-39 はすでに人々を言語から怖がらせて遠ざけている。
これは私がいちばん好きな JS effect システムである MobX にとても似て見える。
MobX 版はこうだ。
import { observable, computed, autorun } from 'mobx';const counter = observable.box(0);const isEven = computed(() => (counter.get() & 1) === 0);const parity = computed(() => isEven.get() ? "even" : "odd");autorun(() => { element.innerText = parity.get(); });setInterval(() => counter.set(counter.get() + 1), 1000);「自分が最近使っているフレームワークを標準ライブラリに焼き込もう!」という感じだ。
彼女の名前を体にタトゥーで入れるようなものだ。
ほとんどのフレームワークが使うように収束してきた構成要素を標準ライブラリに入れるものだ。
Promises も広く使われた後に標準ライブラリに入り、これもそれと似ている。