svelte-store-treeというライブラリーをリリースしました
先日、Svelte用の状態管理ライブラリーをリリースしました:
名前の通り、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
)を作る -
WritableTree
はset
できるstore tree。子孫がzoom
やzoomIn
を使用してReadableTree
を作るまではset
できる
-
-
zoomInWritable<K extends keyof P>(k: K): WritableTree<P[K]>
-
zoomIn
のWritableTree
を返すバージョン
-
サンプルアプリでは、次のようにzoomInWritable
を使うことでKeyValue
型の値が持つkey
とvalue
を書き換えるための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
は、choose
とchooseWritable
と言うメソッドで、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
が返したkey
・value
を使うことで、KeyValue
型のkey
とvalue
だけを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はもちろんそれより一歩踏み込んでいます。それは、子における更新を、直接の親だけに伝播できる、という点です。
例えば、先ほどのkey
とvalue
をtwo-way bindingした二つの<input>
のうち、key
の<input>
が更新された場合をイメージしましょう:
<dl>
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
</dl>
この場合、key
にset
した値は、それぞれkey
自身をsubscribe
している関数と、その親であるkeyValue
をはじめとする、直接の先祖をsubscribe
している関数に伝わります。兄弟に当たるvalue
をsubscribe
している関数や、親の兄弟などをsubscribe
している関数には伝わりません。
図で表すと、例えば木が次のような形になっていたとして...
「key」を更新したとき、更新が伝播する、すなわちsubscribe
している関数を呼び出すのは「List 1」・「KeyValue」・「key」の3つのstoreです:
「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をいろいろ修正したいと思います。乞うご期待。
Discussion