こんにちは、MedPeerのフロントエンド開発を主に担当している森田です。
MedPeer( https://medpeer.jp )ではVue 2 系を長らく利用してきましたが、公式からの発表の通り 2023年12月31日 でEOLとなっております。
With 2024 almost upon us, we would like to take this opportunity to remind the Vue community that Vue 2 will reach End of Life (EOL) on December 31st, 2023. https://blog.vuejs.org/posts/vue-2-eol
EOLを迎えても直ちにセキュリティリスクに晒される可能性は少ないとは思いますが、 MedPeerは医療を扱うサービスの特性上セキュリティリスクは最小限に抑えたいのと、 最新のVueの機能を利用できることは開発体験としても良いためVue 3へのバージョンアップを行うこととしました💪
そして2022年9月頃から検討を含めて着手を始め、約1年間という時間が掛かってしまいましたが 2023年11月29日 にVue 3にアップデートすることができました🥳
EOL直前ではあるもののMedPeerをVue 3にアップデートした際に工夫した点や躓いた点等を整理しましたので、これからVue 3へのアップデートする方の参考になれば幸いです🙏
前提事項
まずMedPeerというサービスですが、バックエンドはRuby on Railsで記載されており基本的にはhamlやerbといったサーバーサイド側のテンプレートエンジンを利用したモノリシックでMPAなサービスで、フォームやモーダル等といったリッチなコンポーネントをVue.jsを使って実装しています。 (フロントエンドエンジニアだけではなく、バックエンドのエンジニアも実装しております。)
また、Vue 2系の最新(v2.7系)にはアップデート済の状態で、Vueを利用したコードベースの規模感としてはリポジトリ内に.vue
ファイルが400ファイル、行数にすると55,000行程度あり、それなりの規模感のサービスなのではないかと思っております。
アップデートの基本戦略
上述の前提事項を踏まえて、Vue 3へアップデート基本戦略として次の2つを取りました。
- バージョンアップ作業は通常の開発を止めずメンバー全員で行えるようにする
- バージョンアップのリリース差分はなるべく小さくする
バージョンアップ作業は通常の開発を止めずメンバー全員で行えるようにする
Vue 3へのバージョンアップは破壊的変更も多く対応箇所が膨大で時間が掛かりそうに思ったので、通常の開発業務への影響を抑えメンバー全員で行えるような方法が望ましいと考え以下を行いました。
- Vue 3の破壊的変更の影響を受ける箇所を静的解析でエラーとしCIで検知できるようにする
- 静的解析のエラーをtodoとして管理することで対応内容・箇所を明確化する
Vue 3の破壊的変更がCIでキャッチアップできないと期間中にどんどん影響を受ける実装が増えてしまい、いつまで経ってもアップデートできないといったことになってしまう懸念があったので、CIでエラーにできるように静的解析で主要な破壊的変更の影響を受ける箇所を検知するようにしました。
- eslint-plugin-vueの
plugin:vue/vue3-recommended
からruleを先行して有効化する Vue.extend
、new Vue
といったGlobal APIやカスタムコンポーネントに対するv-model
等の利用を検知しエラーにする
特にplugin:vue/vue3-recommended
から先行して有効化したruleの中でもVue 2系の最新でも利用できてVue 3から必須になるemits
の定義を強制できるvue/require-explicit-emitsは非常にありがたかったです 🙏✨
Vue.extend
といったGlobal APIの利用はESLintのno-restricted-importsを利用することでimport Vue from 'vue'
のようなGlobal API利用目的のコードを検知しエラーにするようにしました。
"no-restricted-imports": [ "error", { "paths": [ { "name": "vue", "importNames": ["default"] }, ], }, ],
また、カスタムコンポーネントに対するv-model
で渡されるprops
とemit
されるイベントが変更されるのは影響が大きかったので、以下のようなカスタムルールを作成して明示的に@input
、value
を利用して影響を受けない実装を促すようにしました。
'use strict' const utils = require('eslint-plugin-vue/lib/utils') module.exports = { meta: { type: 'problem', docs: { description: 'disallow using v-model for custom component is affected by a breaking change in Vue 3.', categories: undefined, url: 'https://v3-migration.vuejs.org/ja/breaking-changes/v-model.html', }, fixable: null, schema: [ { type: 'object', properties: { ignoreComponentNames: { type: 'array', }, }, }, ], messages: { error: 'カスタムコンポーネントへのv-modelの使用は、Vue 3の破壊的変更の影響を受けるので使用しないでください。\nhttps://v3-migration.vuejs.org/ja/breaking-changes/v-model.html', }, }, /** @param {RuleContext} context */ create(context) { const ignoreComponentNames = context.options[0]?.ignoreComponentNames ?? [] return utils.defineTemplateBodyVisitor(context, { "VAttribute[directive=true][key.name.name='model']"(node) { const element = node.parent.parent if ( utils.isCustomComponent(node.parent.parent) && !ignoreComponentNames.includes(element.name) ) { context.report({ node, loc: node.loc, messageId: 'error', }) } }, }) }, }
静的解析でエラーとなる既存ファイルは以下のようにoverridesでファイルを無効化することで対応箇所・内容を明確化。todoとして管理することでCIが通ればVue 3の破壊的変更に対応できる状態とし、メンバー全員でVue 3のアップデート対応を行えるようにしました。
module.exports = { overrides: [ { files: [ 'app/javascript/components/Foo.vue', ], rules: { 'no-use-v-model-for-custom-component': 'off', }, }, { files: [ 'app/javascript/components/Bar.vue', ], rules: { 'vue/require-explicit-emits': 'off', }, }, { files: [ 'app/javascript/components/Baz.vue', ], rules: { 'no-restricted-imports': [ 'off', { paths: [ { name: 'vue', importNames: ['default'] }, ], }, ], }, }, ], }
バージョンアップのリリース差分はなるべく小さくする
リリース前の動作確認期間等でVue 3アップデートの対応branchをmergeせずに保持する必要があり、競合解決といったメンテナンスコストを抑えるためにリリース差分を最小限に抑えたいと思い、破壊的変更で事前に対応できるものは対応し、なるべくリリース時点での差分を少なくするように主なものとして以下を行いました。
- snapshotテストの差分をリリース時点では無視する
createApp
といったVue 3の記法の一部を先行して利用できるようにする
snapshotテストをリリース時に差分に含めてしまうだけでリリース用のPull Requestの差分が数万行になってしまうのと、Componentのtemplateの実装が変わるだけで競合してしまいメンテナンスコストが非常に高くなってしまいます。 snapshotテストでは以下のようなwrapperメソッド経由で検証することで、環境変数でskipを切り替えられるようにし、ローカルでは差分が問題なさそうなことを確認した上で、リリース時点ではCIでskipしsnapshotテストの更新をリリース後に遅延できるようにしました。
// NOTE: snapshotテストをCIではskip可能にする // ```ts // describe('components/Foo', () => { // skippableIt('snapshot', () => { // const wrapper = mount(Target) // expect(wrapper.element).toMatchSnapshot() // }) // }) // ``` export const skippableIt = (name: string, test: () => void) => { const skip = process.env.SKIP_TEST === 'true' if (skip) { it.skip(name, test) } else { it(name, test) } }
さらに、以下のようなカスタムルールを作成しtoMatchSnapshot
を含むit
でskippableIt
の利用を促し自動的に利用を促せるようにしました。
'use strict' /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', docs: { description: ` require use skippableIt for snapshot test for Vue 3 version up. `, }, fixable: 'code', messages: { error: 'Snapshotテストはskip可能とするために skippableIt を使用してください。', }, schema: [], // no options }, /** @param {RuleContext} context */ create: function (context) { return { /** @param {CallExpression} node */ CallExpression(node) { if ( node.callee.name === 'it' && node.arguments[1].type === 'ArrowFunctionExpression' && node.arguments[1].body.type === 'BlockStatement' ) { const block = node.arguments[1].body const expects = block.body.filter((node) => node.type === 'ExpressionStatement') const isUseSnapShotIt = !!expects.filter((expect) => { return ( expect.expression.type === 'CallExpression' && expect.expression.callee.type === 'MemberExpression' && expect.expression.callee.property.type === 'Identifier' && expect.expression.callee.property.name === 'toMatchSnapshot' ) }).length if (isUseSnapShotIt) { context.report({ node: node, messageId: 'error', }) } } }, } }, }
また、Component周りの破壊的変更に関してはVue 2系の最新(v2.7系)を利用することでComposition APIがvue
から利用できるようになり、Vue 3の記法に近い形で記述できるので助かったのですが、entry等でのGlobal APIを利用した実装への影響が大きくリリース差分が膨大になってしまいそうだったので、以下のようなWrapperを用意してVue 2でもcreateApp
等のVue 3に近い形で実装できるようにし、アップデート時はWrapper内のVueに対する呼び出し部分をVue 3アップデート時に変更するだけで済むようにしました。
// NOTE: 各エントリーで以下のようにcreateApp相当の記述で利用できる // import { createApp } from 'Vue3Impostor' class Vue3AppImpostor { app: Vue constructor(options: object) { this.app = new Vue(options) } mount(...args: Parameters<typeof this.app.$mount>) { return this.app.$mount(...args) } use(...args: Parameters<typeof Vue.use>) { return Vue.use(...args) } component(...args: Parameters<typeof Vue.component>) { return Vue.component(...args) } } export const createApp = (options: object) => { return new Vue3AppImpostor(options) } export const reactive = <T extends object>(...args: Parameters<typeof Vue.observable<T>>) => { return Vue.observable(...args) }
※ @vue/compatの利用も検討しましたが、2021年末でメンテナンスが終了する旨がREADMEに記載があり、いずれはVue 3相当の記述に書き直す必要があるため利用しない方針としました。
これらにより差分を減らすことで、最終的なVue 3アップデートの差分は27ファイル(+ 419, - 430)に収めることができました🎉
アップデートで苦労した点
以下にMedPeer固有の問題もあるとは思いますが、Vue 3へのアップデートした際に苦労した点を参考になればと思い記載します📝
周辺ライブラリの乗り換えやアップデート
MedPeerでは以下のようなVue関連のライブラリがVue 3に対応しておらず、乗り換え・バージョンアップを行いました。
以下の3ライブラリに関しては正式リリースは現時点でまだリリースされていないようですが、alphaやbetaバージョンでVue 3対応が行われており、そちらを利用するような対応を行いました。(現状は特に問題は問題は起きておりません)
- vue-infinite-loading
- vue-multiselect
- vue-select
vue-js-modal
に関しては、vue-final-modalを利用するように既存実装を修正しました。
乗り換えやバージョンアップを行うにあたって既存のコードの修正が発生したものの、 そこまで大きな影響が出ずに対応できたのでalphaやbetaバージョンや代替ライブラリが提供されていて助かりました 🙏
※その他サービス内の一部でだけ利用されているライブラリ等がありましたが仕様を調整、独自で実装して削除しました。
リアクティブにするとプライベートな値に依存したロジックが呼び出せない
バージョンアップ時にプライベートな値(#foo
等)を持つインスタンスをref
でリアクティブにした後、プライベートインスタンスフィールドにロジック実行時にTypeError: Cannot read private member # ...
といったエラーが発生する事象がありました。
Vue 3からref
でリアクティブな状態にする際にProxy
でwrapされるようになりましたが、Proxy
経由ではプライベートな値を参照できないため本事象が発生しているようなので、
プライベートプロパティは転送できない プロキシーは、やはり異なるアイデンティティを持つ別のオブジェクトであり、ラップされたオブジェクトと外部との間を運営する プロキシー です。そのため、プロキシーは元オブジェクトのプライベートプロパティに直接アクセスすることができません。 Proxy - JavaScript | MDN
実行時にtoRaw
を利用して元のオブジェクトに対して実行するようにして対応しました。
toRaw() Vue で作成されたプロキシの、未加工の元のオブジェクトを返します。 https://ja.vuejs.org/api/reactivity-advanced.html#toraw
参考情報) 関連してそうなissue
mountした要素の子要素を別のVueアプリケーションからmountすると、v-bindが削除される
これは元々の実装が良くなかったのですが、以下のように既にVueアプリケーションをmount
している要素の子要素に対して、別のVueアプリケーションをmount
している箇所があり、、、
<div id="vue-app-1"> <!-- ... --> <div id="vue-app-2"> <custom-component :active="false" /> </div> </div>
Vue 3へのアップデート後に、後者の子要素にmount
していたVueアプリケーションのComponentに渡していたpropsへの受け渡し処理 <custom-component :active="false" />
からv-bind
が削除され <custom-component active="false" />
となり、本来であればactive
の値はfalse
となるところ"false"
となり、論理値の結果が正反対になってしまう事象がありました。
MedPeer内で発生したケースは、Componentに渡していたpropsへの受け渡し処理 がそもそも不要だったため該当箇所を削除して対応できたため、大きな影響はなく助かりました🙏
slotで挿入したコンテンツにscoped CSSが適用されない
Vue 3アップデート後に、Vue 2系では適用されていたslotで挿入したコンテンツに対するComponent内の<style scoped>
内で記述したスタイルが適用されない事象が発生しました。
<template> <div class="custom-component"> <slot /> </div> </template> <style lang="scss" scoped> .custom-component{ .slot-content { // 挿入されるslotに対するスタイル } } </style>
Vue 3 Migration Guideには記述が見つけられなかったのですが、以下の通りVue 3のドキュメントには明記されており、
<slot/>
によってレンダリングされるコンテンツは、デフォルトでは親コンポーネントによって所有されていると見なされるため、スコープ付きスタイルの影響を受けません。 https://ja.vuejs.org/api/sfc-css-features#slotted-selectors
当初気づくのに遅れてしまったのですが、ドキュメント記載の通り:slotted
等のディープセレクタを利用して対応しました。
参考情報) 関連してそうなissue
本番ビルドでだけ例外が発生する
Vue 3アップデート後に、propsの値が不正なケース(:prop-name=""
)等で、開発ビルドを利用している場合にはVue warn
でエラーにならないのですが、本番ビルドを利用しているとSyntaxError
が発生する事象がありました。
<div id="vue-app-2"> <custom-component :prop-name="" /> </div>
エラーやVue warn
の発生箇所を特定し対応することで、エラー自体は解消できたのですが受け渡すデータによって発生するため、気づくのが難しく対応に苦労しました・・・!
参考情報) 関連してそうなissue
おわりに
Vue 3のアップデートは破壊的変更も多く作業に着手してから約1年と長い時間が掛かってしまいましたが、 MedPeerの開発メンバーにも多大に協力して貰いEOLを迎える前に無事にVue 3にアップデートできました🎉
静的解析等を活用して対応箇所を可視化しVue 3のアップデートの前に先行して対応できる環境を用意できたのは、 進捗状況が定量的に追いやすく、リリース時の差分もコンパクトで開発メンバー全員で対応・レビューもしやすい状況にでき、 孤独感も感じずバージョンアップを進められて良かったなと今回振り返って思いました 🙏
Vue 3アップデートは一旦無事に完了しましたが、まだまだ伸び代のあるサービスなのでこれからも頑張っていきたいです💪
最後まで読んでいただきありがとうございました✨
アップデートの参考にさせていただいた資料
- 機能開発を止めずに、500コンポーネント規模の Vue 3 移行を完了させた開発プロセス
- Vue2 Vue3 マイグレーション 令和最新 最強
- 既存のVue.jsプロジェクトをVue 3へ移行したときに必要だった修正まとめ
是非読者になってください!
メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!
■募集ポジションはこちら medpeer.co.jp
■エンジニア紹介ページはこちら engineer.medpeer.co.jp
■メドピア公式YouTube www.youtube.com
■メドピア公式note
style.medpeer.co.jp