メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

MedPeerをVue 3にアップデートしました🥳

こんにちは、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でエラーにできるように静的解析で主要な破壊的変更の影響を受ける箇所を検知するようにしました。

特に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で渡されるpropsemitされるイベントが変更されるのは影響が大きかったので、以下のようなカスタムルールを作成して明示的に@inputvalueを利用して影響を受けない実装を促すようにしました。

'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を含むitskippableItの利用を促し自動的に利用を促せるようにしました。

'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)に収めることができました🎉

Vue 3アップデートのPull Requestの差分(27ファイル)

アップデートで苦労した点

以下に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アップデートは一旦無事に完了しましたが、まだまだ伸び代のあるサービスなのでこれからも頑張っていきたいです💪

最後まで読んでいただきありがとうございました✨

アップデートの参考にさせていただいた資料


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp