chenyuan

chenyuan

twitter

zustandソースコードの解読-上

この文章は zustand v4.3.8 に基づいています
WeChat プッシュ Zhihu リンク
zustand の 4.3.8 タグリンク
zustand の ドキュメントアドレス

使用方法#

zustandは、パブリッシュ・サブスクライブモデルに基づいて実装された状態管理ライブラリであり、reactプロジェクトに限定されず使用できますが、reactのサポートは公式に実装されており、非常にシンプルに使用できます。使用例は以下の通りです。

// jsプロジェクトで使用、型は不要  
import { create } from "zustand";  

const initStateCreateFunc = (set) => ({  
  bears: 0,  
  increase: (by) => set((state) => ({ bears: state.bears + by })),  
});  

const useBearStore = create(initStateCreateFunc);  
// tsプロジェクト、型のヒントが必要  
import { create } from "zustand";  

interface BearState {  
  bears: number;  
  increase: (by: number) => void;  
}  

const initStateCreateFunc = (set) => ({  
  bears: 0,  
  increase: (by) => set((state) => ({ bears: state.bears + by })),  
});  

const useBearStore = create<BearState>()(initStateCreateFunc);  

上記のコードでは、create関数を呼び出すと、useStoreのフックが生成されます。このフックの基本的な使用方法は、reduxuseSelectorとまったく同じです。

function BearCounter() {  
  const bears = useBearStore((state) => state.bears);  
  return <h1>{bears} ここにいます...</h1>;  
}  

function Controls() {  
  const increase = useBearStore((state) => state.increase);  
  return <button onClick={increase}>一つ増やす</button>;  
}  

注意深い方は、js と ts の使用に違いがあることに気づくかもしれません。ts は create<BearState>()(initStateCreateFunc) です。その理由は後述します。

ソースコードの主な流れ#

zustandのコアは、外部のstoreとコンポーネントのviewの相互作用です。相互作用のコアプロセスは以下の図の通りです。
zustand

まず、create関数を使用して、注入されたinitStateCreateFuncに基づいてクロージャのstoreを作成し、対応するsubscribesetStategetStatedestory(この API は削除される予定です)などのAPIを公開します。

reactが提供するuseSyncExternalStoreWithSelectorを利用することで、storeview層を結びつけ、外部のstoreを使用してページの表示を制御できます。

zustandmiddlewareの機能もサポートしており、create(middleware(...args))の形式で対応するmiddlewareを使用できます。

コアコードの詳細解説#

この部分では、最も重要なcreateuseSyncExternalStoreWithSelector関数について説明します。

create 関数による store の生成#

読みやすくするために、コードは省略されています。

前提知識の紹介#

create関数によって生成されるstoreはクロージャであり、APIを公開することでstoreへのアクセスを実現します。

コアコードはvanilla.tsreact.tsの 2 つのファイルにあります。vanilla.tsでは、pub-sub機能を持つ完全なstoreが実装されており、reactに依存せずに使用できます。

react.tsでは、useSyncExternalStoreWithSelectorに基づいてuseStoreのフックが実装されており、コンポーネント内でcreateが返す関数を呼び出すと、storeとコンポーネントが結びつきます。この結びつきはuseStoreによって実現されます。このuseSyncExternalStoreWithSelectorについては次のセクションで説明します。

create の実行フロー#

create関数が呼び出されると、まずvanilla.tsからエクスポートされたcreateStoreを使用してstoreを生成し、次にuseBoundStore関数を定義します。返り値はuseStore(api, selector, equalityFn)であり、createStoreから返されたAPIuseBoundStoreに注入し、useBoundStoreを返します。このuseBoundStoreの使用方法はuseSelectorとまったく同じです。

注釈付きの簡略化されたソースコード#

コードを見ると、createStorecreateの 2 つの関数はどちらも(createState) => createState ? createStoreImpl(createState) : createStoreImplの形式であることがわかります。公式ドキュメントのts ガイドを参照すると、公式の ts プロジェクトでの呼び出し方は次のようになります:create()(...args)

ドキュメントのコード例の下に、TypeScript/issues/10571を処理するために実装されたワークアラウンドについての説明があります。これが上記の ts と js の使用方法の不一致の理由です。

// storeのクロージャを生成し、APIを返す  
// createStateは使用者がstoreを作成する際に渡す関数  
const createStoreImpl = (createState) => {  
  type TState = ReturnType<typeof createState>;  
  type Listener = (state: TState, prevState: TState) => void;  
  // ここでのstateはstoreであり、クロージャで、公開されたAPIを通じてアクセスされる  
  let state: TState;  
  const listeners: Set<Listener> = new Set();  

  // setStateのpartialパラメータはオブジェクトと関数をサポートし、replaceはstoreを全量置き換えるかマージするかを示します  
  // 更新は浅い比較です  
  const setState = (partial, replace) => {  
    const nextState = typeof partial === "function" ? partial(state) : partial;  
    // 等しい場合にのみ更新し、listenerをトリガーします  
    if (!Object.is(nextState, state)) {  
      const previousState = state;  
      state =  
        replace ?? typeof nextState !== "object"  
          ? (nextState as TState)  
          : Object.assign({}, state, nextState);  
      listeners.forEach((listener) => listener(state, previousState));  
    }  
  };  

  const getState = () => state;  

  const subscribe = (listener) => {  
    listeners.add(listener);  
    // 購読解除  
    return () => listeners.delete(listener);  
  };  

  // destoryは後で削除されるため、見る必要はありません  
  const destroy: StoreApi<TState>["destroy"] = () => {  
    if (import.meta.env?.MODE !== "production") {  
      console.warn(  
        "[DEPRECATED] `destroy`メソッドは将来のバージョンでサポートされなくなります。代わりにsubscribeから返されたunsubscribe関数を使用してください。storeがガーベジコレクトされると、すべてがガーベジコレクトされます。"  
      );  
    }  
    listeners.clear();  
  };  

  const api = { setState, getState, subscribe, destroy };  
  // ここが公式の例にあるset,get,apiです  
  state = createState(setState, getState, api);  
  return api as any;  
};  

// createStoreを呼び出すとき、理論的にはcreateState関数は必ず存在します  
// ただし、ts型定義のため、createStore<T>()(()=>{})があるため、空値を手動で呼び出す場合があります  
export const createStore = ((createState) =>  
  createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore;  
export function useStore<TState, StateSlice>(  
  api: WithReact<StoreApi<TState>>,  
  selector: (state: TState) => StateSlice = api.getState as any,  
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean  
) {  
  const slice = useSyncExternalStoreWithSelector(  
    api.subscribe,  
    api.getState,  
    api.getServerState || api.getState,  
    selector,  
    equalityFn  
  );  
  useDebugValue(slice);  
  return slice;  
}  

const createImpl = (createState) => {  
  if (  
    import.meta.env?.MODE !== "production" &&  
    typeof createState !== "function"  
  ) {  
    console.warn(  
      "[DEPRECATED] バニラストアを渡すことは将来のバージョンでサポートされなくなります。代わりに`import { useStore } from 'zustand'`を使用してください。"  
    );  
  }  
  // カスタムストアを直接注入するとAPIが注入されず、注入されたストア内で自分で実装する必要があります  
  const api =  
    typeof createState === "function" ? createStore(createState) : createState;  

  const useBoundStore: any = (selector?: any, equalityFn?: any) =>  
    useStore(api, selector, equalityFn);  

  Object.assign(useBoundStore, api);  

  return useBoundStore;  
};  

export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>  
  createState ? createImpl(createState) : createImpl) as Create;  

useSyncExternalStoreWithSelector の解析#

zustandのコアコードがこれほどシンプルである大きな理由は、useSyncExternalStoreWithSelectorを使用しているからです。これはreact公式のuse-sync-external-store/shim/with-selectorパッケージです。このパッケージが作成された理由は、reactuseSyncExternalStoreというフックを提案した後、react v18バージョンで再実装を行い、破壊的な更新があったためです。互換性を考慮してこのパッケージが作成されました。

多くを語らず、ソースコードを見てみましょう。

この実装は、公式のuseSyncExternalStoreに基づいて行われたラッパーであり、公式のフックはselectorを渡すことができませんが、ラッパー後はselectorisEqualをサポートしています。

useSyncExternalStoreには、subscribegetSnapshotの 2 つの関数を渡す必要があり、返り値はgetSnapshotの返り値です。reactsubscribeにコールバック関数を注入し、外部のstoreが変化したときには必ず手動でコールバックを呼び出してreactに外部のstoreが変化したことを通知し、再度getSnapshotを呼び出して最新の状態を取得する必要があります。状態が変わった場合はre-renderをトリガーし、そうでなければre-renderしません。

useSyncExternalStoreWithSelectorの最適化は、1 つの大きなstoreからコンポーネントが使用する部分を取り出すことを許可し、同時にisEqualを利用してre-renderの回数を減らすことです。

export function useSyncExternalStoreWithSelector<Snapshot, Selection>(  
  subscribe: (() => void) => () => void,  
  getSnapshot: () => Snapshot,  
  getServerSnapshot: void | null | (() => Snapshot),  
  selector: (snapshot: Snapshot) => Selection,  
  isEqual?: (a: Selection, b: Selection) => boolean,  
): Selection {  
  // レンダリングされたスナップショットを追跡するために使用します。  
  const instRef = useRef<  
    | {  
        hasValue: true,  
        value: Selection,  
      }  
    | {  
        hasValue: false,  
        value: null,  
      }  
    | null,  
  >(null);  
  let inst;  
  if (instRef.current === null) {  
    inst = {  
      hasValue: false,  
      value: null,  
    };  
    instRef.current = inst;  
  } else {  
    inst = instRef.current;  
  }  

  /**  
   * zustandを使用する際はuseStore(selector)の形式を採用し、毎回re-render時に新しいselectorを取得します  
   * そのため、re-render後のgetSelectionは常に新しいですが、instRef.currentおよびisEqualがあるため  
   * isEqualのときはinstRef.currentにキャッシュされた値を返し、getSelectionの返り値が変わらない  
   * 再度re-renderされず、re-renderの回数が減ります  
   */  
  const [getSelection, getServerSelection] = useMemo(() => {  
    // このメモ化されたgetSnapshot関数のインスタンスにローカルなクロージャ変数を使用してメモ化された状態を追跡します。  
    // useRefフックを意図的に使用していないのは、その状態がフック/コンポーネントのすべての同時コピーで共有されるためです。  
    let hasMemo = false;  
    let memoizedSnapshot;  
    let memoizedSelection: Selection;  
    const memoizedSelector = (nextSnapshot: Snapshot) => {  
      if (!hasMemo) {  
        // フックが初めて呼び出されたとき、メモ化された結果はありません。  
        hasMemo = true;  
        memoizedSnapshot = nextSnapshot;  
        const nextSelection = selector(nextSnapshot);  
        if (isEqual !== undefined) {  
          // セレクタが変わった場合でも、現在レンダリングされている選択が新しい選択と等しい可能性があります。  
          // 可能であれば現在の値を再利用し、下流のメモ化を保持します。  
          if (inst.hasValue) {  
            const currentSelection = inst.value;  
            if (isEqual(currentSelection, nextSelection)) {  
              memoizedSelection = currentSelection;  
              return currentSelection;  
            }  
          }  
        }  
        memoizedSelection = nextSelection;  
        return nextSelection;  
      }  

      // 前回の呼び出しの結果を再利用できるかもしれません。  
      const prevSnapshot: Snapshot = (memoizedSnapshot: any);  
      const prevSelection: Selection = (memoizedSelection: any);  

      if (is(prevSnapshot, nextSnapshot)) {  
        // スナップショットが前回と同じです。前の選択を再利用します。  
        return prevSelection;  
      }  

      // スナップショットが変わったので、新しい選択を計算する必要があります。  
      const nextSelection = selector(nextSnapshot);  

      // カスタムisEqual関数が提供されている場合、それを使用してデータが変わったかどうかを確認します。  
      // 変わっていなければ、前の選択を返します。それはReactに選択が概念的に等しいことを示し、  
      // レンダリングを中止することができます。  
      if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {  
        return prevSelection;  
      }  

      memoizedSnapshot = nextSnapshot;  
      memoizedSelection = nextSelection;  
      return nextSelection;  
    };  
    // Flowが変更できないことを知っているように、これを定数に割り当てます。  
    const maybeGetServerSnapshot =  
      getServerSnapshot === undefined ? null : getServerSnapshot;  
    const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());  
    const getServerSnapshotWithSelector =  
      maybeGetServerSnapshot === null  
        ? undefined  
        : () => memoizedSelector(maybeGetServerSnapshot());  
    return [getSnapshotWithSelector, getServerSnapshotWithSelector];  
  }, [getSnapshot, getServerSnapshot, selector, isEqual]);  

  const value = useSyncExternalStore(  
    subscribe,  
    getSelection,  
    getServerSelection,  
  );  

  useEffect(() => {  
    inst.hasValue = true;  
    inst.value = value;  
  }, [value]);  

  useDebugValue(value);  
  return value;  
}  
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。