🎉

Nuxt2からNuxt3に移行してサービスをリリースするまでの1年9ヶ月の道程

2022/12/13に公開

はじめに

Nuxt3が11月16日にリリースされて1ヶ月弱、現在のチームにジョインしてから2ヶ月後に書いた以前の記事からは1年7ヶ月が経ち、"Nuxt 3.0 stable"のNuxt3をプロダクトに採用し、サービスをリリースするに至りました。

私がジョインした弊チームは、チーム発足から2年足らずで、現在は20名程の規模にまで急成長しています。そんな弊チームのNuxt3への移行事例は、きっとどこかの誰かには役に立つと思いますので、あくまでもひとつのサンプルとして、インターネットに書きおくこととします。

なお、注意事項は、上記の過去記事と同様とします。

移行はNuxt BridgeからではなくNuxt.js + Composition APIから

ジョインした当初から、Nuxt2系の導入から3系への移行は視野に入れておりましたが、実際に動き始めたのは昨年下期となります。ちょうど現在行われている「バーチャルマーケット2022Winter」の1年前に開催された「バーチャルマーケット2021」でのpackageには、他の並行案件と併せて@nuxtjs/composition-apiを導入しています。

// package.json
{
  ...
  "nuxt": "^2.15.8",
  "@nuxtjs/composition-api": "^0.26.0",
}

このイベントのサイト制作開始時点でNuxt Bridgeが提供されていなかったのもありますが、Bridgeを挟んだ場合の移行コストやライブラリの追従状況、Vue3のスタンダードであるComposition APIを採用できていないチームの状況を鑑みて、移行はBridgeを挟まずにNuxt3へのバージョンアップでダイナミックに行うほうが、トータルでのコストが低いと判断しました。

一方で、Nuxt3への移行の判断は上記の通りしたものの、Composition APIへの移行は、Nuxt2のまま順次行う方針にもしました。Vue3でのスタンダードであり、既存プロジェクトへの導入も比較的容易であったためとなります。しかし、過去プロジェクトを含めて全部を全部せーので切り替えるのも、全フロントエンドエンジニアがいきなりComposition APIで書くのも至難であったため、「できる人から順次書いていく」「できるPJから適宜導入していく」という緩いルールにしました。

そのため、現在でもOptions APIならびにNuxt2が生きてしまっているプロジェクトが一定数あります。そこが負債になってしまうのはデメリットではありますが、複数案件を常に走らせながら、Composition APIの導入割合を高めていくことができた点、また、Nuxt3への移行にあたって、Composition APIへの勘所やノウハウが養われた点はメリットとなりました。

Nuxt3のTemplateリポジトリとサンドボックスを構築する

2022年4月21日に、Nuxt3のrc.1(Release Candidate)がリリースされました。そこからNuxt3は爆速で更新されていき、rc.14のリリースとともにNuxt3の"Nuxt 3.0 stable"がリリースされました。弊チームでは同5月10日にリリースされたrc.3から追従し始めております。とはいえ、rc版をプロダクトに採用することは流石に難しいので、いち早くそれらを試すことができるTemplateリポジトリとサンドボックスを構築して、そちらで追従することにしました。

弊チームで運用するTemplateリポジトリは、Githubの同機能を使用して、今後開発するリポジトリの初期環境構築を秒で終わらせることを目的にしたリポジトリです。

https://docs.github.com/ja/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template

ドメインに依存しないMolecules/Atoms層のComponentsやmodules群、factory関数やi18n、Testing、Typing、Linter/Formatter、StorybookなどはこういったTemplateリポジトリに包含させることで、新規案件の環境構築を秒で終わるようにしています。各案件フォルダのupstreamにこのTemplateリポジトリを指定することで、Templateの更新を容易にpullしてこれるのも美味しいところです。Templateリポジトリでは、ここにあげたプロジェクト共通の機能をテストしたり、共通コンポーネントのstoryをCI経由でデプロイできるようにもしてあります。さらに整備を進めることで、将来的には、共通部分は100%テスト済な状態で各案件の基盤にできるようにする予定です。

一方で、Templateリポジトリを元にしたサンドボックスのリポジトリも用意しました。当初はTemplateリポジトリだけを基底のリポジトリとして運用していたのですが、Templateリポジトリは全プロジェクトの根幹となるため、気軽に触って遊びづらいというデメリットがありました。それが理由で、使われなかったり、そもそも放置されがちになっていたんですね。Templateリポジトリをベースにしたサンドボックスリポジトリを用意することで、メンバーが気軽にNuxt3で遊んだり、技術検証することができるようになりました。現在では、Babylon.jsやVuesticが導入されたり、Web3技術が導入されたり、AIのチャットボットができるようになっています。新規メンバーが気軽に現環境を体験できるリポジトリとしても活用することができています。Templateリポジトリとは違い、エッジケースに対応できて、例えば「共通で使えるようなadmin画面を作りたい」みたいな要望に対しては、このサンドボックスにお試しでサクッと実装して、各案件ではこれを参考にしたりComponentsを案件リポジトリに引っ張ることで、スピーディーに開発できるようにしています。

Nuxt3を導入する上でも、Templateリポジトリで基礎を組んだ上で、サンドボックスでサンプルを実装していくことで、案件リポジトリへの導入を円滑にすることに寄与できたはず...だと思っています。

Nuxt3のconfigは全部書いた

Nuxt2からNuxt3に移行すると、結構な破壊的変更があって結構大変です。ディレクトリ構成の変更やNuxt提供コンポーネントの命名変更、composablesの使い方とかにも変更がありました。その中でも影響が大きく、整備が面倒なのは、やはりnuxt.config.tsのプロパティの変更ではないでしょうか?nuxt.config.tsの変更は、実際に変更するのも面倒なのですが、公式ドキュメントでも階層が深く、全文を公開してくれているサンプルも多くはないです。最低限のreplは公開していることはありますが、一方で、実用的な内容は伏せられてことが多いのではないでしょうか?

弊チームのTemplateリポジトリ上のnuxt.config.tsでは、公式ドキュメントに記載されているすべてのプロパティを参考URLとともに記載するようにしました。こうすることで、Templateリポジトリを案件リポジトリに導入し、設定者が不明点にぶち当たった時に、すぐに公式ドキュメントを参照できるようにしています。また、型定義の上で、定数に値を入れるだけでnuxt.config.tsを設定できるようにすることで、そもそもとしてコンフィグの詳細を理解しなくても使用できるようにしてあります。

以下に、一部抜粋版を掲載します。Nuxt3の公式ドキュメントのURLが https://nuxt.com/ になった際には、改修するのがめっちゃ大変でした...


// nuxt.config.ts (一部抜粋版)

import eslintPlugin from 'vite-plugin-eslint'
import svgLoader from 'vite-svg-loader'
import { i18nOptions } from './src/modules/i18n'

/**
 * Types
 */

/** types */
type EnvType = 'local' | 'staging' | 'production'
type Url = {
  [K in EnvType]: string
}
type MetaInfo = {
  title: string
  description: string
  robots: string
}

/** type functions */
// (※ 略)

/**
 * user setting constants
 */

/** env values */
const envUrl: Url = {
  local: 'http://localhost:3000',
  staging: '',
  production: '',
}

/** config values */
// const rootDir = './'
const srcDir = 'src'
const metaTitle = ''
const metaDescription = ''
const isSsr = false
const checkTypeCheckOnBuild = true

/**
 * generated constants
 */
/** env values */
const NUXT_ENV_OUTPUT_ENV: EnvType = process.env.OUTPUT_ENV || 'local'
const NUXT_ENV_URL =
  NUXT_ENV_OUTPUT_ENV === 'local'
    ? envUrl.local
    : NUXT_ENV_OUTPUT_ENV === 'staging'
    ? envUrl.staging
    : NUXT_ENV_OUTPUT_ENV === 'production'
    ? envUrl.production
    : envUrl.local

/** config values */
const needAnalyze = NUXT_ENV_OUTPUT_ENV === 'local'
const enableDebug = NUXT_ENV_OUTPUT_ENV === 'local'
const meta: MetaInfo = {
  title: metaTitle,
  description: metaDescription,
}

/**
 * Nuxt Config
 * @ref https://nuxt.com/docs/api/configuration/nuxt-config
 */
export default defineNuxtConfig({
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#alias */
  // alias: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#app */
  app: {
    // baseURL:
    // buildAssetsDir: '/.nuxt/',
    // cdnURL:
    head: {
      meta: [
        // {"name": "viewport", "content": width=device-width, initial-scale=1 },
        // {"charset": "utf-8"},
        {
          hid: 'description',
          name: 'description',
          content: meta.description,
        },
        {
          hid: 'og:title',
          property: 'og:title',
          content: meta.title,
        },
        {
          hid: 'og:description',
          property: 'og:description',
          content: meta.description,
        },
        // 略
      ],
      // link: [],
      // style: [],
      // script: [],
      noscript: [{ children: 'Javascript is required' }],
    },
    // keepalive: false,
    // layoutTransition: false,
    // pageTransition: false,
    // rootId: '__nuxt',
    // rootTag: 'div',
  },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#appconfig */
  // appConfig: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#build */
  build: {
    // note: https://github.com/btd/rollup-plugin-visualizer#options
    analyze: needAnalyze,
    // templates: [],
    // transpile: [],
  },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#builddir */
  // buildDir: './.nuxt',
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#builder */
  // builder: '@nuxt/vite-builder',
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#components */
  // components: null,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#css */
  // css: [],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#debug */
  debug: enableDebug,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#dev */
  // dev: false,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#devserver */
  // devServer: {
  //   host: 'localhost',
  //   https: false,
  //   port: 3000,
  //   url: 'http://localhost:3000',
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#devserverhandlers */
  // devServerHandlers: [],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#dir */
  // dir: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#experimental */
  // experimental: {
  //   asyncEntry: false,
  //   crossOriginPrefetch: false,
  //   externalVue: true,
  //   inlineSSRStyles: true,
  //   noScripts: false,
  //   payloadExtraction: false,
  //   reactivityTransform: false,
  //   treeshakeClientOnly: true,
  //   viteNode: true,
  //   viteServerDynamicImports: true,
  //   writeEarlyHints: false,
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#extends */
  // extends: undefined,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#extensions */
  // extensions: [],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#generate */
  // generate: {
  //   exclude: [],
  //   routes: [],
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#hooks */
  // hooks: {},
  // ignore: https://nuxt.com/docs/api/configuration/nuxt-config#ignore
  ignore: [
    '.output',
    '**/test/*.{js,ts,jsx,tsx}',
    '**/*.stories.{js,ts,jsx,tsx}',
    '**/*.{spec,test}.{js,ts,jsx,tsx}',
    '**/-*.*',
  ],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#ignoreoptions */
  // ignoreOptions: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#ignoreprefix */
  // ignorePrefix: '-',
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#imports */
  // imports: {
  //   dirs: [],
  //   global: false,
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#modules */
  modules: ['@nuxtjs/i18n'],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#modulesdir */
  // modulesDir: [],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#nitro */
  // nitro: {
  //   routeRules: {},
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#pages-1 */
  // pages: true,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#plugins-1 */
  // plugins: [],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#postcss */
  // postcss: {
  //   config: false,
  //   plugins: {
  //     autoprefixer: true,
  //     cssnano: true,
  //     postcssImport: {},
  //     postcssUrl: {},
  //   },
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#rootdir */
  // rootDir: '/<rootDir>',
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#routerules-1 */
  // routeRules: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#router */
  // router: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#runtimeconfig */
  // runtimeConfig: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#serverdir */
  // serverDir: '',
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#serverhandlers */
  // serverHandlers: [],
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#sourcemap */
  // sourcemap: {},
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#srcdir */
  srcDir: `${srcDir}/`,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#ssr */
  ssr: isSsr,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#telemetry */
  // telemetry: false,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#test */
  // test: false,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#theme */
  // theme: undefined,
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#typescript */
  typescript: {
    // includeWorkspace: false,
    // shim: true,
    // strict: true,
    // tsConfig: {},
    typeCheck: checkTypeCheckOnBuild,
  },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#vite */
  vite: {
    build: {
      // assetsDir: '_nuxt/',
      emptyOutDir: true,
    },
    // clearScreen: false,
    // define: {
    //   'process.env': process.env,
    // },
    // esbuild: {},
    // logLevel: 'warn',
    // mode: 'production',
    // optimizeDeps: {
    //   exclude: ['vue-demi'],
    // },
    // publicDir: '',
    // resolve: {
    //   extensions: [],
    // },
    // root: '',
    // server: {
    //   fs: {},
    //   allow: [],
    // },
    // vue: {
    //   isProduction: true,
    //   template: {
    //     compilerOptions: {},
    //   },
    // },
    plugins: [
      eslintPlugin(),
      svgLoader({
        defaultImport: 'component', // 'component', 'url', 'raw'
        svgo: false,
      }),
    ],
  },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#vue-1 */
  // vue: {
  //   compilerOptions: {},
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#watchers */
  // watchers: {
  //   chokidar: {
  //     ignoreInitial: true,
  //   },
  //   rewatchOnRawEvents: false,
  //   webpack: {
  //     aggregateTimeout: 1000,
  //   },
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#webpack-1 */
  // webpack: {
  //   aggressiveCodeRemoval: false,
  //   analyze: false,
  //   cssSourceMap: false,
  //   devMiddleware: {
  //     stats: 'none',
  //   },
  //   extractCSS: true,
  //   filenames: {},
  //   friendlyErrors: true,
  //   hotMiddleware: {},
  //   loaders: {},
  //   optimization: {},
  //   optimizeCSS: {},
  //   plugins: [],
  //   postcss: {},
  //   profile: false,
  //   serverURLPolyfill: 'url',
  //   terser: {},
  //   warningIgnoreFilters: [],
  // },
  /** @ref https://nuxt.com/docs/api/configuration/nuxt-config#workspacedir */
  // workspaceDir: '',

  /**
   * modules settings
   */
  i18n: i18nOptions,
})

Nuxt2 + Composition APIの構成で案件を進めつつ、Templateリポジトリとサンドボックスを整備することで、どうにかこうにか、今回、Nuxt3を用いたプロダクトのリリースをすることができました。今後は、Hybrid RenderingとかISRを試して、できることなら今後のプロダクトにも採用していきたいですね。

もちろん、前項に挙げた負債の返済も並行して進めていきます。既存プロジェクトのリファクタは、「リファクタプロジェクト」を立ち上げて、進めていく予定です。早々に、Nuxt2が残っているリポジトリをNuxt3に移行できるように努めます。この移行は、目先のパフォーマンス向上のみならず、将来的な技術的な選択肢を増やすことにもつながるので、喫緊の課題であるとは認識しておりますが故。

あとがき ~VR法人HIKKYのWebフロントエンドエンジニアとして~

冒頭から宣伝とかいうのはちょっと...だったので、ここで宣伝しますと、Nuxt3を採用した弊社プロダクトというのは、本日β版がリリースされた"My Vket"という弊社サービスになります。

https://vket.com

今のところはβ版で、四半期も開発工数をかけることができていないサービスなのですが、そんな中でもメンバー各位が最善を尽くし、アニメーションももりだくさん、弊社独自の3Dエンジンも組み込み、vrmアバターで自分だけのプロフィールを彩ったり、別サービスであるAvatar Makerと連携することもできます。将来的にはメタバース空間で遊んだり、ウォレットと連携もできることになるようです。すごいですね!

まあ、ここまで私は全く開発に貢献できていないのですが... ひとえにメンバー各位が非常に優秀なんですね。いつも助けられてばかりです。私はメタバースの端っこでTemplateリポジトリとサンドボックスをコネコネしていただけです。自社の主力プロダクトにこんなにも関わらないチーフエンジニアというのもいるもんなんですね。

メタバース関連サービスとしておそらく全世界初のNuxt3プロダクトである"My Vket"を、今後とも、何卒よろしくお願いいたします!

そんな弊社では、適宜、採用もしています。採用しているロールは随時変わりますので、弊社公式サイトVket Crewから、是非求人情報をチェックしてみてください。カジュアル面談もWebフロントエンドチームとしてはできると思いますので、ご興味のあるNuxterの方は、何卒よろしくお願いいたします!

あとがき ~OmusBridge OÜの代表ならびに個人として~

上の弊社が忙しくて全然プロダクト開発に勤しめていないのですが、今年の年末と来年の頭の2週間は有給をとりまくったので、そこですべてを作り上げようと思います。ここで成果を上げて、来年こそはエストニアに帰りたいですね。

最近、上の弊社外とのコミュニケーションがめっちゃ希薄になっているので、界隈にも顔を出していこうと思います。そういった場で、進捗の共有とかも出していきたいですね。なんかそういう都合のいいコミュニティとかDiscordとかTwitterグループありませんか?Nuxt使いが集まる場所とかもあれば参加したい...Nuxt3について駄弁る場所が欲しい。メタバースに作るか...あとは勉強会とか登壇したいので、そういう機会も募っています。何卒何卒!!

GitHubで編集を提案

Discussion