【SolidJS】作って理解するSignal / Effect / Memo
以下の公式ドキュメントに書いてあることをコードをふんだんに使って長々と説明してみました。
私もまだ理解出来ていないところがあるので、詳しい方はぜひ記事を書いてください(懇願)。
SolidJS?
ReactやVueのようなライブラリです。React Hooksっぽい書き味ですね。
import {
createSignal,
createMemo,
} from 'solid'
import { render } from 'solid/web'
// コンポーネントはただの関数
const Counter = () => {
// createStateではなくcreateSignal
const [count, setCount] = createSignal(0)
// computedな値を作ることもできる
const msg = createMemo(() => `count = ${count()}`)
const handleChange = (event: Event): void => {
if (!(event.target instanceof HTMLInputElement)) {
return
}
const n = Number(event.target.value)
if (Number.isSafeInteger(n)) {
setCount(n)
}
}
// JSXも使える
return (
// classNameではない
<div class="counter">
<label>{msg()}</label>
<input
type="number"
value={count()}
onChange={handleChange}
>
</div>
)
}
render(
() => <Counter />, // 関数に包んで渡す
document.querySelector('main')
)
ただし、SolidはReact HooksっぽいAPIというだけで中身はかなり異なります。Reactでは値が変更される度に①関数コンポーネントを実行して仮想DOMを構築し、②最後に更新した時の仮想DOMとの差分をとってDOMを更新します。しかし、Solidでは仮想DOMは使わずDOMを直接いじりますし、関数コンポーネントも1回しか実行されません。
「えっ、どうなってんの!?」というのが本記事の主題です。
createSignal
モダンなUIライブラリでは何かしらの値が変更されたことがトリガーとなってUIの更新が行われます。ということは値の監視が必要になるわけで、それにはいろいろな方法があるのですが、Solidでは「古き良き(?)Observableのようなもの」を使っています。Observable
はその名の通り「変更を監視(observe)することができる値」です。
「変更を監視する」というのは、任意の関数を登録(subscribe)しておいて値が変更された時にその関数を呼び出すことで実現できます。
以下はミニマルなcreateObservable
の実装です(createSignal
ではないことに注意)。クラスを使ってもいいんですが、Solidを理解するためなのでSolidっぽいAPIにしました。①値の参照と②値の書き換え、そして③監視用の関数の登録ができるよう、それぞれに対応した関数を返してくれます。クロージャを活用しているので慣れていないと読みにくいかもしれませんが、実際にコードを書いて実行してみるとわかりやすいと思います。
// 値が変更されたら実行される関数の型
type Subscriber =
() => void
// ①値を参照するための関数の型
type Getter<T> =
() => T
// ②値を書き換えるための関数の型
type Setter<T> =
(value: T) => void
// ③関数を登録するための関数の型
type Subscribe =
(subscriber: Subscriber) => void
// 値が等しいか調べる関数の型
type Equals<T> =
(a: T, b: T) => boolean
// デフォルトのEquals
const defaultEquals: Equals<any> =
(a, b) => a == b
// React HooksやSolidっぽいAPI
const createObservable =
<T>(
value: T,
equals?: Equals<T>
): [Getter<T>, Setter<T>, Subscribe] => {
const eq = equals ?? defaultEquals
// Observableな値を作る
const observable = {
value,
subscribers: [],
}
// ①値の参照
const getter: Getter<T> = () =>
// valueは参照する度に変わる可能性があるので、関数で包む
observable.value
// ②値の書き換え
const setter: Setter<T> = (value) => {
if (eq(observable.value, value)) {
// 変更されていなければ更新しない
return
}
// 値を書き換えて関数を呼ぶ
observable.value = value
for (const subscriber of observable.subscribers) {
subscriber()
}
}
// ③監視用の関数の登録
const subscribe: Subscribe = (subscriber) => {
// subscriberを登録する
observable.subscribers.push(subscriber)
}
// Getter, Setter, Subscribeを返す
return [getter, setter, subscribe]
}
使い方はこんな感じ。
// Observableを作る
const [count, setCount, subscribe] = createObservable(0)
// 現在の値を取得して表示する関数
const printCount = () => {
console.log(`count = ${count()}`)
}
// 関数を登録する
subscribe(printCount)
// 最初の1回は自分で呼ぶ必要がある
printCount()
// 1秒毎に値を書き換えると、その度にprintCountが勝手に実行される
setTimeout(() => {
setCount(count() + 1)
}, 1000 /* ms */)
はい、なんかこれだけで既にcreateSignal
っぽいですね。違いと言えば「最初の1回は自分で呼び出す必要がある」ことと「明示的にsubscribeする必要がある」ことくらいです。
特に問題になるのは後者です。どうすればsubscribe
する関数を自動的に追加できるのでしょうか。
実はこの問題を解決するにはcreateEffect
を作る必要があります。
createEffect
とは
createEffect
は引数に渡された関数を「なぜかはわからないけれどもSignalを勝手に追跡してくれる関数」に変換して実行する関数です。言葉ではわかりにくいのでコードを示します。
import {
createSignal,
createEffect,
} from 'solid'
// Signalを作る
const [count, setCount] = createSignal(0)
// 以下のコードは1回しか実行されず、countが変更されても再実行されることはない
console.log(`normal: count = ${count()}`)
createEffect(() => {
// ここのコードは「なぜか」countの値が書き変わる度に実行される
console.log(`createEffect: count = ${count()}`)
})
// 1秒毎にカウントアップする
setTimeout(() => {
setCount(count() + 1)
}, 1000 /* ms */)
いったいなぜなんだ……???
とにかく実装してみる
ヒントはSignalのGetterにあります。Getter(上記コードではcount()
)を実行した時にそのコードが実行された関数(上記コードではcreateEffect
に渡している無名関数)がわかれば、その関数をsubscribe
することで「値が変更されたら再実行する」という挙動を実現できます。
「そんなことできるの?」と思いますが、できます。createEffect
の中で、渡された関数をグローバルなスタックに保存しておいて、Getter内でsubscribe
すればいいんです。
言葉での説明よりコードを見た方が早いかもしれませんので、createEffect
とcreateSignal
を書いてみます。Observable
と違い、勝手にsubscribe
してくれるのでObservable
からSignal
へ名前を変えました。とはいえ中身はほとんどcreateObservable
と変わらないので重要なところだけ載せます。
// スタック
class Stack<T> extends Array<T> {
top(): T | undefined {
return this.at(-1)
}
// push(), pop()はArrayのものをそのまま使う
}
// 実行中の関数を表すコンテクストの型
type Context = Subscriber
// コンテクストを保存するためのグローバルスタック
const gContexts = new Stack<Context>()
// Subscribeは自動的にするのでGetterとSetterだけ返せばよい
const createSignal = <T>(value: T, equals?: Equals<T>): [Getter<T>, Setter<T>] => {
// 名前を変えた
const signal = {
value,
subscribers: [],
}
const getter: Getter<T> = () => {
const subscriber = gContexts.top()
if (subscriber) {
// 実行中のsubscriberが存在する場合はsubscribeする
subscribe(subscriber)
}
return signal.value
}
/* 省略 */
}
// この関数に渡されたsubscriber内でGetterを呼ぶと勝手にsubscribeしてくれる
const createEffect = (subscriber: Subscriber): void => {
gContexts.push(subscriber) // スタックにsubscriberを保存する
subscriber() // subscriberの最初の1回を実行
gContexts.pop()
}
いつの間にか「最初の1回は自分で呼び出す必要がある」という問題も同時に解決できました。
挙動を追ってみる
ちょっとどんなことになるのかわかりにくいので、挙動を追ってみましょう。
まずはcreateEffect
を呼び出したところからいきます。
createEffect(() => {
console.log(`createEffect: count = ${count()}`)
})
createEffect
に渡しているのは関数ですから、中にあるcount()
はまだ実行されません。
const createEffect = (subscriber: Subscriber): void => {
/* 省略 */
}
先程の無名関数が引数subscriber
として渡ってきます。
const createEffect = (subscriber: Subscriber): void => {
gContexts.push(subscriber)
subscriber()
/* 省略 */
}
ここで実行中の関数を保存するグローバルスタックにsubscriber
自身を積み、subscriber
を実行しています。
すると……
console.log(`createEffect: count = ${count()}`)
このコードが実行され、count()
、つまりGetterが呼び出されます。
const getter: Getter<T> = () => {
const subscriber = gContexts.top()
if (subscriber) {
// 実行中のsubscriberが存在する場合はsubscribeする
subscribe(subscriber)
}
return signal.value
}
Getterではグローバルスタックにsubscriber
が保存されていればsubscribe
します。
ということは「Getterを含む関数(つまりcreateEffect
の引数であるsubscriber
)」がここで自動的にsubscribe
されたということになります。
どうでしょう、上手くできていると思いませんか。でも実はこれ、似たようなことをReactもやっているんです。
ReactではFiber(≒コンポーネント)という単位で処理を行います。Reactは現在処理しているFiberを把握しているので、createState
が呼ばれた時に「どのFiberで呼ばれたか」がわかります。そこでそのFiberとStateを紐付けておけば、setState
時に該当するFiberを再実行してDOMを更新することができるのです。
※ かなりざっくりした説明なので、詳しくは以下を参照してください。
createMemo
とは
いわゆるcomputedな値を作れるようにしましょう(唐突)。「computedな値」というのは「他の値に依存しており、依存している値が変更された時にだけ再計算される値」のことを言います。その性質からキャッシュとしても働き、Solidでは「Memo」と呼ばれます。
const names = ['Alice', 'Bob', 'Cathy', 'David', 'Elsa']
const [count, setCount] = createSignal(0)
// 依存している値が変更された時にのみ再計算される値
const name = createMemo(() => names.at(count() % names.length))
createEffect(() => {
console.log(name())
})
setTimeout(() => {
setCount(count() + 1)
}, 1000 /* ms */)
このMemoという値はSignalとEffect両方の性質を持っています。例えばSignalのように「変更を監視できる値」でありつつ、Effectのように「ある値が変更されたら再計算する」機能も持っています。
リファクタリング
というわけでcreateSignal
やcreateEffect
を作った際の各機能を再利用するため、Memoを作る前にGetterや Setterなどを切り出しておきます。
interface Signal<T> {
value?: T // optionalなのは伏線
subscribers: Subscriber[]
}
const getterOf = <T>(signal: Signal<T>): Getter<T> =>
() => {
const subscriber = gContext.top()
if (subscriber) {
// subscribe関数はここでしか使わないのでクビにしてインライン化した
signal.subscribers.push(subscriber)
}
return signal.value
}
const setterOf = <T>(signal: Signal<T>, equals?: Equals): Setter<T> =>
(value) => {
const eq = equals ?? defaultEquals
if (eq(signal.value, value)) {
return
}
signal.value = value
for (const subscriber of signal.subscribers) {
subscriber()
}
}
const createSignal = <T>(value: T, equals?: Equals): [Getter<T>, Setter<T>] => {
const signal: Signal<T> = {
value,
subscribers: [],
}
return [getterOf(signal), setterOf(signal, equals)]
}
const withContext = (context: Context, f: () => void): void => {
gContext.push(context)
f()
gContext.pop()
}
const createEffect = (subscriber: Subscriber): void => {
withContext(subscriber, () => {
subscriber()
})
}
createMemo
それではcreateMemo
を作っていきましょう。
まず、createMemo
はcreateSignal
同様、クロージャ内にSignalを保存しており、そのSignalに対するGetterを返します。SetterはMemo自身が依存している値の変更に合わせて呼び出すのでユーザに返す必要はありません。
次に、Memoに渡されるのは値そのものではなく値を作り出すための関数f
です。そしてその関数の中では他のSignalやMemoが参照されます。そのため単純にf
を実行して返値をSignalにセットすることはできません。それでは依存している値が変更されても再実行されないからです。
そこで、withContext()
を使ってコンテクストとともにf
を呼び出し、その返値をSignalにセットします。この時、普通にSetterを使ってしまうとf
を無限に呼び出し続ける事になるので、subscriber
を呼ばずに値をセットするだけにします。逆にwithContext()
に渡すコンテクストである「依存している値が変更される度に呼び出される関数」では通常通りのSetterを使って、Memoの変更を監視している関数たちを呼び出す必要があります。
これは言い換えると「内部Signalに値をセットするタイミングを遅らせている」とも言えます。
少しややこしく感じますが、createEffect
のように処理を追ってみると理解できると思います。
const createMemo = <T>(f: () => T): Getter<T> => {
const signal: Signal<T> = {
// valueは後でセットする
subscribers: [],
}
const context: Context =
() => {
// 値が変更される度にf()を実行してsignal.valueにセットする
const setValue = setterOf(signal)
setValue(f())
}
withContext(context, () => {
// setterOf(signal)を使うとcontextが呼ばれてしまい、無限ループになるので注意!
signal.value = f()
})
return getterOf(signal)
}
DOMをいじる
さて、ここまでで本記事の内容は終わりなのですが、今私たちの目の前にはcreateSignal
とcreateEffect
、そしてcreateMemo
があります。せっかくなのでこれらを使って「Signalが更新されたらDOMを更新する」仕組みを作ってみましょう。
document.createElement
などのAPIを使ってちまちま書いてもいいのですが、めんどくさいし見にくいので、こんな感じで使えるhyperscript風のヘルパ関数h
を作ります。
const counter =
h('div', { class: 'counter' }, [
h('label', {}, [
`count = ${count()}`
]),
h('input', {
type: 'number',
value: count(),
onChange: (_: Event): void => {
return
}
}),
])
本題ではないのでコードだけ置いておきます。
type EventHandler =
(event: Event) => void
const h =
<K extends keyof HTMLElementTagNameMap>(
name: K,
attrs: Record<string, string | EventHandler> = {},
children: (HTMLElement | string)[] = []
): HTMLElementTagNameMap[K] => {
const el = document.createElement(name)
// attrs
for (const [key, value] of Object.entries(attrs)) {
if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.substring(2).toLocaleLowerCase(), value)
} else {
el.setAttribute(key, value)
}
}
// children
el.append(...children)
return el
}
このh
を使ってカウンターを作ってみます。
Signalの値を参照している場合はcreateEffect
を使ってDOMを更新しなければならないため、要素の作成部分と分ける必要があります。
また、関数を使ってコードをまとめ、わかりやすくしてみます。Solidのコンポーネントにかなり近いですね。
// カウンタ表示用のラベル・コンポーネント
const CounterLabel = (count: Getter<number>): HTMLElement => {
const text = createMemo(() => `count = ${count()}`)
// ラベルを作る
const label = h('label')
// countが変更される度にlabelのテキストを更新
createEffect(() => {
label.textContent = text()
})
return label
}
// カウンター入力用のインプット・コンポーネント
const CounterInput = (count: Getter<number>, setCount: Setter<number>): HTMLElement => {
// イベントハンドラ(記事冒頭のコードと全く同じ)
const handleChange = (event: Event): void => {
if (!(event.target instanceof HTMLInputElement)) {
return
}
const n = Number(event.target.value)
if (Number.isSafeInteger(n)) {
setCount(n)
}
}
// HTMLInputElementを作る
const input =
h('input', {
type: 'number',
onChange: handleChange,
})
// countが変更される度にinput.valueを更新
createEffect(() => {
input.value = count()
})
return input
}
// カウンター・コンポーネント
const Counter = (): Node => {
// Signalを作る
const [count, setCount] = createSignal(0)
// 全部まとめてdivにする
return (
h('div', { class: 'counter' }, [
CounterLabel(count),
CounterInput(count, setCount),
])
)
}
// Counterを実際のDOMに挿入
document.querySelector('main')?.append(Counter())
なんとこれだけでリアクティブなUIライブラリもどきが出来上がってしまいました。まだいくつか未解決の重要な問題があるとはいえ、必要最低限の機能は既に備えています。
未解決の重要な問題
以下に挙げる問題は重要ではありますが、私もまだ完全に理解出来ているか怪しいこともあり、「話題提供」程度の信頼性しかありません。気になる方は以下の記事を読んだり、ソースコードを調べてみてください。
メモリリーク
いきなりヤバいフレーズが出てきました。
実は前節で作ったライブラリもどきには破棄するための仕組みがないので、何も考えずにいるとみっともなくお漏らしします。例えばSignalが生きている限りcreateEffect
に渡したsubscriber
は生き続けるので、既に使われなくなったsubscriber
が破棄されずに残り続けることになるかもしれません。また、createEffect
でDOMへの参照をクロージャにキャプチャすることになるため、DOM操作後に古い参照が残り続ける可能性もあります。
この問題を解決するには、親のEffect内でEffectを作成する時にそのEffectを覚えておき、親のEffectを破棄する時に覚えていたEffectを一緒に破棄するようにするらしいです(ほんとか?この辺は曖昧)。SignalやMemoが値の変更を追跡する時と同じような仕組みですね。Solidではこれを実現するためのcreateRoot
という関数があり、render
内部でも使われています。
リアクティブなmap
リストなどをレンダリングする際はDOMノードを順序を保って適切に挿入・削除するのが普通です。さらに既に作成済みのノードは作らないとか、Effectを破棄するタイミングとかいろいろ考えることがあります。
SolidにはmapArray
やそれをもとにしたFor
なんかがあり、リアクティブなデータを効率的にmapすることができます。
タスクスケジューラ
SolidもReactのタスクスケジューラを基にしたものを使っているようです。Suspenseなどを実現するために必要なのかもしれません。ソースをチラッと見ただけなので詳しく知りたい方はこの辺からどうぞ。
おわりに
SignalやEffect、MemoなどリアクティブなデータのコンセプトはS.jsに大きく影響を受けているようで、かなり似通っています。公式ドキュメントでも以下のように言及されています。
このライブラリは、Solid のリアクティブ設計に最も大きな影響を与えました。Solid は数年前から S.js を内部で使用していましたが、機能セットの違いから別々の道を歩むことになりました。
(中略)
Solid のリアクティビティは、最終的には S と MobX のハイブリッドのようなものです。
しかしSolidの「よさ」はリアクティビティなどの機能がモダンで扱いやすいインターフェースで提供されていることや、パフォーマンスにかなり力を入れていることにあると思います。私の理解がまだ浅いこともあり、この記事1つではとても説明しきれない部分もあるのですが、この記事がSolidを理解する一助になったなら嬉しく思います。
参考
Solidの作者Ryan Carniato(ryansolid)氏による記事2つ
公式のガイドとソースコード
Reactの内部についても知っていると理解が深まるはず
S.jsのコンセプトと似通っているので参考になる
Discussion