Nuxt 2 到 Nuxt 3 平滑迁移:企业官网实操笔记

3 阅读6分钟

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 2Nuxt 3
应用根组件app.vue
静态资源目录static/public/
状态管理store/ + Vuexstores/ + Pinia
配置语法CommonJSESM / defineNuxtConfig()
插件语法export default (context, inject) => {}export default defineNuxtPlugin(() => {})
中间件语法export default (context) => {}export default defineNuxtRouteMiddleware((to, from) => {})
HTTP 请求@nuxtjs/axiosuseFetch() / $fetch
构建工具WebpackVite
构建产物.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 移除,改用 $fetch
  • vuex 移除,改用 pinia
  • ant-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: true
  • headapp.head
  • buildModules 合并到 modules
  • build.extendvite 配置
  • 新增 nitro 配置(服务端引擎)
  • CSS 中 antd.cssreset.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()
  • storeuseXxxStore()(Pinia)
  • context.appuseNuxtApp()

步骤 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.i18nvueApp.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.$axiosuseNuxtApp().$indexApi
  • this.$storeuseXxxStore()
  • this.$routeuseRoute()
  • this.$routeruseRouter()

步骤 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-formlayout 属性从 form.layout 改为直接设置 layout="vertical"
  • a-paginationshowSizeChange:show-size-changer="false" 隐藏 pageSize 切换
  • a-selectv-modelv-model:value
  • a-inputv-modelv-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 2Nuxt 3
构建产物.nuxt/.output/
静态资源static/public/(已编译到 .output/public/
启动命令nuxt startnode .output/server/index.mjs
需要配置文件是(nuxt.config.js否(已编译到产物中)
Node.js 版本14+18+

五、升级检查清单

  • 创建 app.vue 根组件
  • 更新 package.json 依赖版本
  • 更新 nuxt.config.jsdefineNuxtConfig() 语法
  • static/public/ 目录重命名
  • store/stores/,Vuex → Pinia
  • @nuxtjs/axios$fetch + 自定义 httpAdapter
  • 插件语法 → defineNuxtPlugin()
  • 中间件语法 → defineNuxtRouteMiddleware()
  • vue-i18n 8.x → 9.x,new VueI18n()createI18n()
  • ant-design-vue 1.x → 4.x
  • asyncData()useFetch() / useAsyncData()
  • this.$routeuseRoute()
  • this.$routeruseRouter()
  • this.$storeuseXxxStore()
  • SCSS 全局变量注入方式更新
  • 客户端组件添加 mode: 'client'<ClientOnly>
  • 更新 .gitignore
  • 更新部署脚本和流程
  • 全面功能测试

六、注意事项

  1. Node.js 版本:Nuxt 3 要求 Node.js 18+,确保开发和服务器环境满足要求
  2. 渐进式升级:建议先完成核心框架升级,再逐步迁移各页面和组件
  3. 保留 API 兼容层:通过 httpAdapter 封装,避免修改所有 API 调用代码
  4. SSR 兼容:注意区分客户端和服务端代码,使用 mode: 'client'<ClientOnly> 处理浏览器 API 依赖
  5. 构建产物变化:Nuxt 3 使用 .output 目录,部署时只需上传该目录
  6. 配置文件不再需要部署nuxt.config.js 已编译到构建产物中,无需上传到服务器