做 Vue 服务端渲染SSR这两年我前后踩了不下二十个坑从首屏白屏到内存泄漏从window is not defined到脱水注水数据不一致几乎把社区里能搜到的问题都过了一遍。这个“09-服务器端渲染”的实战项目本质上是把 SPA 的壳子和 SSR 的里子拼在一起很多人卡在第一步明明照着官方文档搭页面就是出不来或者出来了但数据是空的。这篇博文我不打算复述文档只讲我实际跑通、上线、压过测的经验包括为什么要拆分双端入口、数据预取为什么要在服务端做两次生命周期、注水时 JSON 序列化有哪些隐雷以及上线后怎么应对内存和长任务问题。如果你正准备把 Vue 项目改造成 SSR或者已经在改但被各种报错折磨这篇文章应该能帮你省掉几天的排查时间。1. 为什么需要服务器端渲染SPA 的隐痛与 SSR 的解法1.1 白屏两秒的代价先聊一个真实的业务场景。我之前维护过一个电商活动页纯 Vue SPA 搭建首屏加载需要先下载几百 KB 的 JS 包再在浏览器里执行 Vue 的挂载逻辑然后才能把商品列表渲染出来。用户从点击链接到真正看到内容平均要等 2 秒以上在弱网环境下这个数字能翻到 5 秒。更麻烦的是爬虫抓页面时只拿到一个空壳的div#app页面里所有的商品标题、价格、描述全部是 JS 动态渲染出来的搜索引擎根本收录不到。这两个问题一个影响用户体验一个影响自然流量都是纯前端渲染绕不过去的坎。服务器端渲染解决的就是这两件事在服务器上先把 Vue 组件跑一遍生成完整的 HTML 字符串直接返回给浏览器同时把页面所需的数据也一并带过去。用户一打开页面看到的就是带内容的真实 DOM不需要等 JS 下载完再去执行渲染逻辑。爬虫拿到的 HTML 里也包含了完整的关键信息索引效率直接提升一个量级。1.2 SSR 不是银弹哪些场景真的需要它很多人一听 SSR 能解决 SEO 和白屏就想把所有项目都改成服务端渲染。我的建议是先冷静下来看看自己的业务形态。像后台管理系统、数据可视化大屏、内部运营工具这类纯交互型应用用户都登录了才用不关心 SEO首屏慢一点也影响不大强行上 SSR 只会给自己找麻烦——服务端要维护 Node 环境要处理登录态的同步还要面对集群部署的复杂度。真正适合 SSR 的场景有这几类内容型站点博客、新闻、文档、电商详情页和活动落地页、C 端门户首页。这类页面的共同特点是内容需要被搜索引擎收录且首屏渲染速度直接影响用户留存和转化。我的判断标准很简单如果一个页面脱离了 JS 就完全没有意义那它不需要 SSR如果一个页面脱离了 JS 还能保留 70% 以上的核心信息那它就值得做 SSR。这个标准帮我在不少项目里做出了合适的技术选型。1.3 Nuxt 还是纯 Vue SSR一次选型实测确定了要做 SSR紧接着就是框架选型。市面上主流方案有两个直接用 Vue 官方提供的vue-server-renderer或者vue/server-renderer搭纯 SSR或者用 Nuxt.js 全家桶。这两个方案我都实际用过说下感受。Nuxt 确实是开箱即用的它内置了路由、状态管理、SEO 配置、静态化部署开发体验很接近 Next.js 之于 React。如果你的项目是全新的团队又没有太多 SSR 定制需求直接用 Nuxt 是最稳妥的。但如果你是改造一个已有的 Vue 项目想把纯 SPA 升级成 SSR那我更建议用纯 Vue SSR 方案。原因有两个一是 Nuxt 有自己的一套目录结构和约定迁移老项目本质上是在重写业务代码成本远高于在现有路由和状态管理基础上加服务端渲染层二是纯 Vue SSR 的代码逻辑透明每一行都在自己掌控之下排查问题时不需要去翻 Nuxt 的源码。我这次实战项目就是用纯vue/server-renderer搭的下面的内容也全部基于这个方案。2. 核心细节解析SSR 的关键机制与实操要点2.1 双端渲染入口为什么要拆成两个SSR 项目第一个让人困惑的地方就是为什么同一个应用要写两个入口文件。纯 SPA 项目只有一个main.js创建实例、挂载路由、挂载到 DOM一气呵成。到了 SSR 这里官方推荐的结构是entry-client.js和entry-server.js各干各的。服务端入口的执行环境是 Node.js它的职责是用createSSRApp创建应用实例拿到路由当前匹配的组件然后调用renderToString把组件树渲染成 HTML 字符串。服务端的生命周期只有setup和beforeCreate、created这两个钩子会执行mounted及之后的钩子在服务端根本没有意义因为服务端不存在真实 DOM。客户端入口的执行环境是浏览器它的职责是创建一个相同的应用实例然后调用hydrate方法把服务端渲染好的静态 HTML 变成可交互的动态应用。之所以要分开核心原因是一个应用在两端做的事情完全不同服务端只需要产出 HTML客户端需要绑定事件和恢复响应式。如果强行用同一个入口就必须在代码里到处判断typeof window整个逻辑会乱成一团。我在项目中还把路由的createWebHistory和createMemoryHistory也按环境封装了浏览器端用 history 模式Node 端用内存模式这个细节如果不处理服务端渲染时会直接因为解析不到 URL 而抛错。2.2 路由与数据预取服务端拿数据客户端再取一遍SSR 的数据预取是另一个容易理解偏的点。SPA 的数据请求通常发生在组件的mounted生命周期里但服务端渲染根本不执行mounted所以需要把数据获取逻辑提前到setup或created中并且在组件上暴露一个静态方法比如asyncData或者serverPrefetch让服务端在渲染前主动调用。我的做法是在路由配置里给每个需要服务端数据的组件定义一个asyncData(context)方法服务端入口在renderToString之前先拿到当前匹配的路由记录逐个调用它们对应的asyncData把返回的数据存到一个全局的 store 里。这套流程完整跑下来之后再执行renderToString此时组件内部已经从 store 读取到了数据渲染出来的 HTML 就是带真实内容的完整页面。客户端这边的流程则是页面加载完成后先检查 store 里有没有已经填充好的数据有就直接用没有再触发一次asyncData重新请求。这里有一个很多人踩过的坑如果客户端不去检查服务端已经塞好的数据而是无条件重新请求就会出现服务端渲染的 HTML 是一份数据客户端 hydration 之后又把数据覆盖成另一份的情况造成页面内容闪烁甚至不一致。所以客户端入口的asyncData调用必须包一层“有数据就不调”的判断逻辑。2.3 注水与脱水为什么必须 JSON 序列化服务端把数据塞给客户端用的是一套叫“注水”和“脱水”的机制。服务端在 render 完成后会把 store 里的数据JSON.stringify之后嵌入到一个window.__INITIAL_STATE__全局变量里这段字符串和 HTML 一起返回给浏览器。客户端入口在创建 store 时会优先读取window.__INITIAL_STATE__里的数据作为初始状态这样 hydration 时组件读取的 store 内容和服务端渲染时完全一致页面才能无缝衔接。这里的核心细节是从服务端到客户端传递的数据必须能被JSON.stringify正确处理。我碰到过的典型问题是开发人员把undefined类型的值放进 store序列化之后直接被丢掉了客户端读不到完整的初始数据还有人在数据里封装了Date对象序列化后变成了一串时间字符串客户端反序列化时拿到的类型变了导致时间格式化函数直接报错。更隐蔽的问题是循环引用服务端 store 里的某个对象引用了自身JSON.stringify直接抛异常页面整个 500。我的经验是进入 store 的数据必须做一次 DTO 清洗只保留纯 JSON 类型对象、数组、字符串、数字、布尔值、null其他复杂对象要么拆分、要么用toJSON方法统一转换。这个习惯能避开绝大多数“服务端渲染成功但客户端报错”的问题。3. 实操过程从零搭建一个 Vue 3 SSR 项目3.1 环境准备与项目结构我这次实战用的是 Vue 3.2 Vite 4 Express配合vue/server-renderer实现渲染逻辑。为什么不直接用 webpack因为 Vite 在开发模式下天然支持服务端渲染的模块加载启动和热更新都比 webpack 快一个量级。项目结构我建议这样划分server-render-demo/ ├── index.html ├── vite.config.js ├── src/ │ ├── entry-client.js │ ├── entry-server.js │ ├── app.js │ ├── router/ │ │ └── index.js │ ├── store/ │ │ └── index.js │ └── views/ │ ├── Home.vue │ └── Product.vue └── server/ ├── index.js └── prod.js核心文件是app.js它负责创建应用实例同时被两个入口复用。我把它写成一个工厂函数每次调用都返回全新的 app、router、store 实例。这样做是为了避免服务端多个请求之间互相污染状态——如果应用实例是全局单例第一个请求写的 store 数据会被第二个请求读到这就是经典的跨请求状态污染问题。每次请求都createApp()一行代码换来的是内存和行为的双重安全。// src/app.js import { createSSRApp } from vue import { createRouter } from ./router import { createStore } from ./store import App from ./App.vue export function createApp() { const app createSSRApp(App) const router createRouter() const store createStore() app.use(router) app.use(store) return { app, router, store } }3.2 服务端入口renderToString 与并发处理的平衡服务端入口的核心逻辑分为三步创建应用实例、匹配路由、渲染 HTML。第三步是性能瓶颈所在因为renderToString是同步阻塞的一次只能渲染一个页面。我压测过一个中等复杂度的页面单次渲染大约耗时 80ms如果同时有 20 个请求进来Node 事件循环会被全部占满性能直降。解决并发问题的方向有两个一是用多个 Node 进程分担压力配合 PM2 的 cluster 模式二是在单进程内把渲染拆碎用renderToWebStream做流式渲染让 HTML 分块输出首字节时间大幅缩短。我这次实战项目用renderToWebStream替换了renderToString核心代码如下// src/entry-server.js import { renderToWebStream } from vue/server-renderer import { createApp } from ./app export async function render(url, manifest) { const { app, router, store } createApp() await router.push(url) await router.isReady() const matchedComponents router.currentRoute.value.matched await Promise.all( matchedComponents.map(component { if (component.asyncData) { return component.asyncData({ store, route: router.currentRoute.value }) } return null }) ) const stream renderToWebStream(app) return { stream, state: store.state } }这里有一个容易忽略的细节router.isReady()必须等待完成才能开始渲染否则路由懒加载的组件可能还没加载好渲染出来的 HTML 是空白的。我最初没加这个 await线上页面经常首屏白屏排查了很久才发现是路由没有准备好就触发了渲染。3.3 客户端入口hydration 的注水逻辑客户端入口相对简单但同样有三个关键点。第一个关键点是createApp之后要用router.isReady()把异步路由组件加载完再挂载避免 hydration 时路由对应的组件还没就位。第二个关键点是store.replaceState(window.__INITIAL_STATE__)把服务端传过来的数据塞回 store 里。第三个关键点是用app.mount(#app, true)中的第二个参数开启 hydration 模式让 Vue 复用服务端渲染出来的 DOM 节点而不是全部重建。// src/entry-client.js import { createApp } from ./app const { app, router, store } createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } router.isReady().then(() { app.mount(#app, true) })如果跳过store.replaceState这一步结果就是服务端渲染的页面明明有完整数据但客户端 store 是空的组件重新渲染时把 DOM 里的内容清空再填入空数据用户会看到页面内容闪一下然后消失。这个 bug 的症状很隐蔽因为只在页面刷新时出现如果用 SPA 的方式从其他页面跳转过来store 会被路由钩子重新填充反而看不到问题。3.4 数据预取的完整链路从路由钩子到组件渲染数据预取在 SSR 里是一条完成的链路我在项目里把它整理成了一张清晰的流转图用文字描述浏览器请求页面 → Express 路由接收请求 → 调用服务端入口的render方法 → 创建 store → 匹配路由 → 调用组件asyncData→ 填充 store →renderToWebStream渲染 → HTML 和window.__INITIAL_STATE__一起返回。客户端这边浏览器加载 HTML → 解析到window.__INITIAL_STATE__→ 创建 store 并用该数据初始化 → hydration 完成 → 页面交互正常。关于asyncData的命名Vue 官方没有强制约束但社区里普遍用这个名字它的签名通常是asyncData(context)其中context包含store、route等对象。我在项目中还额外传了一个isServer标识让同一个方法在服务端和客户端可以走不同的分支逻辑。比如某些埋点统计只应该在客户端执行服务端调asyncData时看到isServer为true就直接跳过。3.5 服务端与静态资源Express、缓存和优先级服务端除了渲染页面还承担了静态资源的托管和缓存控制。开发环境下 Vite 会自动处理资源但生产环境需要自己接管。我的静态资源策略是/assets目录下的 JS 和 CSS 文件响应头设置Cache-Control: public, max-age31536000, immutable因为 Vite 构建时会给文件名加上内容哈希文件名变了就说明内容变了浏览器可以放心长期缓存。/img目录下的图片设置max-age86400一小时重新验证一次。渲染出来的 HTML 页面本身设置Cache-Control: no-cache保证每次请求都回归到服务端渲染。还有一个很多人容易漏掉的细节renderToWebStream必须要配合预加载指令才能发挥性能优势。Vite 在构建 SSR 项目时会生成一份构建清单里面记录了每个组件对应的异步 chunk 文件。服务端渲染时需要用这份清单生成link relmodulepreload标签提前告诉浏览器哪些 JS 文件即将用到。没有这份预加载首屏还是要等 JS 下载完才能交互SSR 的性能红利就打了折扣。4. 常见问题与排查技巧实录4.1 window is not defined服务端访问浏览器对象报错这是 SSR 项目遇到概率最高的报错没有之一。原因是组件或第三方库在模块加载阶段就直接访问了window、document等浏览器对象而服务端 Node 环境里根本没有这些对象。我在项目里用了一个判断逻辑把浏览器对象的访问放到客户端才执行的代码块中if (typeof window ! undefined) { // 只有在浏览器中才执行 localStorage.setItem(user, JSON.stringify(user)) window.addEventListener(scroll, handler) }但有些第三方库就没这么好对付了比如某些 UI 组件库在模块顶部就做了document.createElement之类的事情这种库要么换 SSR 兼容的版本要么在 Vite 配置里把它从服务端打包中排除然后用动态导入的方式只在客户端加载。另一个容易被忽略的问题出现在代码分割的场景某个组件用了import(/utils/dom)动态导入但这个模块里有浏览器对象访问。如果这个动态导入被服务端执行到了同样会抛window is not defined。解决方案是把这类模块的导入条件改为if (typeof window ! undefined)。4.2 内存泄漏与长任务压测暴露的隐性风险SSR 服务的内存管理是个大话题我压测时遇到过内存持续上涨的场景。原因有两个一是模块级别的全局变量被不断写入数据比如把每次请求的 store 实例挂到了模块顶部第二个请求覆盖第一个请求但第一个请求的数据还留在闭包里无法回收二是第三方库的缓存机制比如 axios 实例默认的 keep-alive 连接池在长时间运行后会积累大量空闲连接。我的处理经验是所有请求相关的数据必须严格限定在render函数作用域内函数执行完就该被回收axios 实例不要设置过大的连接池生产环境建议maxSockets: 10定时器要在请求结束时清理。还有一点是renderToWebStream本身在执行复杂组件渲染时会占用较多 CPU属于长任务需要通过 PM2 的max_memory_restart配置做兜底保护内存超过阈值自动重启进程。4.3 样式闪烁hydration 后 CSS 的加载顺序SSR 的样式处理如果没做好会出现一种很尴尬的现象页面首次加载时样式是完整的但 hydration 完成之后样式崩溃或者反过来 hydration 之后样式才姗姗来迟。这个问题的根源在于 CSS 注入的位置和时机。在纯 SPA 里样式是 JS 运行时动态注入的页面挂载后样式跟着生效。在 SSR 里服务端渲染出的 HTML 已经包含了组件的样式标签客户端 hydration 时app.mount(#app, true)会尝试复用现有 DOM如果 Vite 构建时把 CSS 抽取成了独立的文件并且通过link标签加载首次渲染和 hydration 之间不会冲突。但如果开发模式下的vue-plugin注入的是style标签客户端会把服务端已有的样式清除再重新注入就会出现闪烁。我的做法是生产环境强制开启 CSS 代码分割把每块路由的样式独立成文件并且保证link标签出现在head里顺序在业务脚本之前。这样浏览器解析 HTML 时就能先加载样式渲染出来的页面从头到尾都是完整的。4.4 devtools 插件服务端渲染下如何调试客户端状态Vue devtools 插件在 SSR 项目里的表现和纯 SPA 略有不同。因为服务端渲染的页面在客户端 hydration 完成后Vue 实例是全新创建的devtools 需要重新连接。我遇到过的一个问题是devtools 插件无法正确显示组件树的某个深层节点原因是那个组件的setup里返回了一个服务端不存在的对象引用hydration 时 Vue 对比不了新旧状态。调试技巧是优先看__INITIAL_STATE__的内容确认服务端塞给客户端的数据是否正确再用 devtools 的 Vuex/Pinia 面板查看 store 里的数据是否与服务端一致最后对比组件树的 props 和 data。如果这三个层面的数据都对得上基本可以断定 SSR 链路是通的剩下的问题多半出在组件自身的生命周期逻辑上。还有个建议是下载稳定版的 devtools 插件旧版本对 Vue 3 的 hydration 调试支持不太好容易出现状态不更新的假象。4.5 问题速查表典型报错出现时机根本原因解决思路window is not defined服务端渲染时模块顶层访问浏览器对象判断环境后加载或排除服务端打包ReferenceError: document is not defined服务端执行组件组件内引用 DOM API延迟到mounted执行或动态导入页面内容闪一下消失浏览器刷新时客户端未读取__INITIAL_STATE__store.replaceState初始化数据服务端渲染后客户端报错hydration 时数据序列化后类型改变或丢失DTO 清洗只用纯 JSON 类型首屏白屏但接口正常首次访问路由懒加载期间渲染触发router.isReady()后再渲染内存持续上涨压测或长时间运行全局变量持有请求数据请求级数据限定在函数作用域内样式闪烁hydration 后CSS 注入顺序冲突生产环境 CSS 独立分割并前置加载跨请求数据串扰并发请求时应用实例为全局单例每次请求调用createApp()全新实例最后再分享一个小技巧如果项目里有一些页面是纯静态的内容比如关于页、帮助文档没必要让它们每次请求都走服务端渲染。我现在的做法是用renderToWebStream渲染一次之后把生成的 HTML 结果缓存到内存里命中缓存的请求直接返回 HTML 字符串不重复创建应用实例。用 Node 内置的Map就能实现这个缓存设置过期时间比如 5 分钟。实测下来有缓存的页面 QPS 能从几十提升到几千这才是 SSR 在生产环境里的正确打开方式不是每个请求都重新渲染而是按需渲染、能缓则缓。我在实际部署中还发现了一个环境相关的坑Node 进程的NODE_ENV必须设置为production否则 Vue 会加载完整版包含模板编译器体积大不说渲染性能还会下降不少。Vite 构建时默认不会帮你改这个变量需要自己在启动脚本里手动指定。这个细节踩过一次当时线上压测 QPS 一直上不去排查到最后就是这个环境变量的问题。希望这些经验能帮你少走一些弯路。