Nuxt重构Vue项目实战:Axios配置、Vuetify字体与SSR异常排查

发布时间:2026/10/16 1:14:44
Nuxt重构Vue项目实战:Axios配置、Vuetify字体与SSR异常排查
“用Nuxt重构已有Vue项目”这个活儿我在实际项目里折腾过不止一次。表面上看是换框架实际上牵一发动全身请求层要重新落地、UI库的字体得换、编辑器这种重度依赖浏览器的组件会在SSR阶段直接把你打懵再配上经典的localStorage is not defined新手第一次跑起来会发现自己全是在跟脚手架默认行为作斗争。这篇文章我就按实际重构顺序把Axios根域名、Vuetify字体、Mavon编辑器这三块硬骨头怎么啃以及SSR环境下一串“浏览器对象未定义”的异常怎么排查完整过一遍。1. 重构前的准备先想清楚Nuxt到底解决了你什么问题1.1 为什么要把做得好好的Vue项目挪到Nuxt很多团队把已有Vue项目重构成Nuxt核心诉求就三个SEO、首屏速度、工程化约束。SPA是空壳HTML加一堆script搜索引擎爬虫能看到的内容极其有限首屏要等JS下载、解析、执行完毕才渲染弱网环境下白屏时间相当难堪。Nuxt带来服务端渲染SSR页面HTML直接由服务端生成爬虫拿到的是完整内容首屏也是直接可读的DOM这两个痛点能同时缓解。除此之外Nuxt还顺手带来了一些工程化上的约定pages目录即路由、plugins目录统一注册插件、middleware干路由守卫、store内置Vuex。这些约定本身不带业务逻辑但在团队协作时很有价值——每个人进来都按一个套路写代码不用在路由表、初始化流程、全局状态上反复横跳。加上Nuxt本身有服务端能力和中间件机制以后要加接口聚合、Cookie解析、服务端转发这类需求不用再单独起一个BFF服务直接在Nuxt里就能搞定。不过别把它想成一键迁移。项目里凡是依赖window、document、localStorage、navigator的代码在服务端渲染阶段全部会炸。重构前第一件事就是给全项目做一次“浏览器对象依赖体检”统计到底有多少地方在setup、created、mounted、模块顶层直接读取这些全局对象。这个统计结果直接决定重构工作量是大改还是小改。1.2 把交互、渲染、业务三个层次拆开评估我习惯在动手前把代码按三个层次过一遍纯业务逻辑层API调用、数据转换、权限判断、工具函数。这层只要不直接读浏览器对象基本可以原样搬进Nuxt重点改请求路径和异常处理。渲染层模板、指令、组件生命周期。Vue组件本身在Nuxt里可以直接用但要检查每个组件在mounted之前做了什么、有没有在初始化阶段访问DOM以及第三方组件库是否兼容服务端渲染。交互层事件绑定、滚动监听、弹窗定位、剪贴板、文件上传。这层代码几乎都离不开浏览器环境重构成Nuxt时要么在客户端生命周期里执行要么用client-only包一层要么动态导入时关掉SSR。以我曾经重构过的项目为例某内容管理后台三个层次占比大概是45%、35%、20%。交互层占比不大但有极强的破坏性任何一个弹窗组件在SSR阶段被实例化都可能直接抛错。先把这三个层次的边界划清楚后面填坑就有路线图了。重构过程中我还会顺手做一件事把项目里所有全局工具函数过一遍给“纯函数”和“浏览器相关函数”打上标记。这不是形式上的分类而是为了让团队成员知道哪些函数可以在服务端安全调用哪些必须在mounted里执行。有了这个标记后面处理localStorage is not defined这类问题时可以直接从异常堆栈定位到具体的函数文件不用一行一行猜。2. Axios默认根域名把请求层的坑一次性填平2.1 别在组件里写死baseURLVue项目里许多人习惯在每个页面的请求里直接写axios.get(http://api.example.com/user)或者全局axios.defaults.baseURL http://api.example.com。前者在Nuxt里继续能跑但会造成两个问题一是环境切换时全局搜索替换URL二是服务端渲染期间发出的请求同样走这个地址DNS解析、跨域、内网路由全混在一起排错体验极差。Nuxt里正确的做法是把Axios配置收敛到插件和运行时配置里。我选的是nuxtjs/axios模块加runtimeConfig的组合处理nuxt.config.js时如果你用的是Nuxt 2可以放publicRuntimeConfig和privateRuntimeConfigNuxt 3则直接走runtimeConfig.public。这样做的核心逻辑是发布到不同环境时配置文件里只换一个环境变量所有请求自动指向正确的根域名不用改一行业务代码。2.2 环境变量与运行时配置的配合以Nuxt 2 nuxtjs/axios为例关键配置长这样// nuxt.config.js export default { publicRuntimeConfig: { axios: { baseURL: process.env.API_BASE_URL || http://127.0.0.1:3000/api } }, modules: [ nuxtjs/axios ], axios: { // 这里的配置是模块级别的优先于默认值 } }然后在插件里把根域名装进Axios实例// plugins/axios.js export default function ({ $axios, $config }, inject) { $axios.defaults.baseURL $config.axios.baseURL $axios.onRequest(config { // 统一的请求头注入比如带token return config }) $axios.onError(error { const code error.response error.response.status if (code 401) { // 统一跳登录或刷新token } return Promise.reject(error) }) }这里有个容易忽略的点服务端渲染阶段发请求用的baseURL和浏览器端用的baseURL很可能是两个值。服务端在机房内网可以直接走内网域名或容器名浏览器端只能走公网域名。所以真正的生产项目我会把配置拆成两个publicRuntimeConfig: { axios: { browserBaseURL: process.env.BROWSER_API_BASE_URL, baseURL: process.env.API_BASE_URL } }nuxtjs/axios内部是支持browserBaseURL和baseURL分开设置的。这个设计的价值在于服务端请求不经过公网又不暴露内网结构客户端请求则走CDN或网关整体链路更稳。2.3 关于本地开发代理和跨域的几个提醒本地开发时前端跑在localhost:3000接口可能跑在localhost:8080直接跨域会导致Cookie带不上、CORS预检失败等一系列问题。nuxtjs/axios配合proxy模块可以解决modules: [ nuxtjs/axios, nuxtjs/proxy ], axios: { proxy: true, baseURL: /api }, proxy: { /api/: { target: http://localhost:8080, pathRewrite: { ^/api/: } } }这段配置的妙处在于写了一行proxy: true之后baseURL变成相对路径/api所有请求先打到Nuxt自身的/api路由上由Nuxt服务端转发到目标后端。这样本地没有了跨域SSR阶段请求也是同源路径不用区分内外网。上线时则把baseURL换成公网环境变量整个迁移文件只动一处。实操心得如果你接手的是一个历史较长的Vue项目先全局搜索axios.create({ baseURL: ... })、axios.defaults.baseURL、以及散落在各文件里的完整URL字符串。最好写一个临时的检查脚本把所有直接写死域名的地方列出来。我碰到的项目里有将近三成请求没有走统一实例直接import axios from axios然后axios.get这种代码在Nuxt里不会错但会让你失去统一拦截、错误处理、携带凭证的能力。重构请求层的价值就在这里不止是换根域名而是让所有请求经过同一个出口。3. Vuetify字体切换一个老生常谈却总有人卡住的配置3.1 先分清你要换的是“Vuetify默认字体”还是“全局字体”Vuetify 2.x默认字体栈是RobotoRoboto这个字体如果在国内网络环境下没加载成功页面看起来会有一瞬间的字体闪跳甚至一直停留在系统默认字体上。Vuetify 3把字体相关的SCSS变量保留了下来但换字体这件事的逻辑没变——Vuetify的字体是通过SCSS变量注入的你要覆盖它就得在样式层面按它的契约来。很多人以为改字体只需要在nuxt.config.js里往vuetify配置项传一个customProperties或iconfont就够了其实字体根本不是从这里换的。Vuetify的字体变量定义在styles/settings/_variables.scss里其中$body-font-family、$heading-font-family、$font-size-root这几个变量决定了全局字体和字号。要换成自定义字体最干净的方式是重写这几个SCSS变量。3.2 Nuxt Vuetify的字体配置完整示例以Nuxt 2 Vuetify 2为例先在assets/scss/variables.scss里做变量覆盖// assets/scss/variables.scss $body-font-family: PingFang SC, Microsoft YaHei, Helvetica Neue, Arial, sans-serif; $heading-font-family: $body-font-family; $font-size-root: 16px;然后在nuxt.config.js里把它接进Vuetify的样式管线// nuxt.config.js export default { css: [ ~/assets/scss/variables.scss ], vuetify: { treeShake: true, defaultAssets: { font: true, // 这里控制是否加载Vuetify默认字体 icons: [mdi] } } }如果你用的是nuxtjs/vuetify模块模块内部会自动把assets/scss/variables.scss作为Vuetify自定义变量文件加载你甚至不需要手写一份新的scss去覆盖直接在nuxt.config.js里指定vuetify: { customVariables: [~/assets/scss/variables.scss], treeShake: true, defaultAssets: { font: { family: Noto Sans SC } } }这里defaultAssets.font是Vuetify模块提供的快捷方式它会在页面里自动加一段Google Fonts的链接。但国内生产环境访问Google Fonts是很不稳定的我一般不会依赖这个快捷方式而是关闭默认字体把字体文件托管到自己的静态资源或CDN上。3.3 字体文件的自托管方案和加载时序关掉自动加载字体后我在static/fonts/下放好字体文件然后在nuxt.config.js里用head.script或直接在layouts/default.vue里手动引入font-face。以layout里引入为例!-- layouts/default.vue -- style langscss font-face { font-family: MyCustomFont; src: url(~/static/fonts/MyCustomFont.woff2) format(woff2), url(~/static/fonts/MyCustomFont.woff) format(woff); font-display: swap; font-weight: 400; } /style注意url(~/static/fonts/...)在Vue SFC的style里不一定能不能正确解析我实践下来更稳妥的做法是用~assets/fonts/即把字体放进assets目录通过Webpack的url-loader处理。路径问题的坑很容易踩先把字体文件放在assets/fonts/下然后这样引用font-face { font-family: MyCustomFont; src: url(~assets/fonts/MyCustomFont.woff2) format(woff2); }这里推荐font-display: swap它保证字体文件还在加载时页面先用回退字体渲染文本不会出现不可见文字FOIT的尴尬体验。切换字体之后要实测一下不同操作系统的渲染差异尤其Windows下中文字体的渲染高度、粗体表现和macOS完全不同建议在变量里把回退字体链写全PingFang SC、Microsoft YaHei、Noto Sans SC、sans-serif。我踩过的坑treeShake开启后Vuetify会按需打包样式如果只在部分组件里设置了自定义字体变量、另一部分组件没设置可能出现同一个页面左边是自定义字体、右边还是Roboto的情况。排查方法是在浏览器里选中文本查看computed style的font-family看是哪个选择器优先级更高。Vuetify的.v-application样式来自预编译的CSS文件如果把自定义变量文件放错位置它根本不会参与编译。所以务必确认customVariables指向的文件真的被模块加载了最简单的验证方式是往variables.scss里写一个明显不合法又可见的变量比如把$font-size-root: 999px看页面字号有没有变化。4. Mavon编辑器接入NuxtSSR环境下最典型的第三方组件适配4.1 Mavon编辑器为什么在Nuxt里会炸Mavon编辑器mavon-editor是一个基于Vue的Markdown编辑器组件内部操作大量DOMtoolbar渲染、代码高亮、图片上传预览、滚动联动、粘贴剪切板读取。这些操作在浏览器里毫无压力但在Nuxt的SSR阶段组件在Node.js环境里执行render函数时会直接访问document对象——Node环境压根没有这个对象于是抛出的就是经典的document is not defined、window is not defined。理解了这一点解决方案就变得清晰让Mavon编辑器只在客户端渲染服务端完全不碰它。Nuxt对这类场景提供了两个现成工具client-only组件和ssr: false的动态导入。4.2 用client-only包一层最直观的方式是在模板里包上client-onlytemplate div client-only mavon-editor v-modelcontent :toolbarstoolbars img-addhandleImgAdd / /client-only /div /template script import MavonEditor from mavon-editor import mavon-editor/dist/css/index.css export default { components: { MavonEditor }, data() { return { content: , toolbars: { bold: true, italic: true, header: true, link: true, image: true } } } } /script需要注意一点client-only内部的组件在服务端渲染阶段不会输出任何内容也就是说当前页面对搜索引擎展示的内容里Markdown正文区域是空的。如果这个页面需要被SEO收录光包client-only不够你应该在服务端渲染时额外输出编辑器的只读预览内容把Markdown渲染成HTML或者至少在client-only外面放一个noscript或静态占位。我的经验是展示型内容用户写的文档、文章必须保留SSR版本编辑功能才走客户端渲染。后来我把这个方案改成了双通道正文区用markdown-it做服务端渲染输出编辑器只在需要编辑时才切换出来SEO和体验兼顾。4.3 动态导入加ssr: false的进阶玩法包client-only虽然省事但组件的JS还是会打进服务端bundle万一Mavon编辑器内部有副作用比如在模块顶层执行了浏览器API依然会在构建或启动阶段出问题。更彻底的方案是动态导入并显式关闭SSRexport default { components: { MavonEditor: () import(mavon-editor).then(m m.default || m) } }放在Nuxt里还要配合ssr: falseconst MavonEditor () import(mavon-editor).then(m m.default || m)更严谨的做法是新建一个包装组件用nuxt.config.js里的build.extend把它从服务端排除build: { extend(config, { isServer }) { if (isServer) { config.externals config.externals || [] config.externals.push(mavon-editor) } } }把mavon-editor加入服务端externals意味着服务端打包时不编译这个模块运行时也不加载它。配合客户端动态导入才能真正做到“服务端零接触”。这种写法对依赖了window、document的第三方库都适用不止MavonEditor一个。4.4 图片上传和自定义工具栏的配置细节Mavon编辑器默认的图片上传是把图片转成base64存在正文里这种方案在本地Demo没问题但生产环境很快会把接口请求体和数据库字段撑爆。我一般通过imgAdd事件接管上传逻辑先把图片传到自己的对象存储再回填URLasync handleImgAdd(pos, $file) { const formData new FormData() formData.append(file, $file) try { const { data } await this.$axios.post(/upload/image, formData) this.$refs.md.$img2Url(pos, data.url) } catch (e) { this.$toast.error(上传失败) } }这里有个Vue 2 Nuxt 2的经验$refs.md的md必须在模板里给Mavon编辑器组件加上refmd且$img2Url是Mavon内部方法操作的是当前编辑器的内容区DOM。如果你用client-only包裹且编辑器还没有完成挂载this.$refs.md会是undefined所以上传回调在imgAdd里触发时通常没问题因为图片事件必然发生在已有DOM之后。工具栏配置也很容易忽略语言问题。MavonEditor的默认文案是中文如果你需要英文或其他语言得通过language属性设置并且把对应语言包import进来。这个属性在设计上只在初始化时生效运行时切换要通过一个key变化强制重建组件跟Vuetify的locale用法差不多。这些细枝末节在官方文档里都有但接入Nuxt后SSR的干扰常常让人误以为是配置没生效其实只是组件没被客户端正确重建。5. 高频异常实录那些让人头疼的localStorage/window/document未定义问题5.1 先弄明白异常的本质Node环境没有浏览器全局对象Nuxt服务端渲染是在Node.js进程中跑Vue组件的render函数生成HTML字符串。Node进程里没有window、document、localStorage、navigator、sessionStorage、location这些浏览器全局对象。你的组件代码一旦在校验undefined之前直接访问它们进程就会抛ReferenceError。这个错误有个特征在本地开发时不一定复现或者在首屏刷新时出现并伴随整个应用白屏。真正让人困惑的不是错误本身而是它的隐蔽性。很多第三方库和业务模块都有一层“间接依赖”——不是你的代码直接访问window而是库内部某个模块在加载时访问了window。典型的例子某个工具函数库在模块顶层使用了window.innerWidth做初始化判断你在组件里只是简单import这个库服务端加载模块时就已经炸了跟你在哪里调用无关。这类模块顶层副作用是SSR迁移中最难排查的一类。5.2 五个典型场景与对应修复方案我把项目中遇到的高频报错场景整理成一个表标出每种情况的判断要点和修法场景报错信息典型代码位置修复策略组件初始化直接读浏览器对象localStorage is not definedcreated/setup/模块顶层把读取逻辑移到mounted或在访问前做类型判断第三方库模块顶层有副作用window is not definedimport某个库时触发动态导入并ssr: false或服务端externals排除插件在服务端执行了浏览器逻辑document is not definedplugins/目录下的插件插件回调里加if (process.client)或注册成客户端插件Vuex store初始化读取storagelocalStorage is not definedstore/index.js里state初始化在服务端返回默认值客户端再重新hydration生命周期里调用了浏览器APInavigator is not definedcreated里调navigator.userAgent移到mounted或使用process.client判断后再执行先看最普遍的组件初始化问题。修复不是简单地把代码搬到mounted就完事还要考虑数据的一致性。我在项目里用到的模式是在mounted里读值并写入Vuex或组件data服务端只负责渲染占位内容data() { return { token: , theme: light } }, mounted() { // 客户端环境才能安全访问 localStorage if (process.client) { this.token localStorage.getItem(token) || this.theme localStorage.getItem(theme) || light } }这样SSR阶段不会炸客户端首次渲染时也能正确恢复状态。注意这里process.client是Nuxt提供的环境标记编译时就会被替换成布尔值理论上不会有运行时代价。第二个场景是第三方库的模块顶层副作用修法要暴力一些。以某个图表库为例它在入口文件里就访问了document我的做法是单独封装一个客户端组件template div refchart/div /template script export default { name: ClientChart, mounted() { // 动态导入第三方库只在客户端执行 import(some-chart-lib).then(({ default: ChartLib }) { this.chart new ChartLib(this.$refs.chart) }) } } /script然后在使用处用client-only包裹或动态注册组件彻底切断服务端的模块加载链路。这也是我在Mavon编辑器那一段提到的思路的通用版。第三个场景是Nuxt插件。插件的默认加载时机是“所有页面”包括服务端渲染阶段。如果插件里写了localStorage.getItem整个应用启动就直接挂掉。修复方式两种加process.client判断或者在nuxt.config.js里把插件标记为客户端专用plugins: [ { src: ~/plugins/local-storage.js, mode: client } ]mode: client的插件不会被加载到服务端bundle里是最省心的处理。注意Nuxt 3里这个选项改成了{ client: true }的写法迁移到Nuxt 3时要注意API变化。第四个场景是Vuex store的state函数里直接初始化localStorageexport const state () ({ token: localStorage.getItem(token) || })这段代码在任何SSR环境下都是必炸的因为state初始化在服务端渲染阶段就会执行。修复方式是抛弃在state里读浏览器对象改为服务端给默认值客户端在mounted之后派发一个initFromStorage的actionexport const state () ({ token: }) export const mutations { SET_TOKEN(state, token) { state.token token } } export const actions { initFromStorage({ commit }) { if (process.client) { commit(SET_TOKEN, localStorage.getItem(token) || ) } } }在应用入口处调用initFromStorage即可。注意这里“抛弃在state里读”这一步很重要哪怕你都做了环境判断只要有另一个模块在服务端引用了这个store仍然有机会触发读取逻辑。第五个场景是生命周期里的浏览器API修复思路和第一个场景类似把created里的逻辑挪到mounted。之所以单独拎出来说是因为有些API只能在created里拿到路由快照比如读取当前路由参数挪到mounted后参数来源要换一种方式获取。我遇到的案例是某支付组件在created里用navigator.userAgent判断移动端还是PC端这个判断在SSR阶段可以用req.headers[user-agent]代替其实服务端拿到的User-Agent更准确因为那是真实请求带过来的。这类“浏览器对象”在服务端是有替代方案的值得按业务场景单独设计。5.3 排查这类异常的通用方法论面对xxx is not defined我的排查顺序固定三件事看堆栈第一行判断异常是发生在渲染阶段还是事件回调阶段。渲染阶段的异常会导致整个请求500或白屏事件回调阶段的异常通常只影响当前交互。区分这两者决定了你要不要先重启开发环境、清服务端缓存。看触发时机是刷新页面SSR渲染路径就报还是进入某个路由才报还是点击某个按钮才报。刷新即报大概率是组件渲染阶段或模块加载阶段的问题路由切换才报可能是某个懒加载组件在客户端实例化时的副作用按钮点击才报则是纯客户端问题跟SSR关系不大。用环境标记切割在可疑代码处临时加上if (process.client) { console.log(reach client) }之类的探针看服务端日志和浏览器控制台各自打印了什么。这种线上二分法比翻代码高效得多。还有一个体力活技巧在nuxt.config.js里暂时禁用SSRssr: false跑一遍项目。如果所有异常消失说明全部是SSR环境引起的如果依然报错说明项目本身就有浏览器对象依赖问题跟重构Nuxt无关。这个简单的开关能帮你快速划分责任边界我在重构中后期几乎天天用它。5.4 一套可以抄作业的浏览器对象封装与其每次在业务代码里写if (process.client)不如封装一个storage工具屏蔽环境差异// utils/storage.js const isClient typeof window ! undefined export const storage { get(key) { if (!isClient) return null return localStorage.getItem(key) }, set(key, value) { if (isClient) { localStorage.setItem(key, value) } }, remove(key) { if (isClient) { localStorage.removeItem(key) } } }业务代码里统一用storage.get(token)服务端自动返回null客户端正常读写。提交前全局搜索一下把所有的localStorage.直接调用都替换成这个封装能消灭掉七成以上的本地存储相关SSR异常。同理window的访问可以封装成utils/window.js用typeof window ! undefined做保护。这套封装解决了“会不会炸”的问题但没有解决“数据一不一致”的问题——比如页面A在客户端已经写了localStorage刷新时SSR输出的HTML里是按空值渲染的客户端会再走一次读取并触发更新。这个过程中的闪烁需要用v-if或一个initialized标志位来控制组件展示时机。我在有些列表页就是用v-ifinitialized来避免空值闪现的。6. 重构收尾阶段的多环境验证清单主体功能迁移完以后我会按下面这份清单做一遍系统性验证每一项都有明确目的避免上线前才发现隐蔽问题本地开发模式npm run dev跑起来正常热更新、正常请求、正常SSR刷新。这个阶段重点看mounted前后的渲染差异和字体加载情况。SSR模式构建npm run build npm run start模拟生产环境。重点验证window is not defined是不是彻底消失以及构建产物里是否还有不该出现的浏览器对象访问。多环境域名切换分别以测试环境、生产环境的API_BASE_URL启动确认Axios的baseURL在各环境都自动替换为正确的根域名。刷新与直接访问直接输入URL和刷新页面走服务端渲染看内容是否完整输出、编辑器是否正常显示、页面有没有因客户端重新渲染导致的闪烁或样式漂移。低网速/弱网模拟关掉部分JS资源确认首屏HTML已经包含了关键内容字体和各样式不会因为网络问题塌陷。爬虫模拟用禁用JS的工具或直接在服务器端curl页面观察HTML里是否包含了正文内容确认SEO改造是有效的。每个环节我都会留一个检查日志记录当时发现了什么、怎么修的。重构这种大动作最容易出现“修好一个地方又坏掉另外一个地方”的连锁反应留下日志能给回滚提供依据。我习惯在代码分支上按阶段提交refactor/axios、refactor/vuetify-font、refactor/mavon-editor、refactor/ssr-fix每个阶段都是独立可构建的提交点。这样如果某个环节出了问题可以精确回退到上一个稳定点而不是整个重构混成一锅粥再倒回去。最后再分享一个经验重构完成后的第一周让团队里的每个人都至少经历一次“从克隆代码到把环境跑起来”的过程。这个过程暴露的文档缺失、环境变量遗漏、启动命令问题往往比代码逻辑问题更影响效率。我在项目里就把环境变量整理成了一份.env.example注明每个变量的含义和来源这个文件成了新同事上手最快的入口。