🔬

【SolidJS】作って理解するSignal / Effect / Memo

2022/06/18に公開

以下の公式ドキュメントに書いてあることをコードをふんだんに使って長々と説明してみました。

https://www.solidjs.com/guides/reactivity

私もまだ理解出来ていないところがあるので、詳しい方はぜひ記事を書いてください(懇願)。

SolidJS?

https://www.solidjs.com/

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

https://www.solidjs.com/docs/latest/api#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

とは

https://www.solidjs.com/docs/latest/api#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すればいいんです。

言葉での説明よりコードを見た方が早いかもしれませんので、createEffectcreateSignalを書いてみます。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を更新することができるのです。

※ かなりざっくりした説明なので、詳しくは以下を参照してください。

https://pomb.us/build-your-own-react/

https://zenn.dev/akatsuki/articles/a2cbd26488fa151b828b

createMemo

とは

いわゆるcomputedな値を作れるようにしましょう(唐突)。「computedな値」というのは「他の値に依存しており、依存している値が変更された時にだけ再計算される値」のことを言います。その性質からキャッシュとしても働き、Solidでは「Memo」と呼ばれます。

https://www.solidjs.com/docs/latest/api#creatememo

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のように「ある値が変更されたら再計算する」機能も持っています。

リファクタリング

というわけでcreateSignalcreateEffectを作った際の各機能を再利用するため、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を作っていきましょう。

まず、createMemocreateSignal同様、クロージャ内に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をいじる

さて、ここまでで本記事の内容は終わりなのですが、今私たちの目の前にはcreateSignalcreateEffect、そして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ライブラリもどきが出来上がってしまいました。まだいくつか未解決の重要な問題があるとはいえ、必要最低限の機能は既に備えています。

未解決の重要な問題

以下に挙げる問題は重要ではありますが、私もまだ完全に理解出来ているか怪しいこともあり、「話題提供」程度の信頼性しかありません。気になる方は以下の記事を読んだり、ソースコードを調べてみてください。

https://indepth.dev/posts/1289/solidjs-reactivity-to-rendering

メモリリーク

いきなりヤバいフレーズが出てきました。

実は前節で作ったライブラリもどきには破棄するための仕組みがないので、何も考えずにいるとみっともなくお漏らしします。例えば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などを実現するために必要なのかもしれません。ソースをチラッと見ただけなので詳しく知りたい方はこの辺からどうぞ。

https://github.com/solidjs/solid/blob/main/packages/solid/src/reactive/scheduler.ts

おわりに

SignalやEffect、MemoなどリアクティブなデータのコンセプトはS.jsに大きく影響を受けているようで、かなり似通っています。公式ドキュメントでも以下のように言及されています。

このライブラリは、Solid のリアクティブ設計に最も大きな影響を与えました。Solid は数年前から S.js を内部で使用していましたが、機能セットの違いから別々の道を歩むことになりました。
(中略)
Solid のリアクティビティは、最終的には S と MobX のハイブリッドのようなものです。

https://www.solidjs.com/guides/comparison#s.js

しかしSolidの「よさ」はリアクティビティなどの機能がモダンで扱いやすいインターフェースで提供されていることや、パフォーマンスにかなり力を入れていることにあると思います。私の理解がまだ浅いこともあり、この記事1つではとても説明しきれない部分もあるのですが、この記事がSolidを理解する一助になったなら嬉しく思います。

参考

Solidの作者Ryan Carniato(ryansolid)氏による記事2つ

https://indepth.dev/posts/1269/finding-fine-grained-reactive-programming

https://indepth.dev/posts/1289/solidjs-reactivity-to-rendering

公式のガイドとソースコード

https://www.solidjs.com/guides/reactivity

https://github.com/solidjs/solid

Reactの内部についても知っていると理解が深まるはず

https://pomb.us/build-your-own-react/

https://zenn.dev/akatsuki/articles/a2cbd26488fa151b828b

S.jsのコンセプトと似通っているので参考になる

https://github.com/vojtechportes/SJS

Discussion