☰
Vue迁移Nuxt实战:SSR下的Axios、Vuetify与localStorage排坑指南
2026/10/16 1:10:58 网站建设 项目流程

最近帮人把一个老管理后台搬到 Nuxt,起因很简单:首屏加载太慢,SEO 约等于零。项目本身是 Vue2 时代的老三样——Vue + Vuetify + Axios,功能齐全,但代码里到处散落着 localStorage、window 的判断,迁移的时候几乎每一步都在跟 SSR 的坑搏斗。这篇文章把我在重构过程中踩过的四个典型问题整理了出来:Axios 默认根域名到底该怎么配、Vuetify 字体切换怎么做才不翻车、MavonEditor 怎么集成到 Nuxt、以及 localStorage/window/document is not defined 这类报错的完整排查思路。适合正在把旧 Vue 项目迁到 Nuxt、或者刚接触 Nuxt 的读者,也适合那些在 SSR 环境里被浏览器 API 折磨到怀疑人生的朋友。

1. 重构前的“体检”:从Vue项目迁到Nuxt需要考虑哪些旧债?

1.1 不是简单换壳:路由、布局和状态管理的迁移起点

很多人以为从 Vue 项目切到 Nuxt,只要把router删掉,目录改成pages,组件挪一挪就完事。但真正的痛点不是目录结构,而是运行环境的变化。Nuxt 默认是 SSR 模式,页面同时在 Node.js 环境里跑一遍、浏览器里跑一遍。这个“跑两遍”会带来一个很反直觉的结论:你原来的代码里,凡是依赖浏览器 API 的逻辑,在服务端都有可能直接炸掉。

所以我建议重构前先做一个“旧债清单”,把项目里所有用到window、document、localStorage、navigator的地方都搜出来,尤其要注意以下方向:

  • 组件里写在created()或beforeMount()中的初始化逻辑;
  • vuex 模块里初始 state 直接读 localStorage 的写法;
  • 第三方库在导入阶段就操作 DOM 的,典型如一些富文本编辑器、图表库;
  • 自己封装过的工具函数模块,比如storage.js、device.js;
  • Axios 拦截器中读取 token 的代码。

这个清单做完了,迁移的路径基本就清晰了。我的经验是:不要试图一次性把整个项目转完,先让新 Nuxt 项目能跑起来,再让核心页面能 SSR 渲染,最后逐个页面做客户端水合。这样每次报错都相对可控,不至于一开始就被几百个报错淹没。

1.2 一个反向常识:SSR 带来的构建与运行环境差异

还有一个容易忽略的细节是 Nuxt 的构建过程和运行时是两个完全不同的世界。构建打包时需要的是依赖能被 Node.js 解析;而运行时,服务端执行的是 Node.js 环境,客户端执行的是浏览器环境。同一个包,在构建时没问题,不代表运行时没问题。

比如mavon-editor这类库,npm install 之后构建很顺利,可一旦在 SSR 阶段被 import,它会在模块加载的顶层代码里直接访问window,这就导致服务端渲染进程立刻报错。后面第 4 节会重点说这个。再比如 Axios 的 baseURL,本地开发时你用相对路径/api还能靠代理转发,一旦部署到 SSR 环境,服务端和客户端的根域名可能是两个东西,这个在第 2 节讲。

所以在动手写代码之前,先给项目建立一个“是否需要在服务端执行”的认知模型。凡是用到浏览器 API 的部分,要么丢到客户端生命周期里去执行,要么用 Nuxt 的客户端插件机制隔离。这个底层认知建立起来,后面所有报错都会变得好理解得多。

2. Axios根域名在Nuxt里的正确姿势:runtimeConfig与默认实例的取舍

2.1 原来写死的baseURL,在SSR下为什么可能“翻车”

旧 Vue 项目里最省事的方案是直接在axios.defaults.baseURL = 'https://api.example.com'里写死一个域名。在纯客户端项目里,这样没什么问题,浏览器所有请求都会发向这个根域名。但迁移到 Nuxt 后,同样的代码至少会遇到两个坑:

第一个坑是环境区分。开发环境你可能要请求本地代理http://localhost:3000/api,生产环境要请求正式域名,如果写死在代码里,换环境就得改代码重发。第二个坑更隐蔽:SSR 阶段,请求是在服务器发起的。如果你的服务器在生产环境里需要通过内部域名访问 API,浏览器通过外部公网域名访问 API,写死一个域名就必然有一侧失败。

所以正确做法是让根域名变成运行时配置。Nuxt 从 2.13 开始支持 runtimeConfig,专门用来解决这类环境配置问题。它可以把配置注入到服务端和客户端,而不需要重新构建。

2.2 使用runtimeConfig统一管理根域名的完整配置

我的推荐方案是使用 Nuxt 的publicRuntimeConfig来管理 Axios 的根域名。之所以用 public 而不是 private,是因为客户端也需要读取这个配置来发起请求;privateRuntimeConfig 只在服务端可用,不能被客户端引用。

在nuxt.config.js里这样配置:

export default { publicRuntimeConfig: { axios: { baseURL: process.env.NUXT_ENV_API_ROOT || '/api' } }, axios: { proxy: true }, proxy: { '/api/': { target: process.env.NUXT_ENV_API_PROXY || 'https://api.example.com', pathRewrite: { '^/api/': '' } } } }

这里我用了@nuxtjs/axios模块。设置proxy: true后,开发环境里浏览器请求/api时,Nuxt 会通过 webpack 代理转发到真实域名,可以完美避开跨域问题。而在服务端,Axios 模块会自动读取publicRuntimeConfig.axios.baseURL,把请求发到对应的根域名。

如果你不想用@nuxtjs/axios,也可以自己在插件里创建一个 axios 实例:

// plugins/axios-custom.js import axios from 'axios' export default function ({ $config }, inject) { const instance = axios.create({ baseURL: $config.axiosBaseURL || '/api' }) inject('api', instance) }

然后在nuxt.config.js里:

publicRuntimeConfig: { axiosBaseURL: process.env.NUXT_ENV_API_ROOT || '/api' }

这两种方式都要注意环境变量前缀。在 Nuxt 2 中,只有带NUXT_ENV_前缀的环境变量才能被直接嵌入到客户端 bundle 中,其他的服务端环境变量对客户端不可见。

2.3 拦截器、token和跨端兼容的收尾细节

配好根域名只是第一步。原来项目的 axios 拦截器里通常会做 token 注入和统一错误提示,比如:

axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })

这段代码在客户端里运行正常,但在 SSR 服务端,localStorage根本不存在。有些同学会直接写typeof localStorage !== 'undefined'来防御,这能避免报错,但逻辑仍然有问题:服务端请求不应该读取浏览器的 localStorage,也不可能读到,因为服务端渲染阶段还没有这个值。

正确的做法是只在客户端注入 token:

axios.interceptors.request.use(config => { if (process.client) { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } } return config })

如果使用@nuxtjs/axios模块,还可以在插件里利用req.headers从服务器请求头中继承 token。这个细节我在项目中用过:服务端请求时优先读取来自浏览器转发过来的Authorization头,这样 SSR 获取到的数据也能带上用户权限。

export default function ({ $axios, req }) { $axios.onRequest(config => { if (process.server && req && req.headers.authorization) { config.headers.common.Authorization = req.headers.authorization } if (process.client) { const token = localStorage.getItem('token') if (token) { config.headers.common.Authorization = `Bearer ${token}` } } }) }

到这里,Axios 根域名和请求头的问题是解决了。但有一个前提别忘了:publicRuntimeConfig的更新需要重新启动服务。如果你改完配置发现不生效,第一件事不是去看代码,而是确认服务有没有重启。

3. Vuetify字体切换:不只是换一个CSS变量

3.1 Vuetify的字体加载机制和Nuxt中的默认行为

Vuetify 2.x 默认用 Roboto 作为全局字体。它的样式文件vuetify.min.css里有一段@import url('https://fonts.googleapis.com/css?family=Roboto...'),如果你通过css: ['vuetify/dist/vuetify.min.css']全局引入,Nuxt 在构建时就会有这个远程字体请求。开发环境一般没事,但有些部署环境访问 Google 字体超时,会导致请求挂起、页面字体等很久才出来。

切换字体的第一件事,就是不要走默认 Roboto。Vuetify 本身支持通过 SCSS 变量覆盖字体,但前提是你要用 SCSS 版本而不是编译好的 CSS 文件。社区里大多数项目会用@nuxtjs/vuetify模块,它提供了tree-shaking和主题配置能力。

如果你用了@nuxtjs/vuetify,默认情况下它不会把所有 CSS 都打包,而是按需要生成主题样式,这样你的字体变量覆盖也能生效。如果不用模块,只是引入vuetify.min.css,那你后面覆盖字体就会比较痛苦,可能需要写一堆!important。

3.2 自定义字体源(例如本地字体)的配置过程

我的常见做法是这样的:在nuxt.config.js里为 Vuetify 加载器配置 scss 路径,然后在自定义的 scss 文件里覆盖变量。

先在assets/scss/custom.scss里写:

@import '~vuetify/src/styles/styles.sass'; $body-font-family: 'AlibabaPuHuiTi', 'PingFang SC', 'Microsoft YaHei', sans-serif; $heading-font-family: $body-font-family;

然后配置nuxt.config.js:

build: { loaders: { scss: { additionalData: '@import "@/assets/scss/custom.scss";' } } }

注意additionalData在 Nuxt 2.14+ 中会被自动注入到每个 scss 文件里。如果你直接丢进去一个完整的@import '~vuetify...',可能会导致样式重复。比较稳妥的方式是只放变量覆盖,让 Vuetify loader 自己承担主要样式生成。

如果你的项目没有用@nuxtjs/vuetify模块,而是手动创建 Vuetify 实例,下面这段代码可以参考:

// plugins/vuetify.js import Vue from 'vue' import Vuetify from 'vuetify/lib' import zhHans from 'vuetify/src/locale/zh-Hans' Vue.use(Vuetify) export default new Vuetify({ lang: { locales: { zhHans }, current: 'zh-Hans' }, theme: { options: { customProperties: true } } })

其中customProperties: true很关键,它允许你在组件中通过 CSS 变量访问主题颜色,对需要动态换肤的项目有帮助。

3.3 字体切换后的SSR样式闪烁问题与处理

切完字体后,一个很容易被忽略的问题是 SSR 与客户端样式不一致导致的闪烁。服务端渲染时,Nuxt 会为用户提供一份带内联样式的 HTML,这个样式是基于服务端时 Vuetify 生成的。但如果你在客户端又通过 CSS 加载了另一种字体,浏览器先看到服务端内联样式,等客户端 CSS 加载完才知道字体被覆盖了,就会闪一下。

解决思路是让字体文件在首个请求时就尽早加载,并且保证服务端和客户端加载的字体是同一份。推荐在nuxt.config.js的head里加字体预连接和样式表链接:

head: { link: [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous' }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=AlibabaPuHuiTi&display=swap' } ] }

如果用本地字体,就把@font-face写在全局assets/scss/custom.scss里,比如:

@font-face { font-family: 'AlibabaPuHuiTi'; src: url('/fonts/AlibabaPuHuiTi-Regular.woff2') format('woff2'); font-display: swap; }

把字体文件放到static/fonts目录下,并在全局scss里引入,这样既不会产生跨域字体请求,也不会因为放错目录导致构建后 404。

字体这块我踩过最大的坑就是:费了半天劲改了 SCSS 变量,结果页面字体没变,最后发现additionalData的路径写错了,而且 Vuetify 的样式文件在它之后才加载,覆盖顺序反了,变量被冲掉。所以如果你发现字体变量不生效,先用浏览器开发者工具查一下body元素上的font-family,看看是什么规则把字体覆盖了,再回溯加载顺序。

4. 让MavonEditor在Nuxt里安静工作:客户端组件与样式引入

4.1 MavonEditor为什么不能在服务端渲染

MavonEditor 是一个纯前端 Markdown 编辑器,顶部工具栏、编辑区、预览区都强依赖 DOM API。它在代码里直接用window.addEventListener,还会读取元素的 offsetWidth 之类。Nuxt 服务端渲染的时候,Node.js 没有window,也没有 DOM,一旦这个库被同步加载,服务端就会抛window is not defined这样的错误。

很多同学遇到这种问题,第一反应是去组件里import MavonEditor from 'mavon-editor'外面套一层if,但这是无效的。因为模块导入是静态的,在构建阶段就会被解析执行。你只能从“不让它在服务端引入”这个层面去解决。

4.2 通过client-only和组件注册完成集成

在 Nuxt 2 中,最干净的做法是建一个客户端插件。Nuxt 2.9+ 支持给插件文件名加.client后缀,只有客户端会加载这个插件。我们先创建plugins/mavon-editor.client.js:

import Vue from 'vue' import MavonEditor from 'mavon-editor' import 'mavon-editor/dist/css/index.css' Vue.component('mavon-editor', MavonEditor)

然后在nuxt.config.js的plugins里注册这个文件:

plugins: [ { src: '@/plugins/mavon-editor.client.js', mode: 'client' } ]

注意这里mode: 'client'是给 Nuxt 2 用的。如果你用的版本支持文件名后缀,其实也可以不写 mode,只靠文件名识别。两种都写也是安全的,它会被去重。

接下来在页面的模板中,要手动用<client-only>把编辑器包住:

<template> <div> <client-only> <mavon-editor v-model="content" /> <template #placeholder> <textarea disabled placeholder="编辑器加载中..." /> </template> </client-only> </div> </template> <script> export default { data() { return { content: '' } } } </script>

之所以要包<client-only>,是因为插件虽然只注册在客户端,但如果页面在 SSR 阶段也试图渲染这个组件,组件解析阶段仍然会失败。client-only告诉 Nuxt:这一段只在客户端渲染,服务端输出一个占位内容即可。

4.3 样式加载顺序与暗黑模式小坑

MavonEditor 的默认样式文件是在插件里通过import 'mavon-editor/dist/css/index.css'引入的。这个方式在客户端插件里没问题,因为浏览器会把它打进客户端 bundle。但如果你把样式文件写在服务端全局css数组中,有时会因为样式顺序问题导致部分组件样式被覆盖。

我的建议是样式文件仍然放在nuxt.config.js的css数组中,而不是插件里。例如:

css: [ 'mavon-editor/dist/css/index.css', '@/assets/scss/custom.scss' ]

这样打包顺序更可控。如果你放在插件里,一旦项目整体引入其他全局样式,出现样式互相覆盖时,排查起来会很费劲。

另外,MavonEditor 默认的主题是亮色背景。如果你的页面用了 Vuetify 的暗黑模式(dark theme),会发现编辑器背景发白,很突兀。处理方式通常是在全局样式里覆盖它的 CSS 变量,比如:

.theme--dark .markdown-body { background-color: #1e1e1e; color: #c9d1d9; }

具体选择器要看版本。我的经验是不要直接改 node_modules 里的源码,而是做一个覆盖层,这样升级依赖的时候不会丢改动。

还有一个真实碰到的场景:通过编辑器编辑已有内容时,如果初始 markdown 内容在 SSR 阶段就被当作纯文本输出,导致首屏显示的 HTML 和后来编辑器渲染的内容瞬间跳变。解决方法是给<client-only>一个与最终编辑器高度相近的占位区,减少跳动感。不过这个属于体验优化,功能上不影响。

5. 高频报错“localStorage is not defined”的完整排查链路

5.1 先理解Nuxt的通用端与客户端分离

Nuxt 应用启动时,服务端会先请求数据、渲染页面、生成 HTML 字符串返回给浏览器。浏览器拿到 HTML 后再执行客户端 JS,完成水合。整个过程存在两个独立的 JavaScript 执行环境:一个是 Node.js,一个浏览器。localStorage是浏览器专属 API,Node.js 环境下对它调用会直接抛ReferenceError。

这个报错通常发生在服务端渲染阶段。你会发现命令行输出里有一行ReferenceError: localStorage is not defined,后面跟着一个堆栈。但很多同学会疑惑:为什么浏览器里跑得好好,服务端跑就报错?其实不是因为代码写错,而是它没有在正确环境下执行。

5.2 从报错栈回推:是插件、组件还是axios拦截器里用了window?

拿到堆栈后不要急着加typeof localStorage !== 'undefined',先看报错出现在哪个模块。常见的触发点有三个层级,我用一个表来梳理:

触发位置典型场景报错特征
页面组件created()中读取 localStorage堆栈里能看到组件文件路径
Vuex 模块state 初始化时读存储报错在 store 的类文件里
插件 / 第三方库axios 拦截器读 token,编辑器初始化堆栈顶部是插件或库的代码
普通工具函数封装的 storage 函数在模块顶层就尝试读 window报错在工具文件顶部

定位思路很简单:先看堆栈第一行,确认是哪个文件。然后回到项目里搜这个文件引用了哪些 API。如果是自己写的 storage 工具函数,多半是顶层用了window.localStorage做变量初始化,比如:

const storage = window.localStorage

这行代码在模块被 import 的时候就会执行,服务端肯定炸。这类代码要改成使用时不初始化,只在调用时才判断环境。

5.3 标准解法:插件注册、生命周期和条件守卫的搭配使用

在 Nuxt 2 中,处理浏览器 API 的优先级是这样的:

  1. 能放到客户端的就放到客户端。比如只在浏览器里运行的插件,文件名加.client.js。
  2. 组件里必须在客户端执行的操作放到mounted()。服务端不会执行mounted,只在客户端执行,可以安全访问 DOM 和浏览器 API。
  3. 无法避开服务端读取的数据,用条件判断包住。比如if (process.client)。

一个典型的 localStorage 读取场景,最初代码写在data()里:

export default { data() { return { token: localStorage.getItem('token') } } }

这个在 SSR 时会直接报错。改成这样:

export default { data() { return { token: '' } }, mounted() { this.token = localStorage.getItem('token') } }

如果页面很多,每个都写 mounted 太啰嗦,你可以做一个 Vuex action 来封装:

// store/index.js export const state = () => ({ token: '' }) export const mutations = { setToken(state, token) { state.token = token } } export const actions = { initFromClient({ commit }) { if (process.client) { commit('setToken', localStorage.getItem('token') || '') } } }

然后在页面asyncData里不要调用localStorage,等客户端渲染后再去 store 里拿。

5.4 最后的防线:兜底函数与同构兼容写法

如果项目里历史代码很多,重构时间紧张,来不及一个个手动摘除 localStorage 调用,可以先做一个同构安全的 storage 封装,作为兜底方案。比如utils/storage.js:

const inBrowser = typeof window !== 'undefined' export function getStorage(key) { if (!inBrowser) return null return window.localStorage.getItem(key) } export function setStorage(key, value) { if (!inBrowser) return window.localStorage.setItem(key, value) } export function removeStorage(key) { if (!inBrowser) return window.localStorage.removeItem(key) }

然后在所有用到 localStorage 的地方,统一替换成这些方法。这样做的好处是:即使某些函数还在 SSR 阶段被调用,也不会抛异常,最多返回 null,之后的逻辑再用默认值兜住。

同样的思路也适用于window、document。如果你需要判断当前是否在浏览器环境,可以写一个通用函数:

export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined'

注意这里不能只判断window,因为某些测试环境可能只模拟 window,却没有 document。

如果是 Nuxt 3,process.client已经不太推荐了,官方推荐使用import.meta.client或者在 Nuxt 3 提供的useNuxtApp()中判断。但语法上大同小异,核心思路仍然是:把浏览器专属 API 隔离到客户端环境里执行。

这次重构做下来,我最大的体感是:Nuxt 本身并不难,难的是改造那些默认“浏览器一定存在”的历史代码。把每个 API 的访问都当成一次环境判断来做,反而会逼着你把项目里的全局状态、请求层和 UI 层拆得干净很多。如果以后有项目从一开始就用 Nuxt,我会直接把 axios 实例、storage 封装、第三方客户端插件这些边界当成基础设施提前设计好,省下的时间远比迁移时补坑要划算。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询