应用架构概述

Vue SSR应用需要同时支持服务端和客户端渲染,因此需要创建三个主要入口文件: - app.js - 通用应用工厂函数 - entry-client.js - 客户端入口 - entry-server.js - 服务端入口

通用应用入口(app.js)

基础应用工厂

// src/app.js
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import { createStore } from './store'
import App from './App.vue'

// 导出应用工厂函数
export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  const store = createStore()
  
  app.use(router)
  app.use(store)
  
  return { app, router, store }
}

为什么使用工厂函数?

  • 避免状态污染:每个请求都创建新的应用实例
  • 内存泄漏防护:确保服务端不会在请求间共享状态
  • 并发安全:支持多个并发请求

客户端入口(entry-client.js)

基础客户端入口

// src/entry-client.js
import { createApp } from './app'

const { app, router } = createApp()

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

客户端激活(Hydration)

// src/entry-client.js
import { createApp } from './app'

const { app, router, store } = createApp()

// 恢复服务端状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.isReady().then(() => {
  app.mount('#app', true) // true 表示激活模式
})

客户端路由处理

// 处理客户端路由跳转
router.beforeResolve((to, from, next) => {
  const matched = router.resolve(to).matched
  const prevMatched = router.resolve(from).matched
  
  // 只加载新增的组件
  const activated = matched.filter((c, i) => {
    return prevMatched[i] !== c
  })
  
  if (!activated.length) {
    return next()
  }
  
  // 加载异步数据
  Promise.all(activated.map(c => {
    if (c.components.default.asyncData) {
      return c.components.default.asyncData({ store, route: to })
    }
  })).then(() => {
    next()
  }).catch(next)
})

服务端入口(entry-server.js)

基础服务端入口

// src/entry-server.js
import { createApp } from './app'

export async function render(url, context = {}) {
  const { app, router, store } = createApp()
  
  // 设置路由位置
  await router.push(url)
  await router.isReady()
  
  // 检查路由是否匹配
  const matchedComponents = router.currentRoute.value.matched
  
  if (!matchedComponents.length) {
    throw new Error(`No matched components for route: ${url}`)
  }
  
  // 预取数据
  await Promise.all(
    matchedComponents.map(component => {
      if (component.components.default.asyncData) {
        return component.components.default.asyncData({
          store,
          route: router.currentRoute.value
        })
      }
    })
  )
  
  // 将状态附加到上下文
  context.state = store.state
  
  return app
}

错误处理

// src/entry-server.js
export async function render(url, context = {}) {
  try {
    const { app, router, store } = createApp()
    
    await router.push(url)
    await router.isReady()
    
    const matchedComponents = router.currentRoute.value.matched
    
    if (!matchedComponents.length) {
      context.statusCode = 404
      throw new Error(`404: Page not found - ${url}`)
    }
    
    // 数据预取
    const asyncDataPromises = matchedComponents
      .filter(component => component.components.default.asyncData)
      .map(component => 
        component.components.default.asyncData({
          store,
          route: router.currentRoute.value,
          context
        })
      )
    
    await Promise.all(asyncDataPromises)
    
    context.state = store.state
    context.statusCode = 200
    
    return app
  } catch (error) {
    context.statusCode = context.statusCode || 500
    context.error = error
    throw error
  }
}

根组件(App.vue)

基础App组件

<!-- src/App.vue -->
<template>
  <div id="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于</router-link>
    </nav>
    
    <main>
      <router-view />
    </main>
    
    <footer>
      <p>&copy; 2024 Vue SSR Demo</p>
    </footer>
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
}

nav {
  padding: 30px;
  background: #f0f0f0;
}

nav a {
  font-weight: bold;
  color: #2c3e50;
  margin-right: 20px;
  text-decoration: none;
}

nav a.router-link-exact-active {
  color: #42b983;
}

main {
  padding: 20px;
  min-height: 400px;
}

footer {
  text-align: center;
  padding: 20px;
  background: #f0f0f0;
  margin-top: 40px;
}
</style>

HTML模板

基础HTML模板

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{title}}</title>
  {{{meta}}}
  {{{preloadLinks}}}
</head>
<body>
  <div id="app">{{{app}}}</div>
  
  <script>
    window.__INITIAL_STATE__ = {{{state}}}
  </script>
  
  {{{scripts}}}
</body>
</html>

应用生命周期

SSR渲染流程

1. 服务器接收请求
   ↓
2. 创建应用实例
   ↓
3. 路由匹配
   ↓
4. 数据预取
   ↓
5. 渲染为HTML字符串
   ↓
6. 发送HTML到客户端
   ↓
7. 客户端激活(Hydration)
   ↓
8. 应用变为可交互状态

最佳实践

1. 状态管理

  • 使用工厂函数避免状态污染
  • 正确处理服务端状态序列化
  • 客户端正确恢复状态

2. 错误处理

  • 服务端渲染错误处理
  • 客户端激活错误处理
  • 404和500错误页面

3. 性能优化

  • 避免在服务端执行副作用
  • 合理使用缓存
  • 优化数据预取策略

下一步

在下一章节中,我们将学习如何配置Vue Router以支持SSR。