Skip to content

第六章:SSR 与服务端集成

在前几章中,我们深入探讨了 Vue Router 的核心功能和生产级实现。现在,让我们关注 Vue Router 在服务端渲染(SSR)环境中的应用,包括内存历史记录、状态同步和路由脱水注水等关键概念。

SSR 支持:服务端路由管理的挑战

在服务端渲染环境中,Vue Router 面临着与客户端不同的挑战。浏览器环境中有 History API 和 DOM,而服务端没有这些 API,需要特殊的处理方式。

问题:服务端渲染时,如何避免路由状态跨请求共享?

在服务端环境中,Node.js 是一个长期运行的进程,如果多个请求共享同一个路由器实例,会导致路由状态污染:

javascript
// 错误示例:共享路由器实例
let router // 全局路由器实例

// 每个请求都使用同一个路由器实例
app.use('*', async (req, res) => {
  // 请求 A 修改了路由状态
  await router.push('/user/123')
  
  // 请求 B 可能会看到请求 A 的路由状态
  const app = createApp()
  app.use(router) // 使用共享的路由器
  
  const html = await renderToString(app)
  res.send(html)
})

// 正确做法:为每个请求创建独立的路由器实例
app.use('*', async (req, res) => {
  // 为每个请求创建独立的路由器实例
  const router = createRouter({
    history: createMemoryHistory(),
    routes
  })
  
  // 初始化路由
  await router.push(req.url)
  await router.isReady()
  
  const app = createApp()
  app.use(router)
  
  const html = await renderToString(app)
  res.send(html)
})

createMemoryHistory():在 Node.js 中创建内存路由

createMemoryHistory 是 Vue Router 专门为 SSR 环境设计的历史记录管理器:

javascript
// MemoryHistory 实现
function createMemoryHistory(base = '') {
  const normalizedBase = normalizeBase(base)
  
  // 内存中的历史记录栈
  let stack = [normalizedBase + '/']
  let index = 0
  let listeners = []
  
  function setupListeners() {
    // 内存历史记录不需要监听器
  }
  
  function push(to, data) {
    // 移除当前位置之后的历史记录
    stack = stack.slice(0, index + 1)
    
    // 添加新的历史记录
    stack.push(normalizedBase + to)
    index++
    
    // 通知监听器
    listeners.forEach(listener => {
      listener(normalizedBase + to, data)
    })
  }
  
  function replace(to, data) {
    // 替换当前位置
    stack[index] = normalizedBase + to
    
    // 通知监听器
    listeners.forEach(listener => {
      listener(normalizedBase + to, data)
    })
  }
  
  function go(delta) {
    const newIndex = index + delta
    if (newIndex < 0 || newIndex >= stack.length) {
      return
    }
    
    index = newIndex
    
    // 通知监听器
    listeners.forEach(listener => {
      listener(stack[index])
    })
  }
  
  function current() {
    return stack[index]
  }
  
  function back() {
    go(-1)
  }
  
  function forward() {
    go(1)
  }
  
  function listen(callback) {
    listeners.push(callback)
    return () => {
      const index = listeners.indexOf(callback)
      if (index > -1) {
        listeners.splice(index, 1)
      }
    }
  }
  
  function destroy() {
    listeners = []
    stack = []
  }
  
  // 返回 MemoryHistory 对象
  return {
    base: normalizedBase,
    location: current,
    push,
    replace,
    go,
    back,
    forward,
    listen,
    destroy,
    state: null // MemoryHistory 不需要状态
  }
}

req.url 初始化

在 SSR 环境中,路由器需要从 HTTP 请求的 URL 初始化:

javascript
// SSR 路由器创建函数
function createSSRRouter(req) {
  // 创建内存历史记录
  const history = createMemoryHistory()
  
  // 从请求 URL 初始化路由
  const url = new URL(req.url, `http://${req.headers.host}`)
  history.push(url.pathname + url.search + url.hash)
  
  // 创建路由器
  const router = createRouter({
    history,
    routes: [
      // 路由配置
    ]
  })
  
  return router
}

// 在 Express 中使用
app.use('*', async (req, res) => {
  try {
    // 为每个请求创建独立的路由器实例
    const router = createSSRRouter(req)
    
    // 等待路由器准备就绪
    await router.push(req.url)
    await router.isReady()
    
    // 创建应用实例
    const app = createApp()
    app.use(router)
    
    // 渲染应用
    const html = await renderToString(app)
    
    // 发送响应
    res.send(`
      <!DOCTYPE html>
      <html>
        <head>
          <title>SSR App</title>
        </head>
        <body>
          <div id="app">${html}</div>
        </body>
      </html>
    `)
  } catch (error) {
    console.error('SSR Error:', error)
    res.status(500).send('Internal Server Error')
  }
})

状态同步:客户端如何接管服务端渲染的路由

在 SSR 应用中,服务端生成的 HTML 需要客户端 JavaScript 来"激活"(hydrate),其中包括路由状态的同步。

状态脱水(Dehydration):服务端序列化路由状态

javascript
// 服务端路由状态脱水
app.use('*', async (req, res) => {
  try {
    // 创建 SSR 路由器
    const router = createSSRRouter(req)
    await router.push(req.url)
    await router.isReady()
    
    // 创建应用
    const app = createApp()
    app.use(router)
    
    // 渲染应用
    const html = await renderToString(app)
    
    // 获取初始路由状态
    const initialState = {
      route: router.currentRoute.value
    }
    
    // 序列化状态(防止 XSS)
    const serializedState = JSON.stringify(initialState)
      .replace(/</g, '\\u003c')
      .replace(/>/g, '\\u003e')
      .replace(/&/g, '\\u0026')
      .replace(/"/g, '\\u0022')
    
    // 注入到 HTML 中
    res.send(`
      <!DOCTYPE html>
      <html>
        <head>
          <title>SSR App</title>
        </head>
        <body>
          <div id="app">${html}</div>
          <script>
            window.__INITIAL_STATE__ = ${serializedState}
          </script>
          <script src="/client.js"></script>
        </body>
      </html>
    `)
  } catch (error) {
    console.error('SSR Error:', error)
    res.status(500).send('Internal Server Error')
  }
})

状态注水(Rehydration):客户端恢复路由状态

javascript
// 客户端路由状态注水
async function hydrateApp() {
  // 获取初始状态
  const initialState = window.__INITIAL_STATE__
  
  // 创建客户端路由器
  const router = createRouter({
    history: createWebHistory(),
    routes
  })
  
  // 恢复路由状态
  if (initialState && initialState.route) {
    // 注意:这里不能直接设置 currentRoute
    // 需要通过导航来同步状态
    await router.push(initialState.route)
  }
  
  // 创建应用
  const app = createApp()
  app.use(router)
  
  // 激活应用
  app.mount('#app')
}

// 启动应用
hydrateApp()

完整的 SSR 实现示例

让我们看一个完整的 SSR 实现示例:

javascript
// router/index.js - 通用路由器配置
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'

export const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/Home.vue')
  },
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('../views/User.vue'),
    meta: { requiresAuth: true }
  }
]

export function createRouterInstance(initialState) {
  // 根据环境选择历史记录模式
  const history = import.meta.env.SSR
    ? createMemoryHistory()
    : createWebHistory()
  
  const router = createRouter({
    history,
    routes
  })
  
  // 如果有初始状态,同步路由
  if (initialState && initialState.route) {
    // 注意:在实际应用中,可能需要更复杂的同步逻辑
  }
  
  return router
}

// server.js - 服务端入口
import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'
import { routes } from './router'

export async function render(url, manifest) {
  // 创建 SSR 路由器
  const router = createRouter({
    history: createMemoryHistory(),
    routes
  })
  
  // 导航到请求的 URL
  await router.push(url)
  await router.isReady()
  
  // 创建应用
  const { app } = createApp()
  app.use(router)
  
  // 执行路由守卫中的数据预取
  const matchedComponents = router.currentRoute.value.matched
  const asyncDataHooks = matchedComponents
    .map(component => component.asyncData)
    .filter(hook => hook)
  
  await Promise.all(asyncDataHooks.map(hook => hook({ router })))
  
  // 渲染应用
  const appHtml = await renderToString(app)
  
  // 获取初始状态
  const initialState = {
    route: router.currentRoute.value
  }
  
  // 生成 HTML
  const html = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR App</title>
      </head>
      <body>
        <div id="app">${appHtml}</div>
        <script>
          window.__INITIAL_STATE__ = ${JSON.stringify(initialState)
            .replace(/</g, '\\u003c')
            .replace(/>/g, '\\u003e')}
        </script>
        <script type="module" src="/src/entry-client.js"></script>
      </body>
    </html>
  `
  
  return html
}

// client.js - 客户端入口
import { createApp } from './main'

// 获取初始状态
const initialState = window.__INITIAL_STATE__ || {}

// 创建应用
const { app, router } = createApp(initialState)

// 等待路由器准备就绪
router.isReady().then(() => {
  // 挂载应用
  app.mount('#app')
})

SSR 中的路由守卫处理

在 SSR 环境中,路由守卫的处理方式与客户端有所不同:

javascript
// SSR 中的路由守卫处理
export async function render(url) {
  const router = createRouter({
    history: createMemoryHistory(),
    routes
  })
  
  // 导航到目标 URL
  await router.push(url)
  await router.isReady()
  
  // 执行全局前置守卫
  const guards = router.beforeEachGuards || []
  const to = router.currentRoute.value
  const from = { path: '', name: null, params: {}, query: {}, hash: '' }
  
  for (const guard of guards) {
    try {
      const result = await new Promise((resolve, reject) => {
        const next = (result) => {
          if (result === false) {
            reject(new Error('Navigation aborted'))
          } else if (typeof result === 'string' || 
                     (typeof result === 'object' && result.path)) {
            reject(new NavigationRedirected(result))
          } else {
            resolve(result)
          }
        }
        
        const guardResult = guard(to, from, next)
        if (guardResult instanceof Promise) {
          guardResult.then(next).catch(reject)
        } else if (guardResult !== undefined) {
          next(guardResult)
        }
      })
    } catch (error) {
      if (error instanceof NavigationRedirected) {
        // 处理重定向
        return render(error.location)
      } else {
        throw error
      }
    }
  }
  
  // 渲染应用
  // ...
}

内存管理与性能优化

在 SSR 环境中,内存管理尤为重要,需要确保每次请求后正确清理资源:

javascript
// 内存安全的 SSR 实现
export async function render(url) {
  let router
  let app
  
  try {
    // 创建路由器和应用
    router = createRouter({
      history: createMemoryHistory(),
      routes
    })
    
    await router.push(url)
    await router.isReady()
    
    const appContext = createApp()
    app = appContext.app
    app.use(router)
    
    // 渲染应用
    const html = await renderToString(app)
    
    // 返回结果
    return html
  } finally {
    // 清理资源
    if (router && router.history.destroy) {
      router.history.destroy()
    }
    
    if (app) {
      // 清理应用资源
      app.unmount && app.unmount()
    }
  }
}

小结

在本章中,我们深入探讨了 Vue Router 在 SSR 环境中的应用:

  1. 服务端路由管理 - 使用 createMemoryHistory 创建内存路由
  2. 避免状态污染 - 为每个请求创建独立的路由器实例
  3. 状态同步 - 服务端脱水和客户端注水机制
  4. 路由守卫处理 - SSR 环境中的守卫执行
  5. 内存管理 - 确保资源正确清理

这些机制使得 Vue Router 能够在 SSR 环境中正常工作,提供一致的路由体验。

在实际项目中,SSR 的实现可能会更加复杂,需要考虑缓存、错误处理、性能优化等多个方面。


思考题

  1. 你在 SSR 项目中如何处理路由守卫中的异步操作?遇到了哪些挑战?
  2. 对于大型 SSR 应用,你通常如何优化路由状态的序列化和传输?