Vue SSR 中的路由同步:服务端与客户端路由状态的精确匹配与切换
大家好,今天我们来深入探讨 Vue SSR (Server-Side Rendering) 中一个至关重要的环节:路由同步。在 SSR 应用中,服务端负责首次渲染,生成 HTML 返回给客户端,客户端接管后进行 hydration(水合),将服务端渲染的 HTML 转化为由 Vue 管理的动态 DOM。在这个过程中,服务端和客户端路由状态的精确匹配与无缝切换至关重要,直接影响用户体验,包括首屏加载速度、SEO 和单页应用流畅性。
SSR 路由同步的核心问题
在标准的客户端 SPA (Single Page Application) 中,路由完全由客户端的 Vue Router 控制。但在 SSR 应用中,情况变得复杂:
- 服务端路由匹配: 服务端接收到 HTTP 请求,需要根据 URL 匹配对应的路由,渲染相应的组件。
- 客户端路由接管: 客户端在 hydration 后,需要接管路由控制,确保路由状态与服务端渲染的内容一致。
- 路由不一致: 如果服务端和客户端的路由状态不一致,会导致客户端重新渲染,造成闪烁,影响用户体验。更严重的情况可能导致应用逻辑错误。
因此,我们需要一种机制来保证服务端和客户端的路由状态同步,并且实现平滑的切换。
服务端路由匹配
服务端路由匹配是 SSR 的第一步,也是路由同步的基础。我们通常使用 vue-router 的服务端 API 来实现。
// server.js (使用 express)
const express = require('express');
const { createSSRApp } = require('vue');
const { createRouter, createMemoryHistory } = require('vue-router');
const { renderToString } = require('@vue/server-renderer');
const app = express();
// 1. 定义路由
const routes = [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/about', component: { template: '<div>About</div>' } },
{ path: '/user/:id', component: { template: '<div>User ID: {{ $route.params.id }}</div>' } }
];
// 2. 创建 Vue 应用实例
function createApp() {
const app = createSSRApp({
template: '<router-view></router-view>'
});
// 3. 创建 Router 实例 (使用 createMemoryHistory)
const router = createRouter({
history: createMemoryHistory(),
routes
});
app.use(router);
return { app, router };
}
app.get('*', async (req, res) => {
const { app, router } = createApp();
// 4. 设置服务端 Router 的当前路由
router.push(req.url);
// 等待 Router 准备就绪 (处理异步组件)
await router.isReady();
// 5. 渲染应用为 HTML
const appHtml = await renderToString(app);
// 6. 将 HTML 发送给客户端
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
</head>
<body>
<div id="app">${appHtml}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log('Server listening on port 3000');
});
代码解释:
createMemoryHistory(): 使用createMemoryHistory创建一个适用于服务端的 History 实例。它不会修改浏览器地址栏,而是将路由状态保存在内存中。router.push(req.url): 这是关键的一步。服务端接收到 HTTP 请求后,将请求的 URL 传递给router.push()方法,设置 Router 的当前路由。这样,vue-router就可以根据 URL 匹配对应的组件。router.isReady(): 这个方法用于等待 Router 准备就绪。如果路由组件包含异步组件,router.isReady()会等待这些组件加载完成,确保服务端渲染的内容包含所有必要的组件。renderToString(app): 使用@vue/server-renderer提供的renderToString方法将 Vue 应用渲染为 HTML 字符串。- HTML 模板: 将渲染后的 HTML 嵌入到 HTML 模板中,并注入客户端 JavaScript 代码 (
/client.js)。
客户端路由接管与 Hydration
客户端 JavaScript 代码负责接管服务端渲染的 HTML,并将其转化为由 Vue 管理的动态 DOM。这个过程称为 Hydration。
// client.js
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/about', component: { template: '<div>About</div>' } },
{ path: '/user/:id', component: { template: '<div>User ID: {{ $route.params.id }}</div>' } }
];
function createAppClient() {
const app = createApp({
template: '<router-view></router-view>'
});
// 创建 Router 实例 (使用 createWebHistory)
const router = createRouter({
history: createWebHistory(),
routes
});
app.use(router);
return { app, router };
}
const { app, router } = createAppClient();
// 等待 Router 准备就绪
router.isReady().then(() => {
// 挂载应用
app.mount('#app');
});
代码解释:
createWebHistory(): 在客户端,我们使用createWebHistory创建 Router 实例,它会使用浏览器 History API 来管理路由状态。app.mount('#app'): 将 Vue 应用挂载到id="app"的 DOM 元素上,激活 Hydration 过程。Vue 会比较服务端渲染的 HTML 和客户端生成的 VDOM,并尽可能复用现有的 DOM 节点,只更新需要更新的部分。
路由同步的关键:__INITIAL_STATE__
为了确保客户端和服务器端的路由状态一致,我们需要一种机制将服务器端的路由信息传递给客户端。常用的方法是使用 __INITIAL_STATE__ 全局变量。
服务端修改:
// server.js (修改)
// 5. 渲染应用为 HTML
const appHtml = await renderToString(app);
// 获取服务器端路由状态
const initialState = router.currentRoute.value;
// 6. 将 HTML 发送给客户端
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
</head>
<body>
<div id="app">${appHtml}</div>
<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
<script src="/client.js"></script>
</body>
</html>
`);
客户端修改:
// client.js (修改)
const { app, router } = createAppClient();
// 从 __INITIAL_STATE__ 获取初始路由状态
if (window.__INITIAL_STATE__) {
router.replace(window.__INITIAL_STATE__);
}
// 等待 Router 准备就绪
router.isReady().then(() => {
// 挂载应用
app.mount('#app');
});
代码解释:
- 服务端注入
__INITIAL_STATE__: 在服务端,我们获取router.currentRoute.value,它包含了当前路由的所有信息,例如path、params、query等。然后,我们将它序列化为 JSON 字符串,并注入到 HTML 模板中,作为__INITIAL_STATE__全局变量的值。 - 客户端读取
__INITIAL_STATE__: 在客户端,我们首先检查window.__INITIAL_STATE__是否存在。如果存在,说明是 SSR 应用,我们需要从__INITIAL_STATE__中获取初始路由状态,并使用router.replace()方法将其应用到 Router 实例上。router.replace()方法会替换当前的路由状态,而不会触发浏览器历史记录的更新。这可以避免在 Hydration 过程中出现不必要的路由跳转。
为什么使用 router.replace() 而不是 router.push()?
router.push() 会在浏览器历史记录中添加一个新的记录,而 router.replace() 则会替换当前的记录。在 Hydration 过程中,我们只需要确保客户端的路由状态与服务端一致,不需要添加新的历史记录。使用 router.replace() 可以避免在用户点击“后退”按钮时出现不必要的行为。
处理异步路由组件
在 SSR 应用中,路由组件通常包含异步组件。异步组件是指通过 import() 动态加载的组件。
// 异步组件示例
const routes = [
{
path: '/async',
component: () => import('./components/AsyncComponent.vue')
}
];
如果服务端在渲染时没有等待异步组件加载完成,那么生成的 HTML 可能不包含异步组件的内容,导致客户端 Hydration 失败。
解决方案:
vue-router 提供了 router.isReady() 方法来等待所有异步组件加载完成。我们已经在服务端和客户端的代码中使用了 router.isReady(),确保在渲染或挂载应用之前,所有异步组件都已加载完成。
处理路由守卫
路由守卫(Route Guards)是 vue-router 提供的一种机制,用于在路由跳转前后执行一些操作,例如权限验证、数据预取等。
在 SSR 应用中,我们需要特别注意路由守卫的执行时机,避免在服务端和客户端重复执行。
分类:
- 全局守卫:
beforeEach、beforeResolve、afterEach - 路由独享守卫:
beforeEnter - 组件内守卫:
beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave
处理策略:
- 全局守卫 (beforeEach, beforeResolve): 这些守卫在服务端和客户端都会执行。在服务端,它们会在
router.push()之后、renderToString()之前执行。在客户端,它们会在 Hydration 之后、路由跳转之前执行。需要注意,如果在beforeEach守卫中修改了路由,可能会导致服务端和客户端的路由状态不一致。 - 路由独享守卫 (beforeEnter): 与全局守卫类似,
beforeEnter守卫在服务端和客户端都会执行。 - 组件内守卫 (beforeRouteEnter, beforeRouteUpdate, beforeRouteLeave):
beforeRouteEnter守卫在服务端不会执行,只在客户端执行。这是因为在服务端渲染时,组件实例还没有被创建。beforeRouteUpdate和beforeRouteLeave守卫在服务端和客户端都会执行。
示例:
// 全局守卫
router.beforeEach((to, from, next) => {
// 权限验证
if (to.meta.requiresAuth && !isAuthenticated()) {
next('/login');
} else {
next();
}
});
// 组件内守卫
beforeRouteEnter(to, from, next) {
// 在组件创建之前执行
// 无法访问 this
next();
},
beforeRouteUpdate(to, from, next) {
// 在当前路由改变,但是该组件被复用时调用
// 可以访问 this
next();
},
beforeRouteLeave(to, from, next) {
// 离开该组件的对应路由时调用
// 可以访问 this
next();
}
注意:
- 在服务端,
beforeRouteEnter守卫不会执行,因此需要在服务端进行额外处理,例如在服务端预取数据。 - 如果在路由守卫中使用了
localStorage或sessionStorage等浏览器 API,需要在服务端进行兼容处理,例如使用jsdom模拟浏览器环境。 - 尽量避免在路由守卫中执行耗时的操作,以免影响服务端渲染的性能。
处理 404 页面
在 SSR 应用中,我们需要正确处理 404 页面。当服务端接收到无法匹配的 URL 时,应该返回 404 状态码和相应的 HTML 内容。
// server.js (修改)
app.get('*', async (req, res) => {
const { app, router } = createApp();
router.push(req.url);
await router.isReady();
const matchedComponents = router.getRoutes().filter(route => route.path === req.url);
if (!matchedComponents || matchedComponents.length === 0) {
// 404 Not Found
res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>404 Not Found</title>
</head>
<body>
<h1>404 Not Found</h1>
<p>The requested URL was not found on this server.</p>
</body>
</html>
`);
return;
}
const appHtml = await renderToString(app);
const initialState = router.currentRoute.value;
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
</head>
<body>
<div id="app">${appHtml}</div>
<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
<script src="/client.js"></script>
</body>
</html>
`);
});
代码解释:
router.getRoutes(): 获取所有已定义的路由。- 匹配路由: 使用
filter过滤出与当前请求 URL 相匹配的路由。 - 404 处理: 如果找不到匹配的路由,则返回 404 状态码和相应的 HTML 内容。
总结一些优化技巧
| 技巧 | 描述 | 优点 |
|---|---|---|
使用 router.replace() |
在客户端 Hydration 时,使用 router.replace() 方法替换初始路由状态,避免添加不必要的历史记录。 |
避免在用户点击“后退”按钮时出现不必要的行为。 |
| 预取数据 | 在服务端预取路由组件所需的数据,减少客户端的请求数量,提高首屏加载速度。 | 提高首屏加载速度,优化用户体验。 |
| 缓存策略 | 对服务端渲染的结果进行缓存,例如使用 Redis 或 Memcached,减少服务器的压力。 | 提高服务器性能,减少响应时间。 |
| 代码分割 | 将应用代码分割成多个 chunk,按需加载,减少初始下载的 JavaScript 文件大小。 | 提高首屏加载速度,优化用户体验。 |
| 优化路由守卫 | 尽量避免在路由守卫中执行耗时的操作,以免影响服务端渲染的性能。 | 提高服务端渲染性能。 |
| 监控与日志 | 添加监控和日志,可以帮助你快速发现和解决 SSR 应用中的问题。 | 快速定位和解决问题,保证应用的稳定性。 |
结论:路由同步是 SSR 的基石
路由同步是 Vue SSR 应用中至关重要的环节。通过正确地处理服务端路由匹配、客户端路由接管、异步组件、路由守卫和 404 页面,可以确保服务端和客户端的路由状态一致,实现平滑的切换,提高用户体验。而采用一些优化技巧能有效提升SSR应用的性能。希望今天的分享能够帮助大家更好地理解和应用 Vue SSR。
更多IT精英技术系列讲座,到智猿学院