🐥

Vue.js 3でコンテクストを用いて状態管理する

2020/09/23に公開

TL;DR

  • inject/provideとslotでcontextを作れる
  • pc/spで同じロジック&違うテンプレートを扱う時に状態を共有したい時とか有用

先日Vue 3.x系のRCが外れ正式にリリースされたということで、みなさんゴリゴリcomposition apiを書いていることかと思います。

https://github.com/vuejs/vue-next/releases/tag/v3.0.0

ここでは、3系になりより扱いやすく強力なAPIとなったprovide/injectを用いて、コンポーネント間の状態管理を行う方法を紹介します。概念的にはReact Contextとほぼ同じようなものなので、目新しいものではありません。また、この機能はsetupプロパティを通してのみ値の取り出しや加工ができるため、Option APIを引き続き使う場合は注意してください。

provide/injectのおさらい

Vue 3になりprovideinjectは関数として外部へ公開され、さらにリアクティブな値でも扱うことができる汎用的な状態管理のツールとなりました。これらを簡単に説明すると、以下のようになります。

  • provideは引数で受け取ったキーとそれに紐づく値を、呼び出したコンポーネントのインスタンスに保存する
  • injectは引数で受け取ったキーを使い、provideで登録された値を取り出す

provideはキーとそれに紐づく値を引数として受け取り、injectはそのキーを用いて保存した値を自由に取り出すことができます。保存できる値には関数やリアクティブな値を含みます。また、injectは呼び出したコンポーネントの一番近くにある親インスタンスから順にキーを探して値を見つけ、取り出します。つまり、適切に保存する場所を決めることができれば、共有を許したいコンポーネントからのみ値を操作することができます。

例えば以下のようにエントリーポイントとなるmain.js(ts)でprovideすれば、すべてのコンポーネントで登録した値をinjectによって取り出すことができます。

main.ts

import { createApp, ref } from 'vue'
import App from './App.vue'

const key = 'count' // 一意なキーを生成する
const value = ref(0)

const app = createApp(App)
app.provide(key, value) // app.provideかvueからimportしたprovide()で登録
app.mount('#app')

components/ReactiveCounter.vue

<script lang="ts">
import { defineComponent, inject } from 'vue'

export default defineComponent({
  name: 'ReactiveCounter',
  setup() {
    const count = inject('count')
    const plusOne = () => count.value++
    return {
      count,
      plusOne
    }
  }
})
</script>

<template>
  <div>{{ count.value }}</div> // カウントアップされる
  <button  @click="plusOne">+</button>
</template>

provideする位置を調整することで、親子関係にないコンポーネントからの参照を防げます。これはVuexのようなどこからでも参照可能な単一の巨大な状態とは対となるような概念です。

この記事で紹介したように、毎回使いたいコンポーネントでinjectしても良いですが、今回はさらにVueのrender propとも言えるslotと組み合わせてContextのようなものを作ってみます。ContextはReactではすでに広く利用されている機能で、コンポーネントpropのバケツリレーを回避する手段として有用です。

テーマ用のContextを作成してみる

injectやprovideによる状態管理はすでにスマートなものですが、あと少し工夫することでより共有状態へのアクセス可能性を明示的にすることができたり、propsを介して初期化を行うことができるようになります。

今回はReact Contextの章にあるようなテーマを切り替えるContextを作成します。まずcomposables/theme.tsに以下のような関数を定義します。

import { InjectionKey, inject, Ref, provide, readonly } from 'vue';

// 1
export type Theme = 'light' | 'dark';
export type ThemeRef = Ref<Theme>;

// 2
export const ThemeSymbol: InjectionKey<ThemeRef> = Symbol();

// 3
export function provideTheme(theme: ThemeRef) {
  provide(ThemeSymbol, theme);
}

// 4
export function useTheme() {
  const t = inject(ThemeSymbol);
  if (!t) {
    throw new Error('useTheme() is called without provider.');
  }

  const theme = readonly(t);
  const setTheme = (theme: Theme) => (t.value = theme);
  return { theme, setTheme };
}
  1. テーマ型を作ります
  2. provideに渡すキーを生成します。今回の場合InjectionKeyは不要ですが、provideに渡す値の型を縛れます
  3. テーマの初期値を登録するprovide関数です。コンポーネントの方で直接provideしてもかまいません
  4. 現在のテーマ情報と、テーマを変化させる専用の関数を提供する関数です。テーマは関数を介してのみ変更することができます

次に、ThemeContextコンポーネントを作成します。ThemeContextは実際にはproviderにpropsで得たテーマ文字列を渡すだけのコンポーネントで、テンプレートにはslotのみを持つようにします(デフォルトスロットになる)。こうすることで、ThemeContextの子要素になったコンポーネントはuseThemeから必要な値を取り出すことができるようになります。

<script lang="ts">
import { defineComponent, PropType, ref } from 'vue';
import { provideTheme, Theme } from '../composables/theme';

export default defineComponent({
  name: 'ThemeContext',
  props: {
    theme: {
      type: String as PropType<Theme>,
      default: 'light',
    },
  },
  setup(props) {
    provideTheme(ref(props.theme));
  },
});
</script>

<template>
  <slot />
</template>

実際に使ってみましょう。その時のテーマによって見た目が変わる簡単なボタンを用意します。useThemeを使って現在のテーマを親インスタンスから取得するようにします。

BaseButton.vue

<script lang="ts">
import { defineComponent } from 'vue';
import { useTheme } from '../composables/theme';

export default defineComponent({
  name: 'BaseButton',
  setup() {
    function handleAlert(text: string) {
      alert(text);
    }
    return {
      ...useTheme(),
      handleAlert,
    };
  },
});
</script>

<template>
  <button :class="theme" @click="handleAlert(theme)">button</button>
</template>

<style scoped>
button {
  width: 120px;
  height: 60px;
}
.dark {
  background: #000;
  color: #fff;
}
.light {
  color: #000;
}
</style>

これをThemeContextでラップします。

<script lang="ts">
import ThemeContext from './components/ThemeContext.vue';
import BaseButton from './components/BaseButton.vue';

export default {
  name: 'App',
  components: {
    ThemeContext,
    BaseButton,
  },
};
</script>

<template>
  <ThemeContext theme="dark">
    <BaseButton />
  </ThemeContext>
</template>

'dark'を渡したのでこのボタンコンポーネントはダークテーマ用のスタイルを適用します。'light'を渡せばライトテーマ用のスタイルを使います。Atomsレベルの基礎となるコンポーネントはこれを応用すれば比較的簡単にダークテーマの対応ができます。セットしたテーマを変更したい場合は更新用の関数を使いましょう。

dark時

light時

余談ですが、veturがpropsの型をチェックするようになったので今回のように'dark' | 'light'型をpropsに付与するとしっかりオートコンプリートしてくれるようです。

最後に

inject/provideをうまく活用すれば、参照範囲を限定した状態の共有が可能になります。コンテクストを作ることでより扱いやすいように抽象化でき、範囲も明示的になるため使いこなしていきましょう。

Discussion