一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました

CTO室プラットフォーム開発チームの山口(@igayamaguchi)です。
プラットフォーム開発チームではさらに内部でプロジェクトチームが分かれており、私はフロントエンド改善チームというチームでリーダーをしています。
フロントエンド改善チームでは主に一休.com、Yahoo!トラベルのフロントエンドの改善を行っております。

今回は一休.com、Yahoo!トラベルで使用しているNuxtのバージョンを2から3にアップグレードしたお話をさせていただきます。

一休.com、Yahoo!トラベルではトップページや検索ページ、ホテル・旅館の詳細ページなど主要なページのフロントエンドはNuxtで開発されています。
NuxtのバックエンドにはGo+gqlgenでGraphQLのサーバーを立てており、NuxtからはApolloを使用してバックエンドと通信を行っています。
このNuxtのバージョンは2となっており、それを今回3にアップグレードしました。

なぜバージョンを上げたのか

話は2022年7月半ばにさかのぼります。
当時は宿泊事業におけるフロントエンド開発の課題について議論をしていました。
一休.com/Yahoo!トラベルではフロントエンドのコードの共通化を行っており、同じリポジトリ内の同じコードで両サイトを実現しています。

user-first.ikyu.co.jp

しかし当初はうまくいっていた一休.com/Yahoo!トラベルのコード共通化、コンポーネント共通化も課題が生まれ、開発速度が大きく落ちてきていました。

上記はフロントエンドの現状のコードの問題をレポーティングしたときの資料から抜粋したものです。
上記画像のようにコードの重複が多く発生していました。(SDとは一休社内におけるスマホを指す用語です)
何か変更をしようとすると一休.com/Yahoo!トラベルとPC/スマホで別々になったコードに変更を加える必要があり変更箇所が4倍になるようになっていました。

この問題に対してデザインシステムを作ることで小さなUIコンポーネントやスタイルの共通化をすることが出来ました。

user-first.ikyu.co.jp

しかしVueの状態やGraphQLに絡んだ処理などロジックに関連する処理の共通化がまだうまくいっていませんでした。
これはうまく抽象化、共通化する手段がNuxt2/Vue2では提供されていないことが問題でした。
Mixinなどを使えば可能ではありますが、Mixinでは見通しが悪くなる可能性が高く暗黙的な抽象化が行われあまりよい形にはなりません。
そこでVue3で登場したComposition APIです。
https://vuejs.org/guide/introduction.html#composition-api
これを使うことで処理の共通化が行いやすくなり、コードの無用な重複も解消することができます。

Nuxt3/Vue3に移行すると他にも利点があり、以下に簡単に記します。

  • 設計を全く違うものにできる
    • Composition APIを使うことができると設計が全く変わる。より良い抽象化、共通化が可能になる
  • 状態管理の選択肢が増える。(useState, Piniaなど)
  • 型が効いて安全に
    • Vue3に上がることでVolar(vue-tsc)と組み合わせて型が効くように
  • 開発環境が爆速に
    • Viteが本当に早い

他にもSuspense、Teleport、ビルドの最適化、などいいことはたくさん。
色々利点がありますがやはり設計が改善できる、ということが大きいです。
Nuxt3の方がより良い方法でコンポーネント、状態管理の設計ができるため移行を決断したのが7月末です。
8月くらいから実際に移行に向けての3人のチームメンバーで作業を進めて2月頭にリリースしました。 (最初期は4人で途中からメンバーの異動があったため基本的に3人)

移行指針

まずは最初に移行の指針を立てました。
それは 最低限のコストで移行する というものです。
Nuxt3にアップグレードする理由はより良い設計にして開発をしやすいようにするためです。
移行しながらきれいな書き方にすることもできますが、その場合移行期間が延びるのと、リリース時にビッグバンリリースとなってしまいます。
移行のコストとリスクを最小限にするためにこの指針を立て、プロジェクト進行時には常にこの指針に従い意思決定を行っていきました。

移行戦略

次に移行戦略を立てました。
Nuxt3/Vue3のドキュメントを読み、Nuxt3/Vue3の破壊的変更を洗い出し、タスクの一覧化、移行戦略を立てました。
移行戦略では以下のことを定めました。

  • Nuxt Bridgeを使うか
  • タスクの切り分け
  • ブランチ運用
  • ミニマムに検証をする
  • リリース戦略

Nuxt Bridgeを使うか

NuxtではNuxt Bridgeというモジュールが提供されています。
https://github.com/nuxt/bridge
Nuxt2に対してこれを導入することで、Nuxt2でありながらNuxt3の機能を利用できる前方互換性のあるレイヤーです。
これを使うことでNuxt2のままNuxt3に対応するコードに徐々に移行するということができます。
しかし私たちはこれを使わない選択をしました。

1つ目の理由はNuxt Bridgeに対応していないモジュールがあることです。
私たちの使用しているNuxt moduleでは、Tailwind CSSなどが例として挙げられます。
そういったモジュールにどういった対応が必要になるのか、そこの対応にどのくらいの時間がかかるか予測できませんでした。
最悪開発が止まりリリースができなくなる可能性がありました。

2つ目の理由は二度テストを行う必要があることです。
上記のモジュール対応もあり、移行時にはサイト全体のテストを行う必要があります。
Nuxt2からNuxt Bridgeに移行するときとNuxt BridgeからNuxt3に移行するときで2度テストを行わなければいけません。

これらの理由によりNuxt Bridgeを使わずNuxt3への移行を行いました。
この決断は後に意思決定もシンプルにすることができ非常によかったです。
あらゆる対応がNuxt Bridge、Nuxt3両方を意識するのではなくシンプルにNuxt3のことだけを考えればよくなりコストが下がりました。

タスクの切り分け

アップグレードをするためにタスクを対応タイミングに応じて切り分けを行いました。
大まかには以下の3つです

  • Nuxt2/Vue2の状態で対応
  • Nuxt3/Vue3の状態で対応
  • リリース前に対応

まず1つ目のNuxt2/Vue2の状態で対応可能なものです。
Nuxt3/Vue3の破壊的変更はNuxt2/Vue2の状態でも対応できるものがあります。
例えばfunctionalコンポーネントがVue3で廃止されたためfunctionalコンポーネントを通常のコンポーネントに変更したり、Vueのfiltersが廃止されたため関数に書き換える、といったものです。

2つ目はNuxt3の状態で対応可能なものです。
1つ目を行ってからNuxt2/Vue2のときに対応できないNuxt3/Vue3の破壊的変更の対応です。
例えばモジュールやプラグインの書き換えです。

3つ目はリリース前に対応するものです。
これはテスト、画面の確認などです。

さらにタスクとは別にこのプロジェクトでやらないことも明記してリスト化するようにしました。
例えばNuxt3/Vue3に移行することでComposition APIが使えるようになるわけですが、「変更を最小限にするために移行時にComposition APIへの書き換えは行わない」、というように理由とともにやらないことを明記してリスト化していました。
実際に作業を進める上でもチームで議論して都度都度これは今やるべきではないとなったものを追加していました。

ブランチ運用

作業を進めるために作業ブランチをどうやって運用するかを決めました。

前述のタスクの切り分けを基に以下のようなブランチ運用を行うようにしました。

  • 破壊的変更に対してNuxt2/Vue2の時点で対応できるものをまず対応してmasterマージ
  • 事前対応できるものが終わり次第Nuxt3移行用のブランチを作成、そのブランチに対して残りの破壊的変更の対応をマージ
  • 各施策のブランチがmasterにマージされた場合、適宜Nuxt3移行用のブランチに取り込み

このようにブランチを運用することでNuxt3移行リリース時にマージするコードの変更は最小限になるようにしました。

ミニマムに検証する

Nuxt3/Vue3を初めて触る際は様々な挙動がわからず悩む時があります。
そういったものを実際のプロダクトで検証しようとすると手間です。
そのため最小限でプロダクションに近しいNuxt3のboilerplateを用意し、そこで様々な実装の検証を行うようにしました。

リリース戦略

本番リリースでは一休.com、Yahoo!トラベルそれぞれを順々に公開するようにしました。

  • 一休.comで10%のユーザーに公開
  • 問題がなければ一休.comで100%のユーザーに公開
  • 問題がなければYahoo!トラベルで10%のユーザーに公開
  • 問題がなければYahoo!トラベルで100%のユーザーに公開

ユーザーの振り分けはFastlyで行い、あるユーザーは常にNuxt2、あるユーザーは常にNuxt3を見るように設定を行いました。
この時、一部のページ、例えばトップページだけNuxt3を公開する、ということはしませんでした。
理由はNuxtがSPA的なJSを使用したページ遷移がありそれを振り分けるのは難しいと判断したためです。

このように移行戦略を立ててNuxt3へのアップグレードを開始しました。

作業を開始する前に

ここまでで戦略を立てて作業を始める準備は整ったのですが、ここでもう一つ作業を開始する前に取り組んだことがあります。
それがNuxt3/Vue3のドキュメントをチームで読み漁ったことです。
プロジェクトを始めた当初はNuxt3/Vue3について解像度が低く当初に洗い出したタスクで過不足がないか、どのくらい時間がかかるのかはかなり不透明だったため、解像度を上げるためにやってみようということで始めました。
毎朝1時間~1時間半ほどチームでZoomで集まりドキュメントを画面共有しながら読みました。
チームメンバーはNuxt2/Vue2の開発経験はあったため、Vueはマイグレーションガイド(https://v3-migration.vuejs.org/ )を、Nuxt3は頭からドキュメント(https://nuxt.com/ )を読んで機能差分について話しながら読み進めました。(当時Nuxt3はマイグレーションガイドが作成途中の項目が薄かったためドキュメントを頭から読んでいます。今なら https://nuxt.com/docs/migration/overview を読めばよいと思います)
結果チームメンバーそれぞれに基礎知識がつきタスクや対応方法の議論が活発になる土壌を作れたためよかった試みでした。

移行作業

ここからは実際に行った移行作業の具体的なお話をします。
とはいえ基本的には前述のNuxt、Vueそれぞれのマイグレーションガイドを読み、破壊的変更となるものを対応しています。
そこで今回、私たちが躓いたものや独自の対応をしているものを紹介させていただきます。

defineComponent/defineNuxtComponent

Nuxt2/Vue2でVueコンポーネントを作成するときに Vue.extend を使っていましたが、Nuxt3では defineNuxtComponent を使う必要があります。
https://nuxt.com/docs/api/utils/define-nuxt-component
今回、破壊的変更は別ブランチを立てて変更をしていくためmasterブランチマージ時に発生する変更は小さくしたいです。
そのためmasterブランチ上でも defineNuxtComponent を使用出来るようにaliasとなる関数を用意しました。

/**
 * defineComponent
 */
import Vue from 'vue'
import { VueConstructor } from 'vue/types'

type VueExtend = VueConstructor['extend']

// @ts-ignore
const defineComponent: VueExtend = (options) => Vue.extend(options)
export { defineComponent }

/**
 * dynamic import
 */
export function defineAsyncComponent(f: Function): Function {
  return f
}

これを用いてmasterブランチに各コンポーネントを Vue.extend から defineComponent に書き換えました。

import { defineComponent } from '~/nuxt3-alias'

export default defineComponent({
  // 中のoption apiはそのまま
})

Nuxt3移行ブランチでは以下のようにaliasを差し替えるだけでよいのでリリース時の変更が最小限になります。

import { defineAsyncComponent as _defineAsyncComponent } from 'vue'

import { defineNuxtComponent } from '#imports'

const defineComponent = defineNuxtComponent
export { defineComponent }

const defineAsyncComponent = _defineAsyncComponent
export { defineAsyncComponent }

fetchフック

Nuxt2ではfetchフックという各コンポーネントで非同期にデータ取得、設定を行うことができる専用のhookが用意されています。 https://nuxtjs.org/docs/components-glossary/fetch/

これがNuxt3では使うことが出来ないようになっていました。 useAsyncData useFetch というものが使えるようになっていてそちらに置き換えが必要です。
https://v3.nuxtjs.org/getting-started/data-fetching
しかし useAsyncData useFetch を使うにはComposition APIへの書き換えが必要です。
これには時間がかかることが予想されたのとmasterブランチとの差分が大きくなってしまうのでfetchフックをそのままにどうにかできないか検討しました。
最終的には以下のようなプラグインを作り、fetchメソッドをそのまま解釈できるようにして対応しました。
難点が1つあって、fetchフックを使用するときはnuxt2FetchKey というキーを定義する必要があるのと、fetchフックを使用するコンポーネントが画面で複数個所で呼ばれないことを前提にしています。

export default defineNuxtPlugin((nuxt) => {
  nuxt.vueApp.mixin({
    async serverPrefetch() {
      if (!hasFetch(this)) {
        return
      }
      if (this.$options.fetchOnServer === false) {
        return
      }
      await this.$options.fetch.call(this)
      if (!nuxt.payload.data) {
        nuxt.payload.data = {}
      }
      nuxt.payload.data[this.$_fetchKey] = this.$data
      this.$_fetchResolve()
    },
    created() {
      if (!hasFetch(this)) {
        return
      }
      this.$_fetchKey = this.$options.nuxt2FetchKey

      this.$_fetchPromise = new Promise((resolve) => {
        this.$_fetchResolve = resolve
      })
    },
    async beforeMount() {
      if (!hasFetch(this)) {
        return
      }

      if (!window.__NUXT__) {
        window.__NUXT__ = { data: {} }
      }

      const serverData = window.__NUXT__.data[this.$_fetchKey]
      if (serverData) {
        Object.assign(this.$data, serverData)
        window.__NUXT__.data[this.$_fetchKey] = undefined
        return
      }

      await this.$options.fetch.call(this)
    },
  })
})

function hasFetch(vm) {
  return (
    vm.$options &&
    typeof vm.$options.fetch === 'function' &&
    !vm.$options.fetch.length
  )
}

使用するコンポーネント側

export default defineNuxtComponent({
  nuxt2FetchKey: "defineNuxtComponentFetchKey",
  async fetch() {
    // 非同期に何かを取得して設定する
  },
});

headメソッド

Nuxt2で使用することができるheadメソッドの内部実装がNuxt3で変わり、computedを解釈することができなくなっていました。
例えば以下のようにheadメソッド内でcomputedを使用しているとundefinedになります。

export default defineComponent({
  head(): MetaInfo {
    return {
      title: this.hoge, // undefinedになる
    }
  },
  comptued: {
    hoge() {
      return 'hoge'
    }
  },
})

これに対応するために独自にプラグインを作り、Nuxt2と同じような動きをできるように、 xxx.call(this) によるVueコンテキストを注入しての実行、実行後の値をwatchにより監視し、変更後に useHead を使用してmeta情報への適用をするようにしました。

export default defineNuxtPlugin((nuxt) => {
  nuxt.vueApp.mixin({
    data() {
      return {
        head: undefined,
      }
    },
    created() {
      if (!this.$options.oldHead || this.$options.head) return
      this.$watch(
        () => {
          return this.$options.oldHead.call(this)
        },
        // @ts-expect-error
        (newValue) => {
          this.head = newValue
        },
        { immediate: true, deep: true },
      )
      this.$_fetchPromise?.then(() => {
        this.head = this.$options.oldHead.call(this)
      })
      useHead(() => this.head)
    },
  })
})

コンポーネントを使用する側は head というメソッドを oldHead というメソッドに書き換えるだけで正しくcomputedが解釈できるようにしました。

export default defineNuxtComponent({
  oldHead() {
    return {
      title: this.hoge,
    }
  },
  comptued: {
    hoge() {
      return 'hoge'
    }
  },
})

他にもいくつか問題があり対応をしています。

  • Apollo
    • NuxtのApolloモジュールは現在Nuxt3対応がされていますが、つい最近まではNuxt3に対応していませんでした
    • vue-apolloを直接使用しNuxtのプラグインとして独自実装
  • Storybook
    • NuxtのStorybookモジュールは現在もNuxt3は未対応のまま
    • @storybook/vue3を使い独自に実装
  • axiosの脱却
    • ApolloのRestLinkへ移行

他にもここには書ききれないほどに様々な対応をしていますが、この記事では省略させていただきます。
私たちはかなり早い段階でNuxt3対応を始めたことでNuxt3のバグを踏むということも多々ありました。
今であればバグも減り、ドキュメントも整ってきているのでもう少し早く移行を進められるかもしれません。
しかし早い段階で対応できたことで本来実現したかった設計の改善に着手できており、この面ではよかったと思っています。

終わり

現在はNuxt3を使って更なる改善に挑戦中です。 この記事がこれからNuxt3へのアップグレードを行う方々の力になれば幸いです。

一休では一緒に働く仲間を募集しています。まずはカジュアル面談からお気軽にご応募ください!

hrmos.co