一个项目做得好好的,突然客户提了一嘴“这页面首屏太慢了,而且百度搜不到我们产品页”,那一刻我才意识到,SPA 做得再爽,在服务器端渲染(SSR)面前,该补的课一节都跑不掉。标题里这期“09-服务器端渲染”,正好讲讲我在 Vue.js 前端开发实战里趟过的那条 SSR 落地之路。
这期内容适合谁?已经会用 Vue 写单页应用,但没正经搞过 SSR 的开发者;或者项目被 SEO、首屏速度卡过脖子,想搞清楚 SSR 到底是万能药还是另一口锅的人。我会从为什么需要 SSR 讲起,顺手对比 Vue 3 原生 SSR、Nuxt 等方案怎么选,再完整拆一个最小可用的 SSR 项目,最后把我踩过的几个运行时坑直接摊开给你看。
1. 先说清楚:业务为什么需要服务器端渲染
1.1 SPA 开发爽了三年,我遇到的两个真实卡点
早些年做后台管理系统,Vue + Vue Router + Vuex 三件套一把梭,打包完扔到 Nginx 上就完事,用户体验也还不错。但一旦项目从纯后台工具转成对外门户站,问题就立刻浮出水面。
第一个卡点是SEO 几乎为零。搜索引擎爬虫虽然现在也能执行 JavaScript,但对一个内容型网站来说,指望爬虫等你的 JS 跑完、异步接口返回、再渲染出 DOM,这种体验极不友好,尤其对于百度这种对 SPA 支持比较保守的搜索引擎。页面标题、描述、正文内容全部是动态生成的,抓取结果经常是白板。
第二个卡点是首屏白屏时间。SPA 的 HTML 模板基本就是个空壳,浏览器要先下载 JS 包,再执行框架初始化,然后才能渲染出用户看到的东西。在低端手机和弱网环境下,这个白屏时间能拉到 3 到 5 秒,用户早就关页面走了。
我当时接手一个资讯门户改版项目,页面访问量很大,但搜索引擎收录率低得可怜,首屏性能审计分数一路飘红。于是我开始系统评估 SSR 方案,后面踩的坑、总结的套路,都在这一期里了。
1.2 CSR 和 SSR 的本质区别
客户端渲染(CSR)的流程可以这么理解:服务器返回一个很薄的 HTML 文件,里面只有一个<div id="app">,浏览器拿到这个空壳后,再去下载 JS 包,JavaScript 接管页面,创建 Vue 实例,组件挂载,DOM 生成,整个过程发生在用户的浏览器里。
服务器端渲染(SSR)则是另一条路线:请求到达 Node 服务,服务端创建一个 Vue 实例,把组件渲染成 HTML 字符串,再塞进模板里返回给浏览器。用户收到的是完整页面,可以直接看到内容;同时这份 HTML 里的文本信息对搜索引擎是可读的。
两者并不是完全二选一的关系,Vue 3 的 SSR 方案里还有一个概念叫客户端激活(hydration):服务端把渲染好的 HTML 发给客户端,浏览器先展示这些 HTML,然后 Vue 在客户端重新创建实例,把事件监听绑定到已有的 DOM 上,而不是重新走一遍渲染。激活之后,页面从静态内容切换成正常可交互的 Vue 应用,用户无感。
1.3 SSR 的能力边界:它能解决什么,不能解决什么
SSR 不是万能银弹,这一点必须先泼盆冷水。它主要解决的是首屏内容的可访问性:SEO、社交分享抓取、首屏速度体验。但如果你以为上了 SSR 就万事大吉,那后面会遇到更多问题,比如服务端压力变大、开发链路变长、构建和部署复杂度上升、内存管理要额外小心。
另外有一种情况 SSR 帮不上忙:你的页面内容完全依赖用户登录后拉取个性化数据,这种情况服务端拿不到合理的初始数据,SSR 出来的 HTML 可能只是个登录框骨架,价值有限。所以判断是否上 SSR,第一件事是盘点页面内容是否需要被公开访问、是否对首屏有强诉求。
2. 方案选型:Vue 3 原生 SSR、Nuxt 还是其他路子
2.1 Vue 3 下的三条技术路线
做 SSR 选型时,我眼前摆了几条不同的路,这也可能是很多人的困惑点。
第一条是Nuxt.js。Nuxt 是 Vue 生态里最成熟的 SSR 框架,提供了文件路由、自动导入、数据获取等一堆开箱即用的能力,甚至可以一键部署到很多 serverless 平台。如果从零开始做一个内容型网站,Nuxt 几乎是效率最高的选择,尤其适合团队里没有专职 Node 服务的场景。
第二条是Vue 3 原生 SSR 方案,也就是用@vue/server-renderer这个官方的服务端渲染包,自己搭建 Node 服务,手动管理渲染、路由和状态注入。这套方案的优点是完全可控,缺点是很多基础设施得自己重复造轮子,比如路由数据预取、HTML 模板拼接、静态资源注入等。
第三条是混合模式,部分页面用 SSR,部分页面继续保持 SPA,通过网关或者反向代理做分流。比如商品列表需要 SEO 走 SSR,登录后台保持 CSR。这种模式对架构能力要求较高,但业务收益非常直接。
三条路线不冲突,甚至可以在同一个大型项目里按需共存。我在实战中常采用一个思路:核心内容页面用 SSR,交互复杂的用户中心用 CSR,这样既保证内容可访问,又降低服务端渲染的复杂度。
2.2 我为什么在这类项目里选择原生方案
Nuxt 那么好用,我为什么还折腾原生方案?原因主要有三。
第一,存量项目改造成本。我们当时是在一个已经写了很多业务组件的 Vue 3 项目里引入 SSR,直接用 Nuxt 等于要把整个项目目录结构、路由方式、依赖管理模式全部迁移过去,风险非常大。用原生方案可以做到最小侵入,只调整入口文件和部分生命周期逻辑。
第二,定制化能力。内容站的 SEO 需求往往不只是“渲染出来就行”,还涉及自定义的 meta 标签、结构化数据、sitemap 动态生成、重定向规则、灰度发布逻辑。原生方案可以直接在中间件层处理这些逻辑,Nuxt 虽然也能做,但绕不开框架自身的约定,有时候为了绕过约定反而要写更多 hack。
第三,学习价值。Nuxt 隐藏了太多细节,出现问题后人容易抓瞎。用原生方案完整走一遍 SSR 链路,你会彻底理解 Vue 在服务端和客户端运行时的差异、状态注入的原理、激活的过程,再看 Nuxt 文档时就轻松多了。
需要说明的是,原生方案并不适合所有人。如果你直接起新项目、团队成员不熟悉 Node 服务端开发,那 Nuxt 仍然是更稳妥的选择,没必要跟自己过不去。
2.3 搭建前提与基础工程结构
原生 SSR 技术栈里,我用的关键依赖有这么几个:
vue和vue-router,Vue 3 全家桶@vue/server-renderer,Vue 3 官方的服务端渲染器express(或者koa),作为 Node HTTP 服务vite,用于开发环境下的客户端构建与模块热更新- 一个用于生产环境的构建步骤,我习惯用
vite build搭配vite-plugin-ssr类似思路,但纯手写也可以
基础目录结构我通常是这样组织的:
project/ ├── index.html # 服务端返回的 HTML 模板(含挂载点) ├── server/ │ ├── index.js # Node 服务入口,创建 HTTP 服务 │ ├── render.js # 核心渲染函数:创建 app、调 router、输出 HTML │ └── template.js # 模板拼接逻辑 ├── src/ │ ├── app.js # 创建 Vue app 的通用工厂函数 │ ├── router.js # 创建 router 的工厂函数 │ ├── entry-server.js # 服务端入口 │ ├── entry-client.js # 客户端入口 │ ├── views/ # 页面组件 │ └── components/ # 公共组件 └── vite.config.js # Vite 配置注意这里有个非常重要的原则:每个请求都要创建一次新的 app 实例和 router 实例,绝对不能在服务端使用单例模式。原因后面在坑点里细说。
3. 手写一个最小 SSR 项目:核心步骤拆解
3.1 第一步:先让 Vue 组件能在 Node 里渲染成字符串
抛开路由和状态管理,SSR 的内核其实就是一句话:把 Vue 组件渲染成 HTML 字符串。这一步跑通了,后面所有东西都是在这个基础上加砖加瓦。
我建了一个极简的验证项目,服务端代码大致长这样:
// server/index.js import express from 'express' import { createSSRApp } from 'vue' import { renderToString } from '@vue/server-renderer' import App from '../src/App.vue' const app = express() app.get('/', async (req, res) => { // 每个请求都创建新的 app 实例,避免状态串扰 const vueApp = createSSRApp(App) try { const appContent = await renderToString(vueApp) const html = ` <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>SSR Demo</title> </head> <body> <div id="app">${appContent}</div> </body> </html> ` res.status(200).send(html) } catch (err) { console.error('SSR render error:', err) res.status(500).send('Internal Server Error') } }) app.listen(3000, () => { console.log('SSR server listening on http://localhost:3000') })createSSRApp和客户端常用的createApp有什么区别?createSSRApp是专门为服务端场景设计的入口,它会对组件渲染做一些环境适配和优化。生产环境里要用createSSRApp,而不是createApp,这点很容易被忽略。
运行起来后,浏览器访问http://localhost:3000,右键查看源代码,能看到Vue组件渲染出的真实 HTML 内容,而不是空壳<div id="app">。到这里,最基本的 SSR 已经通了。
3.2 第二步:接入 vue-router,让服务端能“按路径渲染”
真正的内容站肯定不止一个页面,所以第二步就是接入vue-router。但这里有个容易踩坑的认知差:在服务端渲染时,“路由”这个概念是从请求 URL 来的,需要根据 URL 找到对应的组件,再渲染这个组件。
我把路由定义抽成一个工厂函数,每一次都返回新的 router 实例:
// src/router.js import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router' import Home from './views/Home.vue' import About from './views/About.vue' export function createMyRouter() { const history = import.meta.env.SSR ? createMemoryHistory() // 服务端用内存历史模式 : createWebHistory() // 客户端用浏览器历史模式 return createRouter({ history, routes: [ { path: '/', component: Home }, { path: '/about', component: About }, ], }) }这里记忆点很关键:服务端没有浏览器地址栏,也没有window.history,所以必须用createMemoryHistory,客户端才用createWebHistory。判断依据可以用import.meta.env.SSR,在 Vite 环境下这个变量在服务端构建时为 true。
服务端渲染逻辑变成:先创建 app 和 router,然后router.push(req.url)让路由定位到当前请求路径,再router.isReady()等待异步路由组件准备好,最后把 app 渲染成字符串。这个过程很像导航卫士的执行环境——组件在服务端也会执行,这一点后面还会说到。
服务端渲染逻辑变成:先创建 app 和 router,然后router.push(req.url)让路由定位到当前请求路径,再router.isReady()等待异步路由组件准备好,最后把 app 渲染成字符串。这个过程很像导航卫士的执行环境——组件在服务端也会执行,这一点后面还会说到。
服务端渲染逻辑变成:先创建 app 和 router,然后router.push(req.url)让路由定位到当前请求路径,再router.isReady()等待异步路由组件准备好,最后把 app 渲染成字符串。这个过程很像导航卫士的执行环境——组件在服务端也会执行,这一点后面还会说到。
3.3 第三步:数据预取与状态水合注入
SSR 的最终目的是让首屏 HTML 包含真实数据。如果服务端渲染出来的组件里数据还是空的,用户看到的依然是一个空架子。所以这里要解决两个问题:在服务端获取数据、把数据同步到客户端。
我习惯的做法是在组件上定义一个静态方法,比如asyncData,或者在路由配置里设置meta预取函数。组件渲染时,路由组件里的asyncData被调用,拿到数据后存到全局状态(比如reactive的对象或 Pinia store),然后组件再渲染。
服务端代码加一段逻辑:
// server/render.js import { reactive } from 'vue' export async function renderPage(url) { const app = createSSRApp(App) const router = createMyRouter() router.push(url) await router.isReady() // 找出匹配的组件,执行数据预取 const matchedComponents = router.currentRoute.value.matched .map((record) => record.components?.default) .filter(Boolean) const asyncDataStore = reactive({ data: {} }) for (const component of matchedComponents) { if (component.asyncData) { component.asyncData({ store: asyncDataStore, route: router.currentRoute.value }) } } const appContent = await renderToString(app) return { appContent, initialData: JSON.stringify(asyncDataStore.data), } }然后服务端响应时把这个数据放进 HTML:
const html = ` <!DOCTYPE html> <html> <head>...</head> <body> <div id="app">${appContent}</div> <script>window.__INITIAL_STATE__ = ${initialData}</script> <script src="/assets/client.js"></script> </body> </html> `这个window.__INITIAL_STATE__是服务端和客户端通信的核心通道。客户端初始化时拿到这份数据,直接用它来创建状态,就不用再重复发请求了。这个机制叫状态注入(State Injection),也是很多所谓的“SSR 项目里数据重复请求”问题的根源——如果客户端没拿到状态,它会再请求一次接口,造成浪费。
3.4 第四步:客户端激活与页面挂载
现在服务端已经能输出带数据的 HTML 了,那客户端进来后到底要干什么?答案就是前面提到的“激活”。
传统 CSR 写法是:
createApp(App).mount('#app')SSR 客户端不能用这套,因为 DOM 已经存在了,如果直接从头创建再挂载,它会尝试重建整个 DOM,结果就是闪烁、事件丢失、状态混乱。正确的写法是:
// src/entry-client.js import { createSSRApp } from 'vue' import { createMyRouter } from './router' import App from './App.vue' const router = createMyRouter() const app = createSSRApp(App) app.use(router) // 关键点:用服务端注入的状态初始化 Pinia 或 reactive store if (window.__INITIAL_STATE__) { // 把状态填入对应的 store } router.isReady().then(() => { app.mount('#app', true) // 第二个参数 true 表示激活已有 DOM })Vue 3 的mount方法在 SSR 场景下会执行激活流程,它会把事件监听器绑定到服务端渲染出来的 DOM 节点上。如果你发现点击事件无效、页面组件重新创建了,先检查是不是没传true,或者是不是createApp写成了createApp而不是createSSRApp。
?激活的原理相当于“接管”已有 DOM,而不是“替换”已有 DOM。写 SSR 前没理解这一步,后面调试起来会非常痛苦。
3.5 第五步:处理 HTML 模板和 meta 信息
SSR 项目里 HTML 模板不能写死,因为每个页面的标题、meta 描述、canonical 链接、OG 标签都不一样。我通常的做法是:组件里声明一个metaInfo属性,服务端渲染完拿到后,动态拼进模板。
<template> <div> <h1>产品介绍</h1> </div> </template> <script> export default { metaInfo: { title: '产品介绍 - 某某科技', meta: [ { name: 'description', content: '这里是产品卖点描述' } ] } } </script>服务端渲染完组件后,再读一次router.currentRoute.value.matched里每个组件的metaInfo,把它们合并,替换模板里的占位符。这一步对 SEO 至关重要——如果所有页面的 title 和 description 都一样,搜索引擎会认为你在做重复内容,收录效果大打折扣。
如果项目里有现成的vue-meta之类的库自然也可以用,但在轻量级原生方案里,手写一个mergeMeta函数也就十几行代码,可控性更高。
4. 部署与运行时:那几个能让你折腾半天的坑
4.1 内存泄漏:为什么服务端不能“共用一个 app 实例”
SSR 服务端最容易踩的坑之一,就是图省事把 app 实例提到最外层全局复用。这会导致两个问题:一是不同用户请求之间的数据互相污染,A 用户看到的配置可能混进 B 用户的状态里;二是服务端长驻进程里,旧实例引用的对象没法被垃圾回收,内存缓慢上涨,最终 OOM。
正确姿势是上面的示例:每个请求都新建 app、router、store 实例。这些实例用完就丢,交给 Node 的 GC 处理。刚开始写法啰嗦一点,但这是 SSR 的生命线。
4.2 样式、资源路径与预加载
开发环境热更新时样式正常,一到生产环境 SSR,就发现首屏 HTML 里没有样式,或者加载完 JS 后闪了一下才有样式。这个问题多数出在构建配置上。
在原生 SSR 里,生产构建通常需要把 CSS 提取成独立文件,然后在模板<head>里插入<link rel="stylesheet">。如果用 Vite,可以在构建阶段拿到生成的 CSS 文件名,动态注入模板。这个过程没法完全自动,需要在自己的渲染函数里加一点“构建产物读取”逻辑。
还有一个点是资源路径。很多人把项目放在域名子路径下(比如https://example.com/blog/),如果打包时资源路径写死/assets/...,就会出现 404。需要根据部署位置配置 Vite 的base选项,让模板里的脚本和样式路径动态适配。
4.3 环境判断:process.server 与 process.client 不是随便用的
Vue SSR 项目里,代码会在两个环境各跑一遍,但很多业务代码错误地假设自己只在浏览器运行,比如直接操作window、document。服务端渲染时,组件创建阶段会执行setup(或 Options API 的created、beforeCreate),这时候访问window就直接抛错。
我的习惯是:涉及浏览器 API 的操作全部放到onMounted里执行。onMounted只在客户端执行,服务端不会触发;如果确实要在服务端和客户端分别执行不同逻辑,再用环境判断变量。
比如一个组件要根据窗口宽度改变布局,服务端没必要知道这个值,直接在onMounted里初始化就可以了。但有些场景,服务端需要知道“某个功能是否启用”的状态,那就要通过环境变量在服务端配置时注入,而不是在运行时判断浏览器环境。
4.4 接口请求的“双重发送”与服务端超时
SSR 时,页面的数据预取在服务端完成,客户端激活时如果一不小心触发重新请求,就会出现一个页面发出两份相同接口请求的现象。原因一般有两种。
第一种是组件使用onMounted拉数据,但asyncData在服务端也拉了一遍。要规避,就要约定清楚:服务端数据预取只在asyncData里做,客户端挂载后不要再重复拉取;或者客户端检测到window.__INITIAL_STATE__有值时,直接跳过初始化请求。
第二种是接口在服务端请求超时。Node 服务端请求外部接口时,如果下游响应很慢,整个页面渲染都会被拖住。务必给所有服务端请求加超时控制,比如AbortController或fetch的超时选项。我通常在数据预取函数里统一包一层超时逻辑,超过比如 3 秒就降级渲染(比如渲染骨架屏或错误提示),而不是让整个请求挂死。
4.5 开发体验的修缮:热更新与调试点
原生 SSR 在开发环境的体验比 Nuxt 差一些,但不至于不能忍。用 Vite 做开发服务器时,可以利用它的中间件模式,把 SSR 请求打到 Vite 的 transform 流程里,这样组件源码修改后,页面刷新就能拿到最新渲染结果。
不过热更新对 SSR 的支持始终有限,很多时候改完组件数据逻辑还是得手动刷新页面。我一般不追求 SSR 下的热更新,更多依赖客户端侧的viteHMR 来调试交互逻辑,服务端逻辑则单独用日志和断点排查。
调试 SSR 时有个小技巧:在服务端渲染函数里加一个?ssr=1之类的请求参数,输出渲染出的 HTML 文本,这样可以直接检查输出内容里数据是不是齐全、meta 标对不对。比在浏览器里看源码更直接。
最后再分享几个实战经验
如果你正准备给自己项目引入 SSR,我个人的建议是先别急着接业务代码,花两三天跑通一个最小 SSR 骨架,把数据预取和激活流程弄明白,再考虑迁移。很多人一上来就想把整个项目改成 SSR,结果路由、生命周期、状态管理全揉在一块,排错排到怀疑人生。
工具链上如果团队没有专门 Node 服务运维经验,我更推荐用 Nuxt 做新站,它有很成熟的部署方案和生态;但如果像我们一样改造存量项目、或者对渲染链路有强定制需求,原生 SSR 值这个成本。技术选型没有高下之分,只有合不合适。
另外提一个容易忽略的点:SSR 项目上线后,一定要监控服务端渲染耗时和内存占用。我用express中间件给每个渲染请求记录耗时,内存则通过进程监控平台每天看曲线。这两个数据能直观反映 SSR 的稳定性,很多隐蔽的内存泄漏和慢接口问题就是在监控曲线里先暴露出来的。
服务器端渲染这条路,踩坑是难免的,但只要把“服务端输出完整 HTML、客户端负责激活接管”这条主线刻在脑子里,很多问题都能迅速定位。下期如果大家感兴趣,我打算展开讲讲 SSR 项目的缓存策略——那个话题展开讲内容也不少,到时候再一起复盘。