Nuxt 2 升级 Nuxt 3 操作文档
基于企业官网项目实际升级经验整理,适用于类似 Nuxt 2 项目升级到 Nuxt 3 的场景。
一、升级前后目录结构对比
Nuxt 2 项目结构(升级前)
project-root/
├── api/ # API 接口定义
│ └── index.js
├── assets/ # 需要编译的静态资源
│ ├── css/
│ │ └── iconfont.css
│ ├── images/
│ └── scss/
│ ├── common.scss
│ ├── ant.scss
│ ├── _variable.scss
│ └── reset.css
├── components/ # Vue 组件
│ ├── CustomHeader/
│ ├── CustomFooter/
│ └── ...
├── layouts/ # 布局组件
│ └── default.vue
├── locales/ # 多语言文件
│ ├── en.json
│ └── zh.json
├── middleware/ # 中间件
│ ├── i18n.js
│ └── navigation-guard.js
├── pages/ # 页面路由
│ └── [lang]/
│ ├── index.vue
│ ├── aboutus.vue
│ ├── contactus.vue
│ └── ...
├── plugins/ # 插件
│ ├── axios.js
│ ├── vue-i18n.js
│ ├── antd-ui.js
│ └── ...
├── static/ # 纯静态资源(不编译)
│ ├── css/
│ ├── js/
│ └── favicon.ico
├── store/ # Vuex 状态管理
│ ├── index.js
│ ├── lang.js
│ └── ...
├── .babelrc
├── .editorconfig
├── .gitignore
├── nuxt.config.js # Nuxt 2 配置
└── package.json
Nuxt 3 项目结构(升级后)
project-root/
├── api/ # API 接口定义(保持不变)
│ └── index.js
├── app.vue # 【新增】应用根组件
├── assets/ # 需要编译的静态资源(保持不变)
│ ├── css/
│ ├── images/
│ └── scss/
├── components/ # Vue 组件(保持不变)
│ ├── CustomHeader/
│ ├── CustomFooter/
│ └── ...
├── layouts/ # 布局组件(保持不变)
│ └── default.vue
├── locales/ # 多语言文件(保持不变)
│ ├── en.json
│ └── zh.json
├── middleware/ # 中间件(语法变更)
│ ├── i18n.js
│ └── navigation-guard.js
├── pages/ # 页面路由(保持不变)
│ └── [lang]/
│ ├── index.vue
│ ├── aboutus.vue
│ ├── contactus.vue
│ └── ...
├── plugins/ # 插件(语法变更)
│ ├── axios.js
│ ├── vue-i18n.js
│ ├── antd-ui.js
│ └── ...
├── public/ # 【重命名】static → public
│ ├── css/
│ ├── js/
│ └── favicon.ico
├── stores/ # 【重命名】store → stores,Vuex → Pinia
│ ├── lang.js
│ └── ...
├── .gitignore
├── nuxt.config.js # Nuxt 3 配置(语法变更)
└── package.json # 依赖版本更新
关键差异总结
| 项目 | Nuxt 2 | Nuxt 3 |
|---|---|---|
| 应用根组件 | 无 | app.vue |
| 静态资源目录 | static/ | public/ |
| 状态管理 | store/ + Vuex | stores/ + Pinia |
| 配置语法 | CommonJS | ESM / defineNuxtConfig() |
| 插件语法 | export default (context, inject) => {} | export default defineNuxtPlugin(() => {}) |
| 中间件语法 | export default (context) => {} | export default defineNuxtRouteMiddleware((to, from) => {}) |
| HTTP 请求 | @nuxtjs/axios | useFetch() / $fetch |
| 构建工具 | Webpack | Vite |
| 构建产物 | .nuxt/ | .output/ |
二、升级步骤
步骤 1:创建新项目骨架
# 创建 Nuxt 3 项目
npx nuxi@latest init project-name
# 或在现有目录初始化
npx nuxi@latest init .
步骤 2:更新 package.json
升级前(Nuxt 2):
{
"dependencies": {
"nuxt": "^2.x",
"@nuxtjs/axios": "^5.x",
"vuex": "^3.x",
"vue-i18n": "^8.x",
"ant-design-vue": "^1.x"
}
}
升级后(Nuxt 3):
{
"dependencies": {
"nuxt": "3.13.0",
"@pinia/nuxt": "^0.5.1",
"pinia": "^2.1.7",
"ant-design-vue": "^4.2.6",
"vue-i18n": "^9.14.0",
"swiper": "^11.1.14",
"vue-baidu-map-3x": "^1.0.40",
"vue-clipboard3": "^2.0.0",
"vue3-video-play": "^1.3.1",
"animate.css": "^3.7.2",
"animejs": "^3.2.2",
"crc": "4.3.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"echarts": "^5.6.0",
"less": "^4.2.0",
"sass": "1.77.8",
"sass-loader": "^14.2.1"
}
}
关键变更:
nuxt升级到3.x@nuxtjs/axios移除,改用$fetchvuex移除,改用piniaant-design-vue升级到4.x(Vue 3 版本)vue-i18n升级到9.x(Vue 3 版本)vue-baidu-map替换为vue-baidu-map-3x
步骤 3:创建 app.vue
Nuxt 3 新增 app.vue 作为应用根组件:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
步骤 4:更新 nuxt.config.js
升级前(Nuxt 2):
export default {
mode: 'universal',
head: {
title: 'my-app',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
]
},
css: [
'ant-design-vue/dist/antd.css',
'~/assets/scss/common.scss'
],
plugins: [
'@/plugins/antd-ui',
'@/plugins/axios'
],
modules: [],
buildModules: [],
build: {
extend(config, ctx) {}
}
}
升级后(Nuxt 3):
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: false },
devServer: {
port: 80
},
ssr: true,
app: {
head: {
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: process.env.npm_package_description || '' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: '/css/bootstrap.min.css' }
],
script: [
{ src: '/js/jquery.slim.min.js' },
{ src: '/js/bootstrap.bundle.min.js' }
]
},
pageTransition: {
name: 'layout',
mode: 'out-in'
}
},
css: [
'ant-design-vue/dist/reset.css',
'swiper/swiper-bundle.css',
'animate.css/animate.css',
'~/assets/css/iconfont.css',
{ src: '~/assets/scss/common.scss', lang: 'scss' },
{ src: '~/assets/scss/ant.scss', lang: 'scss' }
],
plugins: [
'@/plugins/antd-ui',
'@/plugins/axios',
'@/plugins/swiper',
{ src: '@/plugins/vue-clipboard2', mode: 'client' },
'@/plugins/animate',
'@/plugins/vue-i18n',
'@/plugins/anime',
'@/plugins/uuid',
{ src: '@/plugins/video-player', mode: 'client' },
{ src: '@/plugins/adapter', mode: 'client' },
{ src: '@/plugins/vue-baidu-map', mode: 'client' },
{ src: '@/plugins/echarts', mode: 'client' }
],
modules: [
'@pinia/nuxt'
],
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "~/assets/scss/_variable.scss" as *;',
api: 'modern-compiler'
}
}
}
},
nitro: {
prerender: {
routes: ['/zh', '/en'],
failOnError: false
},
server: {
host: '0.0.0.0'
}
},
compatibilityDate: '2024-11-01'
})
关键变更:
mode: 'universal'→ssr: truehead→app.headbuildModules合并到modulesbuild.extend→vite配置- 新增
nitro配置(服务端引擎) - CSS 中
antd.css→reset.css
步骤 5:迁移状态管理(Vuex → Pinia)
升级前(Vuex):
// store/index.js
export const state = () => ({
counter: 0
})
export const mutations = {
increment(state) {
state.counter++
}
}
export const actions = {
increment({ commit }) {
commit('increment')
}
}
升级后(Pinia):
// stores/lang.js
import { defineStore } from 'pinia'
export const useLangStore = defineStore('lang', {
state: () => ({
defaultLanguage: 'zh',
Language: {
'zh': 'zh',
'en': 'en'
},
locales: ['en', 'zh'],
locale: 'zh',
}),
actions: {
SET_LANG(locale) {
if (this.locales.indexOf(locale) !== -1) {
this.locale = locale
}
}
}
})
使用方式变更:
// Nuxt 2 (Vuex)
this.$store.state.lang.locale
this.$store.dispatch('SET_LANG', 'en')
// Nuxt 3 (Pinia)
const langStore = useLangStore()
langStore.locale
langStore.SET_LANG('en')
步骤 6:迁移 HTTP 请求(@nuxtjs/axios → $fetch)
升级前(Nuxt 2 + @nuxtjs/axios):
// plugins/axios.js
export default function ({ $axios, store }, inject) {
$axios.setBaseURL('https://www.fgwj.com/api/website')
$axios.onRequest((config) => {
config.headers['Content-Type'] = 'application/json'
config.headers['Accept-Language'] = obj[store.state.lang.locale]
return config
})
$axios.onResponse((response) => {
if (response.status === 200) {
return response.data
}
})
$axios.onError((error) => {
console.error(error)
})
inject('indexApi', indexApi($axios))
}
升级后(Nuxt 3 + $fetch):
// plugins/axios.js
import indexApi from '@/api/index.js'
import { useLangStore } from '@/stores/lang.js'
export default defineNuxtPlugin(() => {
const langStore = useLangStore()
const baseURL = 'https://www.fgwj.com/api/website'
const httpAdapter = {
get: async (url, options = {}) => {
const params = options.params || {}
const langMap = { 'en': 'en-US', 'zh': 'zh-CN' }
const startTime = new Date().getTime()
try {
const response = await $fetch(url, {
baseURL,
method: 'GET',
params,
headers: {
'Content-Type': 'application/json',
'Accept-Language': langMap[langStore.locale] || 'zh-CN',
}
})
const endTime = new Date().getTime()
console.info(url, '请求时间', endTime - startTime + 'ms')
return response
} catch (error) {
handleError(error, url, 'GET', params, startTime)
throw error
}
},
post: async (url, data = {}, options = {}) => {
const langMap = { 'en': 'en-US', 'zh': 'zh-CN' }
const startTime = new Date().getTime()
const headers = {
'Content-Type': options.headers?.['Content-Type'] || 'application/json',
'Accept-Language': langMap[langStore.locale] || 'zh-CN',
}
try {
const response = await $fetch(url, {
baseURL,
method: 'POST',
body: data,
headers,
params: options.params || {}
})
const endTime = new Date().getTime()
console.info(url, '请求时间', endTime - startTime + 'ms')
return response
} catch (error) {
handleError(error, url, 'POST', data, startTime)
throw error
}
}
}
function handleError(error, url, method, data, startTime) {
const endTime = new Date().getTime()
console.error('请求时间', endTime - startTime + 'ms')
console.error('$fetch.onError: ', error)
const response = error.response || {}
console.error('错误处理提示 ', {
url: baseURL + url,
status: response.status,
statusText: response.statusText,
method,
data,
responseData: response._data || response.data,
})
}
const api = indexApi(httpAdapter)
return {
provide: {
indexApi: api
}
}
})
API 定义文件适配(api/index.js):
// 升级前
export default ($axios) => {
return {
getRecipeList: (params) => $axios.get('/open/befor/look', { params }),
addUserMsg: (data) => $axios.post('/open/befor/add/msg', data),
}
}
// 升级后(无需修改,接口保持兼容)
export default ($axios) => {
return {
getRecipeList: (params) => $axios.get('/open/befor/look', { params }),
addUserMsg: (data) => $axios.post('/open/befor/add/msg', data),
}
}
注意:API 定义文件无需修改,因为我们在插件中封装了兼容的
httpAdapter,保持与$axios相同的调用签名。
步骤 7:迁移插件语法
升级前(Nuxt 2):
export default ({ app }, inject) => {
inject('myPlugin', { ... })
}
升级后(Nuxt 3):
export default defineNuxtPlugin(() => {
return {
provide: {
myPlugin: { ... }
}
}
})
客户端插件标记变更:
// Nuxt 2: 在 nuxt.config.js 中
plugins: [
{ src: '@/plugins/adapter', mode: 'client' }
]
// Nuxt 3: 同样在 nuxt.config.js 中(语法不变)
plugins: [
{ src: '@/plugins/adapter', mode: 'client' }
]
步骤 8:迁移中间件语法
升级前(Nuxt 2):
export default function ({ store, redirect, route }) {
const lang = store.state.lang.locale
if (!route.params.lang) {
return redirect('/' + lang)
}
}
升级后(Nuxt 3):
export default defineNuxtRouteMiddleware((to, from) => {
const langStore = useLangStore()
const { $i18n, $cookies } = useNuxtApp()
if (!to.name || to.name === 'index') {
let lang = $cookies.get('lang')
const locale = langStore.Language[lang] || langStore.defaultLanguage
langStore.SET_LANG(locale)
$cookies.set('lang', locale)
$i18n.locale = langStore.locale
return navigateTo('/' + locale)
}
})
关键变更:
redirect()→navigateTo()store→useXxxStore()(Pinia)context.app→useNuxtApp()
步骤 9:迁移 i18n 插件
升级前(Nuxt 2):
import VueI18n from 'vue-i18n'
import enLocale from '~/locales/en.json'
import zhLocale from '~/locales/zh.json'
Vue.use(VueI18n)
export default ({ app, store }, inject) => {
const i18n = new VueI18n({
locale: store.state.lang.locale,
fallbackLocale: 'zh',
messages: { en: enLocale, zh: zhLocale }
})
app.i18n = i18n
inject('lang', (text) => i18n.t(text))
}
升级后(Nuxt 3):
import { createI18n } from 'vue-i18n'
import { crc32 } from 'crc'
import enLocale from '~/locales/en.json'
import zhLocale from '~/locales/zh.json'
export default defineNuxtPlugin(({ vueApp }) => {
const langStore = useLangStore()
const i18n = createI18n({
legacy: true,
locale: langStore.locale,
fallbackLocale: langStore.locale,
messages: {
'en': enLocale,
'zh': zhLocale,
}
})
function lang(text) {
let keyArr = Array.prototype.slice.apply(arguments)
let hashKey = `K${crc32(text).toString(16)}`
keyArr[0] = hashKey
let words = i18n.global.t(...keyArr)
if (words == hashKey) {
words = text
console.warn(text, '-无匹配语言key')
}
return words
}
function img(path) {
if (!path) return ''
const imgUrl = 'https://fgwj-website.oss-cn-guangzhou.aliyuncs.com/static/images'
if (path.startsWith(imgUrl)) {
path = path.replace(imgUrl, '')
}
return `${imgUrl}/${langStore.locale}/${path}`
}
vueApp.provide('lang', lang)
vueApp.provide('img', img)
vueApp.provide('i18n', i18n)
vueApp.config.globalProperties.$lang = lang
vueApp.config.globalProperties.$img = img
vueApp.config.globalProperties.$i18n = i18n.global
return {
provide: {
lang,
img,
i18n: i18n.global
}
}
})
关键变更:
new VueI18n()→createI18n()i18n.t()→i18n.global.t()app.i18n→vueApp.provide()+vueApp.config.globalProperties
步骤 10:迁移页面组件
Vue 2 → Vue 3 语法变更:
// 升级前(Vue 2 Options API)
export default {
data() {
return { count: 0 }
},
computed: {
...mapState(['lang'])
},
methods: {
...mapActions(['SET_LANG'])
},
async asyncData({ $axios, params }) {
const data = await $axios.$get('/api/data')
return { data }
}
}
// 升级后(Vue 3 Options API,保持兼容)
export default {
data() {
return { count: 0 }
},
computed: {
...mapState(useLangStore, ['locale'])
},
methods: {
...mapActions(useLangStore, ['SET_LANG'])
},
async setup() {
const { data } = await useFetch('/api/data')
return { data }
}
}
关键变更:
asyncData()→useFetch()/useAsyncData()this.$axios→useNuxtApp().$indexApithis.$store→useXxxStore()this.$route→useRoute()this.$router→useRouter()
步骤 11:迁移静态资源目录
# 将 static/ 目录重命名为 public/
mv static/ public/
步骤 12:更新 .gitignore
# Nuxt dev/build outputs
.output/
.data/
.nuxt/
.nitro/
.cache/
# Node dependencies
node_modules/
# Logs
logs/
*.log
# Misc
.DS_Store
.fleet/
.idea/
三、常见问题与解决方案
1. $fetch 替代 $axios 后的兼容问题
问题:$fetch 不支持拦截器,无法像 $axios 一样全局配置请求/响应拦截。
解决方案:封装 httpAdapter,在插件中统一处理请求头、错误处理、响应时间统计。
2. Ant Design Vue 1.x → 4.x 升级
问题:组件 API 发生变化,部分组件不兼容。
解决方案:
a-form的layout属性从form.layout改为直接设置layout="vertical"a-pagination的showSizeChange→:show-size-changer="false"隐藏 pageSize 切换a-select的v-model→v-model:valuea-input的v-model→v-model:value
3. Swiper 升级
问题:Swiper 6+ 的导入方式变化。
解决方案:
// 升级前
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/swiper-bundle.css'
// 升级后
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/swiper-bundle.css'
// 模块导入方式变更
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
Swiper.use([Autoplay, Pagination, Navigation])
4. SCSS 全局变量注入
问题:Nuxt 3 + Vite 中 SCSS 全局变量注入方式变化。
解决方案:
// nuxt.config.js
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "~/assets/scss/_variable.scss" as *;',
api: 'modern-compiler'
}
}
}
}
5. 客户端组件渲染问题
问题:部分组件(如视频播放器、百度地图)依赖浏览器 API,SSR 时报错。
解决方案:
- 在
nuxt.config.js中设置mode: 'client' - 或使用
<ClientOnly>包裹组件:
<ClientOnly>
<CustomVideo />
</ClientOnly>
6. 页面过渡动效丢失
问题:升级后页面切换动效消失。
解决方案:
// nuxt.config.js
app: {
pageTransition: {
name: 'layout',
mode: 'out-in'
}
}
7. 导航栏选中状态丢失
问题:路由切换后导航栏未高亮当前菜单项。
解决方案:
// 在 Header 组件中添加路由匹配方法
methods: {
isActive(path) {
const currentPath = this.route.fullPath
if (path === '/') {
return currentPath === `/${this.lang}/` || currentPath === `/${this.lang}`
}
return currentPath.includes(`/${this.lang}${path}`)
}
}
8. Cookie 使用变更
问题:Nuxt 3 中 Cookie 的使用方式变化。
解决方案:
// Nuxt 2
app.$cookies.get('lang')
app.$cookies.set('lang', locale)
// Nuxt 3
const { $cookies } = useNuxtApp()
$cookies.get('lang')
$cookies.set('lang', locale)
// 或使用 useCookie composable
const lang = useCookie('lang')
lang.value = 'zh'
四、部署变更
Nuxt 2 部署方式
# 构建
npm run build
# 部署文件
# - .nuxt/
# - static/
# - package.json
# - nuxt.config.js
# 服务器启动
npm install --production
npm run start # nuxt start
Nuxt 3 部署方式
# 构建
npm run build
# 部署文件
# - .output/ (替代 .nuxt/)
# - public/ (替代 static/)
# - package.json
# 服务器启动
npm install --production
node .output/server/index.mjs
部署差异对比
| 项目 | Nuxt 2 | Nuxt 3 |
|---|---|---|
| 构建产物 | .nuxt/ | .output/ |
| 静态资源 | static/ | public/(已编译到 .output/public/) |
| 启动命令 | nuxt start | node .output/server/index.mjs |
| 需要配置文件 | 是(nuxt.config.js) | 否(已编译到产物中) |
| Node.js 版本 | 14+ | 18+ |
五、升级检查清单
- 创建
app.vue根组件 - 更新
package.json依赖版本 - 更新
nuxt.config.js→defineNuxtConfig()语法 -
static/→public/目录重命名 -
store/→stores/,Vuex → Pinia -
@nuxtjs/axios→$fetch+ 自定义httpAdapter - 插件语法 →
defineNuxtPlugin() - 中间件语法 →
defineNuxtRouteMiddleware() -
vue-i18n8.x → 9.x,new VueI18n()→createI18n() -
ant-design-vue1.x → 4.x -
asyncData()→useFetch()/useAsyncData() -
this.$route→useRoute() -
this.$router→useRouter() -
this.$store→useXxxStore() - SCSS 全局变量注入方式更新
- 客户端组件添加
mode: 'client'或<ClientOnly> - 更新
.gitignore - 更新部署脚本和流程
- 全面功能测试
六、注意事项
- Node.js 版本:Nuxt 3 要求 Node.js 18+,确保开发和服务器环境满足要求
- 渐进式升级:建议先完成核心框架升级,再逐步迁移各页面和组件
- 保留 API 兼容层:通过
httpAdapter封装,避免修改所有 API 调用代码 - SSR 兼容:注意区分客户端和服务端代码,使用
mode: 'client'或<ClientOnly>处理浏览器 API 依赖 - 构建产物变化:Nuxt 3 使用
.output目录,部署时只需上传该目录 - 配置文件不再需要部署:
nuxt.config.js已编译到构建产物中,无需上传到服务器