ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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 idapp浏览器拿到这个空壳后再去下载 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-routerVue 3 全家桶vue/server-rendererVue 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 charsetutf-8 / titleSSR Demo/title /head body div idapp${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 idapp。到这里最基本的 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), } }然后服务端响应时把这个数据放进 HTMLconst html !DOCTYPE html html head.../head body div idapp${appContent}/div scriptwindow.__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 relstylesheet。如果用 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 时有个小技巧在服务端渲染函数里加一个?ssr1之类的请求参数输出渲染出的 HTML 文本这样可以直接检查输出内容里数据是不是齐全、meta 标对不对。比在浏览器里看源码更直接。最后再分享几个实战经验如果你正准备给自己项目引入 SSR我个人的建议是先别急着接业务代码花两三天跑通一个最小 SSR 骨架把数据预取和激活流程弄明白再考虑迁移。很多人一上来就想把整个项目改成 SSR结果路由、生命周期、状态管理全揉在一块排错排到怀疑人生。工具链上如果团队没有专门 Node 服务运维经验我更推荐用 Nuxt 做新站它有很成熟的部署方案和生态但如果像我们一样改造存量项目、或者对渲染链路有强定制需求原生 SSR 值这个成本。技术选型没有高下之分只有合不合适。另外提一个容易忽略的点SSR 项目上线后一定要监控服务端渲染耗时和内存占用。我用express中间件给每个渲染请求记录耗时内存则通过进程监控平台每天看曲线。这两个数据能直观反映 SSR 的稳定性很多隐蔽的内存泄漏和慢接口问题就是在监控曲线里先暴露出来的。服务器端渲染这条路踩坑是难免的但只要把“服务端输出完整 HTML、客户端负责激活接管”这条主线刻在脑子里很多问题都能迅速定位。下期如果大家感兴趣我打算展开讲讲 SSR 项目的缓存策略——那个话题展开讲内容也不少到时候再一起复盘。
返回列表