なぜSvelte風Solid.jsのstoreは作れないか、およびsvelte-store-treeの新バージョンの紹介
For English speakers: I've published the English version of this article, which I translated and combined with the last article in Japanese to reedit.
先ほど、前回紹介した、Svelte用の状態管理ライブラリー、svelte-store-treeの新しいバージョン(v0.3.1)をリリースしました:
このバージョンでは、APIをSolid.jsのstoreという機能を参考に、ガラッと変更してより使いやすくしました。そこでこの記事では、新しくなったAPIを紹介するとともに、類似のライブラリーとして参考にした、Solid.jsのstoreと比較したいと思います。それを通じて、Svelteのstoreについて一つ問題点を紹介しますので、今後の仕様を検討する上で参考にしていただければ幸いです(前回の記事と併せて再編集し、英語版を公開しました)。
あらまし
- svelte-store-treeはv0.3.1以降、
WritableTree
とReadableTree
自身のメソッドを単純化して、代わりにAccessor
を作成したり合成したりしやすくした - 類似のライブラリーであるSolid.jsのstoreを参考にしたが、Solid.jsのstoreと異なり、svelte-store-treeは
choose
というAPIを使ってstoreの中の値を選択しなければ、union型をうまく扱えない。それは、Svelteのstoreが「storeの中の値を読むAPI」と「storeの値を更新するAPI」を一つのオブジェクトに属させていることに起因している。
使い方の例
まずは前回と同様のサンプルに沿って、新しい使い方を紹介します。以降では、こちらで動かせるサンプルアプリにおける、以下のような型を扱います:
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>): WritableTree<C>
-
Accessor
という、親P
から子C
を取得したり、親P
における子C
の値を書き換える関数を備えたオブジェクトを渡すことで、新しいWritableTree
型のstore treeを作る
-
-
zoomNoSet<C>(readChild: (parent: P) => C | Refuse): ReadableTree<C>
- 親
P
から子C
を取得する関数readChild
を渡すことで、新しいReadableTree
型のstore treeを作る-
Refuse
型については後述
-
-
ReadableTree
は、set
できなくなっているstore tree(子をzoom
メソッドで作成した場合、その子についてはset
できるので、完全にread-onlyというわけではない)
- 親
以前はオブジェクトのプロパティーに対するstore treeを作る、zoomInWritable
などのメソッドもありましたが、新しいバージョンではAccessor
を簡単に作るためのAPIを複数用意することで、zoom
とzoomNoSet
だけ使えるようにしました。ちょっとシンプルになりましたね!
サンプルアプリでは、次のようにinto
というAccessor
を作る関数を使うことで、KeyValue
型の値が持つkey
とvalue
を書き換えるためのWritableTree
を作っています:
const key = tree.zoom(into("key"));
const value = tree.zoom(into("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)
...
/svelte-store-tree/example/Tree.svelte:15:37
Error: Argument of type 'string' is not assignable to parameter of type 'never'. (ts)
...
parameter of type 'never'
とあるとおり、引数の型がnever
になっていて分かりづらいですが、これはtree
の型がWritableTree<Tree>
、つまりTree
型を含むWritableTree
となっていて、そのTree
型は先ほどの定義通りundefined
も取り得るunion型なので、keyof
の結果がnever
になってしまうためです。
これを修正するには、WritableTree<Tree>
をなんとかしてWritableTree<KeyValue>
に変換しないといけません。それを実現するのが、次の節で紹介するchoose
関数で作るAccessor
です。
choose
して、storeの値が特定の場合のみ値を取得する
choose
関数が作ったAccessor
をzoom
に渡すことで、「storeの値が特定の条件にマッチする場合だけ、subscribe
している関数を呼ぶ」store treeを作ることができます。
サンプルアプリから、choose
を使っている箇所を抜き出しましょう:
...
const keyValue = tree.zoom(choose(chooseKeyValue));
...
choose
は、引数として、「storeの中の値(上記の場合Tree
型の値)を受け取って、別の値、あるいはRefuse
を返す関数」を受け取ります。上の例におけるchooseKeyValue
ですね。実装は次の通りです:
export function chooseKeyValue(tree: Tree): KeyValue | Refuse {
if (tree === undefined || typeof tree === "string" || tree instanceof Array) {
return Refuse;
}
return tree;
}
choose
が返したWritableTree
は、引数として受け取った関数が、Refuse
という専用のunique symbol[1]を返した場合はsubscribe
している関数を呼ばず、それ以外の値を返した場合のみその値でsubscribe
している関数を呼びます。
なぜboolean
を返す関数にしないのか、あるいはundefined
を使わずRefuse
という専用の値を用意したのか、という疑問が思い浮かぶ方もいらっしゃるかも知れません。
まずboolean
を返す関数にしないのは、TypeScriptによる型の絞り込みでWritableTree
の中の型を変換するため、変換結果を関数の型で明示しないといけないからです。ユーザー定義の型ガード(戻り値の型がTree is KeyValue
のようになっている関数)は、choose
のような高階関数には使えませんでした。
それから、Refuse
という専用の値を用意したのは、{ property: T | undefined }
のような、undefined
を含むプロパティーも柔軟に扱えるようにするためです。
話を戻しましょう。先ほどtree.zoom(into("key"))
などと書いて型エラーになっていた箇所は、次のようにchoose
と組み合わせることで修正できます:
const keyValue = tree.zoom(choose(chooseKeyValue));
const key = keyValue.zoom(into("key"));
const value = keyValue.zoom(into("value"));
あとはzoom
が返した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>
...
変更点まとめ
まとめるとsvelte-store-treeのv0.3.1では、次の変更を加えました:
-
WritableTree
とReadableTree
のメソッドをzoom
とzoomNoSet
のみに絞り、代わりにAccessor
を組み立てるAPIを充実させました-
Accessor
をクラスにして、and
というメソッドで合成できるようにしました(後述します)
-
-
ReadableTree
の仕様を変えて、ReadableTree
からzoom
したstore treeは、WritableTree
を返すようにしました-
ReadableTree
を完全にset
できないようにすると、derived
で実現できるものと変わらない程度の機能になることに気づいたので、このように変更しました
-
対Solid.jsのstore
冒頭で触れたとおり、svelte-store-treeのv0.3.1は、目的がよく似ているSolid.jsのstoreという機能を参考にしています。なのでここからは、svelte-store-treeがどのような点でSolid.jsのstoreに近づいたか、また、逆に、Svelteの仕様上Solid.jsのstoreと同等に提供するのが難しい、と気づいた部分も共有することで、今後Svelteの仕様を考える上で参考になる情報が提供できれば幸いです。
Solid.jsのstoreを簡単に紹介
Solid.jsのstoreは、先ほどリンクを張ったページで「ネストしたリアクティビティに対する Solid の答えです」と謳われているとおり、入れ子の構造を部分的に更新したり、部分的にトラックしたりする仕組みです。下記のようにcreateStore
関数を実行すると、(ReactのuseState
などとうっすら似たように)storeの現在の値と、値を更新するための関数を含むペアを返します:
ℹ️この節の例はすべて公式サイトにおける例から引用しました。
const [state, setState] = createStore({
todos: [
{ task: 'Finish work', completed: false },
{ task: 'Go grocery shopping', completed: false },
{ task: 'Make dinner', completed: false },
]
});
ペアの一つ目に含まれる値(上記の例で言うところのstate
)は、Proxyでラップされています。そうすることで、storeの値や、その一部のプロパティーに対するアクセスを追跡してくれます。
それから、ペアの二つ目に含まれる関数(上記の例で言うところのsetStore
)は、次のように入れ子構造の各段階におけるプロパティーの名前や、(該当する値が配列であれば)どの要素を更新するか指定する関数などを、「パス」として各引数で指定できます:
// `todos` プロパティーにおける0番目と2番目の要素の`completed`プロパティーを`true`に
setState('todos', [0, 2], 'completed', true);
// `todos`プロパティーにおける要素のうち、
// `completed`が`true`の要素の`task`プロパティーの末尾に`'!'`をつける
setState('todos', todo => todo.completed, 'task', t => t + '!');
setState
関数一つだけで入れ子構造を何段階でも深掘りして更新できる、というパワフルな機能を備えています。
また、上記の通りSolid.jsのstoreは、createStore
関数が返すProxy
に包まれた値と、更新するための関数を分けて扱うように設計されています。Svelteのstoreは、two-way bindingとの兼ね合いもあり、基本的にset
とsubscribe
を実装したオブジェクトを一つのオブジェクトとして取り回すように作られていますので、その点が大きく異なりますよね。
どこを参考にしたのか
そんなSolid.jsのstore機能から(少し)影響を受け、svelte-store-treeはAccessor
、すなわち入れ子構造にどうアクセスするか指定するためのAPIを改善しました。具体的には、Solid.jsのstoreでは、前の節で言ったところのsetStore
関数の引数において、プロパティー名や要素を選択する関数を複数指定することで、それらを組み合わせることができると紹介しました。一方、svelte-store-treeでは、Accessor
クラスのand
というメソッドを呼ぶことで、入れ子構造へのアクセス方法を組み合わせることができます。
例えば「store
の値におけるfoo
というプロパティーの値が、undefined
でない場合のみsubscribe
した関数を呼ぶ」storeは、次のようにinto
とisPresent
というAccessor
を組み合わせることで作ることができます:
store.zoom(into('foo').and(isPresent()));
これを使えば、当記事前半で紹介したサンプルコードにおける、tree
からプロパティーkey
を取り出す処理は、次のようにも書き換えることができます:
const key = tree.zoom(choose(chooseKeyValue).and(into("key")));
なぜ、Solid.jsのstoreを見習ってzoom
メソッドの引数にAccessor
を複数渡せる形式にしなかったのかというと、zoom
の実装をシンプルにするのと、一度に何段階もAccessor
をand
で積み重ねて入れ子構造の深い位置にアクセスするのは、あまり望ましい使用方法でないだろうと考えたためです。
まず前者について詳述すると、Solid.jsの場合と異なりzoom
メソッドは引数を一つしか受け取らないので、zoom
メソッドは複数のAccessor
を組み合わせる必要がなくなり、その分処理が簡潔になります。Accessor
を組み合わせるのはあくまでand
メソッドの仕事なのです。
そして後者は、使用した場合における設計の問題です。そもそも入れ子構造に対して一度に何段階も深く潜る処理を書くのは、内部構造を切り開くようなものであり、変更に弱くなるリスクを孕んでいます。またそもそも、svelte-store-treeを開発した当初私が想定していたような、再帰的な構造は入れ子の深さが動的に変わるので、関数の引数を複数列挙することで組み合わせる方式ではうまく合成できません。
複数の引数として渡す代わりにand
メソッドで組み合わせるのは少々冗長な書き方となってしまいますが、私が期待した利用頻度と使い勝手に基づき、このような設計にしました。
どこを参考にできなかったのか
ある意味、「Solid.jsのstoreのSvelte向けバージョン」とも言えるsvelte-store-treeですが、どうしても取り入れられなかった特徴があります。それは、choose
関数によるAccessor
が必要な点です。利用方法の紹介で1セクション割いて説明したchoose
... ですが、Solid.jsのstoreに、これに相当するものがありません[2]。不要だからです。
なぜSolid.jsのstoreではchoose
のような機能が要らないのでしょうか?それは、createStore
関数が返す、storeの値を読み出すためのオブジェクト(Proxy
で包まれたstoreの現在の値)と、更新するための関数を、別々に扱うよう設計されているところにあります。
choose
が担うような、storeの値が特定の条件にマッチするよう選ぶ処理は、Solid.jsのstoreでは、単にコンポーネントの中でShow
やSwitch
/ Match
(Solid.jsのコンポーネントにおけるif
文に相当するもの)を使ってstoreの値による分岐をすれば良いのです。
これは同じことがsvelte-store-treeにも当てはまりそうにも聞こえますが、うまくいきません。choose
を最初に紹介したとき用いた例を思い出してください:
<script lang="ts">
// ...
const keyValue = tree.zoom(choose(chooseKeyValue));
const key = keyValue.zoom(into("key"));
const value = keyValue.zoom(into("value"));
// ...
</script>
{#if typeof $tree === "string"}
...
{:else if $tree === undefined}
...
{:else if $tree instanceof Array}
...
{:else}
...
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
...
{/if}
上記のコード例におけるkey
とvalue
という二つのstoreは、{#if} ... {/if}
で選択しているとおり、tree
の値がstring
でもundefined
でもArray
でもない、つまりKeyValue
型の値である場合だけ使用されます。key
とvalue
をchoose(chooseKeyValue)
を経由して取得するのは、実は{#if} ... {/if}
で行う絞り込みを、chooseKeyValue
関数でも行うことに他なりません。余分な絞り込みなのです。
そこで、どうせkey
とvalue
は上記の{:else}
節の中でしか使われないのだから、と、次のように{@const ...}
で書き換えてみたとします:
{:else}
...
{@const key = tree.zoom(into("key"))}
{@const value = tree.zoom(into("value"))}
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
...
{/if}
しかしこれでは、svelte-checkが次のようなエラーを起こします:
/svelte-store-tree/example/Tree.svelte:69:42
Error: Stores must be declared at the top level of the component (this may change in a future version of Svelte) (svelte)
storeはトップレベル[3]で宣言しなければならず、{@const ...}
のなかでstoreを新しく定義してsubscribe
することはできない、というエラーです。「特定の場合にのみ作成してsubscribe
するstore」というのは作れなくなっているんですね。
強引にこのエラーを回避するなら、<input type="text" bind:value={$key} />
などの部分を独立したコンポーネントに切り出して、そのコンポーネントがWritableTree<KeyValue>
を受け取るようにする、という手があります。しかしそうしたとしても、型エラーが残ります。上記の{#if typeof $tree === "string"}
で始まる分岐において型を絞り込めたのは、あくまでも$tree
、すなわちtree
がsubscribe
している関数に渡した現在の値であり、tree
をWritableTree<Tree>
からWritableTree<KeyValue>
に絞り込めたわけではないのです。TypeScriptはそこまでこちらの事情をくみ取ってはくれません。
以上のような問題が発生するため、少なくともSvelteにおける従来のstoreに倣うような形で件の機能を加えるのは、難しいと判断しました。
なぜこのような、storeの値だけでない、storeそのものに対する型の絞り込みが必要なのでしょうか?それは、Svelteのstoreが値を読み出すAPI(subscribe
)と、更新に用いるAPI(set
など)の両方を、一つのオブジェクトに担わせているからです。詳細について、WritableTree
を次のように簡略化して解説します:
WritableTree<T> = {
// storeの現在の値を取得する。
// 実際のstoreにはgetはなくsubscribeを使う必要があるが、
// 説明を単純化するために変更した
get: () => T;
// storeの値を更新する。これはオリジナルのstoreと同じ
set: (newValue: T) => void;
};
このWritableTree
に、例えばnumber | undefined
という型を渡して、値にundefined
を含みうるstoreを作ってみます:
WritableTree<number | undefined> = {
get: () => number | undefined;
set: (newValue: number | undefined) => void;
};
そしてこれからundefined
を取り除き、WritableTree<number>
に変換したいとしましょう。get
メソッドを使ってWritableTree<number>
から値を取得するコードは、get
がundefined
を返すことを想定していないので、get
メソッドがnumber
のみを返すよう変換しなければなりません。一方、WritableTree<number>
に対してnumber
の値を書き込むコードは、set
メソッドにnumber
の値しか渡さないので、(newValue: number | undefined) => void
という型の関数がそのまま使えるのです[4]。
したがって、型の絞り込みによって変換する必要があるのは、実はstoreの値を読み込むAPIだけなのです。Solid.jsのstoreは、storeの現在の値のみを分岐によって絞り込めば事足りる一方、svelte-store-treeは、現在の値を取得するAPIと値を更新するAPIが一つのオブジェクトに含まれている関係上、両方をひっくるめて型の絞り込みを行うAPIが必要なのです。
-
「zoom」、「choose」、「refuse」の3つで韻を踏んでいます。 ↩︎
-
⚠️Solid.jsのstoreにおいて、storeの値を関数でフィルタリングしたりする機能は、一見
choose
と似ているように見えますが、storeの値が配列である場合のみ使えます。choose
のように型の絞り込みに使うのとは趣旨が異なるのでご注意ください。 ↩︎ -
<script>
ブロックにおけるトップレベルのことのようです。 ↩︎ -
この違いは「反変・共変」として知られる関係で、かのプロを目指す人のためのTypeScript入門でも解説されています。 ↩︎
Discussion