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 を行う場合はこの形になると思います。
npm でインストール後、 nuxt.config.ts
の defineNuxtConfig
に proxy
設定を追加します。
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_ENV
は development
と production
のどちらかのみ想定しているもが多く、ビルドする上でも不都合がありましたので、 NODE_ENV
は development
と production
以外の値を入れるのはやめました。
サーバ環境で設定を分岐する場合は、 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
を使って書いても良さそうに見えます。実際それでも動くのですが、ref
は setup
セクション以外で使うとサーバで状態が共有されメモリリークが発生するため、 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.ts
で head
というセクションに書いていましたが、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 で使う場合はこのように書きます。
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 に変換する必要があります。
リクエストヘッダについても、Host
や Accept
等のヘッダを必要に応じて付与します。サーバサイドプロセスから外部のサーバにリクエストする場合、プロキシではないため 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
を防ぐためのトークン送信なども必要になると思いますので、$fetch
や useFetch
を使わず、 fetch
のラッパーを作って使っています。
package.json
Nuxt3 の場合、 package.json
の dependencies
は空のオブジェクトにして、必要な依存関係はすべて 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 を使うようになってからの大きな利点だと感じます。