Vue SSR中的路由同步:服务端与客户端路由状态的精确匹配与切换

Vue SSR 中的路由同步:服务端与客户端路由状态的精确匹配与切换

大家好,今天我们来深入探讨 Vue SSR (Server-Side Rendering) 中一个至关重要的环节:路由同步。在 SSR 应用中,服务端负责首次渲染,生成 HTML 返回给客户端,客户端接管后进行 hydration(水合),将服务端渲染的 HTML 转化为由 Vue 管理的动态 DOM。在这个过程中,服务端和客户端路由状态的精确匹配与无缝切换至关重要,直接影响用户体验,包括首屏加载速度、SEO 和单页应用流畅性。

SSR 路由同步的核心问题

在标准的客户端 SPA (Single Page Application) 中,路由完全由客户端的 Vue Router 控制。但在 SSR 应用中,情况变得复杂:

  1. 服务端路由匹配: 服务端接收到 HTTP 请求,需要根据 URL 匹配对应的路由,渲染相应的组件。
  2. 客户端路由接管: 客户端在 hydration 后,需要接管路由控制,确保路由状态与服务端渲染的内容一致。
  3. 路由不一致: 如果服务端和客户端的路由状态不一致,会导致客户端重新渲染,造成闪烁,影响用户体验。更严重的情况可能导致应用逻辑错误。

因此,我们需要一种机制来保证服务端和客户端的路由状态同步,并且实现平滑的切换。

服务端路由匹配

服务端路由匹配是 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');
});

代码解释:

  1. createMemoryHistory(): 使用 createMemoryHistory 创建一个适用于服务端的 History 实例。它不会修改浏览器地址栏,而是将路由状态保存在内存中。
  2. router.push(req.url): 这是关键的一步。服务端接收到 HTTP 请求后,将请求的 URL 传递给 router.push() 方法,设置 Router 的当前路由。这样,vue-router 就可以根据 URL 匹配对应的组件。
  3. router.isReady(): 这个方法用于等待 Router 准备就绪。如果路由组件包含异步组件,router.isReady() 会等待这些组件加载完成,确保服务端渲染的内容包含所有必要的组件。
  4. renderToString(app): 使用 @vue/server-renderer 提供的 renderToString 方法将 Vue 应用渲染为 HTML 字符串。
  5. 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');
});

代码解释:

  1. createWebHistory(): 在客户端,我们使用 createWebHistory 创建 Router 实例,它会使用浏览器 History API 来管理路由状态。
  2. 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');
});

代码解释:

  1. 服务端注入 __INITIAL_STATE__ 在服务端,我们获取 router.currentRoute.value,它包含了当前路由的所有信息,例如 pathparamsquery 等。然后,我们将它序列化为 JSON 字符串,并注入到 HTML 模板中,作为 __INITIAL_STATE__ 全局变量的值。
  2. 客户端读取 __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 应用中,我们需要特别注意路由守卫的执行时机,避免在服务端和客户端重复执行。

分类:

  • 全局守卫: beforeEachbeforeResolveafterEach
  • 路由独享守卫: beforeEnter
  • 组件内守卫: beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave

处理策略:

  • 全局守卫 (beforeEach, beforeResolve): 这些守卫在服务端和客户端都会执行。在服务端,它们会在 router.push() 之后、renderToString() 之前执行。在客户端,它们会在 Hydration 之后、路由跳转之前执行。需要注意,如果在 beforeEach 守卫中修改了路由,可能会导致服务端和客户端的路由状态不一致。
  • 路由独享守卫 (beforeEnter): 与全局守卫类似,beforeEnter 守卫在服务端和客户端都会执行。
  • 组件内守卫 (beforeRouteEnter, beforeRouteUpdate, beforeRouteLeave): beforeRouteEnter 守卫在服务端不会执行,只在客户端执行。这是因为在服务端渲染时,组件实例还没有被创建。beforeRouteUpdatebeforeRouteLeave 守卫在服务端和客户端都会执行。

示例:

// 全局守卫
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 守卫不会执行,因此需要在服务端进行额外处理,例如在服务端预取数据。
  • 如果在路由守卫中使用了 localStoragesessionStorage 等浏览器 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>
  `);
});

代码解释:

  1. router.getRoutes() 获取所有已定义的路由。
  2. 匹配路由: 使用 filter 过滤出与当前请求 URL 相匹配的路由。
  3. 404 处理: 如果找不到匹配的路由,则返回 404 状态码和相应的 HTML 内容。

总结一些优化技巧

技巧 描述 优点
使用 router.replace() 在客户端 Hydration 时,使用 router.replace() 方法替换初始路由状态,避免添加不必要的历史记录。 避免在用户点击“后退”按钮时出现不必要的行为。
预取数据 在服务端预取路由组件所需的数据,减少客户端的请求数量,提高首屏加载速度。 提高首屏加载速度,优化用户体验。
缓存策略 对服务端渲染的结果进行缓存,例如使用 Redis 或 Memcached,减少服务器的压力。 提高服务器性能,减少响应时间。
代码分割 将应用代码分割成多个 chunk,按需加载,减少初始下载的 JavaScript 文件大小。 提高首屏加载速度,优化用户体验。
优化路由守卫 尽量避免在路由守卫中执行耗时的操作,以免影响服务端渲染的性能。 提高服务端渲染性能。
监控与日志 添加监控和日志,可以帮助你快速发现和解决 SSR 应用中的问题。 快速定位和解决问题,保证应用的稳定性。

结论:路由同步是 SSR 的基石

路由同步是 Vue SSR 应用中至关重要的环节。通过正确地处理服务端路由匹配、客户端路由接管、异步组件、路由守卫和 404 页面,可以确保服务端和客户端的路由状态一致,实现平滑的切换,提高用户体验。而采用一些优化技巧能有效提升SSR应用的性能。希望今天的分享能够帮助大家更好地理解和应用 Vue SSR。

更多IT精英技术系列讲座,到智猿学院

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注