🚉

なぜSvelte風Solid.jsのstoreは作れないか、およびsvelte-store-treeの新バージョンの紹介

2022/10/31に公開

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)をリリースしました:

svelte-store-tree - npm

このバージョンでは、APIをSolid.jsのstoreという機能を参考に、ガラッと変更してより使いやすくしました。そこでこの記事では、新しくなったAPIを紹介するとともに、類似のライブラリーとして参考にした、Solid.jsのstoreと比較したいと思います。それを通じて、Svelteのstoreについて一つ問題点を紹介しますので、今後の仕様を検討する上で参考にしていただければ幸いです(前回の記事と併せて再編集し、英語版を公開しました)。

あらまし

  • svelte-store-treeはv0.3.1以降、WritableTreeReadableTree自身のメソッドを単純化して、代わりに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を複数用意することで、zoomzoomNoSetだけ使えるようにしました。ちょっとシンプルになりましたね!

サンプルアプリでは、次のようにintoというAccessorを作る関数を使うことで、KeyValue型の値が持つkeyvalueを書き換えるための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関数が作ったAccessorzoomに渡すことで、「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が返した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>
...

該当箇所

変更点まとめ

まとめるとsvelte-store-treeのv0.3.1では、次の変更を加えました:

  • WritableTreeReadableTreeのメソッドをzoomzoomNoSetのみに絞り、代わりに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との兼ね合いもあり、基本的にsetsubscribeを実装したオブジェクトを一つのオブジェクトとして取り回すように作られていますので、その点が大きく異なりますよね。

どこを参考にしたのか

そんなSolid.jsのstore機能から(少し)影響を受け、svelte-store-treeはAccessor、すなわち入れ子構造にどうアクセスするか指定するためのAPIを改善しました。具体的には、Solid.jsのstoreでは、前の節で言ったところのsetStore関数の引数において、プロパティー名や要素を選択する関数を複数指定することで、それらを組み合わせることができると紹介しました。一方、svelte-store-treeでは、Accessorクラスのandというメソッドを呼ぶことで、入れ子構造へのアクセス方法を組み合わせることができます。

例えば「storeの値におけるfooというプロパティーの値が、undefinedでない場合のみsubscribeした関数を呼ぶ」storeは、次のようにintoisPresentというAccessorを組み合わせることで作ることができます:

store.zoom(into('foo').and(isPresent()));

これを使えば、当記事前半で紹介したサンプルコードにおける、treeからプロパティーkeyを取り出す処理は、次のようにも書き換えることができます:

const key = tree.zoom(choose(chooseKeyValue).and(into("key")));

なぜ、Solid.jsのstoreを見習ってzoomメソッドの引数にAccessorを複数渡せる形式にしなかったのかというと、zoomの実装をシンプルにするのと、一度に何段階もAccessorandで積み重ねて入れ子構造の深い位置にアクセスするのは、あまり望ましい使用方法でないだろうと考えたためです。

まず前者について詳述すると、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では、単にコンポーネントの中でShowSwitch / 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}

上記のコード例におけるkeyvalueという二つのstoreは、{#if} ... {/if}で選択しているとおり、treeの値がstringでもundefinedでもArrayでもない、つまりKeyValue型の値である場合だけ使用されます。keyvaluechoose(chooseKeyValue)を経由して取得するのは、実は{#if} ... {/if}で行う絞り込みを、chooseKeyValue関数でも行うことに他なりません。余分な絞り込みなのです。

そこで、どうせkeyvalueは上記の{: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、すなわちtreesubscribeしている関数に渡した現在の値であり、treeWritableTree<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>から値を取得するコードは、getundefinedを返すことを想定していないので、getメソッドがnumberのみを返すよう変換しなければなりません。一方、WritableTree<number>に対してnumberの値を書き込むコードは、setメソッドにnumberの値しか渡さないので、(newValue: number | undefined) => voidという型の関数がそのまま使えるのです[4]

したがって、型の絞り込みによって変換する必要があるのは、実はstoreの値を読み込むAPIだけなのです。Solid.jsのstoreは、storeの現在の値のみを分岐によって絞り込めば事足りる一方、svelte-store-treeは、現在の値を取得するAPIと値を更新するAPIが一つのオブジェクトに含まれている関係上、両方をひっくるめて型の絞り込みを行うAPIが必要なのです。

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

  2. ⚠️Solid.jsのstoreにおいて、storeの値を関数でフィルタリングしたりする機能は、一見chooseと似ているように見えますが、storeの値が配列である場合のみ使えます。chooseのように型の絞り込みに使うのとは趣旨が異なるのでご注意ください。 ↩︎

  3. <script>ブロックにおけるトップレベルのことのようです。 ↩︎

  4. この違いは「反変・共変」として知られる関係で、かのプロを目指す人のためのTypeScript入門でも解説されています。 ↩︎

GitHubで編集を提案

Discussion