SolidJS の Fine-Grained Reactivity を理解するために Ryan Carniato 氏の記事を読む
Ryan Carniato 氏は SolidJS の作者。
SolidJS のリアクティビティシステムを理解するにあたって、Ryan Carniato 氏の記事は必読。
下記スクラップで SolidJS のコードリーディングを始めたが、そもそもリアクティブとは何かを理解する必要があるため、実装を見る前に調べる。
※ Deepl で翻訳しているため Reactivity => 反応性 などとなってしまっている箇所がある
※ 引用で個人的に重要だと思った箇所は太文字にしている
はじめに
複数の関係者が全く同じ時期に同じような解決策を導き出すことは、時に驚くべきことである。宣言型JavaScriptフレームワークの登場では、3ヶ月の間に3つの試みが発表されました:Knockout.js(2010年7月)、Backbone.js(2010年10月)、Angular.js(2010年10月)です。
Angularのダーティチェック、Backboneのモデル駆動型リレンダ、Knockoutのきめ細かな更新。それぞれ少しずつ違っていましたが、最終的には今日の状態管理やDOMの更新方法の基礎となるものでした。
Knockout.jsはこの記事のテーマにとって特別重要な存在です。当初はobservable (状態) とcomputed (副作用) の2つの概念を導入していましたが、その後数年の間に3番目のpureComputed (派生状態) をフロントエンドの言語に導入することになります。
Knockout.js、Backbone.js、Angular.js 全て触ったことがないが、モデル駆動型リレンダは React の UI = f(state)
的なことで、きめ細かな更新とは Vue、Svelte、Solid などにみられるリアクティブプログラミングの類だろうか。ダーティチェックは現時点ではわからない。
=> mizchi さんのスライドで Dirty Check が出てきた。名前からしループでモデルの差分を監視するのが Dirty ということか?
const count = ko.observable(0);
const doubleCount = ko.pureComputed(() => count() * 2);
// logs whenever doubleCount updates
ko.computed(() => console.log(doubleCount()))
ここで出てきた概念を、React、SolidJS それぞれで考えると下記のようになるだろうか (React は厳密には違う仕組みのため役割的に考える)
observable
: React (useState
)、SolidJS (createSignal
)
computed
: React (useEffect
)、SolidJS (createEffect
)
pureComputed
: React (useMemo
)、SolidJS (createMemo
)
Wild West
パターンは、サーバーでのMVC開発で学んだパターンと、ここ数年のjQueryで学んだパターンが混在していました。特に共通していたのは、Angular.jsとKnockout.jsの両方に共通する「データバインディング」というもので、若干の違いはあるものの、この2つのパターンがありました。
データバインディングとは、状態の一部をビューツリーの特定の部分に添付するという考え方です。強力なのは、これを双方向にすることです。つまり、状態がDOMを更新し、その結果、DOMイベントが自動的に状態を更新するということが、簡単に宣言できるようになるのです。
しかし、このパワーを乱用すると、結局は足で稼ぐことになる。しかし、よくわからないまま、私たちはこの方法でアプリケーションを構築しました。Angularでは、どのような変更があったのかわからないまま、ツリー全体をチェックすることになり、上方への伝播によって複数回発生する可能性がありました。Knockoutでは、ツリーを上下に移動するため、変更の経路を追うのが難しくなり、サイクルが発生することがよくありました。
Reactが解決策を提示し、私個人としては、Jing Chenの講演でそれを確信した時には、私たちは船に乗る準備ができていました。
つまり、双方向データバインディングはつらかったが、React (単方向データバインディング) が解決した。
Glitch Free
その後に続くのがReactの大量採用でした。まだリアクティブモデルを好む人もいましたし、Reactは状態管理についてあまり意見を言わないので、両方を混ぜることは大いに可能でした。
Mobservable(2015年、後にMobXと略される)はその解決策でした。しかし、Reactで動くこと以上に、それは新しいものをもたらしました。それは、一貫性とグリッチフリーの伝搬を重視したことです。つまり、与えられた変更に対して、システムの各部分が一度だけ、適切な順序で同期して実行されるということです。
これは、前任者に見られる典型的なプッシュベースの反応性を、プッシュとプルのハイブリッドシステムに置き換えることで実現しました。変更の通知はプッシュされるが、派生した状態の実行は、それが読み取られた場所まで延期された。
この詳細は、Reactが変更を読み込んだコンポーネントを再レンダリングするだけという事実によって大きく影を潜めたが、これはこれらのシステムをデバッグ可能で一貫性のあるものにするための記念すべき前進であった。その後数年間、アルゴリズムがより洗練されるにつれて、よりプルベースのセマンティクスに向かう傾向が見られるようになりました。
きめ細かな反応性は、プリミティブのネットワークから構築されます。プリミティブとは、JavaScriptの文字列や数値のようなプリミティブな値ではなく、Promises のような単純な構成要素のことを指しています。
それぞれがグラフのノードとして機能する。理想化された電気回路と考えることができます。どんな変化も、すべてのノードに同時に適用されます。解決すべき問題は、ある時点における同期化です。これは、ユーザーインターフェイスを構築する際によく取り組む問題空間である。
プリミティブの種類
Signals
シグナルは、リアクティブシステムの最も主要な部分である。ゲッター、セッター、値から構成される。学術論文ではシグナルと呼ばれることが多いが、オブザーバブル、アトム、サブジェクト、リファレンスなどとも呼ばれることがある。
const [count, setCount] = createSignal(0);
// read a value
console.log(count()); // 0
// set a value
setCount(5);
console.log(count()); //5
もちろん、それだけではあまり面白くありません。これらは多かれ少なかれ、何でも格納できる値でしかない。重要なのは、get とset の両方が任意のコードを実行できることです。これは、アップデートを伝播させるために重要なことでしょう。
関数がその主な方法ですが、オブジェクトゲッターやプロキシを使った方法を見たことがあるかもしれません
// Vue
const count = ref(0)
// read a value
console.log(count.value); // 0
// set a value
count.value = 5;
コンパイラの後ろに隠れていたり
// Svelte
let count = 0;
// read a value
console.log(count); // 0
// set a value
count = 5;
Signalsは、イベントエミッターです。しかし、重要な違いはサブスクリプションを管理する方法です。
Reactions
シグナルだけでは、リアクションという相棒がいなければ、あまり面白くありません。リアクションは、エフェクト、オートラン、ウォッチ、コンピューテッドとも呼ばれ、シグナルを観察し、その値が更新されるたびに再実行します。
これらはラップされた関数式で、最初に実行され、信号が更新されるたびに実行されます。
console.log("1. Create Signal");
const [count, setCount] = createSignal(0);
console.log("2. Create Reaction");
createEffect(() => console.log("The count is", count()));
console.log("3. Set count to 5");
setCount(5);
console.log("4. Set count to 10");
setCount(10);
これは一見魔法のように見えますが、シグナルがゲッターを必要とする理由なのです。シグナルが実行されるたびに、ラップ関数がそれを検出し、自動的にそれを購読するのです。
重要なのは、これらのシグナルはどんな種類のデータでも運ぶことができ、リアクションはそれを使って何でもできることです。
第二に、更新は同期的に行われます。次の命令を記録する前に、リアクションはすでに実行されています。
そして、これだけです。きめ細かな反応性に必要なピースが揃ったのです。シグナルとリアクション。観察される側と観察する側。実際、この2つだけで、ほとんどの振る舞いを作ることができます。しかし、もう1つ、核となるプリミティブについて説明する必要があります。
Derivations
これらは派生値として知られていますが、メモ、計算機、純粋計算機とも呼ばれています。
console.log("1. Create Signals");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
console.log("2. Create Derivation");
const fullName = createMemo(() => {
console.log("Creating/Updating fullName");
return `${firstName()} ${lastName()}`
});
console.log("3. Create Reactions");
createEffect(() => console.log("My name is", fullName()));
createEffect(() => console.log("Your name is not", fullName()));
console.log("4. Set new firstName");
setFirstName("Jacob");
派生したものは同期していることが保証されています。いつでも、その依存関係を判断し、古くなっていないかどうかを評価することができます。
Reactive Lifecycle
細かい反応性は、多くの反応性ノード間の接続を維持します。任意の変更時にグラフの一部が再評価され、接続を作成したり削除したりすることができます。
条件によって、値を導き出すために使うデータが変わる場合を考えてみましょう:
console.log("1. Create");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const [showFullName, setShowFullName] = createSignal(true);
const displayName = createMemo(() => {
if (!showFullName()) return firstName();
return `${firstName()} ${lastName()}`
});
createEffect(() => console.log("My name is", displayName()));
console.log("2. Set showFullName: false ");
setShowFullName(false);
console.log("3. Change lastName");
setLastName("Legend");
console.log("4. Set showFullName: true");
setShowFullName(true);
注意すべき点は、ステップ3でlastName を変更しても、新しいログは得られないということです。これは、反応式を再実行するたびに、その依存関係を再構築するためです。単純に、lastName を変更した時点では、誰もそれを聞いていないのです。
依存関係とは、反応式がその値を生成するために読み取るシグナルのことです。 これらのシグナルは、多くのリアクティブ・エクスプレッションのサブスクリプションを保持します。シグナルが更新されると、シグナルに依存しているサブスクライバーに通知されます。
これらのサブスクリプション/依存関係は、実行のたびに構築します。そして、反応式が再実行されるたびに、または最終的に解放されるときに、それらを解放します。
Synchronous Execution
きめ細かなリアクティブシステムは、変更を同期して即座に実行する。このシステムは、矛盾した状態を観察することができないという点で、グリッチフリーであることを目指しています。これは、任意の変更においてコードが一度しか実行されないため、予測可能性につながる。
依存関係はどの実行でも変わる可能性があることを忘れないでください。Fined-grainedリアクティブ・ライブラリは、プッシュとプルのハイブリッド・アプローチで一貫性を維持する。イベントやストリームのような純粋な「プッシュ」でもなく、ジェネレータのような純粋な「プル」でもない。
コメント欄より
DOMレンダリングはエフェクトです。より正確には、最も近い変化のみを再トリガーするようにネストされたエフェクトです。
Fine-grainedに見られるような自動追跡の最大の利点は、依存関係がどこにでも構築されることです。つまり、明示的に配線することなく、グラフを構築することができます。
=> useEffect や useMemo の 第二引数のようなものが必要なく、依存関係を構築できるということか
反応状態をS.data ではなく[get,set]=data で表現するのが好きな人が多いのが気になりますね🤔。
Reactがそのように導入しているからでしょうか。
という質問に対する回答が興味深かった。
確かに私はReact Hooksを見る前に何年もこの問題を見つめていましたが、2秒でタプルが私が常に望んでいたものだとわかりました。
Getter / Setter のタプルを返す以外の選択肢
- 単一の Getter / Setter
const s = createSignal(0);
// read a value
console.log(s()); // 0
// set a value
s(5);
console.log(s()); //5
これはおそらく最も厄介なもので、ある時点でこうなってしまいます。それは信号なのか、関数なのか、書き込み可能なのか?もしそうなら、私はどうすればいいのでしょうか?もし、それを直接関数に渡して追跡し、その関数が後で引数付きで呼び出し始めたらどうするのでしょう。私はこれらすべてを実際に体験してきました。KnockoutJSはこのアプローチを普及させましたが、ローカルスコープを離れるとかなり曖昧になってしまいます。型付き言語でないからかもしれませんが、これは最もエラーを起こしやすいアプローチです。
- get メソッド / set メソッド
const s = createSignal(0);
// read a value
console.log(s.get()); // 0
// set a value
s.set(5);
console.log(s.get()); //5
これはMobXやSvelte 2での動作で、React Hooksを見なければ、おそらく私が行き着くところだったでしょう。これは悪くはないのですが、少し冗長です。でも、いつでも分割することができます:
const { get, set } = createSignal(0);
しかし、複数のSignalがあると、結局はエイリアシングが多くなってしまいます:
const { get: s, set: setS } = createSignal(0);
-
.value
ゲッター
const ref = createSignal(0);
const s = () => ref.value;
const set = v => ref.value = v;
シンプルな.value ゲッターを使用するVueは、第3の選択肢ですが、冗長性もあり、個別の .get .setを設定するために代入セマンティクスを使用しているので、単にデストラクトすることはできません。もちろん、各パーツを関数でラップすることは可能です
これらすべてを見た結果、どれも好ましいとは思えませんでした。タプルの特徴は、好きなように名前をつけることができることです。明示的な意味を持つ。読み込みは常に単なる関数です。
個人的にも useState のインターフェースが最も使いやすく感じる。やはり Hooks を設計した人は天才だ。
まずこれから読むと良さそう
fine grained
は「細かい粒子から成る」「きめ細かい」といった和訳になる。
「きめ細かい」とは?といった疑問はのちにわかる。
ここで重要なのは、このアプローチによって宣言的なデータが可能になることです。ビューだけでなく、ステートやステートの派生も宣言型になりました。私が「悲しみの5段階」と呼んでいる、ライフサイクル関数と呼ばれるアーティストのような、コンポーネント全体に広がる状態条件の楽しい連鎖にコードを分割するのではなく、それを逆手にとって、各データ原子の旅路によってコードをグループ化します。これの何がすごいかというと、データがまとまったことで、抽象化できるようになったことです。ビヘイビアを作成し、好きなコンポーネントに適用することができるのです。
The Basics: Observables & Computeds
ライブラリは問わない:MobX、Vue、Ember、KnockoutJS、React、Solid、Svelte、などなど。すべてのきめ細かなリアクティブシステムは、2つのプリミティブを中心に構成されています。これらはライブラリによって異なる名前を持っていますが常に2つあります。observablesとcomputeds!
Observables
ファイングレイン・リアクティブ・プログラミングのコーディングは、スプレッドシートの操作に似ています。データを含むセルと、そのデータに基づいて値が計算されるセルがある。Observablesは、そのようなデータセルです。機械的には、ゲッターとセッターを含む単純なプリミティブである。値がアクセスされたときと、値が設定されたときの両方を検出する必要があります。次のようになります:
// KnockoutJS
const x1 = observable(5);
console.log(x1()); // get value
x1(8); //set value
// Vue RFC
const x2 = value(5);
console.log(x2.value);
x2.value = 8;
// Solid
const [x3, setX3] = createSignal(5);
console.log(x3());
setX3(8);
// MobX
const x4 = observable({data: 5});
console.log(x4.data);
x4.data = 8;
// React (no actual getter but for comparison purposes)
const [x5, setX5] = useState(5);
console.log(x5);
setX5(8);
一つの関数に可変の引数を与えたり、別々の関数を使ったり、あるいはオブジェクトゲッター/セッターやES6プロキシを使用することもできます。重要なのは、変更はすべてセッターによって捕捉される必要があり、すべてのアクセスはゲッターの実行時に追跡されることを理解することです。次のセクションで説明するように、このアプローチでは、値にアクセスするタイミングが非常に重要です。
Computeds
observablesが表計算ソフトのデータセルだとすれば、computedは計算セルです。計算機は依存関係を知っていて、その値が変わるたびに再実行することができます。一般的に、ライブラリで見かける計算機は2種類あります:値の導出に使用される「純粋計算」と、副作用を生み出す「効果的計算」です。
ここでは、Pure Computedの例を紹介します:
// KnockoutJS
const c1 = pureComputed(() => x1() * 2);
console.log(c1()); // get value
// Vue RFC
const c2 = computed(() => x2.value * 2);
console.log(c2.value);
// Solid
const c3 = createMemo(() => x3() * 2);
console.log(c3());
// MobX
const c4 = computed(() => x4.data * 2);
console.log(c4.get());
// React (no actual getter but for comparison purposes)
const c5 = useMemo(() => x5 * 2, [x5]);
console.log(c5);
そして、Effectful(不純)なComputedの例を紹介します:
// KnockoutJS
computed(() => console.log(x1() / 10));
// Vue RFC
watch(() => console.log(x2.value / 10));
// Solid
createEffect(() => console.log(x3() / 10));
// MobX
autorun(() => console.log(x4.data / 10));
// React
useEffect(() => console.log(x5 / 10), [x5]);
Pure Computedがアクセサーを返すのに対し、Effectful Computedは関数の中のコードを実行するだけであることに注目してください。正直なところ、Pure Computedで副作用を発生させることを止めることはできません。しかし、この2つは一般的に異なる目的を持っていることを理解することが重要です。
その仕組みについて
自動依存性検出
すべてのObservableは、その値を取得するときにゲッターやアクセサーを持つことを覚えていますか?すべてのComputedは実行時にそれ自身をグローバルスコープに登録し、Observableがアクセスされると、それ自身を依存関係のリストに追加します。単純すぎる実装は次のようなものです:
let currentContext;
function observable(value) {
const subscribers = [];
return function() {
// setter
if (arguments.length) { /* update value & notify subs */ }
// getter
else {
if (currentContext) subscribers.push(currentContext);
return value;
}
}
}
function computed(fn) {
let value;
function execute() {
/* do some initialization/cleanup of previous run */
const outerContext = currentContext;
currentContext = execute;
value = fn();
currentContext = outerContext;
}
//initial run
execute();
return /* getter of value */
}
ここで注意しなければならないことがいくつかあります。まず、計算の実行は最初に一度だけです。これは、observableが更新されるたびにcomputedが再び実行されるように、依存関係を確立するために必要です。計算機が渡された関数を実行すると、Observableのゲッターを呼び出し、サブスクリプションを作成します。Observableが更新されると、そのサブスクライバーに通知され、Computedは再び実行される。
第二に、コンテキストは各実行をラップするので、計算の中に計算を入れ子にすることができます。これにより、リアクティブグラフを階層化することができる。各コンテキストは、それ自身の依存関係に基づいて再実行される。親が再実行すると、すべての子が再作成される。しかし、子や兄弟が再評価されると、そのコンテキストは評価されない。
しかし、ここでの本当の力は、再評価の際に、すべての依存関係がクリーンアップされ、実行ごとに再構築されることです。つまり、依存関係は動的なのです。ある計算の条件が早く戻ってきた場合、他の分岐からの依存関係は登録されません。条件が変更された場合のみ、計算が再評価されます。これにより、動的な依存関係を実現し、不必要な再評価の必要性を減らすことができます。
記事ではこの後 showFullName
の例が出てくるが、「きめ細かい」の意味が理解できた。
これは、依存する値が実際に変更されたときにのみ(分岐パスの評価を含む)作業を行うという、きめ細かな変更検出の威力です。
パフォーマンスは?
Reactが登場した頃、リアクティブシステムの欠点が指摘されていたのを覚えている人もいるかもしれませんね。以前のバージョンのライブラリの問題の多くは、きめ細かい処理を行うことで驚異的なパフォーマンスを実現できる一方で、各要素を個別に、おそらく複数回評価し直す必要があるため、オーバーヘッドが発生する可能性があるというものだった。Reactでは、更新がスケジュールされると、その時点の安定した状態を表す単一の実行として発生することが分かっています。現時点では、一般的なリアクティブシステムのほとんどが、この問題を解決しています。次のマイクロタスクでの遅延実行(KnockoutJS)からトランザクションの作成(MobX)、さらにはSRPクロックサイクルの使用(S.js、Solid)まで、我々はとっくにその時代を超えている。Svelteは、コンパイラを使って依存するステートメントを適切な実行順序に並べることで、これを実現する方法まで発見しました。
なお、リアクティブグラフの設定にはオーバーヘッドが発生し、初期レンダリングに影響を与える可能性があります。近年、プリコンパイルなど、この問題に対処するさまざまな手法が見られるようになりましたが、覚えておいて損はないでしょう。
特にアップデートが早い。ただ、リアクティブグラフ構築のため初期レンダリングが遅いというデメリットがあるが、プリコンパイルすることで対応。
let view = fn(state);
私たちの仕事のほとんどが、データをインタラクティブなビューに変換することだとしたら、データフローと変換に焦点を当てたパターンが望ましいと考えるのは難しいことではありません。
React
Vue
Svelte
Solid
SolidはReactのような関数型プログラミングパラダイムに影響を受け、Svelteのようなコンパイラを使用し、それでもVueのようなプロキシを使用しています。しかし、他のものとの最大の違いは、Solidの構造がコンポーネントではなく、リアクティブスコープそのものに縛られていることです。
コードをどのように整理してコンポーネントを分割しても、実行時には1つのリアクティブグラフにフラット化されます。コンパイラは、他のリアクティブテンプレートDSLと同様に、JSX変換を処理するだけです。明らかな利点は、シンタックスハイライトやツールのサポート、そしてTypeScriptはほとんど無料であることです。
// reactive atom
const [count, setCount] = createSignal(0);
// reactive memo(derivation)
const double = createMemo(() => count() * 2);
// reactive effect(reaction)
createEffect(() => console.log(double()));
// update atom
setCount(count() + 1);
レンダーシステムは、props を遅延的に評価します。つまり、コンポーネントは、状態をラップするためのクロージャを作成することだけが目的の、儚いファクトリー関数なのです。コンポーネントは、Vueのsetup 関数のように、一度実行されると基本的に消滅します。状態は、それに依存するリアクティブな計算の中にのみ存在します。
Reactive Effects
まず知っておいていただきたいのは、反応性はそれ自体がシステムやソリューションではないということです。問題をモデル化するための手段なのです。反応性で多くの問題を解決することができ、それらの解決策は、選択した解決策によって長所や短所を持つことがあります。
だから、ここに銀の弾丸はないのです。リアクティビティは、生まれつきのものではありません。リアクティビティは、作成時に実際のパフォーマンスコストが発生しますし、気をつけないと、ソフトウェアがカスケードアップデートで崩壊するような混乱に陥ることもあります。しかし、これについては後で詳しく説明します。
この例が身近に感じられるように、反応性システムを試す機会があればいいのですが:
const [name, setName] = createSignal("John");
createEffect(() => console.log(`Hi ${name()}`)); // prints: Hi John
setName("Julia") // prints: Hi Julia
setName("Janice") // prints: Hi Janice
ここではSolidの構文を使っていますが、Vue、MobX、React、Knockout、Svelteにはそれぞれバリエーションがあります。John "という値を持つ単純な反応アトム(シグナル)を作成します。そして、name が更新されるたびにそれを追跡し、コンソールに挨拶をログに記録する、副次的な効果を生み出す計算を作成します。私たちが新しい名前の値を設定すると、そのエフェクトが再実行され、コンソールに新しい挨拶がロギングされます。
ですから、もし本当にDOMをレンダリングするのであれば、副次的な効果としてDOMも表示すればいいのです:
const [name, setName] = createSignal("John");
const el = document.createElement("div");
createEffect(() => el.textContent = `Hi ${name()}`);
// <div>Hi John</div>
setName("Julia") // <div>Hi Julia</div>
setName("Janice") // <div>Hi Janice</div>
「DOMの更新は副作用」この説明わかりやすい。
The Journey to Isomorphic Rendering Performance
にて読むことを推奨されている記事
開発者である私たちは、しばしばアプリケーションのアーキテクチャ全体に影響を与えるような決断を迫られることがあります。ウェブ開発者が決定しなければならない核心的な決定の1つは、アプリケーションのどこにロジックとレンダリングを実装するかということです。ウェブサイトを構築するにはさまざまな方法があるため、これは困難なことかもしれません。
この領域に関する私たちの理解は、過去数年にわたりChromeで大規模なサイトと対話する作業から得たものです。大まかに言えば、開発者には、完全なリハイドレーションのアプローチよりも、サーバーレンダリングや静的レンダリングを検討することをお勧めします。
コメントより
Q
Ryan ライブラリーの優しい紹介をありがとうございます。私は、速い/遅いということが、現実的に、エンドユーザーエクスペリエンスにどのような影響を与えるのかについて、ちょっとした疑問があります。エンドユーザーにとって、それは本当に知覚できるものなのでしょうか?エンドユーザーにとって、すべてが同じようになる境界線があるはずです。
A
お忙しい中、ご回答いただきありがとうございます。一般的なシステムでの典型的なケースでは、一般的に生の速度はあまり顕著ではないと思います。バンドルサイズや低電力デバイスの話もありますが、そこでも10-20kbは数百ミリ秒に過ぎません。では、SolidがCore I7でReactより50ms速くページ上の5000個のdom要素をレンダリングすることに、どのような違いがあるのでしょうか。私のCore I5ノートパソコンでは300ms程度です。現実的には、最初のロード時にしか気づかないでしょう。そして、そうでない場合もあります。1.4秒と1.7秒の違いは、知覚的にどうでしょうか?ほぼゼロです。Realworld Demoでは、Solidと私がテストした最も遅いライブラリであるReact Reduxの差は、3GシミュレーションとCPUスロットリングによるリソースロードで約800msしかありませんでした。TTI(Time to Interactive)は4秒以上の差がありました。
パフォーマンスというのは簡単な指標で、もっと簡単なのは kb weight です。私がSolidに取り組み始めたのは、KnockoutJSのような10年前にこれらのパターンを持っていたライブラリに見られるような、きめ細かい反応性を好んだからです。ReactがReact Hooksでそれを基本的にコピーしているのを見たとき(そしてVueの人たちは、自分たちがずっとこれらのプリミティブを持っていたことをようやく認めた)、私はこのアプローチを推進し始める良い時期だと思いました。MobXやKnockoutJSなどのリアクティブライブラリが私の研究を利用できるようにDOM Expressionsというライブラリを作りましたが、Solidをこのアプローチの頂点として開発し続けました。
ですから、性能でリードすると安っぽくなるのは確かですが、Solidをじっくり見ていただければ、ユーザーインターフェースを効果的に構築するために、非常によく設計され、熟考されたアプローチであることがおわかりいただけると思います。
Ryan Carniato 氏は fine grained reactivity のために SolidJS を作成し、パフォーマンスやビルドサイズの小ささはあくまで副産物ということか。というより fine grained reactivity を適切に実装したための必然的な結果と言えるだろうか...。
Reactivity について理解するには結局 SolidJS の公式サイトが一番わかりやすいかもしれない
Solidは3つの主要なプリミティブで構成されています:Signal, Memo, Effect です。その核となるのはObserverパターンで、Signal(とMemo)はMemoとEffectsを包むことで追跡されます。
シグナルは最もコアなプリミティブです。信号には値が含まれており、getとsetの関数があるので、信号の読み書きを妨害することができる。
エフェクトは、シグナルの読み込みをラップし、依存するシグナルの値が変化するたびに再実行する関数です。これは、レンダリングのようなサイドエフェクトを作成するのに便利です。
最後に、メモとはキャッシュされた派生値です。SignalとEffectsの両方の特性を備えています。シグナルの変更時にのみ再実行され、それ自身は追跡可能なシグナルであり、依存するシグナルを追跡します。
シグナルは、サブスクリプションのリストを保持するイベントエミッターです。その値が変化するたびに、サブスクライバーに通知します。
さらに興味深いのは、これらのサブスクリプションがどのように行われるかという点です。Solid は自動依存性追跡を使用しています。データが変更されると、自動的に更新が行われます。
その仕掛けは、実行時のグローバルスタックです。エフェクトやメモが開発者が提供する関数を実行(または再実行)する前に、そのスタックに自分自身をプッシュします。そして、読み込まれたシグナルは、スタック上に現在のリスナーがいるかどうかをチェックし、もしいれば、そのリスナーを購読に追加します。
こんな風に考えることができます:
function createSignal(value) {
const subscribers = new Set();
const read = () => {
const listener = getCurrentListener();
if (listener) subscribers.add(listener);
return value;
};
const write = (nextValue) => {
value = nextValue;
for (const sub of subscribers) sub.run();
};
return [read, write];
}
クラス コンポーネントをサポートする意図はありません。Solidのライフサイクルは、リアクティブシステムのスケジューリングと結びついており、人工的なものです。クラスを作成することもできますが、事実上、レンダー関数を含むすべての非イベント ハンドラー コードはコンストラクターで実行されることになります。これは、データの粒度を小さくする口実のための構文にすぎません。
ライフサイクルではなく、データとそのビヘイビアを一緒にグループ化する。これは、何十年にもわたって機能してきたリアクティブなベストプラクティスです。