はじめに
この記事では、私たちが開発しているAnewsというプロダクトでのVue 3移行プロジェクトについて紹介していきます。 Anewsはビジネス向けにユーザーの趣向に合わせて日々のニュースなどの最新情報を提供するプロダクトです。フロントエンドはVue.jsというフレームワークを使用しています。2023年末にVue 2のサポート期限が迫っており、それまでにAnewsのVue 3移行を完遂すべく、Vue 3移行プロジェクトを行いました。 そこで得られた知見としてVue 2とVue 3の違いや、必要だった対応、実際に行った移行戦略などまとめていきたいと思います。
移行前の環境
今のAnewsは2020年に開発がはじまり、AnewsはVue 2.6.12、TypeScriptを使用していました。 TypeScriptを活用して開発するため、TypeScriptのクラスでVueのコンポーネントを定義できるVue Class Componentを使った開発をしていました。
移行を見据えてやってきたこと
Composition APIへの移行
Vue 3ではVue Class Componentがサポートされなくなっています。これによりコンポーネントの書き方をクラスコンポーネントから、Composition APIの書き方に変える必要がありました。 以下のパッケージを導入し、Vue 2.6.12のままComposition APIの移行しました。
yarn add @vue/composition-api
Vue 2.7への移行
Vue 2.7では、@vue/composition-api相当の機能がVue本体に取り込まれたので、追加のライブラリなしに、Composition APIを使用することができるので、アップグレードを行いました。 以下のようにdefineComponentなどのAPIのインポートパスを変更しました。
// Vue 2.6.12 import { computed, defineComponent, PropType, ref } from '@vue/composition-api'; // Vue 2.7 import { computed, defineComponent, PropType, ref } from 'vue';
本格的に移行プロジェクト始動
ブランチ戦略
Anews開発におけるブランチ戦略の詳細は以前のテックブログの記事で紹介しているのですが、主に以下の3つのブランチを用いた開発をしています。
- developmentブランチ:開発用
- stagingブランチ:検証用
- masterブランチ:リリース用
普段の開発はdevelopmentブランチからブランチを切り、pull requestがapproveされたコードをdevelopmentブランチにmergeしていきます。そして、developmentブランチをstagingブランチにマージし、テストを行い、最後にstagingブランチをmasterブランチにmergeしリリースとなります。 Vue 3の変更もdevelopmentブランチからブランチを切り、変更後にmergeするという方法もあったのですが、以下の理由で現実的でないと判断しました。
- 変更箇所がコード全域に及ぶので影響範囲が大きすぎる
- Vue 2からVue 3への移行には破壊的変更が多いため、変更の観点ごとにそれぞれレビューしてもらいたい
- 今回の移行プロジェクト行った変更のみでテストしたい
そのため、developmentブランチから移行用ブランチ(development-vue3ブランチ)を作成し、そこをベースとしてpull requestを出してmergeしていくという方法を取ることにしました。 さらに、並行して通常の開発も進んでいるので、その差分も細かく取り込みながら作業を行なっていきました(developmentブランチから移行用ブランチへのマージ)。 そして、移行用ブランチがリリース可能な状態になったらstagingブランチにマージさせ、リリースフローに乗せるという方法をとりました。
移行作業
以下のステップでVue 3移行作業を進めました。
step.1 移行用ブランチにVue 3をインストール、移行ビルドを導入
step.2 Vue 3に対応していないパッケージを調査
step 3 移行作業
- Vue 2からVue 3での変更内容を確認しながら、コードを修正し、移行用ブランチにmergeしていく
- Vue 3対応していないpackageのバージョンアップして移行用ブランチにmergeしていく
- 移行ビルドの削除
step4 動作確認
step5 リリース
Step1
まずはじめに、Vue 2からVue3にアップデートします。Vue 3には「移行ビルド」が用意されています。 移行ビルドはVue 3とVue 2で挙動が違う部分について、Vue 2の挙動をエミュレートするような動作にしてくれるライブラリになります。各非互換な挙動ごとにエミュレートのOn/Offをフラグで切り換えることができるので、ひとつずつ互換性のない挙動を確認しながら、コードの修正を行うことができます。 マイグレーションガイドを参考に、以下のコマンドを実行し、Vueのアップデートと移行ビルドの導入を行いました。
yarn add vue@latest @vue/compat@lastst yarn add --dev @vue/compiler-sfc yarn remove vue-template-compiler
以下のように移行用フラグを設定します。
import { configureCompat } from 'vue' configureCompat({ MODE: 2, GLOBAL_MOUNT_CONTAINER: true, CONFIG_DEVTOOLS: true, CONFIG_IGNORED_ELEMENTS: true, PROPS_DEFAULT_THIS: true, INSTANCE_DESTROY: true, GLOBAL_PRIVATE_UTIL: true, CONFIG_PRODUCTION_TIP: true, CONFIG_SILENT: true, TRANSITION_CLASSES: true, GLOBAL_MOUNT: true, GLOBAL_EXTEND: true, GLOBAL_PROTOTYPE: true, GLOBAL_SET: true, GLOBAL_DELETE: true, GLOBAL_OBSERVABLE: true, CONFIG_KEY_CODES: true, CONFIG_WHITESPACE: true, INSTANCE_SET: true, INSTANCE_DELETE: true, INSTANCE_EVENT_EMITTER: true, INSTANCE_EVENT_HOOKS: true, INSTANCE_CHILDREN: true, INSTANCE_LISTENERS: true, INSTANCE_SCOPED_SLOTS: true, INSTANCE_ATTRS_CLASS_STYLE: true, OPTIONS_DATA_FN: true, OPTIONS_DATA_MERGE: true, OPTIONS_BEFORE_DESTROY: true, OPTIONS_DESTROYED: true, WATCH_ARRAY: true, V_ON_KEYCODE_MODIFIER: true, CUSTOM_DIR: true, ATTR_true_VALUE: true, ATTR_ENUMERATED_COERCION: true, TRANSITION_GROUP_ROOT: true, COMPONENT_ASYNC: true, COMPONENT_FUNCTIONAL: true, COMPONENT_V_MODEL: true, RENDER_FUNCTION: true, FILTERS: true })
// vite.config.js ・・・ plugins: [ vue({ template: { compilerOptions: { compatConfig: { COMPILER_V_IF_V_FOR_PRECEDENCE: true, COMPILER_INLINE_TEMPLATE: true, COMPILER_IS_ON_ELEMENT: true, COMPILER_V_BIND_SYNC: true, COMPILER_V_BIND_PROP: true, COMPILER_V_BIND_OBJECT_ORDER: true, COMPILER_V_ON_NATIVE: true, COMPILER_NATIVE_TEMPLATE: true, COMPILER_FILTERS: true } } } }), ] ・・・
Step2
package.jsonのパッケージを調査し、Vue 3に対応しているかどうか確認していきます。 いくつかマイグレーション対応が必要なものがあったので、リストアップしていきます。
Step 3
移行用フラグを一つずつtrueからfalseにし、動作に問題がないかを確認、修正しながら移行用ブランチにmergeしていきます。実際に対応した内容を紹介していきます。
INSTANCE_SCOPED_SLOTS
slot記法はVue 2の段階でdeprecatedではあったのですが、Vue 3で廃止されたため対応が必要でした。
// Vue 2 <SomethingComponent> <div slot="header">ヘッダー</div> </SomethingComponent> // Vue 3 <SomethingComponent> <div v-slot:header>ヘッダー</div> </SomethingComponent> // or <SomethingComponent> <div #header>ヘッダー</div> </SomethingComponent>
COMPILER_V_FOR_TEMPLATE_KEY_PLACEMENT
v-forのkeyをネストしたタグ内で使用できなくなりました。
// Vue 2 <template v-for="(n, i) in requiredActionCount"> <div :key="`filled-bar-${i}`"> ... </div> </template> // Vue 3 <template v-for="(n, i) in requiredActionCount" :key="`filled-bar-${i}`"> <div> ・・・ </div> </template>
GLOBAL_SET
Vue 2ではオブジェクトに新しいプロパティを追加するとリアクティブにならないので、Vue.set
を使用する必要がありました。しかし、Vue 3からはオブジェクトにプロパティを追加した時に、自動的にリアクティブになるためVue.set
が不要になり、削除されました。
// Vue 2 Vue.set(article, 'marks', somethingArray); // Vue 3 article.value.marks = somethingArray;
INSTANCE_EVENT_EMITTER
Vue 2では$emit
と$on
を使用し、グローバルなイベントリスナーを実現できていましたが、Vue 3では$emit
、$on
、$off
がなくなりました。そこで、マイグレーションガイドにも記載があるのですが、同様の機能を実現するためmitt パッケージを導入しました。
Vue 3では、コンポーネント間の通信にグローバルイベントバスを使用することは推奨されておらず、provide / injectやPiniaなどを使用することが推奨されているのですが、これは既存の仕組みを大きく変更することになります。ですので移行を最小限にするため、置換することで移行できる、mittを使用して対応することにしました。
// Vue 2 const root = new Vue() export function useRoot() { return root; } const root = useRoot(); root.$emit('article-updated', {}); root.$on('article-updated', handleArticleUpdate); // Vue 3 const emitter = mitt<any>(); export function useEmitter() { return emitter; } const emitter = useEmitter(); emitter.emit('article-updated', {}); emitter.on('article-updated', handleArticleUpdate);
COMPONENT_V_MODEL
カスタムcomponentで使用できるv-model
ディレクティブの使い方が変更されました。
// ParentComponent.vue <ChildComponent v-model="pageTitle" /> // Vue 2 ChildComponent.vue export default { model: { prop: 'title', event: 'change' }, props: { value: String, Title: { type: String, default: 'Default title' } }, methods: { changePageTitle(title) { this.$emit('input', title) } } } // Vue 3 ChildComponent.vue export default { props: { modelValue: String }, emits: ['upDate:modelValue'], methods: { changePageTitle(title) { this.$emit('upDate:modelValue', title) } } }
COMPILER_V_BIND_SYNC
v-model
ディレクティブが複数使用できるようになり、.sync修飾子の必要性がなくなりました。
// Vue 2 <ChildComponent :title.sync="pageTitle" /> // Vue 3 <ChildComponent v-model:title="pageTitle" />
COMPILER_V_ON_NATIVE
Vue 3では子要素でコンポーネント発信イベントとして定義されていないすべてのイベントリスナーを、子要素のルート要素のネイティブイベントリスナーとして追加するようになったため.native
修飾子は不要になりました。
// Vue 2 <my-component v-on:click.native="handleNativeClickEvent" /> // Vue 3 <my-component v-on:click="handleNativeClickEvent" />
気をつけなければいけないのは、コンポーネント内でカスタムイベントとしてclick
イベントを定義した場合、二重にクリックイベントが発生してしまいます。 例えば、あるカスタムコンポーネントのクリックイベント契機でポップアップ表示をトグルするような処理を作っていたのですが、二重にイベントが発生した影響でポップアップが表示されないように見えるようなことが起こっており、修正が必要でした。
vue2-datepickerバージョンアップ
vue2-datepicker
はVue 2までしか対応していないため、vue-datepicker-next
に変更しました。ほとんど使い方は同じですが、CalendarPanel
コンポーネントがCalendar
コンポーネントになっていたりと、細かい修正が必要でした。
vue-routerバージョンアップ
pathに*を指定できなくなりました。
// before const routes: RouteConfig[] = [ { path: '*', redirect: '/' } ] // after const routes: RouteConfig[] = [ { path: '/:pathMatch(.*)', redirect: '/' } ]
scrollBehaviorのreturnにx, yが使用できなくなりました。
// before scrollBehavior(to, from, savedPosition) { if (to.path === from.path) { return savedPosition; } else { return { x: 0, y: 0 }; } } // after scrollBehavior(to, from, savedPosition) { if (to.path === from.path) { return savedPosition; } else { return { left: 0, top: 0 }; } }
router-link
でtag
属性とevent
属性が廃止されました。
// before <router-link to="/about" tag="span" event="dblclick">About Us</router-link> // after <router-link to="/about" custom v-slot="{ navigate }"> <span @click="navigate" @keypress.enter="navigate" role="link">About Us</span> </router-link>
vue/test-utilsバージョンアップ
wrapper
の型が変更になりました。
// Vue 2 import { Wrapper, mount } from '@vue/test-utils'; let wrapper: Wrapper<Vue>; wrapper = mount(); // Vue 3 import { VueWrapper, mount } from '@vue/test-utils'; let wrapper: VueWrapper; wrapper = mount()
findAll
での要素へのアクセス方法が変更になりました。Vue 3ではfindAll
での返り値が配列になったため.at()
を使用せずにアクセスできるようになりました。JavaScriptにも.at()
が入ったのでロジック的には変更する必要はなかったのですが、.at()
を使うと返り値の型がT | undefined
になり既存のテストコードを変更する必要があったため、とりいそぎ[]
アクセスに変更しました。
// Vue 2 wrapper.findAll('[data-test="token"]').at(0); // Vue 3 wrapper.findAll('[data-test="token"]')[0];
props
の渡し方が変更になりました。
const App = { props: ['foo'] } // Vue 2 const wrapper = mount(App, { propsData: { foo: 'bar' } } // Vue 3 const wrapper = mount(App, { props: { foo: 'bar' } }
オブジェクトの比較
Vue 2とVue 3ではReactivityの実装が変わっており、例えば以下のようにobj2.value = obj1;
にてVue 2ではオブジェクトを代入しようとすると、オブジェクトが直接ラップされ、元のオブジェクトと同一性を保持します。一方でVue 3だとProxyオブジェクトにラップされた上で代入されるので、元のオブジェクトとは異なる状態になります。
const obj1 = {id: 3}; // 普通のObject const obj2 = ref<{id: number}>({id: 0}); obj2.value = obj1; obj2.value === obj1; // Vue 3: false、Vue 2: true
NODE_OPTIONを追加
Vue 3にアップデートしたことにより、ビルド時のメモリが足りなくなる問題が発生したため、使用可能なメモリサイズを増やして対応しました。
NODE_OPTIONS="--max-old-space-size=2048" yarn build
移行ビルドの削除
移行ビルドのフラグの変更と、パッケージの対応が終わった後、移行ビルド自体の削除も行いました。移行ビルド自体は残したままリリースし、しばらく運用するという手もあったのですが、非互換なコードが量産される可能性もあり、完全にVue 3に移行し切った状態でリリースしたかったためこの対応を行いました。 動作確認で不具合を洗い出し、気合いで直し切りました。
Step 4
移行用ブランチを検証用環境にデプロイし、触れる環境を用意しました。 この環境で、通常行っている、フロントエンドのユニットテストと手動での動作確認を行い、かつモンキーテストで動作を確認しました。
Step 5
基本的にリリースは週1回木曜に行っており、ここでフロントエンド、サーバーサイドそれぞれのコードが反映されることになります。ただ、今回のVue 3移行でのリリースに限り、Vue 3移行に関する変更のみをリリースするようにしました。
最後に
ブランチ運用に関しては、通常の開発と並行して、Vue 3移行対応ができ、細かく差分も取り込みながらの作業であったため、都度対応しなければいけなかったのは大変でした。ただ、そのおかげでスムーズにリリースすることができ、さらにリリース後も大きな不具合はなく動いていたので、この選択は良かったなと思っています。
今回の移行対応でマイグレーションガイドやVueのドキュメントを読みながらの作業であったため、Vue 2とVue 3での違いを理解することができ、フロントエンドやVue自体の知識を深めることもできました。
今回の記事を書くにあたって当初の計画やチケットやpull requestなどを遡りながら、具体的な変更内容の例をまとめることができたので、今後Vue 3への移行作業をされる方や、同じような不具合にあった方などの一助になればいいなと思います。