LoginSignup
282

More than 1 year has passed since last update.

2020年後半版Vue.jsを使ってる人には必ず知っていてほしいドキュメントに書かれていないComposition APIのコードの書き方とVuex vs provide/inject

Last updated at Posted at 2020-08-27

まえがき

最近iCAREさんの所で Vue.js を一緒にやらせていただいているのですが、フロントの技術スタックがかなりモダンであり、開発体験が良く、ノウハウをどんどん公開して良いと言っていただけたので、その内容を共有するシリーズです.
今回の記事の内容はiCareさんのDev Meetupで話した内容になります.

最近公式のソースを追った所、 Composition API はapiの紹介はあれども、コードの書き方やその背景、Tips等は全然見当たりませんでした.
また、すごく強力なapiである provide/inject の紹介も全然見当たりません.
今回はiCAREさんの所で Vue.js を書く際に皆で意識している Composition API と活用している provide/inject のノウハウとその詳細な理由を共有します.

※ 直接編集リクエストをくれていつもありがとうございます. いつも通り記事の内容に意見がありましたら直接編集リクエストをください.

Composition API のコードの書き方

はじめに

Composition API のソース・コードは、ルールを守って書いた上だと可読性・再利用性・凝集性を向上させれるポテンシャルがありますが、ベストプラクティスを十分学ぶ前に自由に書いた場合のコードではそれらのメリットが失われおり、むしろ悪化する事があります.
ここでは銀の弾丸ではない Composition API を利用する上で必要な知識を学んで行きます.

用語

React hooks を利用して作られた Component の中で利用する為の関数の事を Custom Hooks と呼びます.
Composition API を利用して作られた関数は、rfc では composition function と呼ばれていましたので、その様に呼んでいきます. (一応Vue.js公式イベントでも composition function と読んでいたのでこれで良さそう

ソース: https://vue-composition-api-rfc.netlify.app/api.html

Composition APIのコンセプト

Composition API のコンセプトは、提供されているAPIを利用して composition function を作成していき、作成した composition function 達を組み合わせて、その Componentrender関数 に必要な最低限の event handler や、 reactive な文言等のコンテキストを決定します.
これ自体は既出の一般的なアイディアであり、テクニックです.

setup

Vue 3.x では ComponentOptionssetupメソッド を追加できます.
Composition API を利用するには setupメソッド を実装する必要があります.
Vue 2.xComposition API を利用するためには npm に登録されている @vue/composition-apiinstall し、Composition API を利用するための準備をします.

import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)

その後 Vue 3.x のように Composition API を使いたい場所で defineComponent 関数を利用すれば、ComponentOptionssetupメソッド を実装できます.
以下が実装例です.

import { defineComponent } from '@vue/composition-api'

export default defineComponent({
  name: 'HelloWorld',
  props: {
    counter: {
      type: Number,
      required: true,
    },
  },
  setup(props, context) {
  }
})

setupメソッドの解説

setupメソッド の第1引数には解決された props が渡されます.
setupメソッドthis には undefined が束縛されています.
this の代わりに第2引数の context が提供されています.
context の中には以前 this でアクセスしていた多くの項目が存在します.
以下は @vue/composition-api@1.0.0-beta.10 で提供されている setupメソッド の型定義です.

declare type SetupFunction<Props, RawBindings = {}> = (this: void, props: Props, ctx: SetupContext) => RawBindings | (() => VNode | null) | void;

interface SetupContext {
  readonly attrs: Record<string, string>;
  readonly slots: {
    [key: string]: (...args: any[]) => VNode[];
  };
  readonly parent: ComponentInstance | null;
  readonly root: ComponentInstance;
  readonly listeners: {
    [key: string]: Function;
  };
  emit(event: string, ...args: any[]): void;
}

write composition function code for setup

今回サンプルコードではカウンターアプリを作ってみます.

まずは Composition API を使わないで書いた例です.

import Vue from 'vue'

export default Vue.extend({
  template: `
<div>
  <div>count: {{ count }}</div>
  <button @click="inc">inc</button>
  <button @click="dec">dec</button>
  <button @click="reset">reset</button>
</div>
  `,
  name: 'SampleCounter',
  props: {
    countNum: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      count: this.countNum
    }
  },
  methods: {
    set(count) {
      this.count = count
    },
    inc() {
      this.count++;
    },
    dec() {
      this.count--;
    }
  },
  watch: {
    count(count) {
      this.$emit('change', count)
    },
    countNum(count) {
      this.set(count)
    }
  },
})

今までの書き方だとn箇所でカウントロジックを利用したい場合、 mixin を作って再利用をするのは1つの手段です.
しかし mixin が抱えている問題を持ち込みたくない自分みたいな人だと、n箇所に似たようなコードを書く方を選択します.

Composition API を利用すれば、mixin の課題を解決した composition function を作成できます.
以下が例です.

import { reactive, computed } from '@vue/composition-api'

function useCounter(initial = 0) {
  const state = reactive({ count: initial });
  return {
    count: computed(() => state.count),
    set: count => {
      state.count = count
    },
    inc: () => {
      state.count++;
    },
    dec: () => {
      state.count--;
    }
  };
}

Composition API のおかげでカウンターに関連するコードを纏めることができました.
以下が useCounter の使用例です.

defineComponent({
  template: `
<div>
  <div>count: {{ count }}</div>
  <button @click="inc">inc</button>
  <button @click="dec">dec</button>
</div>
  `,
  name: 'SampleCounter',
  props: {
    countNum: {
      type: Number,
      default: 0
    }
  },
  setup(props, context) {
    const counterContext = useCounter(props.countNum)
    watch(() => props.countNum, count => counterContext.set(count))
    watch(counterContext.count, count => context.emit(count))
    return counterContext
  },
})

n箇所でカウントロジックを利用したい場合、今回作成した composition function をn箇所で使う事ができます.
mixin が抱えていた衝突等の問題は一切ありません.

Composition APIの幾つかの注意事項

こちらのドキュメントを見てみます.
https://composition-api.vuejs.org/#basic-example

Lifecycle Hooksの項目に書かれている一文を紹介します.

These lifecycle registration methods can only be used during the invocation of a setup hook. It automatically figures out the current instance calling the setup hook using internal global state.

これの意味は以下の通りです

これらのライフサイクル登録メソッドは、setupフック の呼び出し時にのみ使用できます。内部のグローバル状態を利用して、 setupフック を呼び出している現在のインスタンスを自動的に把握します。

つまり、onMounted 等のライフサイクルに登録する関数は setupメソッド の実行時にしか利用できず、setTimeout で非同期に、もしくは watch 等で非同期に登録することはできません.
これと同じ制約をもっているapiをもう1つ紹介します.
それは、 provide/inject です.
これも内部のグローバル状態に保持されている現在のインスタンスを利用するapiです.

composition functionの注意事項

幾つかの Composition APIsetupフック の呼び出し時にのみ使用できるapiがあります.
それらを 利用している/将来的に利用する 予定のある composition function も同様に setupフック の呼び出し時以外では使用できなくなります. (もし setupフック の呼び出し時以外で composition function を呼出した場合、その composition function は将来に新たに追加された setupフック 呼出し時でしか使えない便利なapi又は同様の制約を抱えている関連ライブラリ等も全て利用できなくなるということです.

関数を利用する際に、作られた関数のインターフェースは理解する必要がありますが、程度によりますが、基本的には詳細な実装を全て追ってない状態で、この様な関数は利用されても大丈夫です.
以上のことから、 composition function は、 setupフック の呼び出し時にのみ使用する事で、不用意なバグや事故を回避し、恩恵を受け続けることができます.
恩恵を受け続ける為に、 setupフック の中でのみ、 composition function を使用するようにすべきです.
つまり、 setTimeoutcomputed, watchevent handler の中等で composition function を呼出してはいけません.

setupフック呼出し後に生成させるrefやreactive等

setupフック 呼出し完了後に refreactive 等を生成する事は可能です.
例えば、computed の中で ref を作成したり、reactive を作成することが可能です.
コードレビューしているとその様なコードでは refreactive が必要ない場合が全てでした.
setupフック の呼び出し完了後に refreactive を作らなければ実装できない物は今の所は存在しないし、今後も存在しないはずです.

制限のまとめ

制限をまとめると、setupフック の呼出し時に computedメソッド 等の参照する値は全てそれまでに束縛されており、 setupフック 呼出し完了後に束縛対象が消えたり、新たに産まれたり、 composition function が呼び出されたりする事はないということです.

以下は準拠している例と違反している例です.

まずは、準拠している例です.
これは共有される counter を利用している例です.
setupフック の呼出し時に全ての依存の束縛が完了しており、その後に composition function を呼出したりしていません.
これは正しく動作します.
CodeSandboxはこちらです.
https://codesandbox.io/s/vue-composition-api-forked-1q916

Image from Gyazo

import Vue from "vue";
import VueCompositionApi from "@vue/composition-api";
import { inject, provide } from "@vue/composition-api";
import { defineComponent, reactive, computed } from "@vue/composition-api";

Vue.use(VueCompositionApi);

function createContext(contextName = "") {
  const contextKey = Symbol(contextName); // Reflect.ownKeys をサポートしていない環境もサポートする場合はuuid生成のライブラリをベースにkeyの構築をする方が良いかも
  return [
    contextValue => provide(contextKey, contextValue),
    () => inject(contextKey)
  ];
}

const [useSharedCounterProvider, useSharedCounter] = createContext(
  "SharedCounter"
);

function useCounter(initial = 0) {
  const state = reactive({ count: initial });
  return {
    count: computed(() => state.count),
    set: count => {
      state.count = count;
    },
    inc: () => {
      state.count++;
    },
    dec: () => {
      state.count--;
    }
  };
}

const providerComponentFactory = (providerHooks, providerName = "") =>
  defineComponent({
    template: "<div><slot/></div>", // Vue 3.xではfragmentが使える
    name: providerName,
    setup() {
      useSharedCounterProvider(providerHooks());
    }
  });

// useCounterの実行結果をprovideするコンポーネントの作成
const SharedCounterProvider = providerComponentFactory(
  useCounter,
  "SharedCounterProvider"
);

const SampleInjectCounter = defineComponent({
  template: `
<div>
  <div>count: {{ count }}</div>
  <button @click="inc">inc</button>
  <button @click="dec">dec</button>
</div>
  `,
  name: "SampleInjectCounter",
  setup() {
    return useSharedCounter();
  }
});

new Vue({
  components: {
    SharedCounterProvider,
    SampleInjectCounter
  },
  template: `
<shared-counter-provider>
  <sample-inject-counter/>
  <sample-inject-counter/>
  <shared-counter-provider>
    <sample-inject-counter/>
  </shared-counter-provider>
</shared-counter-provider>
  `,
  name: "SampleApp"
}).$mount("#app");

次は event handlercomposition function を呼出している例です. (違反例
これは event handlerError を吐きます.
CodeSandboxはこちら
https://codesandbox.io/s/vue-composition-api-forked-onjbj

invalidInc をクリックすると以下のように Error がでます.
Image from Gyazo

const InvalidSampleInjectCounter = defineComponent({
  template: `
<div>
  <div>invalidComputed: {{ invalidComputed }}</div>
  <button @click="invalidInc">invalidInc</button>
  <button @click="invalidDec">invalidDec</button>
</div>
  `,
  name: "InvalidSampleInjectCounter",
  setup() {
    const { count } = useSharedCounter();
    return {
      invalidComputed: computed(() => count.value ** 2),
      invalidInc: () => useSharedCounter().inc(),
      invalidDec: () => useSharedCounter().dec()
    };
  }
});

Errorメッセージ はこの通りで、この Error は想定通りです. [Vue warn]: inject() can only be used inside setup() or functional components.

Composition APIのまとめ

基本的に Composition API を利用すると今までの Option API にあった制限やスコープの影響で正常動作しないリスクのあったいくつかの手法が解禁されます.
それらの手法を使わない場合は Option API の方が上手く書ける場合は多々あります.
今までの Option API で良くも悪くも慣れている状況下で Composition API をチームでいきなり使いこなすには超えるハードルが多く、その為チーム一丸となって段階的に浸透させる戦略等をチームに合う形で考える事が必要かもしれないです.

Vuex vs provide/inject

共有ステートを作成する時に、Vue.js公式 からは幾つかの手段が用意されています.
以下がその一覧です. (他にもあるかも

  1. Vue.observable
  2. Vuex
  3. dataを共有する専用の Vue.js インスタンスの作成
  4. provide/inject

共有ステートを作成したい要望がある時、最近だと Vue.observable も有力な選択肢に入ってきます.
しかし、Vue.observablestateglobal に作成して利用する場合は、singleton であり、singleton が持つ新たな課題を産みます. (例えば、同一プロセスでのテストの同時実行数への制限や、各テストで singleton の初期化を忘れない、差し替え難易度等... etc

例:

// これはsingletonなglobal stateで新たな課題を産みます.
export const sharedCounter = Vue.observable({
  count: 0
})

以前だと多くのプロジェクトで Vuex を使うか使わないかの話になる場合が多いです.
Vuex は分離できない単一の大きなモジュールのコードを複数人で書き、作成していく為、複数人で単一のモジュールを開発する文脈由来の課題が発生し、品質維持の難易度が高いです. Vuex を初めて導入した後の多くのプロジェクトでは可能なら多くのコードを書き直したいと考えているチームは多いはずです.

provide/inject は小さく分離できる小さなモジュールをアプリケーション 全体/部分 で簡単に共有できるものであり、provideするものをある程度まで小さくすれば影響範囲の把握がしやすいです.
provide/inject の乱用もよくありませんが、認証情報、環境依存情報、一部のコンポーネント専用の情報(Form等)等の共有を実現するのに役立ちます.
当然 singleton でもないですし、テストも容易です.
provide/inject は基本的にはVuexより手軽にできると思います.

これは先程と同じコードですが、 provide/inject のデモとして十分であるため、再利用させていただきます.

CodeSandboxはこちらです.
https://codesandbox.io/s/vue-composition-api-forked-1q916

Image from Gyazo

stateの共有関係はこの色同士です.
Frontend Architecture@2x (2).png

import Vue from "vue";
import VueCompositionApi from "@vue/composition-api";
import { inject, provide } from "@vue/composition-api";
import { defineComponent, reactive, computed } from "@vue/composition-api";

Vue.use(VueCompositionApi);

function createContext(contextName = "") {
  const contextKey = Symbol(contextName); // Reflect.ownKeys をサポートしていない環境もサポートする場合はuuid生成のライブラリをベースにkeyの構築をする方が良いかも
  return [
    contextValue => provide(contextKey, contextValue),
    () => inject(contextKey)
  ];
}

const [useSharedCounterProvider, useSharedCounter] = createContext(
  "SharedCounter"
);

function useCounter(initial = 0) {
  const state = reactive({ count: initial });
  return {
    count: computed(() => state.count),
    set: count => {
      state.count = count;
    },
    inc: () => {
      state.count++;
    },
    dec: () => {
      state.count--;
    }
  };
}

const providerComponentFactory = (providerHooks, providerName = "") =>
  defineComponent({
    template: "<div><slot/></div>", // Vue 3.xではfragmentが使える
    name: providerName,
    setup() {
      useSharedCounterProvider(providerHooks());
    }
  });

// useCounterの実行結果をprovideするコンポーネントの作成
const SharedCounterProvider = providerComponentFactory(
  useCounter,
  "SharedCounterProvider"
);

const SampleInjectCounter = defineComponent({
  template: `
<div>
  <div>count: {{ count }}</div>
  <button @click="inc">inc</button>
  <button @click="dec">dec</button>
</div>
  `,
  name: "SampleInjectCounter",
  setup() {
    return useSharedCounter();
  }
});

new Vue({
  components: {
    SharedCounterProvider,
    SampleInjectCounter
  },
  template: `
<shared-counter-provider>
  <sample-inject-counter/>
  <sample-inject-counter/>
  <shared-counter-provider>
    <sample-inject-counter/>
  </shared-counter-provider>
</shared-counter-provider>
  `,
  name: "SampleApp"
}).$mount("#app");

この provide/inject は公式ドキュメントにも書かれていますが、ReactContext API と酷似しています.
Vuex vs provide/injectの話をもし深追いしたい場合は、Redux vs React ContextGoogle検索 すれば、期待以上の沢山の有意義な議論を目にすることが可能です.

iCARE では Vuex を使わずに、 provide/inject を積極的に活用しています.

最後に

如何だったでしょうか?
Vue 3.x がリリースされ、jsxfunctional component が使いやすくなる日が待ち遠しいですね.
FragmentTeleportSuspense 等も凄く楽しみです

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
282