一、为什么需要服务端渲染数据预取

想象一下你打开一个网页,白屏转圈好几秒才看到内容,这种体验肯定让人抓狂。服务端渲染(SSR)就是为了解决这个问题而生的,它让服务器先把页面内容准备好,用户一打开就能看到完整页面。但光有SSR还不够,数据预取才是真正让首屏快起来的关键。

传统客户端渲染就像点外卖:浏览器拿到空碗(HTML模板),再叫外卖(API请求),最后才能吃上饭(渲染数据)。而数据预取相当于让服务器提前把外卖准备好,用户打开页面直接开吃。

技术栈说明:本文所有示例基于Vue 3 + Vue Server Renderer + Express

// 基础SSR示例 - server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')

const app = express()
app.get('*', async (req, res) => {
  const vueApp = createSSRApp({
    data() {
      return { message: 'Hello SSR!' }
    },
    template: `<div>{{ message }}</div>`
  })
  
  const html = await renderToString(vueApp)
  res.send(`<!DOCTYPE html><html><body>${html}</body></html>`)
})

app.listen(3000)

二、数据预取的三种实现方式

2.1 路由级别预取

最常用的方式,在路由跳转前就把数据准备好。Vue Router的beforeResolve钩子是绝佳的切入点。

// 路由预取示例 - router.js
router.beforeResolve(async (to, from, next) => {
  // 检查目标路由是否需要预取
  if (to.matched.some(record => record.meta.shouldPrefetch)) {
    try {
      // 并行发起所有需要的请求
      await Promise.all(
        to.matched.map(record => {
          return record.meta.prefetch 
            ? record.meta.prefetch({ store, route: to })
            : Promise.resolve()
        })
      )
    } catch (error) {
      console.error('预取失败:', error)
    }
  }
  next()
})

// 路由配置示例
{
  path: '/product/:id',
  component: ProductPage,
  meta: {
    shouldPrefetch: true,
    prefetch: async ({ store, route }) => {
      await store.dispatch('fetchProduct', route.params.id)
      await store.dispatch('fetchRelatedProducts', route.params.id)
    }
  }
}

2.2 组件级别预取

Vue的serverPrefetch钩子让组件自己声明需要的数据:

// 组件内预取示例 - ProductComponent.vue
export default {
  name: 'ProductPage',
  async serverPrefetch() {
    // 这里返回Promise会被SSR自动等待
    return this.fetchProductData()
  },
  methods: {
    async fetchProductData() {
      const [detail, reviews] = await Promise.all([
        this.$store.dispatch('fetchProduct', this.$route.params.id),
        this.$store.dispatch('fetchReviews', this.$route.params.id)
      ])
      return { detail, reviews }
    }
  }
}

2.3 混合预取策略

实际项目中,我们常常需要组合使用多种策略:

// 混合预取示例 - 结合store和组件
// store.js
export default {
  state: () => ({
    userInfo: null,
    products: []
  }),
  actions: {
    async fetchUserData({ commit }) {
      const data = await api.get('/user')
      commit('SET_USER', data)
    },
    async prefetchGlobalData() {
      await Promise.all([
        this.dispatch('fetchUserData'),
        this.dispatch('fetchAnnouncements')
      ])
    }
  }
}

// 然后在入口文件中
if (context.isServer) {
  await store.dispatch('prefetchGlobalData')
}

三、关键技术与性能优化

3.1 状态序列化与脱水

服务端获取的数据需要安全地传递到客户端:

// 序列化示例 - server.js
const serialize = require('serialize-javascript')

app.get('*', async (req, res) => {
  const store = createStore()
  await store.dispatch('prefetchAll')
  
  const appHtml = await renderToString(app)
  const stateScript = `<script>window.__INITIAL_STATE__=${serialize(store.state)}</script>`
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR App</title></head>
      <body>
        <div id="app">${appHtml}</div>
        ${stateScript}
      </body>
    </html>
  `)
})

// 客户端恢复状态 - client.js
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

3.2 缓存策略

聪明的缓存能大幅提升性能:

// API缓存示例 - serverCache.js
const LRU = require('lru-cache')

const apiCache = new LRU({
  max: 500,            // 最大缓存数量
  maxAge: 1000 * 60 * 5 // 5分钟有效期
})

async function fetchWithCache(url) {
  const cached = apiCache.get(url)
  if (cached) return Promise.resolve(cached)
  
  const freshData = await api.get(url)
  apiCache.set(url, freshData)
  return freshData
}

3.3 代码分割与异步组件

即使SSR也要注意代码体积:

// 异步组件示例 - router.js
const ProductPage = () => ({
  component: import('./views/ProductPage.vue'),
  loading: LoadingComponent,
  delay: 200
})

// 服务端处理 - 需要特殊配置
module.exports = {
  configureWebpack: {
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
          }
        }
      }
    }
  }
}

四、实战中的坑与解决方案

4.1 内存泄漏问题

SSR服务长时间运行容易内存泄漏:

// 内存管理示例 - server.js
const vm = require('vm')
const sandbox = {
  app: null,
  store: null,
  url: req.url
}

// 使用沙箱环境执行
const context = new vm.createContext(sandbox)
const script = new vm.Script(`
  app = createSSRApp()
  store = createStore()
`)

script.runInContext(context)
// 每次请求结束后清理引用
sandbox.app = null
sandbox.store = null

4.2 异步操作处理

SSR中所有异步必须完成才能渲染:

// 异步处理工具函数
async function renderWithTimeout(app, timeout = 5000) {
  let timer
  const timeoutPromise = new Promise((_, reject) => {
    timer = setTimeout(() => {
      reject(new Error(`渲染超时: ${timeout}ms`))
    }, timeout)
  })

  try {
    return await Promise.race([
      renderToString(app),
      timeoutPromise
    ])
  } finally {
    clearTimeout(timer)
  }
}

4.3 第三方库兼容性

不是所有库都支持SSR:

// 条件性引入示例 - plugin.js
if (process.server) {
  // 服务端专用mock
  module.exports = require('./server-mock')
} else {
  // 客户端真实实现
  module.exports = require('./real-implementation')
}

// 或者在组件中
export default {
  mounted() {
    // 只在客户端执行的代码
    if (process.client) {
      import('client-only-library').then(module => {
        module.init()
      })
    }
  }
}

五、应用场景与选型建议

适合SSR数据预取的场景:

  1. 内容展示型网站(新闻、博客)
  2. SEO要求高的页面
  3. 首屏性能要求严苛的应用

不适合的场景:

  1. 高度交互的管理后台
  2. 对服务器成本敏感的项目
  3. 纯静态内容网站

六、完整项目示例

最后来看一个完整的实现:

// 完整SSR服务示例 - server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')
const serialize = require('serialize-javascript')
const createStore = require('./store')

const app = express()

app.get('*', async (req, res) => {
  const store = createStore()
  const router = createRouter()
  
  const vueApp = createSSRApp({
    store,
    router,
    render: h => h(App)
  })

  // 设置服务器端router位置
  router.push(req.url)
  
  try {
    // 等待路由和组件预取完成
    await new Promise((resolve, reject) => {
      router.onReady(resolve, reject)
    })
    
    // 等待组件级预取
    await Promise.all(
      router.getMatchedComponents().map(Component => {
        return Component.serverPrefetch
          ? Component.serverPrefetch({ store, route: router.currentRoute })
          : Promise.resolve()
      })
    )
    
    const appHtml = await renderToString(vueApp)
    const state = serialize(store.state)
    
    res.send(`
      <!DOCTYPE html>
      <html>
        <head><title>我的SSR应用</title></head>
        <body>
          <div id="app">${appHtml}</div>
          <script>window.__INITIAL_STATE__=${state}</script>
          <script src="/client-bundle.js"></script>
        </body>
      </html>
    `)
  } catch (error) {
    console.error('SSR渲染失败:', error)
    res.status(500).send('服务器错误')
  }
})

app.listen(3000, () => {
  console.log('SSR服务已启动: http://localhost:3000')
})

七、总结与最佳实践

经过上面的探索,我们可以总结出以下经验:

  1. 分级预取策略:全局数据用store预取,路由级数据用router钩子,组件特有数据用serverPrefetch
  2. 缓存一切可缓存的:API响应、渲染结果、组件实例
  3. 完善的错误处理:SSR环境没有window对象,所有客户端API调用都需要保护
  4. 性能监控:记录首屏时间、数据预取耗时等关键指标
  5. 渐进式方案:可以从部分页面开始SSR,逐步扩大范围

记住,SSR不是银弹,它带来了首屏性能提升,但也增加了服务器负担。根据你的业务场景,找到平衡点才是关键。