🚉

svelte-store-treeというライブラリーをリリースしました

2022/09/29に公開約7,700字

先日、Svelte用の状態管理ライブラリーをリリースしました:

svelte-store-tree - npm

名前の通り、Svelteのstoreという機能について、tree、すなわち木のように入れ子の構造を扱いやすくするメソッドを加えたものです[1]。現在お仕事のアプリケーションで扱っている、複雑な木構造をなるべく素直に扱えるようにするために開発しました。

使い方の例

以降では、こちらで動かせるサンプルアプリにおける、以下のような型を扱います:

export type Tree = string | undefined | KeyValue | Tree[];

export type KeyValue = {
  key: string;
  value: string;
};

いろいろな値を取り得る、再帰的な構造になっていますね。

なお、このサンプルアプリのコードはGitHubにも置いているので、今後具体的な箇所を例示する際は執筆時点のバージョンにおける該当の行へのリンクを張ります。

WritableTreeオブジェクトの作成

svelte-store-treeが提供するWritableTreeオブジェクトを作るには、その名の通りwritableTreeという関数を使います:

export const tree = writableTree<Tree>([
  "foo",
  "bar",
  ["baz1", "baz2", "baz3"],
  undefined,
  { key: "some key", value: "some value" },
]);

本家のwritable関数と同様に、storeの初期値を引数で指定します。Tree型はunion型なので、型推論を助けるために型パラメーターを明示しておきましょう。

普通にsubscribeしたりsetしたりする

svelte-store-treeは本家SvelteのstoreのようにStore contractを守っているので、おなじみのドルマーク $ で自動でsubscribeしたり、two-way binding機能を通じてsetすることができます:

<script lang="ts">
  export let tree: WritableTree<Tree>;
  ...
</script>
...
{#if typeof $tree === "string"}
  <li>
    <NodeTypeSelector label="Switch" bind:selected {onSelected} /><input
      type="text"
      bind:value={$tree}
    />
  </li>
...
{/if}

該当箇所

zoomして、部分木に対するWritableTreeを作る

svelte-store-treeは、部分木に対するstoreを作るためのAPIとして、以下のzoomで始まる名前のメソッドを提供します:

  • zoom<C>(accessor: Accessor<P, C>): ReadableTree<C>
    • Accessorという、親Pから子Cを取得したり、親Pにおける子Cの値を書き換える関数のペアを渡すことで、読み取り専用のstore tree (ReadableTree)を作る
    • ReadableTreeは、子のstore treeも含めてsetできなくなっているstore tree
  • zoomIn<K extends keyof P>(k: K): ReadableTree<P[K]>
    • Accessorを都度作るのは面倒なので、親のオブジェクトが持つフィールドの名前(または配列のindex)を指定することで、子のReadableTreeを作る
  • zoomWritable<C>(accessor: Accessor<P, C>): WritableTree<C>
    • zoomと同様にAccessorを渡すことで、読み書きできるstore tree (WritableTree)を作る
    • WritableTreesetできるstore tree。子孫がzoomzoomInを使用してReadableTreeを作るまではsetできる
  • zoomInWritable<K extends keyof P>(k: K): WritableTree<P[K]>
    • zoomInWritableTreeを返すバージョン

サンプルアプリでは、次のようにzoomInWritableを使うことでKeyValue型の値が持つkeyvalueを書き換えるためのWritableTreeを作っています。

const key = tree.zoomInWritable("key");
const value = tree.zoomInWritable("value");

... が、こちらは正しくありません。svelte-checkで調べてみると、TypeScriptが次のような型エラーを出します:

/svelte-store-tree/example/Tree.svelte:14:35
Error: Argument of type 'string' is not assignable to parameter of type 'never'. (ts)
  const keyValue = tree.chooseWritable<KeyValue>(chooseKeyValue);
  const key = tree.zoomInWritable("key");
  const value = tree.zoomInWritable("value");


/svelte-store-tree/example/Tree.svelte:15:37
Error: Argument of type 'string' is not assignable to parameter of type 'never'. (ts)
  const key = tree.zoomInWritable("key");
  const value = tree.zoomInWritable("value");

parameter of type 'never'とあるとおり、引数の型がneverになっていて分かりづらいですが、これはtreeの型がWritableTree<Tree>、つまりTree型を含むWritableTreeとなっていて、そのTree型は先ほどの定義通りundefinedも取り得るunion型なので、keyofの結果がneverになってしまうためです。

これを修正するには、WritableTree<Tree>をなんとかしてWritableTree<KeyValue>に変換しないといけません。それを実現するのが次の節で紹介するchooseWritableです。

chooseして、storeの値が特定の場合のみ値を取得する

WritableTree並びにReadableTreeは、choosechooseWritableと言うメソッドで、storeの値が特定の条件にマッチする場合だけsubscribeしている関数を呼ぶ、storeを作ることができます。

サンプルアプリから、chooseWritableを使っている箇所を抜き出しましょう:

...
const keyValue = tree.chooseWritable<KeyValue>(chooseKeyValue);
...

chooseWritableは、引数として、「storeの中の値(上記の場合Tree型の値)を受け取って、別の値、あるいはRefuseを返す関数」を受け取ります。上の例におけるchooseKeyValueですね。実装は次の通りです:

export function chooseKeyValue(tree: Tree): KeyValue | Refuse {
  if (tree === undefined || typeof tree === "string" || tree instanceof Array) {
    return Refuse;
  }
  return tree;
}

該当箇所

chooseWritableが返したWritableTreeは、chooseKeyValueのような関数がRefuseという専用のunique symbol[2]を返した場合はsubscribeしている関数を呼ばず、それ以外の値を返した場合はその値でsubscribeしている関数を呼びます。

なぜbooleanを返す関数にしないのか、あるいはundefinedを使わずRefuseという専用の値を用意したのか、という疑問が浮かぶ方もいらっしゃるかも知れません。

まずbooleanを返す関数にしないのは、TypeScriptによる型の絞り込みでWritableTreeの中の型を変換するため、変換結果を関数の型で明示しないとならないからです。ユーザー定義の型ガード(戻り値の型がTree is KeyValueのようになっている関数)は、chooseに渡す高階関数には使えませんでした。

それから、Refuseという専用の値を用意したのは、{ property: T | undefined }のような、undefinedを含むプロパティーも柔軟に扱えるようにするためです。

話を戻しましょう。先ほどtree.zoomInWritable("key")などと書いて型エラーになっていた箇所は、次のようにchooseWritableと組み合わせることで修正できます:

const keyValue = tree.chooseWritable<KeyValue>(chooseKeyValue);
const key = keyValue.zoomInWritable("key");
const value = keyValue.zoomInWritable("value");

該当箇所

あとはzoomInWritableが返したkeyvalueを使うことで、KeyValue型のkeyvalueだけをtwo-way bindingすることができます:

<dl>
  <dt><input type="text" bind:value={$key} /></dt>
  <dd><input type="text" bind:value={$value} /></dd>
</dl>

該当箇所

子による更新に親が応じる

これまでの例は、実際のところあまりstore treeの良さを生かせていません。わざわざ一つのデータ構造が入った一つのstoreとして管理しなくても、個別のコンポーネントの状態としてそれぞれの値を管理すれば事足りるからです。

svelte-store-treeはもちろんそれより一歩踏み込んでいます。それは、子における更新を、直接の親だけに伝播できる、という点です。

例えば、先ほどのkeyvalueをtwo-way bindingした二つの<input>のうち、key<input>が更新された場合をイメージしましょう:

<dl>
  <dt><input type="text" bind:value={$key} /></dt>
  <dd><input type="text" bind:value={$value} /></dd>
</dl>

この場合、keysetした値は、それぞれkey自身をsubscribeしている関数と、その親であるkeyValueをはじめとする、直接の先祖をsubscribeしている関数に伝わります。兄弟に当たるvaluesubscribeしている関数や、親の兄弟などをsubscribeしている関数には伝わりません。

図で表すと、例えば木が次のような形になっていたとして...

List 1を根としてList 2, string, KeyValueと言う子を含む木

「key」を更新したとき、更新が伝播する、すなわちsubscribeしている関数を呼び出すのは「List 1」・「KeyValue」・「key」の3つのstoreです:

List 1を根としてList 2, string, KeyValueと言う子を含む木を更新したとき

「value」、「string」や「List 2」、「List 2」の子にあたるstoreには更新が伝わりません。そうすることで、余分な再レンダリングを避けることができます。

以上の特徴を活かすためにサンプルアプリでは、木のルートにおいて、木に含まれるノードの数を種類毎に集計することにしました:

<script lang="ts">
  import TreeComponent from "./Tree.svelte";
  import { tree, type NodeType, type Tree } from "./tree";

  $: stats = countByNode($tree);
  function countByNode(node: Tree): Map<NodeType, number> {
    ...
  }
</script>

<table>
  <tr>
    <th>Node Type</th>
    <th>Count</th>
  </tr>
    {#each Array.from(stats) as [nodeType, count]}
  <tr>
      <td>{nodeType}</td>
      <td>{count}</td>
  </tr>
    {/each}
</table>
...

該当箇所

子における更新が確実に伝播するので、親において木全体に対する処理を書くことも容易にできます!

今後

私のTypeScript力が不足していたこともあり、他のライブラリーを調べているうちにいろいろ改善点が見えてきた[3]ので、使い方を紹介した後で恐縮ですが、APIをいろいろ修正したいと思います。乞うご期待。

脚注
  1. 「加えた」とは言っても、実装は本家のstoreをコピペしてから内部構造ごと書き換えたものなので、正確には「storeを参考に拡張した」という方が正しいですが。 ↩︎

  2. 「zoom」、「choose」、「refuse」の3つで韻を踏んでいます。 ↩︎

  3. 作る前に他のライブラリーを調べろよ、という批判は甘んじて受けます😥 ↩︎

GitHubで編集を提案

Discussion

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