当社プロダクトにおける、nuxt2→nuxt3 への移行時の記録


2022年11月16日、Nuxt3 が正式リリースされました。

当社でサービスしているプロダクトの1つを、Nuxt2 から Nuxt3 に書き換えましたので、変更した実施内容を書きます。

Nuxt2 から Nuxt3 への変更点

概要としては、 Zennの下記記事が参考になります。

祝・正式リリース!5つのテーマで理解する Nuxt3 の魅力

具体的な変更点や使い方は、今回の記事には記載しません。公式サイトの docs タブにまとめられています。マイグレーションを行う際には、docs 内の記事は一通り読むことをおすすめします。

Introduction · Get Started with Nuxt

移行(マイグレーション)の進め方

既存の Nuxt2プロジェクトを Nuxt3 にマイグレーションする場合、リポジトリをそのまま使うか新しく作るかの選択がありますが、新しく作ることを推奨します。

変更点は膨大になるため、既存のリポジトリをそのまま使うのは現実的ではありませんし、既存リポジトリを流用していく方法では、最初の動作確認をするまでにしなければいけないコード修正の量が膨大です。最小のコード修正で最初の動作確認ができるようにしたほうが、間違った時のコストを少なくできます。

npx nuxi init <project-name>

で新しいプロジェクトを開始し、リポジトリも新しく作ってください。

新しいプロジェクトの作成後は、まずは利用規約ページ、FAQページ、問い合わせページ等、依存リソースの少ないページを1つづつ移行していくと良いと思います。

移行にかかった時間

今回のプロジェクトは、pages が 20, components が 100 ほどの規模でしたが、空き時間にちまちまやって3週間かかりました。集中して時間がとれれば1週間ぐらいでできると思います。

  • ユニットテストはありません。
  • この時間には、スタッフによる品質検証の時間は含まれていません。

移行時の目立った問題

Vue2 で使用していたプラグインが Vue3 に対応していない場合がある

Nuxt ではなく Vue の話になるのですが、いくつかの Vue2 用のライブラリが Vue3 に対応していなため、代替を探したり自作する必要があります。

Nuxt2 から Nuxt3 のマイグレーションで、一番時間がかかったのはこの部分でした。

今回は、以下の変更がありました。

  • hooper → swiper (トップページのヒーローカルーセルで使用)
  • vue-markdown → vue3-markdown-it (MarkDown のレンダリング)
  • vue-multiselect → @vueform/multiselect (Selectウィジェットの代替)

動作に互換性は無いため、使用箇所は新しいライブラリの API に適合できるよう、大きく書き直しています。

以下のライブラリは、バージョンアップで Vue3 にも対応していたため、そのまま使用できました。(プラグインファイルだけ書き直しました)

  • v-calendar

以下のライブラリは、Vue3 に対応しておらず、代替のライブラリも無かったため、一旦機能を無効化しています。
再現するには自作する等の対応が必要になります。

  • vue-highlight-words (検索語句のハイライト)

コード修正箇所

Nuxt2 プロジェクトから Nuxt3 プロジェクトにマイグレーションする際の目立った変更箇所です。

APIプロキシ

当社のプロダクトは、 /api/ 等のリクエストをバックエンドサーバにプロキシを行うようにしています。

(Nuxt3 にビルトインされている、 /server/ ディレクトリを用いるサーバサイド API は使っていません。)

プロダクトによっては、Nuxt に入ってくる前に AWS の アプリケーションロードバランサー や Kubernetes Ingress でルーティングする場合と、一度 Nuxt で受けてから Nuxt のサーバサイドプロセスでプロキシする場合と、両方のパターンがあります。

Mac での開発環境では、Kubernetes Ingress や Nginx は使わないため、必ず Nuxtでのプロキシが必要になります。

Nuxt2 では、nuxt.config.ts の proxy 設定で書いていましたが、Nuxt 3 では本番環境で使うか (つまりビルド成果物に含めるか) どうかで2通りのプロキシ方法があります。

本番環境ではプロキシは必要なく、開発環境でだけ実現だければ良い場合

本番環境では、ALB や Kubernetes Ingress でルーティングが実現できるため必要なく、開発環境でのみプロキシを行いたい場合は、Nitro の devProxy の機能で実現できます。

主に、SSR を行わず、成果物を SSG ( generate ) して動作させる場合はこの方式になると思います。

nitro の標準機能で実現できるため、追加のライブラリは必要がなく、設定ファイルを1つ追加させるだけで可能です。

nitro.config.ts の例
import {defineNitroConfig} from 'nitropack'

function createNitroConfig() {
// 開発環境用のプロキシを設定する
const configFile = process.env.CONFIG_FILE || 'local'

const config = require(`./config/${configFile}.ts`)

if (!config.makeProxyValue) {
console.error(`./config/${configFile}.ts に makeProxyValue がありません`)
}

const proxyPaths = [
'/api/',
'/login/',
'/media/',
...
]

for (const proxyPath of proxyPaths) {
proxySettings[proxyPath] = config.makeProxyValue(proxyPath)
}
return {
devProxy: proxySettings
}
}

export default defineNitroConfig(createNitroConfig())
config/development.ts
// nitro プロキシの設定
export function makeProxyValue(path: string) {
return {
target: `https://api.example.com${path}`,
changeOrigin: true,
hostRewrite: true,
cookieDomainRewrite: true,
headers: {
'X-Forwarded-Host': 'localhost:3006',
'X-Forwarded-Proto': 'http',
'Referer': 'https://api.example.com'
}
}
}

詳しくは、 Configuration | ⚗️ Nitro の devProxy の項目をご覧ください。

本番環境でも開発環境でも同様にプロキシを行う場合

本番環境でも Nuxt のプロセス(コンテナ) でプロキシを行う場合、標準機能では実現できないため nuxt-proxy のライブラリを使います。

SSR を行う場合はこの形になると思います。

nuxt-proxy - npm

npm でインストール後、 nuxt.config.tsdefineNuxtConfigproxy 設定を追加します。

nuxt.config.ts
export default defineNuxtConfig({
  ...
  modules: ['nuxt-proxy'],
  proxy: {
    options: {
    target: ...,
changeOrigin: true,
headers: ...,
pathRewrite: {
'^/some/url/path/': '/api/path/'
...
},
pathFilter: [
'/more/url/path/',
...
]
}
}

環境設定変数の変更

Nuxt3 とは直接的な関係が薄い内容ですが、
当社では、今までのプロジェクトでは環境変数 NODE_ENV に、development, production 以外の設定名も指定していました。
例えば、ステージングサーバでは NODE_ENV=staging といった形での指定がありました。

ただし、ライブラリによっては NODE_ENVdevelopmentproduction のどちらかのみ想定しているもが多く、ビルドする上でも不都合がありましたので、 NODE_ENVdevelopmentproduction 以外の値を入れるのはやめました。

サーバ環境で設定を分岐する場合は、 CONFIG_FILE という新たな環境変数を使用するようにしています。

CONFIG_FILE で設定ファイルを指定し、記載されている設定を Nuxt に伝えるために、環境別の設定ファイルと nuxt.config.ts はこのような指定となっています。

config/devserver.ts
export const runtimeConfig = {
  // public 内で定義したものはクライアントでも使える
public: {
baseUrl: 'https://example.com',
}
}

export const proxyConfig = {
target: 'http://host.docker.internal:8000',
headers: {
'Host': 'example.com',
'X-Forwarded-Host': 'example.com',
'X-Forwarded-Proto': 'https',
'Referer': 'https://example.com'
}
}
nuxt.config.ts
const configBaseName = process ? (process.env.CONFIG_FILE || 'development') : 'development'
const configContent = require(`./config/${configBaseName}.ts`)

export default defineNuxtConfig({
// runtimeConfig は useRuntimeConfig() で参照できる
runtimeConfig: configContent.runtimeConfig,
...
modules: ['nuxt-proxy'],
proxy: {
options: {
target: configContent.proxyConfig.target,
changeOrigin: true,
headers: configContent.proxyConfig.headers,
...
}
}

※ 定義した設定をクライアント側で使うには、nuxt.config.ts の中で runtimeConfig.public に設定を入れ、クライアント側で useRuntimeConfig を実行します。

useRuntimeConfig · Nuxt Composables

vueコンポーネントの変更

script セクションは、すべて script setup に書き換えました。

setup 構文は、Vue3 の一番目立つ変更点だと思います。

Nuxt3の自動インポートの機能もあいまって、今までの Vue コンポーネントの script 部分を 1/2 程度の分量で記述でき、
とても良い開発体験があります。

多くの記事で説明されているため、概要の紹介はここでは書きませんが、ざっくり書いたカウンターのコンポーネントは以下のようになります。

<script lang="ts" setup>
const count = ref<number>(0)

function increment() {
count.value++
}
</script>

<template>
<button @click.prevent="increment">
{{ count }}
</button>
</template>

本当に必要な内容だけをコードで書けばよくなっています。コンポーネント内で、import を書くケースはほとんどありません。

以下の記事が参考になります。

【Vue.js 3.2】`<script setup>` 構文がすごくすごい

page コンポーネントの変数プレースホルダの変更

今まで、 _id.vue と命名していたファイルは、 [id].vue とつける必要があります。

components ディレクトリの自動インポート

components ディレクトリ内に書いた vue コンポーネントは、 import 文無しで使うことができます。

例えば、components/header/HeaderSearchButton.vue という名前のコンポーネントは、 インポート無し<HeaderSearchButton> として使えます。

また、 components/header/SearchButton.vue という名前のコンポーネントも、同じく、 <HeaderSearchButton> として使えます。

ディレクトリ名 + ファイル名 でコンポーネント名になりますが、ファイル名の先頭とディレクトリ名が重複する場合、重複する部分は打ち消されます。

components/global/ ディレクトリは特別で、この中に入れたものは Global を省略して、ファイル名そのままでコンポーネントとして使えます。

今までの当社のプロダクトは、区分が必要無いコンポーネントについては components/common/ ディレクトリに配置することが多かったのですが、components/common/ ディレクトリは components/global/ ディレクトリに変えるようにしています。

Vuex ストア → composables ディレクトリ内の composable 関数

Nuxt2 では、グローバルストアは Vuex + vuex-module-decorators (もしくは nuxt-typed-vuex) を使って書いていました。

Nuxt3 では、Vuex は使わなくなり、かわりに composable を使ってグローバル状態管理を構築します。

composables/ · Nuxt Directory Structure

composables/ 以下のディレクトリにある useXxxxx 関数は、自動インポートの対象になります。

例えば、vuex-module-decorators では、カウンターの Vuex ストアは以下のように書いていました。

Nuxt2: store/counter.ts ( vuex-module-decorators の場合)
import { Module, VuexModule, Mutation, Action } from 'vuex-module-decorators'

@Module({
name: 'counter',
stateFactory: true,
namespaced: true
})
export default class extends VuexModule {
count: number = 0

get doubleCount (): number {
return this.count * 2
}

@Mutation
setCount (value: number) {
this.count = value
}

@Action({ rawError: true })
async increment () {
this.setCount(this.count + 1)
}
}

nuxt-typed-vuex で書いた場合は以下のようになります。

Nuxt2: store/counter.ts ( nuxt-typed-vuex の場合)
import { getterTree, mutationTree, actionTree } from 'typed-vuex'

export const state = () => ({
count: 0 as number
})

export type RootState = ReturnType<typeof state>

export const getters = getterTree(state, {
doubleCount(state): number {
return state.count * 2
}
})

export const mutations = mutationTree(state, {
setCount(state, count: number) {
state.count = count
}
})

export const actions = actionTree(
{ state, mutations },
{
increment({ commit }) {
commit('setCount', state.count + 1)
}
}
)

Nuxt3 composable の場合、composable を使って以下のように書きます。

composables/counter.ts
export const useCounterComposable = () => {
const count = useState<number>('count', () => 0)

const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}

return {
count,
doubleCount,
increment,
}
}

Vueコンポーネント内で使う際は、このようになります。

composables/counter.vue
<template>
<button @click.prevent="counterComposable.increment" >
{{ counterComposable.count}} * 2
= {{ counterComposable.doubleCount }}
</button>
</template>
<script lang="ts" setup>
const counterComposable = useCounterComposable()
</script>

Vuex Store から composable 関数へのマイグレーションは、完全に 1:1 で行えます。
(ミューテーションは不要になります。)

ファイル名はそのままに、手動で機械的に composable 関数に変換していくと良いでしょう。

注意点

composables/counter.ts の

const count = useState<number>('count', () => 0)

は、

const count = ref<number>(0)

と、ref を使って書いても良さそうに見えます。実際それでも動くのですが、refsetup セクション以外で使うとサーバで状態が共有されメモリリークが発生するため、 setup セクション以外では使えません。

State Management · Get Started with Nuxt

useState の第一引数のキー名は、利用者のアプリ内で一意のものであり、キー名が一致したらどこでコールしても共通のカウント数が取得できます。

const count = useState<number>('count', () => 0)

ただし、使用箇所が分散するとメンテナンスが難しくなるため、composables/ の中だけで扱うと決めるのが良いと思います。

サイトすべてで共通の HTML の head セクション

title タグ、viewport や文字コード等の meta タグ、共通スタイルシートの link タグなどの、サイト内で共通の HTML の head セクションの定義は2通りの方法があります。

nuxt.config.ts の app.head に書く

Nuxt2では、nuxt.config.tshead というセクションに書いていましたが、Nuxt3 以降は app.head に書きます。

Nuxt Configuration Reference · Nuxt #head

基本的な書き方は変わっていません。

app.vue で useHead を使う

一番大枠のVueコンポーネントである、app.vue に、script setup を作り、useHead 関数を使って Head 項目の定義をすることもできます。

nuxt.config.ts で定義すれば良いものなので基本的には使うことは無いと思いますが、昔にこの形式でやってたこともあるので、これでも設定できることを書き残しておきます。

テンプレートフィルタ構文の廃止

テンプレートでの、下記のようなフィルタ構文廃止になり、Vue3 からは使えません。

{{ taxIncluedTotalPrice | addComma }}

そのため、普通の関数に変更する必要があります。

{{ $addComma(taxIncluedTotalPrice) }}

プラグインの書き方の変更

上記、addComma 関数は、プラグイン関数として定義すると、どこからでもインポート無しで使えて便利です。プラグイン関数はこのように書きます。

plugins/filter.ts
export default defineNuxtPlugin((nuxtApp) => {

/**
* 金額をカンマ区切りに表示します。
* $addComma(1000) => 1,000
*/
nuxtApp.provide('addComma', function (val: number) {
if (val === 0) {
return '0'
}

if (!val) {
return ''
}

if (typeof val === 'string') {
val = Number(val)
}

return val.toLocaleString()
})
})

nuxtApp.provide を実行することでで、システムグローバルに $xxxxx という名前で使える関数を定義できます。
テンプレート内でも、script setup 内でも使えます。

app.use する場合

v-calendar のように、Vue3 で app.use でプラグインを登録するライブラリを Nuxt3 で使う場合はこのように書きます。

Vue 3 | V-Calendar

plugins/v-calendar.ts
import {defineNuxtPlugin} from 'nuxt/app'
import VCalendar from 'v-calendar'

// https://vcalendar.io/vue-3.html
export default defineNuxtPlugin((nuxtApp) => {
if(process.client) {
nuxtApp.vueApp.use(VCalendar, {})
}
})

ページを遷移した時に自動的にページの先頭にスクロールアップするプラグイン

ページを移動した時にページの先頭にスクロールアップし、さらにブラウザを戻った場合に前回見ていたスクロール位置をキープする必要がある時、下記ディスカッションで書かれているコードを適用すると良いです。

scrollToTop in v3 ? · Discussion #1661 · nuxt/framework

このディスカッションの、この発言が要件を満たす完璧なコードです。

https://github.com/nuxt/framework/discussions/1661#discussioncomment-3967225

長くて仰々しいですが、使用感は一番良いです。

とはいえ…正直な所、JS のフレームワークを使っている以上、ブラウザの「戻る」の体験は、ページごとに HTML を返すクラシックなスタイルに並ぶまでは至りません。

検索結果のリストを表示して、クリックして詳細表示に遷移し、ブラウザの戻る機能で戻り、その下のものをクリックして…といった当たり前に行う操作において、クラシックなHTMLアプリに近い操作感に達するまでに、書かなければいけないコードや気にしなければいけないことが多すぎます。今後も研究していく課題だと思っています。

データフェッチ周りの改修

当社では、Nuxt2 では axios を使ってデータフェッチを行うこと多かったですが、Nuxt3 からは ohmyfetch 改め ofetch を使います。

リアクティブな状態変化に対応できるのは良いのですが、サーバでフェッチした内容がクライアントにハイドレーションされる時の挙動など慣れが必要となると思います。

また内容をキャッシュする機能があり、場合によっては意図しない所で内容がキャッシュされていまい、利用者が操作しても内容が切り替わらないこともよくあります。

データフェッチ用の関数も、 useAsyncData, useLazyAsyncData, useFetch, useLazyFetch, $fetch と、いくつもあります。

Data Fetching · Get Started with Nuxt

await useFetch('/api/count') と書いた場合、クライアント側とサーバ側で処理が変わります。

これは、私のプロダクトが /server/api/ を作っておらず、外部サーバへプロキシしているためかもしれません。

サーバ側で、 useFetch('/api/count') が発生した場合、リクエストするホスト名が不明なため、リクエストが失敗します。

そのため、サーバ側では、useFetch のオプションの baseUrl でスキーマとホストhttps://example.com を指定するか、リクエスト先のURLの先頭にスキーマとホスト等をつけて、 'https://example.com/api/count' といった完全な形のURL に変換する必要があります。

リクエストヘッダについても、HostAccept 等のヘッダを必要に応じて付与します。サーバサイドプロセスから外部のサーバにリクエストする場合、プロキシではないため nuxt.config.ts の proxy の設定は使われません。少し混乱しやすい箇所だと思いますのでご注意ください。

また、useFetch のキャッシュのキーの生成についても、私の使い方の問題だとは思うのですが、期待通りに一意のキーができてないように思うので、 useFetch のラッパーを作って明示的にキーを作っています。

export async function useFetchAPI<T>(url: string, options: any = {}) {

if (!options.key) {
// キャッシュキーを明示的に作る
options.key = url + JSON.stringify(options)
}
let requestUrl: string = url
if (process.server) {
// サーバリクエストの場合は完全な URL を作ってリクエストする
const runtimeConfig = useRuntimeConfig()
options.baseURL = runtimeConfig.proxy.options.target

// Docker 内から host.docker.internal にアクセスする際に Host ヘッダを付与する
if (runtimeConfig.proxy.options.headers.Host) {
if (!options.headers) {
options.headers = {}
}
options.headers.Host = runtimeConfig.proxy.options.headers.Host
}
}

return await useFetch<T>(
requestUrl, options)
}

$fetch は、 axios の .$get のように、レスポンスの中で必要なコンテンツのみを返してくれる関数ですが、axios の .get や、python の requests, また生の fetch のように、リクエストの結果をレスポンスオブジェクトとして取得し、レスポンスオブジェクトの HTTP ステータスや Content-Type を見て処理を分岐するといったコードは書けません。

ただ、データの Put や Post 等の更新系のリクエストは、$fetch を使わずにもっとプリミティブなレスポンスを扱ってエラーハンドリングをしたいのと、CSRF を防ぐためのトークン送信なども必要になると思いますので、$fetchuseFetch を使わず、 fetch のラッパーを作って使っています。

package.json

Nuxt3 の場合、 package.jsondependencies空のオブジェクトにして、必要な依存関係はすべて devDependencies に書きます
Svelte でもそうで、Vite を使う場合は基本的にこの形になるのだと思います。

package.json
{
"private": true,
"scripts": {
"build": "nuxt build",
"build:devserver": "CONFIG_FILE=devserver nuxt build",
"build:production": "CONFIG_FILE=production nuxt build",
"dev": "PORT=3006 nuxt dev",
"dev:local": "CONFIG_FILE=local PORT=3006 nuxt dev",
"generate": "nuxt generate",
"preview": "PORT=3006 nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"nuxt": "3.0.0",
"sass": "^1.56.1",
"@vueform/multiselect": "^2.5.6",
"nuxt-proxy": "^0.3.4",
"swiper": "^8.4.4",
"v-calendar": "^3.0.0-alpha.8",
"vue3-markdown-it": "^1.0.10"
},
"dependencies": {
}
}

ビルドを行うと、nitro/h3 の Webサーバエンジンも含めたすべての依存ライブラリを output ディレクトリにまとめて書き出します。
output ディレクトリさえあれば他に依存せずアプリを動作させることができるようになっています。

Dockerfile

動作時に外部の依存ライブラリが必要無いということは、 Dockerfile をかなりシンプルに書けます。

Dockerfile
FROM node:16-slim

WORKDIR /app

COPY .output /app/.output

USER nobody
CMD [ "node", ".output/server/index.mjs"]

当社の環境では、ビルドごとに Docker イメージを作っています。
.output ディレクトリを実行環境にコピーできれば、node の docker コンテナにマウントさせて実行させるのも簡単なため、独自の Dockerイメージすら必要無いでしょう。

本番の実行環境の構築が非常に楽になったのは Vite を使うようになってからの大きな利点だと感じます。

現在の評価: 4.8

コメント

コメントを投稿
コメントするには TORICO-ID にログインしてください。
ログイン コメント利用規約