Vue.js composition-api を使った開発でおすすめしたいこと

7 min read読了の目安(約6800字

はじめに

Vue.js はバージョン 3 がリリースされたこともあり、バージョン 2.x 系を使っていてもプラグインを利用して composition-api を使った開発をしている人が多いと思います。

私も composition-api が公開されて比較的早い段階から利用してきました。結果、使い方が私の中で固まったので共有したいと思います。

なお、比較的大規模かつ、長期的にメンテナンスしたいようなプロダクトを想定しています (大体そうだと思いますが)。

プロトタイピングや寿命の短いソフトウェアなら、ここで説明することはあまり合わないかもしれません。
また、Typescript の利用を前提としています。

composition-api と Options API について

composition-api 自体についてを知りたい場合は以下を御覧ください。

https://composition-api.vuejs.org/

私が composition-api を利用して最もよいと思ったことはリアクティブな値に依存するロジックとUIコンポーネント (View) の密結合を解消できることです。

ロジックの分離、再利用のためにコンポーネント志向開発では高階コンポーネント (HOC) などが利用されてきましたが、これはUIの持つ振る舞い (状態やイベントによってトリガーされる更新処理) を分離しようと思ってもコンポーネントとするしかなかったから(仕方なく)生じた構図だと理解しています。

本来は、リアクティブな状態、それを変更するロジック、そしてリアクティブな値に応答して表示を変更してくれる View がそれぞれ存在してくれれば十分だったはずです。

しかしながら、Options API では this を経由してコンポーネントが利用するリアクティブな状態にアクセスするというインターフェースであったため、これを切り離すのがとても難しかったです。
また、あるリアクティブな値から算出される値の生成もVueコンポーネントの computed 属性に生やす必要がありましたし、変更に対して処理をトリガーするにも watch 属性に生やす必要がありました。
リアクティブな値を用いたロジックというのはコンポーネントが持つ興味の一部に過ぎないにもにも関わらず、リアクティブな値を用いたロジックの実装はコンポーネントから切り離せない状態でした。

composition-api ではリアクティブな値は refreactive で生成できますし、これらから算出される値は computed で生成できます。

また、Vueのコンポーネントをコンポーネントとしてテストすることはできますが、プレーンなJSやTSのモジュールをテストするほうが簡単なので、ユニットテストの実装も捗ります。

推奨する構成

前章の通り composition-api で得られることは、リアクティブな値を用いたロジックをコンポーネントから分離できることにあると考えています。

そのため、Vueコンポーネントとリアクティブな値を用いたロジックを別のモジュールで実装することを推奨します。
加えて、ロジックの中でステートレスなロジックがあればそれも別のモジュールで実装することを推奨します。

ここまでで、以下の3つの要素が登場しています。

  • ステートレスなロジック (リアクティブな値に依存しないロジック)
  • ステートフルなロジック (リアクティブな値を用いたロジック)
  • Vueコンポーネント (多くの場合は SFC)

それぞれについて掘り下げていきます。

ステートレスなロジック

普通のロジックです。
基本的にはここを厚くなるように実装することをおすすめします。
ここには refreactive などを始めとした Vue.js に由来するリアクティブな値などの情報はもたせません。
DDDなどの文脈で言うところの Domain のレイヤに相当すると思います。

例えば TODO メモのアプリを作っているのなら、 TODO の概念とそれに関するステートレスな操作をまとめる感じです。
また、永続化層へのアクセスが有る場合は、そのクライアントのインターフェースもここで宣言しておくことをおすすめします。

export type Status = "UNDO" | "DOING" | "DONE"
export type Todo = {
  text: string;
  status: Status;
}
export type SaveTodoApiClient = (todos: Todo[])=> Promise<Todo[]>
export type LoadTodoApiClient = ()=> Promise<Todo[]>

export const make = (text: string)=> ({
  text,
  status: "UNDO"
})

export const changeState = (status: Status)=> (todo: Todo)=> ({
  ...todo,
  status
})

export const toUndo = changeState("UNDO")
export const toDoing = changeState("DOING")
export const toDone = changeState("DONE")

ステートフルなロジック

リアクティブな値を変更したり、変更をウォッチして実現するようなロジックを実装するケースです。

ステートの宣言

リアクティブな値のパターンで以下の2つがあります。

  • コンポーネントごとに独立した値
  • コンポーネント間で共有する値

それぞれの実装方法について説明します。

コンポーネントごとに独立した値

リアクティブな値らを返すような関数内でリアクティブな値の宣言をします。
以下の場合は useTodos がコールされるたびに新しい todos が生成されます。

const useTodos = ()=> {
  const todos = ref<Todo[]>([])
  return {
    todos
  }
}

そのため、以下のようになります。

describe("demo", ()=> {
  test("test", ()=> {
    const { todos: todos1 } = useTodos()
    const { todos: todos2 } = useTodos()
    todos1.value.push(make("text"))
    expect(todos1.value).not.toEqual(todos2.value)
  })
})

コンポーネント間で共有する

モジュール内でリアクティブな値を宣言をします。

これをそのまま export しても使えますが、コンポーネントごとに独立した値の取得と同じインターフェースのほうがわかりやすいので、同様に生成関数から返すほうが良いと思っています。
また、共有状態はダイレクトにプロパティを変更されないほうがよいことが多いので readonly な状態にするのが良いと思います。

const todos = ref<Todo[]>([])
const addTodo = (text: string)=> todos.push(make(text))
export const useTodoStore = ()=> {
  return {
    todos: readonly(todos),
    addTodo
  }
}

前述の通り、useTodoState で返されるオブジェクトは毎回同じ参照です。
そして、addTodo で変更されるのも同じ参照なので、コンポーネントなどの間で同じ状態を見ることになります。

describe("demo", ()=> {
  test("test", ()=> {
    const { todos: todos1, addTodo } = useTodos()
    const { todos: todos2 } = useTodos()
    addTodo("text")
    expect(todos1.value).toEqual(todos2.value)
  })
})

ステート変更関数の実装方針

ここでは状態を変更する関数の実装について説明します。前述の addTodo のような関数です。
状態を変更する関数 (副作用を発生させる関数) はテストなどを行う際に厄介になることが多いです。

また、変更される可能性がある値がわからないと意図しない挙動につながることもあるので、そのあたりを意識したインターフェースにするのが大切です。

以下を推奨します。

  • リアクティブな値の宣言は ref のみを利用し、 reactive は使わない
  • 参照するだけのリアクティブな値は readonlycomputed でラップする
  • リアクティブな値は部分適用する
  • useXXX という名前で定義するいわゆるコンポジション関数内では、リアクティブな変数宣言と関数への注入のみを行う

順番に説明をします。

リアクティブな値の宣言は ref のみを利用する

理由は簡単で、リアクティブな値であることが明らかだからです。

逆に reactive を使いたい理由は、リアクティブな値であることを意識しないでリアクティブな値を使いたいということだと思います。
このケースはなんとなく理解ができて、JSで開発をしている時に value プロパティが生えているかどうかを意識するのがめんどくさい。みたいなことなんだと思います。
Typescriptならこのデメリットは存在しませんので、問題ないと思います。
それよりも値の構造的にリアクティブであるかどうかが明らかであるメリットのほうが大きと思います。

参照するだけのリアクティブな値は readonlycomputed でラップする

これも理由は簡単で変更する必要がないからです。
関数の型定義の時点で、読み取り専用のリアクティブな値であることを明示するのは、利用者に意図を明確に伝えるために良いことだと思っています。
これはステートレスな関数でも同様で、オブジェクトなど参照の引数に対して変更をしないことを表明することはよいことだと思います。

リアクティブな値は部分適用する / コンポジション関数ではリアクティブな変数宣言と注入のみを行う

この2つの推奨事項は関連するので一緒に説明します。
まず、どうするといいのか?という点ですが、前述した addTodo を例に説明します。

addTodo は以下のような関数でした。

const addTodo = (text: string)=> todos.push(make(text))

これを以下のようにします。

export const addTodo = (todos: Ref<Todo[]>) => (text: string) => todos.push(make(text))

以下のようにコンポジション関数から公開します。

const todos = ref<Todo[]>([])
export const addTodo = (todos: Ref<Todo[]>) => (text: string) => todos.push(make(text))
export const useTodoStore = ()=> {
  return {
    todos: readonly(todos),
    addTodo: addTodo(todos)
  }
}

この例は共有状態を変更するケースですが、コンポーネントごとに独立した値でも同様にすることを推奨します。

このようにすることで、格段に addTodo の単体テストが書きやすくなります。
加えて、リアクティブな状態と呼び出し時に利用する値が明らかになりわかりやすいです。

これは、computed でも同様です。
例えば、 UNDO の個数を返すような値を算出するとしましょう。

const todos = ref<Todo[]>([])
// 説明の都合でここに書きますが
// この関数はステートレスなモジュールに配置されているべきです
const statusIs = (status: Status) => (todo: Todo) => todo.status === status
const isUndo = statusIs("UNDO")

const numOfUndo = (todos: Ref<Todo[]>) => ()=> todos.value.filter(isUndo).length
export const useTodoStore = ()=> {
  return {
    todos: readonly(todos),
    numOfUndo: computed(numOfUndo(todos))
  }
}

このように関数を組み合わせてロジックが作れることは composition-api を使う大きな理由になると思います。

Vue コンポーネント

Vueコンポーネントでは以下に集中させます

  • UIのデザイン
  • リアクティブな値とUIのバインド
  • リアクティブな値の変更関数とUIのバインド
  • 依存関係の解決

ここまで説明してきたように、ステートレスなロジックを利用して、ステートフルなロジックを組み上げて、これをコンポジション関数から公開するような実装をします。

Vue コンポーネントでは必要な状態を提供するコンポジション関数を setup でコールして、状態と状態を変更する関数をテンプレートにバインドすることに集中します。

また、あるコンポジション関数が外部のリアクティブな値に依存することがあります。
これらの依存関係の解決も行います。

<script lang="ts">
export default defineComponent({
  setup(){
    const { valA } = useA();
    const { valB } = useB(valA)
    return {
      valB
    }
  }
})
</script>

それ以外に、コンポジション関数がAPIクライアントなどに依存している場合も、ここで依存関係を解決します。
このとき、外部通信などに関する依存関係は inject / provide で注入することを推奨します。
ここでは詳しく触れませんが、 storybook などを利用する場合などコンポーネント単体で動作させる時に、APIクライアントなどを任意な値を返すものを provide することで、特定のユースケースの動作確認をしやすくなります。

終わりに

もしかしたら、『こういう書き方はVue.jsの良さを損なっている。React.jsでやれ』みたいなことを思う方もいるかも知れません。
ですが、ここで言う『Vue.jsの良さ』を全面に出したような書き方もできるし、今回説明したような書き方どちらもできると言うのが、プログレッシブなフレームワークを謳う Vue.js の真骨頂だと思っています。
composition-api のリリースでこのことをより強く実感しました。

提供される道具を上手に利用してスケールする Vue.js アプリケーションを作りましょう。