news 2026/10/3 4:11:44

Vue.js 服务器端渲染(SSR)实战:SEO、首屏优化与踩坑记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue.js 服务器端渲染(SSR)实战:SEO、首屏优化与踩坑记录

一个项目做得好好的,突然客户提了一嘴“这页面首屏太慢了,而且百度搜不到我们产品页”,那一刻我才意识到,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 项目的缓存策略——那个话题展开讲内容也不少,到时候再一起复盘。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 4:11:43

SpringBoot+Vue历史馆藏管理系统:从数据库设计到部署全解析

做历史馆藏管理这块的项目&#xff0c;最磨人的往往不是前端动画有多酷、并发能扛多高&#xff0c;而是先把业务理顺&#xff1a;一件藏品从进馆登记到盘点、借展、修复、再入库&#xff0c;中间到底要过多少道手。我见过不少博物馆、档案馆、学校院系还在用Excel甚至纸质台账管…

作者头像 李华
网站建设 2026/10/3 4:11:37

STM32 SDIO 4bit模式切换卡死?HAL_SD_ConfigWideBusOperation排查指南

1. 这个函数到底是干什么的&#xff1a;先看懂它卡住之前发生的三件事先别急着改代码。我调试嵌入式固件这些年有个习惯&#xff1a;遇到一个卡死的API&#xff0c;第一件事不是怀疑库函数写错了&#xff0c;而是先搞清楚它内部到底走了哪几步、每一步依赖什么前置条件。HAL_SD…

作者头像 李华
网站建设 2026/10/3 4:11:12

C/C++连接MySQL避坑指南:从编译配置到运行时排查

C/C链接MySQL&#xff0c;听起来就是个“基础知识”&#xff0c;但真正动手的时候&#xff0c;一堆坑会让你怀疑自己学的C是假的。特别是现在MySQL 8普及之后&#xff0c;认证插件、SSL、字符集、64位库衔接&#xff0c;每一个环节都可能让编译通过但运行时报错。这些内容适合哪…

作者头像 李华
网站建设 2026/10/3 4:10:12

运维转型DevOps:从背锅到技术深耕的实战路线

1. 运维三年&#xff0c;我见的每一个凌晨两点都叫"背锅"先说个真实的场景。某天凌晨两点&#xff0c;监控大屏突然飘红&#xff0c;某核心业务的接口超时率直线上升。我爬起来一看&#xff0c;服务器负载正常、网络流量正常、数据库慢查询也没有明显飙升&#xff0c…

作者头像 李华
网站建设 2026/10/3 4:10:07

跳跃游戏贪心解法:从DFS超时到O(n)最远可达距离

1. 先搞清楚这道题到底在考什么1.1 从题目描述里读出真实意图LeetCode 55题“跳跃游戏”是热题100里非常经典的一道贪心题目。题面不复杂&#xff1a;给你一个非负整数数组nums&#xff0c;你一开始站在下标 0 的位置&#xff0c;数组里的每个元素代表你在当前位置最多能跳多远…

作者头像 李华
网站建设 2026/10/3 4:10:04

从论文到代码:HER事后经验回放算法如何攻克稀疏奖励难题

“hindsight”这个词&#xff0c;在强化学习圈子里可不是“事后诸葛亮”的贬义说法。它背后是一个相当经典的算法——Hindsight Experience Replay&#xff08;事后经验回放&#xff0c;习惯简称HER&#xff09;。我第一次听说这个概念的时候&#xff0c;心里想的是&#xff1a…

作者头像 李华