Vue Router 结合 SSR 该怎么玩?从概念到实践一次讲透
现在不少前端同学做项目时,碰到首屏加载慢、SEO 搞不定的问题,就会想到服务端渲染(SSR),可 Vue 项目里结合 Vue Router 做 SSR,到底该咋上手?从概念到踩坑再到优化,很多细节容易懵,这篇文章就用问答形式,把 Vue Router SSR 的关键知识点、实践步骤、避坑技巧一次性聊透,不管是刚接触还是想深入优化的同学,看完心里都能有清晰的脉络。
Vue Router SSR 到底是啥?和普通 SPA 路由有啥不一样?
首先得把概念拆明白。Vue Router 是 Vue 官方的路由管理器,负责 SPA 里组件切换、路由匹配这些事儿;而 SSR(服务端渲染) 是让 Vue 组件在服务端先渲染成 HTML,再发给浏览器,浏览器拿到后“激活”成可交互的 SPA。
那 Vue Router SSR,就是把路由系统和服务端渲染结合起来——服务端收到请求后,用 Vue Router 匹配到对应的页面组件,把组件渲染成 HTML 返回;客户端拿到 HTML 后,Vue Router 再接管,让页面变成能响应路由跳转的 SPA(这个过程叫 hydration
「水合」)。
和普通 SPA 路由比,核心差异在这几点:
- 渲染阶段:SPA 是客户端 JS 渲染,首屏得等 JS 下载执行完才出内容;SSR 是服务端先渲染好 HTML,首屏能更快展示。
- 路由匹配时机:SPA 里路由匹配是客户端异步的(比如点击链接后 JS 处理);SSR 里服务端得 同步匹配路由(因为要在服务端就确定渲染哪个组件)。
- 状态隔离:SPA 里路由实例是单例(整个应用共用一个
router
);SSR 里每个请求都得新建router
实例,不然不同用户请求会互相干扰(状态污染)。
为啥非要给 Vue 项目加 SSR+Vue Router?纯 SPA 不行吗?
不是不行,是场景倒逼,纯 SPA 在这两类场景下很吃亏:
SEO 需求高的场景
搜索引擎爬虫抓页面时,很多时候不会等 JS 执行完再爬内容,比如做官网、电商列表页、资讯详情页,纯 SPA 的首屏是空白 HTML(只有根节点),爬虫抓不到有效内容,排名自然上不去,SSR 能让服务端直接返回带内容的 HTML,爬虫秒抓,SEO 友好度拉满。
首屏加载速度敏感的场景
像移动端弱网环境、用户手机配置差,SPA 得等 JS 下载、解析、执行完才渲染页面,中间白屏时间长,用户体验差,SSR 直接发 HTML,浏览器拿到就能渲染出结构,哪怕 JS 还没加载完,用户也能先看到内容,感知上快很多。
复杂交互页的「首屏确定性」
比如后台管理系统首页有很多图表、数据列表,SPA 要等接口请求完、Vue 渲染完才展示,过程中可能有 Loading 动画;SSR 能把初始数据直接渲染到 HTML 里,用户打开就看到完整结构,后续交互再靠客户端 JS 接管,体验更流畅。
Vue Router 做 SSR 的核心流程和原理是啥?
得把服务端和客户端的流程拆成「渲染前→渲染中→渲染后」三个阶段,理解每个环节 Vue Router 的作用:
服务端流程(处理请求时):
- 接收请求:用户访问
https://xxx.com/about
,服务端(Node.js 服务)接到这个请求。 - 创建新路由实例:因为每个请求要隔离状态,所以每次请求都
new
一个 Vue Router 实例(配置和客户端的路由规则保持一致)。 - 匹配路由:用
router.match(location)
找到对应的路由记录(比如匹配到About
组件)。 - 渲染组件成 HTML:创建 Vue 实例(把
router
注入进去),用vue-server-renderer
把整个应用渲染成 HTML 字符串,返回给浏览器。
客户端流程(浏览器拿到 HTML 后):
- hydration 水合:浏览器先展示服务端返回的 HTML(这时候是静态的),然后客户端 JS 执行,Vue Router 重新创建路由实例,和服务端的路由状态对齐,让静态 HTML 变成可交互的 SPA(比如点击链接能跳转、组件能响应数据变化)。
- 接管路由:之后的路由跳转(比如点击
<router-link>
),就和普通 SPA 一样,由客户端 Vue Router 处理,不再走服务端。
这里有个关键:服务端和客户端的路由配置必须完全一致,不然会出现「hydration mismatch」——服务端渲染的 HTML 结构和客户端 JS 渲染的不一样,浏览器控制台会报错,页面交互也会出问题。
从零搭建 Vue Router SSR 项目,具体步骤是啥?
很多同学觉得搭建复杂,其实拆成「初始化项目→配置路由→服务端渲染逻辑→客户端 hydration→处理异步数据」这几步,思路就清晰了,下面用 手动配置 的方式讲(不用 Nuxt.js,先理解底层逻辑):
步骤 1:初始化项目结构
新建项目文件夹(vue-ssr-demo
),初始化 package.json
:
npm init -y
安装核心依赖(Vue、Vue Router、服务端渲染工具、Node 服务框架):
npm install vue vue-router vue-server-renderer express
步骤 2:配置 Vue Router(客户端+服务端通用)
在 src/router.js
里写路由规则,注意服务端每次请求要新建 router
实例,所以导出一个创建 router
的函数:
import Vue from 'vue' import Router from 'vue-router' // 假设 Home、About 是同步组件(也可以用异步组件) import Home from './components/Home.vue' import About from './components/About.vue' Vue.use(Router) // 导出创建路由的函数,服务端每次请求都调用生成新实例 export function createRouter() { return new Router({ mode: 'history', // 服务端用 history 模式更友好(避免 # 号) routes: [ { path: '/', component: Home }, { path: '/about', component: About } ] }) }
步骤 3:创建 Vue 实例(服务端+客户端通用)
在 src/app.js
里,创建 Vue 根实例,同样导出一个创建 App 的函数(服务端每次请求要新建 Vue 实例,避免状态污染):
import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' export function createApp() { const router = createRouter() const app = new Vue({ router, render: h => h(App) }) return { app, router } }
步骤 4:服务端渲染逻辑(Node.js 服务)
新建 server.js
,用 express
处理请求,结合 vue-server-renderer
渲染页面:
const express = require('express') const Vue = require('vue') const server = express() const { createRenderer } = require('vue-server-renderer') const { createApp } = require('./src/app') // 创建渲染器(把 Vue 实例转成 HTML) const renderer = createRenderer() server.get('*', (req, res) => { // 每次请求都新建 App 和 Router const { app, router } = createApp() // 跳转到用户请求的路径(比如用户访问 /about,router 就匹配到 /about) router.push(req.url) // 渲染 Vue 实例成 HTML renderer.renderToString(app, (err, html) => { if (err) { res.status(500).send('Internal Server Error') return } // 把渲染好的 HTML 返回给浏览器 res.send(` <!DOCTYPE html> <html> <head><title>Vue SSR Demo</title></head> <body><div id="app">${html}</div></body> </html> `) }) }) server.listen(3000, () => { console.log('Server running at http://localhost:3000') })
步骤 5:客户端 hydration(让静态 HTML 变可交互 SPA)
新建 src/client.js
,负责在浏览器中挂载 Vue 实例,完成水合:
import { createApp } from './app' // 客户端创建 App,和服务端的结构对齐 const { app } = createApp() // 挂载到 #app 节点(服务端返回的 HTML 里要有这个节点) app.$mount('#app')
步骤 6:处理异步数据(路由组件需请求接口时)
上面的例子是同步组件,实际项目中很多组件需要先请求数据再渲染(About
组件要获取用户列表),这时候得让 服务端等待数据加载完成后再渲染,否则服务端返回的 HTML 是没有数据的,客户端 hydration 时再请求数据,会导致页面闪烁(服务端 HTML 没数据,客户端加载后才有)。
这里以 结合 Vuex 管理状态 为例,演示核心逻辑:
定义 Vuex Store(服务端+客户端通用)
新建 src/store.js
,导出创建 Store 的函数:
import Vue from 'vue' import Vuex from 'vuex' import axios from 'axios' // 假设用 axios 请求数据 Vue.use(Vuex) export function createStore() { return new Vuex.Store({ state: { users: [] }, mutations: { setUsers(state, data) { state.users = data } }, actions: { // 异步 action,获取用户数据 async fetchUsers({ commit }) { const res = await axios.get('/api/users') commit('setUsers', res.data) } } }) }
路由组件中声明「需要提前获取数据」
修改 src/router.js
,给 /about
路由加 asyncData
(自定义逻辑,标记该路由需要预取数据):
export function createRouter() { return new Router({ routes: [ { path: '/about', component: About, // asyncData 接收 store,触发数据请求 asyncData(store) { return store.dispatch('fetchUsers') } } ] }) }
服务端等待数据加载完成后再渲染
修改 server.js
的请求处理逻辑,等待所有路由的 asyncData
执行完:
server.get('*', async (req, res) => { // 改成 async 函数 const { app, router, store } = createApp() // 假设 createApp 现在返回 store router.push(req.url) // 找到匹配的路由记录 const matchedRoutes = router.getMatchedRoutes() // 执行每个路由的 asyncData,等待所有 Promise 完成 const promises = matchedRoutes.map(route => { return route.component.asyncData ? route.component.asyncData(store) : Promise.resolve() }) try { await Promise.all(promises) // 等待所有数据请求完成 const html = await renderer.renderToString(app) // 把 store 状态注入到 HTML,客户端 hydration 时复用 res.send(` <!DOCTYPE html> <html> <head><title>Vue SSR Demo</title></head> <body> <div id="app">${html}</div> <script> window.__INITIAL_STATE__ = ${JSON.stringify(store.state)} </script> </body> </html> `) } catch (err) { res.status(500).send('Error') } })
客户端 hydration 时复用服务端数据
修改 src/client.js
,初始化 Store 时读取服务端注入的状态:
import { createApp } from './app' import { createStore } from './store' const store = createStore() // 复用服务端注入的状态 if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } const { app } = createApp(store) // 假设 createApp 现在接收 store app.$mount('#app')
到这里,一个能处理异步数据的 Vue Router SSR 项目就跑通了!但实际开发中,还会遇到很多「坑」,得重点避坑。
Vue Router SSR 实践中最容易踩的坑是啥?咋解决?
坑 1:状态污染(服务端路由实例复用导致数据串了)
问题:服务端如果复用同一个 router
实例,用户 A 访问 /about
,用户 B 接着访问 ,可能拿到的还是 /about
的路由状态,数据就串了。
解决:服务端每次请求都要 新建 router
实例(像步骤 2 里那样,用 createRouter
函数每次返回新实例)。
坑 2:Hydration Mismatch(服务端和客户端渲染的 DOM 不一样)
问题:浏览器控制台报错「Hydration completed but contains mismatches」,原因是服务端和客户端渲染的 HTML 结构、属性、文本不一致。
常见原因:
- 路由配置不一致(比如服务端用
history
模式,客户端用hash
模式); - 组件里有依赖浏览器 API 的代码(
window
、document
,服务端没有这些,渲染时结构不同); - 异步数据没处理好,服务端渲染时没数据,客户端 hydration 时有数据,导致 DOM 结构变化。
解决: - 确保服务端和客户端的路由配置、模式完全一致;
- 组件里避免在
created
、mounted
之外的钩子(render
)中使用浏览器 API,或者用条件判断(if (process.browser) {}
); - 严格处理异步数据,服务端必须等数据加载完再渲染,客户端 hydration 时复用服务端数据。
坑 3:异步组件加载时机不对(服务端渲染时没加载完)
问题:用 () => import('./About.vue')
这种异步组件,服务端渲染时可能组件还没加载完,导致渲染的 HTML 缺失内容。
解决:服务端渲染时,要确保异步组件已经加载完成,可以用 router.onReady
等待路由就绪,或者在 webpack 打包时把异步组件改成同步加载(服务端打包和客户端打包配置不同)。
坑 4:SEO 优化没做透(只有内容,meta 标签还是静态的)
问题:服务端返回的 HTML 里 title
、meta
是固定的,不同页面需要动态设置(比如文章详情页的 title
是文章标题)。
解决:
- 在路由组件里加
metaInfo
配置(比如用vue-meta
库),服务端渲染时把 meta 信息注入到 HTML 的head
里; - 服务端渲染时,根据当前路由的
meta
信息,动态生成title
、meta
标签。
自己搭 SSR vs 用 Nuxt.js,该咋选?
很多同学纠结是自己手动配置,还是直接用 Nuxt.js 这类框架,先看两者的核心差异:
自己搭 SSR:
- 优势:完全自定义,想怎么改路由、怎么处理数据都可以;适合需要深度定制的项目(比如公司内部框架整合、特殊性能优化)。
- 劣势:工作量大,要处理路由、数据、渲染、缓存等所有细节;容易踩坑,对 SSR 原理理解要求高。
用 Nuxt.js:
- 优势:开箱即用!内置 Vue Router、Vuex、SSR 配置,还有 约定式路由(
pages
目录下的.vue
文件自动生成路由),不用手动配路由;支持 <
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。