🚀

ステート管理ライブラリを作ってみた

2022/07/08に公開約10,700字

はじめまして、ピースケです。

前のインターン先で、Redux系のライブラリからCustom Hooksを使ったステート管理方法へと移行する事になり、useReducer+Contextで実装をし始めました。しかし、Contextのレンダー回数の抑止策にステート更新系とステート参照系のロジックを切り分けたり、useReducerを型でゴリゴリに固めていく中で、コードがかなり冗長で可読性に乏しい実装になりつつありました。また、Contextの独特な実装方法が陳腐化したことによって開発負債を生むのではないかという懸念や、車輪の再開発を行っているような不安も生じました。そこで私は、Contextの代わりとなるステート管理ライブラリの調査を任されました。

今までブラックボックスに感じていたステート管理ライブラリのZustandやJotaiの蓋を開けてみると、意外とコード量が少なく、読みやすい物となっていたので、驚きました。

実装方法は異なるにせよ、自分もシンプルなものであれば作れるのではないかと思い、butcher.jsを作ってみました。

https://github.com/DrPoppyseed/butcher.js

ステート管理の仕組み

React-ReduxやZustandのようなステート管理ライブラリは、裏で多くの事をこなしてくれていて、純粋なステート管理だけでなく、非同期処理の対応や他のライブラリ(immerなど)との連結なども行ってくれています。

しかし、一番基本な部分に戻ると、ステート更新のステップは以下のように要約することができます。

1. ステートの作成・更新する
2. ステートの更新に購読し、更新時の副作用を渡す
3. ステート更新後に購読先に副作用を発火させる(主にDOMのアップデート)

フロントエンドのステート管理に、単純なObjectではなくステート管理ライブラリを使う理由は、ステートを更新する事に留まらず、更新を反映するようにDOMもアップデートさせたいからです。

Reactで使うステート管理ライブラリは、2番をReactのレンダー、3番をuseSyncExternalStoreというReact提供のhooksで処理していますが、butcher.jsはvanilla Javascriptのために作ったので、それぞれを働きを分かりやすく見ることができます。

では、順番に追っていきましょう。

https://github.com/reduxjs/react-redux

https://github.com/pmndrs/zustand

useSyncExternalStore hookはまた別の機会に試してみようと思います。

※ React-Redux

useSyncExternalStoreをReact-Redux内部の独自Hooksに渡し、そのReact-ReduxがReactのステートに作用します。initializeConnectの実装自体コメントが多く据えられており、ライブラリ製作者の創意工夫が見受けられるのですが、ここでは割愛します。

https://github.com/reduxjs/react-redux/blob/master/src/components/connect.tsx#:~:text=try {-,actualChildProps %3D useSyncExternalStore(,),-} catch
// 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を使って独自にステート管理ライブラリの機能を再現する記事もあったので、参考としてリンクを貼っておきます。

https://zenn.dev/hatchinee/articles/c075fdef8f54d0

ステート更新と副作用の発火

ステートとは、更新すると副作用が発火できる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)を発火させます。

https://github.com/pmndrs/valtio

Proxyを使ったステート管理ライブラリは、古いものだとBeedleなどがあり、ライブラリ作成者がBeedle作成前に、Proxyを使ったステート管理ライブラリの書き方を紹介しています。

https://github.com/hankchizljaw/beedle

https://css-tricks.com/build-a-state-management-system-with-vanilla-javascript/

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の基本的な操作を傍受して再定義することが可能です。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy#:~:text=The Proxy object enables you to create a proxy for another object%2C which can intercept and redefine fundamental operations for that 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はプロキシハンドラのものと同じです。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect#:~:text=Reflect is a built-in object that provides methods for interceptable JavaScript operations. The methods are the same as those of proxy handlers
// 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.definePropertyProxyなどと似ているところが多く、素のObjectのプロパティのsetやget時などに副作用を追加することができます。MDNでも例があります。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty#custom_setters_and_getters
// 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に簡単な紹介があり、かなり読みやすく書かれているので、こちらも貼っておきます。

https://developer.mozilla.org/en-US/docs/Web/Events/Creating_and_triggering_events

工夫した点

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のソースコードの奥深くに潜り、useSyncExternalStoreuseStateuseEffectなどの処理のされ方を見ていくことができれば面白いと思います。

Discussion

ログインするとコメントできます