🧊

Composition APIを使って感じた「これどうなの?」

2022/01/05に公開

はじめに

Vue.js のバージョン 3 系から導入された新しいコンポーネント形式として、「Composition API」があります。
昨年業務の中で、Composition API を使ったコードを書いてきましたが、その中でいくつも「これどうなの?」となることがたくさんありました。
そんな「これどうなの?」を自分の中での整理を含めて書いていきたいと思います。

Composition API とは

Composition API を知らない方にざっくりとだけ伝えると、従来の Options API で切り離すことのできなかったリアクティブ値とリアクティブ値に関連した処理を UI コンポーネント(View)から切り離すことのできる技術です。
簡単に例を書きます。

Options API
export default {
  data: () => ({
    count: 0,
  }),
  methods: {
    increment() {
      this.count++;
    },
    decrement() {
      this.count--;
    },
  },
  computed: {
    double() {
      return this.count * 2;
    },
  },
};

上記が Options API のコードになります。
こちらのコードの問題として

  • this を経由してリアクティブな状態にアクセスする必要があるため、リアクティブな状態を変更するロジック、変更を受けて行われる計算系メソッドが View から切り離せない
  • View が肥大化していく
  • ロジックと View に関するコードが混ざる

といったことがあります。
一番大きなポイントは、「リアクティブ値を View から分離できないため、ロジックも切り出せない」という問題です。
その分離が可能になるのが Composition API です。

Composition API
// import 文は省略

export default defineComponent({
  setup() {
    const state = reactive({
      count: 0,
    });

    const increment = () => ({
      state.count++;
    });

    const decrement = () => ({
      state.count--;
    });

    const double = computed(() => {
      return state.count * 2;
    });

    return {
      state,
      increment,
      decrement,
      double,
    }
  }
})

Composition API では setup 関数内で state、プロパティ、メソッド、ライフサイクルを設定します。
ここまでだと書き方の違いになりますが、Composition API における恩恵はここからで、Composition API では上記から state, ロジックを合成関数として切り出すことができます。
「Composition Function」 と呼ばれるもので、下記がその例です。

useCount
// import 文は省略

export const useCount = () => {
  const state = reactive({
    count: 0,
  });

  const increment = () => ({
    state.count++;
  });

  const decrement = () => ({
    state.count--;
  });

  const double = computed(() => {
    return state.count * 2;
  });
  return {
    state,
    increment,
    decrement,
    double,
  }
}

上記が Composition Function です。
こちらは Vue ファイル上で下記のように使えます。

Test.vue
// import 文は省略

export default defineComponent({
  setup() {
    const { state, increment, decrement, double } = useCount();

    return {
      state,
      increment,
      decrement,
      double
    };
  },
});

かなり vue ファイルがスッキリしたかと思います。
上記のように行うことで

  • ロジックが分離されるので再利用性を高められる
  • View とロジックが密にならない
  • (テストがプレーンな JS/TS をテストするだけになるので、ユニットテストの実装も捗るらしい、が、ユニットテストを書くに至らなかったので恩恵えれず。)

といった恩恵が得られます。

mixin との違いを考える

Vue には mixin なるものも存在します。
参考:ミックスイン
こちらも Composition API と同様に、コンポーネントから再利用可能なロジック、data を切り出すことが可能です。
mixin の例

mixin
export default {
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    increment() {
      this.count ++;
    },
  },
};
Test.vue
import CountMixin from "~/XXXXX";

export default {
  mixins: [CountMixin],
};

上記のように記載することで、mixin で定義した共通なオプション(data, methods, computed, life cycle)を component 内で使用ができるようになります。
Vue3 がリリースされた現在、mixin はサポートが続けられていますが、Composition API を使用することが推奨されています。
これは mixin について検索するとほぼ必ず出てくる下記の記事に書かれているデメリットが原因です。
俺がやらかした Vue mixin のアンチパターンから学ぶ mixin の使い方と代替案
簡単にかいつまんで記事に記載されたデメリットを書くと

  • 暗黙的挙動が読み取れない
    mixin 内部で呼び出されたライフサイクルを起点としたメソッドの実行、メソッドの Override 時の注意、mixin で勝手に増える this で参照できる値 ...etc
  • 名前競合
    mixin で宣言している変数、メソッドが component で宣言する変数、メソッドと競合しうる
  • component に依存する箇所がある
  • mixin で記載する this は component での this と同じなので、結局完全に疎にはなれない

といったところです。
Composition API も mixin と似たような機能にはなりますが、上記のデメリットをそこそこ解決できるような気がしています。
Composition API では Composition Function で定義したデータ、メソッドを component 内の setup 関数内で注入する必要があります。
そのため、暗黙的な値(mixin で定義され、this で参照できる値などがない)、名前競合も発生しづらい(注入されているので、名前競合した変数、メソッドも拾える)です。
(component への依存については上手く composable を作れるかの手腕になると思います。)
mixin を正直それほどにがっつり使ったことがないのであまり確実なことは言えませんが、上記のような理由で Composition API の方を使うように Vue としても進めていきたいのかなと思います。

Provide/Inject と Composition API

ついでに、Composition API と切り離せない Vue の機能、Provide/Inject についても軽く説明をします。
Composition API では「状態」と「ロジック」をコンポーネントの外側へ切り出すことに成功しました。
この「状態」を子のコンポーネントで仕様したいと思います。
以下はその簡単な例です。

useCounter
// import 文は省略

export const useCounter = () => {
  const state = reactive({
    count: 0,
  });

  const increment = () => ({
    state.count++;
  });

  return {
    state,
    increment,
  }
}
CountDisplay.vue
<template>
  <div>{{ count }}</div>
</template>

<script lang="ts">
// importは省略

export default defineComponent({
  setup() {
    const { state } = useCounter();
    return {
      ...toRefs(state),
    };
  },
});
</script>
CountIncrementButton.vue
<template>
  <button @click="increment">+</button>
</template>

<script lang="ts">
// importは省略

export default defineComponent({
  setup() {
    const { increment } = useCounter();
    return {
      increment,
    };
  },
});
</script>
Name.vue
<template>
  <CountDisplay></CountDisplay>
  <CountIncrementButton></CountIncrementButton>
</template>

<script lang="ts">
// importは省略

export default defineComponent({
  components: {
    CountDisplay,
    CountIncrementButton,
  },
  setup() {
    return {};
  },
});
</script>

さて、上記のようにコードを書いた時にボタンを押下した時にどうなるかですが、表示されている「count」は変わりません。
これはそれぞれのコンポーネントで import、注入を行ったことで、各々の「state」がインポートされているためです。
(コンポーネント毎にインスタンスが生成されているようなイメージ)
この「状態」(state)の共有をするための手段がProvide/Injectです。
実際のコードから見ていきましょう。

useCounter
export const useCounter = () => {
  // 省略
}

type CounterStore = ReturnType<typeof useCounter>
export const CounterKey: InjectionKey<CounterStore> = Symbol('CounterStore');

Composition Function からキーを定義、export します。

Name.vue
<template>
  <CountDisplay></CountDisplay>
  <CountIncrementButton></CountIncrementButton>
</template>

<script lang="ts">
// importは省略

export default defineComponent({
  components: {
    CountDisplay,
    CountIncrementButton,
  },
  setup() {
    // provide
    provide(CounterKey, useCounter());
    return {};
  },
});
</script>

続いて、親のコンポーネントで Provide します。
こうすることで、この親コンポーネントから下の子、孫コンポーネントでは同様の「状態」を見ることができるようになります。

CountDisplay.vue
<template>
  <div>{{ count }}</div>
</template>

<script lang="ts">
// importは省略

export default defineComponent({
  setup() {
    // エラーハンドリングを一旦無視
    const counterStore = inject(CounterKey)!;
    return {
      count: counterStore.state.count,
    };
  },
});
</script>
CountIncrementButton.vue
<template>
  <button @click="increment">+</button>
</template>

<script lang="ts">
// importは省略

export default defineComponent({
  setup() {
    // エラーハンドリングを一旦無視
    const counterStore = inject(CounterKey)!;
    return {
      increment: counterStore.increment,
    };
  },
});
</script>

これにより、状態を親子間での共有ができるようになりました。
また、今まで props/emit のリレーで行ってきた親 ⇄ 子間のデータのやりとりについても Provide/inject で props/emit を使わずとも実現もできるようになりました。

本題

Composition API についてつらつら書いたところで、本題の「これどうなの?」を書いていきます。

事例の前に、結論

先に結論から。
私が「これどうなの?」となったことは、全て Composition API のメリットでありデメリットが引き起こしたことです。
Composition API におけるメリットでありデメリットは、 「自由度の高さ」 です。
Composition API ではどのようなロジック、data でも切り出すことが可能です。
が、どのようなロジック、data が切り出せる可能な反面、

  • Composition Function で切り出す単位をどのようにすべきか
  • SFC、Vuex だけで収まっていた世界をどのように線引きするか

を明確にしないと、完全無秩序、ジャングルのようなコードが生まれてしまいます。
Composition API は今まで敷かれていた Vue の作法、しきたりから抜け出すことと同じだからです。
ジャングルを生み出さないためには、 「設計」「アーキテクチャ」 という力が必要だと思います。
この自由さ故に様々な疑問が生まれ、私は何度も悩まされることになりました。

これどうなの?

ようやく本題です。

①ref vs reactive

Composition API ではリアクティブな値を宣言するのに

  • ref
  • reactive

の 2 つが使えます。
下がそれぞれの例です。

// importは省略

export default defineComponent({
  setup() {
    // ref example
    const count = ref(0);

    // reactive example
    const state = reactive({
      count: 0,
    });

    return {};
  },
});
</script>

ref については Ref によって値をラップするため「.value」をつけて参照、更新する必要がある、reactive についてはプリミティブな値を許容しない、といった軽い差はありますが、どちらもリアクティブを実現するという文脈では同様なものに思えます。
ひとまず内部的な実装を見ていくことにします。

ref

// 省略
export function ref(value?: unknown) {
  return createRef(value, false);
}

// 省略
function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue;
  }
  return new RefImpl(rawValue, shallow);
}

// 省略
class RefImpl<T> {
  private _value: T;
  private _rawValue: T;

  public dep?: Dep = undefined;
  public readonly __v_isRef = true;

  constructor(value: T, public readonly _shallow: boolean) {
    this._rawValue = _shallow ? value : toRaw(value);
    this._value = _shallow ? value : toReactive(value);
  }

  get value() {
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    newVal = this._shallow ? newVal : toRaw(newVal);
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      this._value = this._shallow ? newVal : toReactive(newVal);
      triggerRefValue(this, newVal);
    }
  }
}

参考:vuejs/vue-next ref

※vue3 系からは Vue2 までとは異なるリポジトリで管理されている
簡単に順序を説明すると
①createdRef を呼ぶ
②value が既に ref ではない場合、RefImpl オブジェクトを作成、返却する
となっています。
get/set 内部の実装についての詳細については省略します(というより track や trigger の先がやんわりとしか理解しきれていないので)が、この RefImpl で定義されている_value を get/set を用いることでリアクティブを実現しています。
(get/set についてはJavaScript に新しく導入された accessor property についてあたりを参照してください。)
※ちなみに ref は一般的にはプリミティブな値を入れますが、Object、Array も入れることができます。
_shallow を見て toReactive で reactive に変換をしているようです。

続いて reactive を見ていきます。

reactive

// 省略
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target;
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  );
}
// 省略
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`);
    }
    return target;
  }
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target;
  }
  // target already has corresponding Proxy
  const existingProxy = proxyMap.get(target);
  if (existingProxy) {
    return existingProxy;
  }
  // only a whitelist of value types can be observed.
  const targetType = getTargetType(target);
  if (targetType === TargetType.INVALID) {
    return target;
  }
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  );
  proxyMap.set(target, proxy);
  return proxy;
}

参考:vuejs/vue-next reactive

ref との大きな違いは先に述べていますが、

if (!isObject(target)) {
  if (__DEV__) {
    console.warn(`value cannot be made reactive: ${String(target)}`);
  }
  return target;
}

とあるように、Object ではないプリミティブな値は許容されないこと、また、Proxy が返されることが大きな違いです。
Proxy については簡単に説明すると、

  • Proxy オブジェクト自体は別のオブジェクトをラップする
  • ハンドラを設定することで、読み取り/書き込みの操作をインターセプトする

というものになります。
そのため、reactive は実際のところ、オブジェクトを返しているのではなく、Proxy を返しています。

さて、内部的な実装を見たところで結論ですが、実装でどちらを使うかは、 案件などでルール決めするしかない と思います。
理由としては、ref はプリミティブな値をリアクティブとするのに使用することが推奨されてはいるが、結局のところ Object も Array も取り回しはできてしまう、reactive も Object でプリミティブな値を取り回せば ref を使わずとも済むといえば済むので、どちらかだけでも実装はうまくいきます。
そのため、結局は個人や案件単位でどうするべきかを方針を決めるしかないのかなと思います。
Composition API をしばらく使ってきた中での個人的な意見としては、基本的には ref を使うで良いでいいと思っています。
理由は何点かあって

①Computed で返される値は readonly な ref
記事が肥大化するので内部実装については掲載しませんが、Computed で返される値は readonly な ref でラッピングされた値です。
また、こちらの Computed な値は返される値が Object や Array の場合でも ref でラッピングされています。
②「XXX.value」で一目でリアクティブな値を取り回していることが認識できる
ref は参照、更新の際には Ref でラッピングがされているため、「.value」をつけてアクセスが必要です。
煩わしさもある一方、「.value」が付いているのであれば、リアクティブな値を取り回していると認識することもできると思っています。
※ここは好みの問題な気もしています。
基本的に reactive にすれば.value を書かなくて良いので記述量は減ります。
また、Vue3 公式では ref、 reactive を使い分けるか基本的に reactive を使うと過去に記載がありました。
③reactive は分割代入ができない
reactive は toRefs を使用しない場合、分割代入をしてしまうとリアクティブが損なわれます。
この toRefs は各プロパティを ref にしているので、であればそもそも ref でもいいのではないのか、、、?と考えさせられます。

上記のような理由で、基本的には ref を取り回していくのでも良いかなと思いました(あくまで個人的な意見です、ぜひ反論や意見が欲しいと思います)。

②util と Composition Function の切り分け

Composition API を使うことで様々なロジックを共通で使えるように切り出しが可能になりました。
その時、Composition Function と util(共通関数) ってどんな区分けをしたらいいのだろうか?というところが疑問になりました。
こちらも極論どのようにしてもいいと言えばいいです。util をまとめた Composition Function を作ろうが、util として関数を 1 つ 1 つ定義、export してもコードは動きます。
実際にやってみた中での個人的な感想は

  • Composition Function は state を含んだ場合。
  • util は util として存続させる

とすると良いかなと思いました。
理由としては、Composition Function と util とを明確に分類したいからです。

  • ディレクトリがカオスにならない
    util を Composition Function にすると Composition Function を配置する composable 配下に util ディレクトリを置く必要があるが、そこってネストさせなくても良いのでは?と個人的に思います
  • 単一の関数を export したい時にわざわざ Composition Function とする必要があるのか?
    また、関数を Composition Function としてまとめる単位を考えたり、Composition Function の命名を考える時間が面倒では?

上記のような理由で、明確に区分けをした方がいいかなと思っています。

③Vuex をどうすべきか

状態管理で Vuex を導入することがありますが、Composition API、Provide/inject を使って Vuex を代替することが可能です。
Vuex は状態がグローバルになってしまうため、特定画面でしか使わない状態なのにもかかわらず、どこからでも参照・書き換えが可能となるという状況になります。
Composition API、Provide/inject を使えば、Provide を行うコンポーネントを制御することで、上記のグローバルになるという問題の解決をできます。
また、Vue であれば app.ts、Nuxt であれば default.vue などで Provide を行うことで、グローバルで取り回したい値も定義できます。

使った所感、また、個人的な意見としては、できる範囲は Composition API を使ったストアパターンにすると良いのかなと思いました。

  • グローバルで取り扱うほどでもない状態をスコープを決めて制御できるのが良い
  • Vuex の action が mutation を呼ぶだけになっていることがあり、あまり好みでない

というところが理由です。
ただ、Vuex を使わないことのデメリットもあります。

  • Composition API を使ったストアパターンのガイドラインを作る必要がある
    Vuex を導入すれば、その箇所の工数がいらなくなる、また、Vuex 的なクリーンなアーキテクチャにはなる
  • Vue devtools での Vuex デバック機能が使えなくなる
    state などを追いづらい
  • Provide/inject を使うとコンポーネントの再利用性が下がる
    store に対して依存関係が生まれてしまうため

上記のようなデメリットもあるので、Composition API だから Vuex を使わないとすぐに決定するのも難しいと思います。

④ グローバルな状態を使った Composition Function

Composition Function を使ったストアパターンを実装するとき、Provide/inject を使わずに、グローバルな状態を使った Composition Function を使えば同様のことを実現できます。
まずは例を示しましょう。

testCompositionFunction
// import 文は省略

// 外部に切り出した状態
const state = reactive({
  count: 0,
});

export const useCount = () => {
  // いろんな処理
  return {
    state,
    ...
  }
}

上記のコードとこの記事で書いてきた Composition Function との大きな違いは状態が Composition Function の外部で定義されていることです。
Provide/inject を使う理由として、それぞれのコンポーネントで import、注入を行うため、別の状態を参照する事になると記載しましたが、上記のコードでは状態が外部に切り出されたことで 1 度のみ生成され、その後再生成されることがないため、常に共通の状態を保てる →Provide/inject と同様のことを実現できるというわけです。
この方法は Vue School でも記載されています。
参考リンク:State Management with Composition API
Provide/inject を使わずとも状態が共有できるのでコードの記述量も減りそうで良さそうな雰囲気がありますが、現実に実装をした中では下記のような問題に当たりました。

  • state がグローバルである故、初期化処理を適切にできていないと別のページなどで同様の状態を参照すると過去の状態が残ってしまっている
  • Provide/inject をしなくてよくなる一方で、全ての子孫コンポーネントで import、注入が必要になってそれはそれで煩わしい
  • Vuex を採用しなかったのに、結局親子間で状態を共有するためだけに使うのグローバルな変数が生まれてるなどした

state の取り回しが楽になるというところはよかったとも思いましたが、個人的にはせっかく強力な機能があるのだから、Provide/inject を使う方が良いかなと個人的には落ち着きました。

おわりに 結局何が言いたかったのか

結論でも書きましたが、結局のところ、 「設計がきちんとできないと Composition API の使用は難しい」 というところに行き着くかなと思いました。
以前業務でコードを書いていたときは、Nuxt の Vue2 系をガリガリ書いていたため、SFC&Vuex という優しい世界の中で生きていたのだな、、、と一時期しみじみしたりしてました。
また、自身の FE の設計/アーキテクチャの無知さ、学習の重要度も学べました。
Composition API は未だベストプラクティスが完全にはない気がします(ちょくちょく記事は出るが、それが正しいと言えないと思っています)。
そんなところも Composition API の難しさなのかな、と思いました(というより、設計やアーキテクチャの難しさなのかなとも)。

Discussion