Vue 2.xのOptions APIからVue 3.0のComposition APIへの移行で知っておくと便利なTips

ヘッダー画像

こんにちは。ECプラットフォーム部のMA(マーケティングオートメーション)アプリケーションチームで、社内向けのマーケティング運用ツールを開発している長澤(@snagasawa_)です。

先日、日本時間の2020年7月18日にVue 3.0のRelease Candidate(v3.0.0-rc.1)がリリースされ、今後は最終リリースまで主要なAPIのbreaking changeは想定していないとのアナウンスがされました。アナウンスを受け、現在社内ツールで進めているOptions APIからComposition APIへの移行で得られたTipsについて紹介します。

この記事では公開時点でのVue 3.0 betaへのアップグレードの方法と、Vue + TypeScriptでのOptions APIからComposition APIへの移行のTipsについてまとめました。Vue 3.0へのアップグレードを検討されている方、またはComposition API単体での導入を検討されている方の参考になりましたら幸いです。なお、あくまで公開時点での情報であるため、今後は更新される可能性があることをご留意ください。

Vue CLIによるVue 3.0 betaへのアップグレード方法

Vue本体のアップグレード

初めにVue CLI(v4.5.3)で利用可能(または提供予定)なVue 3.0 betaへのアップグレードの方法を紹介します。npmかyarnでvue@nextをインストールすることもできますが、Vue 3.0 betaを試すためのプラグインをインストールする方法もあります。

github.com

vue add vue-next

このコマンドではvue-cli-plugin-vue-nextというプラグインをインストールします。

このプラグインではVue本体だけでなく、Vuex, Vue Routerもアップグレードされます。また、コードが自動で書き換えられ、src/main.ts, src/store/index.ts, src/router/index.tsがそれぞれのbetaのAPIに書き換えられます。以下はサンプルコードのdiffです。

diff --git a/src/main.ts b/src/main.ts
index e9c1f28..39f0f54 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,12 +1,6 @@
-import Vue from 'vue';
+import { createApp } from 'vue';
 import App from './App.vue';
 import router from './router';
 import store from './store';

-Vue.config.productionTip = false;
-
-new Vue({
-  router,
-  store,
-  render: (h) => h(App),
-}).$mount('#app');
+createApp(App).use(router).use(store).mount('#app');
diff --git a/src/router/index.ts b/src/router/index.ts
index 25a46df..f6e682c 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -1,9 +1,6 @@
-import Vue from 'vue';
-import VueRouter, { RouteConfig } from 'vue-router';
+import { RouteConfig, createRouter, createWebHashHistory } from 'vue-router';
 import Home from '../views/Home.vue';

-Vue.use(VueRouter);
-
 const routes: Array<RouteConfig> = [
   {
     path: '/',
@@ -20,7 +17,8 @@ const routes: Array<RouteConfig> = [
   },
 ];

-const router = new VueRouter({
+const router = createRouter({
+  history: createWebHashHistory(),
   routes,
 });

diff --git a/src/store/index.ts b/src/store/index.ts
index 9ea7685..73b4b8d 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -1,9 +1,6 @@
-import Vue from 'vue';
 import Vuex from 'vuex';

-Vue.use(Vuex);
-
-export default new Vuex.Store({
+export default Vuex.createStore({
   state: {
   },
   mutations: {

このように一括でのアップグレードとコードの自動変換を行ってくれますが、あくまでトライアウト用のプラグインのようです。issueのコメントによるとVue CLIのvue createコマンドでVue 3を選択可能になった現在はこのプラグインが不要になるとのことでした。開発も最終コミットが5月でそれ以降は中断されているようで、このプラグインの最新バージョンのv0.1.3ではVue 3.0 betaのバージョンが最新ではないことに注意してください。

とはいえ、既存プロジェクトでの自動コード変換は現状では他の手段で提供されていないようなので、そのために利用してみるのもよいかもしれません。

マイグレーションヘルパー

次に、マイグレーションヘルパーの提供が予定されていますが、こちらは8月21日現在では開発中のため利用できません。アップグレードが急ぎでない、もしくは比較的規模が大きいプロダクトは、こちらの完成を待つのが得策かもしれません。

Where should I start in a migration? Start by running the migration helper (still under development) on a current project.

v3.vuejs.org

過去のv1.xからv2.0へのマイグレーションヘルパーではdeprecatedな記法をwarningとして出力できます。おそらく、これに近いものが提供されるものと予想しています。

以上の方法でVue 3.0 betaやパッケージのアップグレードの一部を簡略化できます。最終リリース以降には開発中のマイグレーションヘルパーを含めて、より便利なアップグレード方法が提供されることを期待したいですね。

Vue 3.0 へアップグレード可能かどうかの判断

前述のようにアップグレードのサポートは提供されているとはいえ、Vue 3.0へのアップグレードはGlobal APIの書き換えが必要なため、影響範囲はプロダクト全体に及びます。当然のことながら、アップグレード後のプロダクトの正常な挙動を担保しなければなりませんが、中〜大規模であった場合にQAのリソースの確保が難しくなることも予想されます。

以下の観点をクリアできている場合には、Vue 3.0へのアップグレードも可能でしょう。

  • Vue 3.0に未対応のパッケージを利用していない
  • テストコードが十分に書かれている
  • プロダクトの規模がそれほど大きくない、あるいはQAのリソースを十分に確保できる

一方でVue 3.0へのアップグレードが困難ではあるものの、Vue 3.0 betaの新APIを採用したい場合はComposition APIをプラグインとしてその一部をコンポーネント単位で利用することもできます。ここからは冒頭で触れた社内ツールの開発において進めているComposition APIへの移行について紹介します。

Vue 3.0 betaへのアップグレードを断念した理由

社内ツールでComposition APIを採用した一方で、Vue 3.0 betaへのアップグレードは一旦断念することになりました。理由としては利用しているVuetifyがVue 3.0に未対応だったためです。なお、VuetifyのRoadmapでは2020 Q3/Q4での対応を予定しているとのことです。

Composition APIのメリットと導入理由

Composition APIのメリットはいくつかありますが、特に採用の決め手となったのはロジックの関心ごとにコードをまとめやすくなることでした。

v2.xのOptions APIの課題は、コードがコンポーネントのthisに依存するため関心ごとの単位でまとめることが難しく、Options APIのoptions(data, computed, methodsなど。以下: v2 options)にコードが分散しがちであることでした。

実際に開発している社内ツールでは、入力フォームの画面でフィールドが入れ子になるようなケースが複数ありました。フォームは複数のコンポーネントに分割し、関数はシンプルな実装を心がけていましたがコードの分散は避けられませんでした。そのため、機能追加や修正を行う際にコードの中からしばしば該当のコードを探す必要があり、致命的ではないものの可読性に課題感を持っていました。

そんな状況でComposition APIのRFCを読み、実装を試してみたところ手応えがあったため、この課題を解消すべく採用を決めました。

Options APIからComposition APIへの書き換え

ここからはサンプルコードを交えて、コンポーネントをOptions APIからComposition APIに書き換えていく際のTipsについて紹介します。

インストール方法はGitHubリポジトリのREADMEにある通りなので省略します。

書き換えの順番

Composition APIへの移行で念頭に置いておきたいことはOptions APIと併用可能という点です。

これはコンポーネント内の依存関係を考慮して順番に書き換えを進めることで、そのすべてが終わらなくとも、途中でテストコードや動作確認で正しい挙動を確認しつつ進められることを意味しています。特に大きなコンポーネントの際に、すべてを書き換え終えたつもりでようやく動作確認を始めたものの、正常に動作せずバグ修正に苦心するという事態を避けられるかもしれません。

併用した場合の処理の順番はComposition APIのsetupが先に呼ばれ、そのあとv2 optionsが解決されます。setup内ではthisundefinedでなおかつv2 optionsの前に呼ばれるため、setup内からv2 optionsのプロパティにはアクセスできません。逆にsetupでreturnしたプロパティはv2 optionsからアクセスできます。したがって、移行の流れとしてはコンポーネント内で他の実装に依存していないものからsetup内に移していくとよさそうです。

以下が順番の例です。順番には絶対解はなく、ある程度入れ替え可能です。

  1. Vue.extend -> defineComponent
  2. data -> reactive
  3. methods -> Functions
  4. v2.x computed -> v3.0 computed
  5. emit -> context.emit
  6. props -> setup(props)
  7. Composition Function

サンプルコードは去年のアドベントカレンダーで書いた記事のリポジトリを元にOptions APIで実装し直したものです。

github.com

<template>
  <div>
    <div>
      <input v-model="taskName" type="text" />
      <button @click="addTask">Add</button>
    </div>
    <div><input v-model="searchText" type="text" />Search</div>
    <div class="task-list-wrapper">
      <ul>
        <h4>DOING</h4>
        <li v-for="(task, index) in doingTasks" :key="index">
          <input type="checkbox" :checked="task.status" disabled />
          <label>{{ task.name }}</label>
          <button @click="toggleTask(task, true)">toggle</button>
        </li>
      </ul>
      <ul>
        <h4>COMPLETED</h4>
        <li v-for="(task, index) in completedTasks" :key="index">
          <input type="checkbox" :checked="task.status" disabled />
          <label>{{ task.name }}</label>
          <button @click="toggleTask(task, false)">toggle</button>
        </li>
      </ul>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import { Task } from '../types';

interface Data {
  taskName: string;
  searchText: string;
  tasks: Task[];
}

export default Vue.extend({
  data: (): Data => {
    return {
      taskName: '',
      searchText: '',
      tasks: [],
    };
  },
  computed: {
    doingTasks(): Task[] {
      return this.searchedTasks.filter(t => !t.status);
    },
    completedTasks(): Task[] {
      return this.searchedTasks.filter(t => t.status);
    },
    searchedTasks(): Task[] {
      return this.tasks.filter(t => t.name.includes(this.searchText));
    },
  },
  methods: {
    addTask() {
      this.tasks.push({
        name: this.taskName,
        status: false,
      });
      this.taskName = '';
    },
    toggleTask(task: Task, status: boolean) {
      const index = this.tasks.indexOf(task);
      this.tasks.splice(index, 1, { ...task, status: status });
    },
  },
});
</script>
<style scoped>
.task-list-wrapper {
  display: flex;
  justify-content: center;
}
</style>

Vue.extend -> defineComponent

初めにVue.extenddefineComponentに変更します。

Options APIでTypeScriptの型推論のために Vue.extend が必要だったのと同様、Composition APIでもdefineComponentが必要になります。

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

-export default Vue.extend({
+export default defineComponent({
+  setup() {
+  },

空のsetupを定義してv2 optionsの挙動を確認した後、順次このsetupにコードを移していきます。

data -> reactive

次にdataをreactive関数に書き換えます。

dataのinterfaceはreactiveの型引数として渡します。

returnでは...toRefs(state)のように書くのがオススメです。stateをそのままreturnすることもできますが、その場合はtemplateやv2 optionsでstate.xxxのように書き換える必要があります。

また、toRefsに渡さずspread operatorで展開する場合はstateがリアクティブではなくなってしまうため、この場合はtoRefsが必須です。

 export default defineComponent({
   setup {
-  data: (): Data => {
-    return {
+    const state = reactive<Data>({
       taskName: '',
       searchText: '',
       tasks: [],
+    });
+
+    return {
+      ...toRefs(state),
     };
   },

methods -> Functions

methodsは関数をsetupに切り出し、thisstateに変えてreturnで返すだけです。

 export default defineComponent({
   setup() {
     const state = reactive<Data>({
       taskName: '',
       searchText: '',
       tasks: [],
     });

+    const addTask = () => {
+      state.tasks.push({
+        name: state.taskName,
+        status: false,
+      });
+      state.taskName = '';
+    };
+
+    const toggleTask = (task: Task, status: boolean) => {
+      const index = state.tasks.indexOf(task);
+      state.tasks.splice(index, 1, { ...task, status: status });
+    };
+
     return {
       ...toRefs(state),
+      addTask,
+      toggleTask,
     };
   },
   computed: {
@@ -60,19 +75,6 @@ export default defineComponent({
       return this.tasks.filter(t => t.name.includes(this.searchText));
     },
   },
-  methods: {
-    addTask() {
-      this.tasks.push({
-        name: this.taskName,
-        status: false,
-      });
-      this.taskName = '';
-    },
-    toggleTask(task: Task, status: boolean) {
-      const index = this.tasks.indexOf(task);
-      this.tasks.splice(index, 1, { ...task, status: status });
-    },
-  },
 });

v2.x computed -> v3.0 computed

Composition APIのcomputedはgetter関数を引数にとり、computedプロパティを返します。

こちらも書き換えはシンプルでmethodsとほぼ同じ要領です。

以下がその2つの比較です。

  • Options API
  computed: {
    searchedTasks(): Task[] {
      return this.tasks.filter(t => t.name.includes(this.searchText));
    },
    doingTasks(): Task[] {
      return this.searchedTasks.filter(t => !t.status);
    },
    completedTasks(): Task[] {
      return this.searchedTasks.filter(t => t.status);
    },
  },
  • Composition API
    const searchedTasks = computed(() => {
      return state.tasks.filter(t => t.name.includes(state.searchText));
    });

    const doingTasks = computed(() => {
      return searchedTasks.value.filter(t => !t.status);
    });

    const completedTasks = computed(() => {
      return searchedTasks.value.filter(t => t.status);
    });

今回はシンプルなアプリのため、ここまでの変更でv2 optionsはすべてsetup内に書き換えられました。

ここまでの変更に少し手を加えたバージョンのソースコードは以下で確認できます。

github.com

emit -> context.emit

emitはsetupの第2引数のcontextから呼び出します。

以下の例はTaskを表示する列をコンポーネント化したコードです。

github.com

注意点として、emitcamelCaseでは呼び出せなくなっており、kebab-caseで呼び出す必要があります。

export default defineComponent({
  props: {
    title: { type: String, required: true },
    tasks: { type: Array as () => Task[], required: true },
  },
  setup(props, context) {
    const toggleTask = (task: Task) => {
      context.emit('toggle-task', task);
    };

    return {
      toggleTask,
    };
  },
});

props -> setup(props)

propsの定義は上の例のようにOptions APIの時と変わらず、setupの第1引数として受け取ることができます。setupの引数に型を指定せずともpropsの型推論が効きます。

Composition Function

ここまでの説明でコンポーネント内の主要なAPIの書き換えの流れはおおよそ掴むことができるかと思います。最後にComposition APIによって実装可能なComposition Functionを実装する際のTipsを紹介します。

Composition Functionとは、関連するロジックでまとめられ、カプセル化された関数のことです。Composition APIによってthisへの依存がなくなり、コードは関心ごとでまとめられるようになりました。まとめられた関数は純粋なJavaScript or TypeScriptの関数として抽出し、他のコンポーネントで再利用しやすくなります。

Notice how all the logic related to the create new folder feature is now collocated and encapsulated in a single function. The function is also somewhat self-documenting due to its descriptive name. This is what we call a composition function.

vue-composition-api-rfc.netlify.app

methodsやcomputedの関数化

methodscomputedを関数でラップ(あるいはカリー化)しておくと、Composition Functionが作りやすくなり、関数合成がしやすくなるというメリットがあります。

下は先述のcomputedを関数化した例です。

    const searchedTasks = ((tasks, text) =>
      computed(() => {
        return tasks.value.filter(t => t.name.includes(text.value));
      }))(toRef(state, 'tasks'), toRef(state, 'searchText'));

    const doingTasks = (tasks =>
      computed(() => {
        return tasks.value.filter(t => !t.status);
      }))(searchedTasks);

    const completedTasks = (tasks =>
      computed(() => {
        return tasks.value.filter(t => t.status);
      }))(searchedTasks);

先ほどのcomputedの例に戻って関数化していない例をもう一度見てください。setup内であれば関数化しない実装も可能ですが、その場合は他の実装に依存します。例のcomputedをComposition Function化する場合は、state.taskssearchedTasksへの依存を関数化した時と同じように引数への変更が必要です。その点、関数化しておくと変更が少なくて済みます。

以下がComposition Functionとして抽出した例です。

import { computed, Ref } from '@vue/composition-api';
import { Task } from '@/types';

export default function useFilter(tasks: Ref<Task[]>) {
  const doingTasks = computed(() => tasks.value.filter(t => !t.status));
  const completedTasks = computed(() => tasks.value.filter(t => t.status));

  return {
    doingTasks,
    completedTasks,
  };
}

逆に関数化をしない方がパッと見ではシンプルでわかりやすいというメリットがあるため、Composition Function化の可能性が低い場合は2番目のような実装に留めておくのがよさそうです。

引数の型はRef<T>

Composition Functionや関数化された関数で実装する時に、リアクティブな引数を受け取る場合の型定義はどうすべきか疑問に浮かぶかもしれません。

引数次第では推論でRef<T>ComputedRef<T>などになる可能性があり、一見すると使い分けるか、もしくはいずれかで統一するといった選択の余地がありそうに見えます。 結論としてはRef<T>で統一すればよさそうですが、理解を深めるために型定義を確認してみましょう。

Ref<T>refreactiveなどでリアクティブ化されたオブジェクトで、 ComputedRef<T>computedが返すオブジェクトの型です。

ComputedRef<T>WritableComputedRef<T>をextendsしていますが、WritableComputedRef<T>Ref<T>をextendsしているため、引数の型定義と渡される引数の組み合わせがいずれでもTypeErrorにはなりません。

したがって基本となるRef<T>で統一するとよいでしょう。

interface ComputedRef<T = any> extends WritableComputedRef<T> {
    readonly value: T;
}
interface WritableComputedRef<T> extends Ref<T>{
}
interface Ref<T = any> {
    readonly [_refBrand]: true;
    value: T;
}

まとめ

Vue 3.0 betaへのアップグレード方法の紹介と、社内ツール開発でのComposition APIへの移行について紹介しました。Composition APIでの実装で最近よく考えることは、コードの再編の自由度が上がった分、コードをまとめる境界をどのように見つけるのがよいかという点です。Composition Functionへの分割をパターン化できるとより開発をスムーズに進められそうです。その点を踏まえて、引き続きVue 3.0と周辺パッケージのキャッチアップと移行を進めていきたいと思います。

さいごに

ZOZOテクノロジーズではマーケティングに関連するプロダクトの開発や、フロントエンド開発に興味のあるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください!

tech.zozo.com

また、8/27に、この記事で紹介した社内ツールを含めたMAの取り組みについてのイベントを行いますので、こちらも奮ってご応募ください!

zozotech-inc.connpass.com

カテゴリー