ステート管理ライブラリを作ってみた
はじめまして、ピースケです。
前のインターン先で、Redux系のライブラリからCustom Hooksを使ったステート管理方法へと移行する事になり、useReducer+Contextで実装をし始めました。しかし、Contextのレンダー回数の抑止策にステート更新系とステート参照系のロジックを切り分けたり、useReducerを型でゴリゴリに固めていく中で、コードがかなり冗長で可読性に乏しい実装になりつつありました。また、Contextの独特な実装方法が陳腐化したことによって開発負債を生むのではないかという懸念や、車輪の再開発を行っているような不安も生じました。そこで私は、Contextの代わりとなるステート管理ライブラリの調査を任されました。
今までブラックボックスに感じていたステート管理ライブラリのZustandやJotaiの蓋を開けてみると、意外とコード量が少なく、読みやすい物となっていたので、驚きました。
実装方法は異なるにせよ、自分もシンプルなものであれば作れるのではないかと思い、butcher.jsを作ってみました。
ステート管理の仕組み
React-ReduxやZustandのようなステート管理ライブラリは、裏で多くの事をこなしてくれていて、純粋なステート管理だけでなく、非同期処理の対応や他のライブラリ(immerなど)との連結なども行ってくれています。
しかし、一番基本な部分に戻ると、ステート更新のステップは以下のように要約することができます。
1. ステートの作成・更新する
2. ステートの更新に購読し、更新時の副作用を渡す
3. ステート更新後に購読先に副作用を発火させる(主にDOMのアップデート)
フロントエンドのステート管理に、単純なObjectではなくステート管理ライブラリを使う理由は、ステートを更新する事に留まらず、更新を反映するようにDOMもアップデートさせたいからです。
Reactで使うステート管理ライブラリは、2番をReactのレンダー、3番をuseSyncExternalStore
というReact提供のhooksで処理していますが、butcher.js
はvanilla Javascriptのために作ったので、それぞれを働きを分かりやすく見ることができます。
では、順番に追っていきましょう。
※ useSyncExternalStore
hookはまた別の機会に試してみようと思います。
※ React-Redux
useSyncExternalStore
をReact-Redux内部の独自Hooksに渡し、そのReact-ReduxがReactのステートに作用します。initializeConnect
の実装自体コメントが多く据えられており、ライブラリ製作者の創意工夫が見受けられるのですが、ここでは割愛します。
// react-redux/src/index.ts
import { useSyncExternalStore } from 'use-sync-external-store/shim'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
...
initializeUseSelector(useSyncExternalStoreWithSelector)
initializeConnect(useSyncExternalStore)
...
※ Zustand
Zustandは、React-Reduxと同じように、useSyncExternalStoreWithSelector
のwrapperのhooksをライブラリ消費者に提供しています。
// zustand/src/react.ts
export function useStore<TState extends State, StateSlice>( ... ) {
const slice = useSyncExternalStoreWithSelector( ... )
return slice
}
useSyncExternalStore
を使って独自にステート管理ライブラリの機能を再現する記事もあったので、参考としてリンクを貼っておきます。
ステート更新と副作用の発火
ステートとは、更新すると副作用が発火できるObject
の進化版のようなものと捉えることができます。副作用は、例えばユーザが定義したステート更新時の挙動のcallback functionであったり、Reactの場合はレンダーであったりします。このステート更新時に呼ばれるcallback functionは一般的にlistener
と名付けられており、ReduxでもZustandでもlistener
という命名がされています。
ステートに結びついたlistener
はArrayやSetで持っておき、ステート更新毎にArray・Setを巡り、それぞれを呼んでいくことで副作用をもたらしている事がほとんどです。
では、ステートが更新されたか否かはどう判断するのでしょうか。
Zustandではステート更新用のsetState
functionがあり、ステート更新後にcallback functionを呼ぶものもあれば、ValtioのようにJavascriptのProxyを使って更新を察知し、発火させたり、butcher.jsのようにObject.defineProperty
で更新を察知してCustom Eventを発火させるものもあります。
Zustandの例
// zustand/src/vanilla.ts
...
const setState: SetState<TState> = (partial, replace) => {
// Zustandは引数にstateを返すfunctionを渡せるので、functionでも
// stateを取得するように処理されます。
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
// 既存のステートと新しい値を比べ、両者の値が異なれば書き換えられます。
if (nextState !== state) {
const previousState = state
state = replace
? (nextState as TState)
: Object.assign({}, state, nextState)
// これは以下で扱いますが、ステートの変更が反映されるようにそれぞれの
// listenerを巡り、呼んでいきます。
listeners.forEach((listener) => listener(state, previousState))
}
}
...
Valtioの例
ValtioはProxyを使ってステート更新を察知し、ステート更新時にcallback function (listener
)を発火させます。
Proxyを使ったステート管理ライブラリは、古いものだとBeedleなどがあり、ライブラリ作成者がBeedle作成前に、Proxyを使ったステート管理ライブラリの書き方を紹介しています。
ProxyやReflectは、JavascriptのObject自体の挙動を変えたり、操作の傍受ができるようにするfunctionなどで、MDNでは以下のような説明が与えられています。
The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.
Proxy Objectは、他のObjectのプロキシを作成し、そのObjectの基本的な操作を傍受して再定義することが可能です。
Reflect is a built-in object that provides methods for interceptable JavaScript operations. The methods are the same as those of proxy handlers.
Reflect は、JavaScript の操作を傍受するためのmethodを提供する組み込みObjectです。methodはプロキシハンドラのものと同じです。
// valtio/src/vanilla.ts
...
const notifyUpdate = (op: Op, nextVersion = ++globalVersion) => {
// Zustandと同じように、ステート更新後にlistenerを巡り、呼んでいきます
if (version !== nextVersion) {
version = nextVersion
listeners.forEach((listener) => listener(op, nextVersion))
}
}
...
const handler = {
// ... getやdeletePropertyなどの定義がされてます。
set(target: T, prop: string | symbol, value: any, receiver: any) {
// ... PromiseをnextValue(新しい値)に貰った時の処理など
else {
nextValue = value
}
// ステート更新が行われます。
Reflect.set(target, prop, nextValue, receiver)
// 更新の副作用を発火されます。
notifyUpdate(['set', [prop], value, prevValue])
return true
}
}
...
// Proxyのgetやsetの挙動をhandler関数で定義します。
const proxyObject = new Proxy(baseObject, handler)
...
butcher.jsの例
Object.defineProperty
もProxy
などと似ているところが多く、素のObjectのプロパティのsetやget時などに副作用を追加することができます。MDNでも例があります。
// butcher.js/src/butcher.ts
...
Object.defineProperty(obj, relKey, {
// setの挙動を変更することで、変更時に副作用を発火させることができます。
set: (value: any) => {
butchered[internalRef] = value
station.dispatchEvent(
new CustomEvent<CutDetail>(absKey, {
...
Reduxの例
Reduxも上の例と全く同じように、listenerを巡り、変更を発火させていきます。
// redux/src/createStore.ts
...
function dispatch(action: A) {
...
// dispatch(変更を発火させるfunction)も、ValtioやZustandと同じように、
// 全てのlistenerを巡り、発火させます。
const listeners = (currentListeners = nextListeners)
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
return action
}
...
ステート更新に購読する
ここまで、ステートの更新と副作用を発火させる仕組みを見てきましたが、そもそもステート更新を察知するには、どうすれば良いのでしょうか?
多くのフロントエンドライブラリでは、ステート更新時に呼ばれるcallback functionをArrayやSetとして持っています。ステート更新時にはそれらを巡り、呼んでいきます。したがって、新しく購読するとき、そのArray(もしくはSet)にlistener
という名のcallback functionを追加し、逆に購読を破壊する時は、そのlistener
を同じようにArray(もしくはSet)から消します。
Zustandの例
Zustandのlistener
は次のように定義されており、
// zustand/src/vanilla.ts
export type StateListener<T> = (state: T, previousState: T) => void
ステート更新と同時に購読先のコンポーネントにステートが変われば、新しいステートを反映させることが分かります。
// zustand/src/vanilla.ts
...
const subscribe: Subscribe<TState> = (listener: StateListener<TState>) => {
// 購読する
listeners.add(listener)
// 購読を破壊する
return () => listeners.delete(listener)
}
...
Valtioの例
Valtioの場合、listener
は以下のようになっており、
// valtio/src/vanilla.ts
type Op =
| [op: 'set', path: Path, value: unknown, prevValue: unknown]
| [op: 'delete', path: Path, prevValue: unknown]
| [op: 'resolve', path: Path, value: unknown]
| [op: 'reject', path: Path, error: unknown]
type Listener = (op: Op, nextVersion: number) => void
Zustandと少し異なりますが(どのような操作なのかも一緒に含まれている)、根本的な機能では同じであることが見て取れます。
// valtio/src/vanilla.ts
...
export function subscribe<T extends object>( ... ) {
...
// 購読されます
;(proxyObject as any)[LISTENERS].add(listener)
// 購読が破壊されます
return () => {
;(proxyObject as any)[LISTENERS].delete(listener)
}
}
Reduxの例
Reduxの場合も、ステート変更ごとに毎回呼ばれるcallback functionを登録させ、購読を破壊する際はArrayから消しているのがよく分かります。
// redux/src/createStore.ts
...
function subscribe(listener: () => void) {
...
nextListeners.push(listener)
return function unsubscribe() {
...
// 消すべきcallback functionのArray内のindexを探し、Arrayから消します
const index = nextListeners.indexOf(listener)
nextListeners.splice(index, 1)
...
}
}
...
Butcher.jsでの実装
シンプルなvanilla Javascriptでのステート管理となると、ZustandやReduxで行ってる購読の処理の仕方を真似しなくても、Custom EventとeventListenerを使えば、副作用の発火とそれの察知を簡単に実装することができます。
また、vanilla Javascriptで直接DOM操作を行うため、購読先のロジックもリポジトリの例にあるように、以下の通りになります。
// butcherjs/examples/counter-button/src/components/count.ts
...
const counterEl = document.getElementById('counter')!
listen(count, 'count', () => {
counterEl.innerHTML = `${count.count}`
})
...
JavascriptでEventの作成と発火について、MDNに簡単な紹介があり、かなり読みやすく書かれているので、こちらも貼っておきます。
工夫した点
butcher.jsでは、ステートObjectの末端のプロパティの更新だけに購読することも出来ますが、その際listen()
の第2引数として、プロパティまでの相対キーをstringとして渡す必要があります。せっかくTypescriptを使った実装を行っているのに、相対キーが型補完されないのは残念なので、第2引数のtypeには以下のものを指定してみました。
// 末端プロパティまでは`foo.bar.baz`のようにキーがdotで仕切られて作れます
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`
type AllKeysDotSeparated<T> = (
T extends object
?
| {
[K in keyof T]: `${string & K}${DotPrefix<
// Typescriptではあまり見ませんが、実はrecursiveに定義できます
// 上の`DotPrefix`型と合わせると、プロパティにプロパティが無くなるまで
//(末端に到達するまで)dotで区切られたキーができ上がっていきます。
AllKeysDotSeparated<T[K]>
>}`
}[keyof T]
| {
// ステートが単一階層のObjectの場合の対応のために用意しています
[K in keyof T]: K
}[keyof T]
: ''
) extends infer D
// エディタに表示されるキー候補が、`stateObject.property`のように根本のObjectが
// 必ず表示されるのではなく、直接`property`から指定できるように一階層分抽出します
? Extract<D, string>
: never
最後に
今回はポピュラーなステート管理ライブラリの表層を扱うことしかできませんでしたが、実際にソースコードを見てみると、分かりやすいコメントを読めたり、ライブラリを跨いで同じパターンが見えてきたりと、非常に勉強になりました。
次回、またこのような記事を書くとすれば、Reactのソースコードの奥深くに潜り、useSyncExternalStore
やuseState
、useEffect
などの処理のされ方を見ていくことができれば面白いと思います。
Discussion