Nuxt.js 详解(三):迁移踩坑与最佳实践
Nuxt.js 详解(三):迁移踩坑与最佳实践
这是 Nuxt 系列的最后一篇。前两篇讲了 Nuxt 是什么、怎么用。这篇讲实际项目里你会踩的坑——SSR 兼容性、数据水合、性能优化、部署问题,以及怎么避开它们。
一、SSR 兼容性问题(最高频踩坑)
问题是什么
Nuxt 开启 SSR 后,组件会在服务端先执行一次,再到客户端执行一次。服务端环境里没有window、document、localStorage、sessionStorage这些浏览器对象。
只要你的代码在服务端碰到了这些对象,直接报错:
ReferenceError: window is not defined ReferenceError: document is not defined错误写法
<script setup> // ❌ setup 顶层直接用 window,SSR 阶段会炸 const width = window.innerWidth const token = localStorage.getItem('token') </script>正确写法一:用 onMounted
onMounted只在客户端执行,服务端不跑:
<script setup> const width = ref(0) onMounted(() => { width.value = window.innerWidth }) </script>正确写法二:用 process.client 判断
if(process.client){// 这段代码只在客户端执行consttoken=localStorage.getItem('token')}Nuxt 3 也支持import.meta.client:
if(import.meta.client){consttoken=localStorage.getItem('token')}正确写法三:用 包裹
有些组件只能在客户端跑(比如用到 canvas、地图 SDK),用<ClientOnly>包起来:
<template> <ClientOnly> <MapComponent /> <template #fallback> <div>地图加载中...</div> </template> </ClientOnly> </template>#fallback是服务端渲染时的占位内容,避免白屏。
二、第三方库 SSR 兼容处理
问题
很多第三方库(图表库、编辑器、地图)默认依赖浏览器环境,在 SSR 阶段会报错。
方案一:动态导入 + ssr:false
<script setup> const MonacoEditor = defineAsyncComponent(() => import('@guolao/vue-monaco-editor') ) </script> <template> <ClientOnly> <MonacoEditor /> </ClientOnly> </template>方案二:nuxt.config 配置
// nuxt.config.tsexportdefaultdefineNuxtConfig({build:{transpile:['vue-monaco-editor']// 让 Nuxt 处理这个库的 SSR},vite:{ssr:{noExternal:['some-ssr-unfriendly-lib']// 不走外部化,打包进 SSR bundle}}})方案三:用插件按需加载
// plugins/echarts.client.ts// 文件名带 .client 后缀,只在客户端加载import{use}from'echarts/core'import{CanvasRenderer}from'echarts/renderers'import{BarChart}from'echarts/charts'use([CanvasRenderer,BarChart])exportdefaultdefineNuxtPlugin(()=>{// 初始化逻辑})三、数据水合(Hydration)问题
问题是什么
SSR 时服务端渲染了一份 HTML,客户端拿到后会把这份 HTML 和 JS 状态"对齐"(hydration)。如果服务端和客户端渲染出来的内容不一致,就会报 hydration mismatch 警告,甚至页面错乱。
常见触发场景:
时间不一致:服务端渲染 12:00:00,客户端水合时已经 12:00:01。
<!-- ❌ 会出问题 --> <template> <div>{{ new Date().toLocaleTimeString() }}</div> </template>随机数不一致:
<!-- ❌ 服务端和客户端随机数不同 --> <template> <div>验证码:{{ Math.random() }}</div> </template>解决方案
把不确定的内容放到onMounted里生成:
<script setup> const timeStr = ref('') onMounted(() => { timeStr.value = new Date().toLocaleTimeString() }) </script> <template> <div>{{ timeStr || '--:--:--' }}</div> </template>四、useFetch vs useAsyncData 怎么选
这是新手最容易困惑的点。
| 特性 | useFetch | useAsyncData |
|---|---|---|
| 定位 | 封装好的 HTTP 请求工具 | 通用数据获取 |
| 数据来源 | $fetch(HTTP) | 任意异步操作 |
| 参数 | URL + options | key + handler |
| 适用 | 调接口 | 组合多数据源、非 HTTP 数据 |
简单记法:调接口用 useFetch,其他场景用 useAsyncData。
常见错误:不用 useFetch 直接 $fetch
<!-- ❌ 这样不会做 SSR 预取,还会在客户端重复请求 --> <script setup> const data = await $fetch('/api/users') </script>正确:用 useFetch 包一层:
<script setup> const { data } = await useFetch('/api/users') // SSR 阶段预取,客户端复用,不重复请求 </script>避免重复请求:给 key
多个组件用同一份数据时,给相同的 key,Nuxt 会复用缓存而不是重复请求:
// 组件 Aconst{data}=awaituseFetch('/api/config',{key:'app-config'})// 组件 Bconst{data}=awaituseFetch('/api/config',{key:'app-config'})// 只请求一次,第二个复用第一个的结果五、状态管理最佳实践
SSR 下 Pinia 状态共享
SSR 模式下,每次请求是独立的,不能在模块顶层创建全局单例,否则状态会串到其他用户。
错误写法:
// ❌ 模块顶层创建单例,多用户共享会串数据conststore=createPinia()正确:在setup里调用useXxxStore(),Nuxt 会保证每次请求独立。
跨请求状态用 useState
Nuxt 内置useState,专门处理 SSR 下的共享状态,自动处理服务端到客户端的序列化:
// composables/useCart.tsexportconstuseCart=()=>{returnuseState('cart',()=>({items:[],total:0,}))}不要用普通的全局变量存状态——SSR 下会串用户数据。
六、SEO 优化进阶
基础设置
<script setup> useSeoMeta({ title: '商品详情', ogTitle: '商品详情', description: '商品描述', ogDescription: '商品描述', }) </script>动态 SEO
数据驱动的 SEO,等数据回来再设置:
<script setup> const { data: product } = await useFetch(`/api/products/${route.params.id}`) useSeoMeta({ title: () => `${product.value?.name} - 我的商城`, description: () => product.value?.description, }) </script>站点全局默认值
// nuxt.config.tsexportdefaultdefineNuxtConfig({app:{head:{titleTemplate:'%s - 我的商城',meta:[{name:'viewport',content:'width=device-width, initial-scale=1'},{name:'description',content:'我的商城默认描述'},],}}})sitemap 和 robots
npx nuxi moduleinstallsitemap// nuxt.config.tsexportdefaultdefineNuxtConfig({modules:['@nuxtjs/sitemap'],site:{url:'https://example.com',},sitemap:{sources:['/api/__sitemap__/urls'],}})自动生成/sitemap.xml和/robots.txt。
七、图片优化
安装 NuxtImage
npx nuxi moduleinstallimage使用
<template> <NuxtImg src="/images/product.jpg" width="400" height="300" format="webp" loading="lazy" alt="商品图" /> </template>效果:
- 自动生成多种尺寸的响应式图片
- 自动转 WebP 格式(体积小 30%-50%)
- 懒加载默认开启
- 生成 srcset 适配不同屏幕
八、性能优化
路由级缓存(routeRules)
// nuxt.config.tsexportdefaultdefineNuxtConfig({routeRules:{'/':{prerender:true},// 构建时预渲染'/blog/**':{swr:3600},// 1小时增量缓存'/api/heavy/**':{swr:600},// 重计算接口缓存 10 分钟'/admin/**':{ssr:false},// 后台不走 SSR}})prerender:构建时生成静态 HTML,运行时零开销swr(stale-while-revalidate):返回缓存的同时后台刷新,兼顾速度和新鲜度ssr: false:不走服务端渲染,省服务器资源
组件懒加载
不立即需要的组件用懒加载:
<script setup> // 只在需要时才加载编辑器组件 const Editor = defineAsyncComponent(() => import('~/components/Editor.vue')) </script> <template> <ClientOnly> <Editor v-if="showEditor" /> </ClientOnly> </template>数据预取
<script setup> definePageMeta({ // 进入这个页面时预取 /api/users,不用等组件加载 async middleware() { await useFetch('/api/users') } }) </script>九、部署注意事项
Node 部署环境变量
构建后的产物需要运行时读取环境变量。构建时写死的值不会生效,要用运行时配置:
// nuxt.config.tsexportdefaultdefineNuxtConfig({runtimeConfig:{public:{apiBase:process.env.NUXT_PUBLIC_API_BASE||'http://localhost:3000'}}})启动时传入:
NUXT_PUBLIC_API_BASE=https://prod-api.example.comnode.output/server/index.mjs静态站点部署注意
SSG 模式下,动态路由的页面要告诉 Nuxt 去预渲染哪些:
// nuxt.config.tsexportdefaultdefineNuxtConfig({nitro:{prerender:{crawlLinks:true,// 自动爬取页面里的链接routes:['/sitemap.xml'],}}})或者用routeRules指定:
routeRules:{'/blog/**':{prerender:true}}反向代理配置
用 Nginx 反代 Nuxt 应用:
server { listen 80; server_name example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }十、常见报错排查
1.window is not defined
原因:SSR 阶段用了浏览器 API。
解决:用process.client判断或放onMounted里。
2.Hydration text mismatch
原因:服务端和客户端渲染内容不一致(时间、随机数、依赖客户端状态的数据)。
解决:把不确定内容放onMounted,或用<ClientOnly>包裹。
3.useFetch 重复请求
原因:没有 SSR 预取,或 key 重复。
解决:确保useFetch在setup顶层 await 调用,不要包在函数里。
4. 第三方库报Cannot read properties of undefined
原因:库依赖浏览器环境。
解决:用<ClientOnly>包裹,或配vite.ssr.noExternal。
5. 部署后 502 / 端口不对
原因:Nitro 默认 3000 端口,被占用或没配对。
解决:PORT=8080 node .output/server/index.mjs指定端口。
6. 静态生成后动态路由 404
原因:SSG 模式下动态路由没有被预渲染。
解决:配置nitro.prerender.crawlLinks或手动指定 routes。
十一、结语
三篇文章走完了 Nuxt 的完整认知链路:
- 是什么:Vue 之上的全栈框架,解决 SSR、SEO、路由工程化、前后端一体
- 怎么用:约定式路由、自动导入、useFetch、Pinia、server/api、部署
- 踩什么坑:SSR 兼容性、hydration、第三方库、性能、部署
核心心法一条:凡是涉及浏览器 API 的代码,先想"服务端阶段会不会执行到这里"。这一条想通了,Nuxt 大半的坑都不会踩。
本系列共三篇:
- 第一篇:Vue 开发者为什么要关注 Nuxt
- 第二篇:从零搭建一个 Nuxt 项目
- 第三篇:迁移踩坑与最佳实践(本文)