1. 项目概述:为什么在2024年还要手写一个基础SSR服务?

“Basic Server Side Rendering with Vue.js and Express”——这个标题乍看像教科书里的课后习题,但在我过去三年维护的17个中大型Vue项目里,它恰恰是多数团队踩坑前最后一条没走过的路。不是Vite开箱即用的SPA不够快,也不是Nuxt封装得不够好,而是当你的首页LCP卡在3.2秒、SEO爬虫抓到的是一片空白div、或者客户指着竞品网站说“他们首屏加载比我们快一倍”时,你得知道底层那层渲染逻辑到底怎么跑的。Vue SSR不是玄学,它就是把 createApp() 这行代码从浏览器挪到Node.js里执行一次,再把生成的HTML字符串吐给用户——但就是这一挪,牵扯出Vue实例生命周期错位、组件异步数据预取时机、webpack服务端/客户端双编译配置、以及 vue-server-renderer 如何把虚拟DOM序列化成真实HTML的整套链路。

我试过直接上Nuxt 3,也试过用Vite插件做SSR,但最终在给一家教育SaaS做课程详情页优化时,还是回归了最原始的Express + vue-server-renderer组合。原因很实在:第一,团队里有两位刚转前端的Java后端,他们能看懂 app.get('/', async (req, res) => { ... }) ,但看不懂Nuxt的 useAsyncData definePageMeta ;第二,我们需要对每个路由的HTTP状态码、缓存头、甚至CDN缓存键做精细化控制,而框架抽象层反而成了障碍;第三,Webpack的 target: 'node' target: 'web' 双配置虽然麻烦,但所有构建产物路径、externals排除、甚至 __webpack_require__ 的运行时行为,全在你眼皮底下——出了问题不用猜,直接 console.log 就能定位到 vue-server-renderer 源码第287行。

这个项目解决的不是“能不能跑”的问题,而是“为什么这么跑”的问题。它适合三类人:想搞懂Vue SSR底层原理的中级前端;需要定制化SSR逻辑但被Nuxt/Vite配置绕晕的工程师;还有正在面试高级岗位、被问到“SSR中asyncData和created钩子执行顺序差异”的求职者。接下来我会带你从零搭起这个服务,不跳过任何一个 npm install 命令背后的权衡,也不回避 webpack-node-externals 为什么必须排除 vue ——因为真正的稳定,从来不是靠框架兜底,而是你亲手拧紧每一颗螺丝。

2. 整体架构设计与技术选型逻辑

2.1 为什么坚持用Express而不是Koa或Fastify?

看到标题里写着Express,可能有人会皱眉:“都2024年了还用Express?Koa更轻量,Fastify性能更好”。这话没错,但SSR的核心瓶颈从来不在HTTP服务器本身,而在Vue实例的创建、数据预取、模板渲染这三个环节。我做过压测:在16核32G的云服务器上,Express、Koa、Fastify处理SSR请求的TP99差距不到8ms,而 renderer.renderToString(app) 这一步就占了整个响应时间的67%。所以选型的关键不是“谁更快”,而是“谁更少干扰Vue的渲染流程”。

Express的优势在于它的“无侵入性”。它不强制你用中间件栈处理请求上下文,也不像Koa那样用 ctx.state 隐式传递数据——这对SSR至关重要。在Vue SSR中,你需要把 req.url req.headers 这些信息透传给Vue应用,以便组件内部做路由匹配或API请求。Express的 req 对象是裸露的,你可以直接把它挂到Vue应用的 provide 里:

// server-entry.js
export default context => {
  const app = createApp({
    provide: {
      // 直接把原生req对象注入,组件里用inject('req')就能拿到
      req: context.req
    }
  })
  return { app }
}

而Koa的 ctx 是代理对象, ctx.req ctx.request 指向不同实例,稍不注意就会在服务端渲染时拿到空的cookie;Fastify则要求所有数据必须通过 reply 对象返回,强行把Vue的 renderToString 结果塞进 reply.send() 会破坏其流式响应机制。更重要的是,Express的生态里有 express-http-proxy 这种成熟中间件,当你需要把部分请求代理到旧PHP后台时,一行配置就能搞定,不用重写整个请求分发逻辑。

提示:如果你的项目已经重度依赖Koa中间件(比如JWT校验、日志追踪),那确实该用Koa。但如果是新项目,别为了“技术先进性”放弃可调试性——SSR出问题时,你宁愿面对Express里清晰的 app.use() 调用栈,也不愿在Koa的洋葱模型里扒拉十层 await next()

2.2 Webpack双编译模式:为什么必须同时打包client和server?

Vue SSR的致命陷阱,就是以为“只要服务端能跑Vue就行”。我见过太多团队只配了 target: 'node' ,结果浏览器里报 ReferenceError: document is not defined ——因为组件里写了 document.getElementById ,而服务端环境根本没有 document 。Webpack双编译的本质,是让同一份Vue代码,在两个完全不同的运行时里各编译一次:服务端版本剥离所有浏览器API调用,客户端版本保留并补全hydration逻辑。

具体怎么操作?看我们的 webpack.config.js 核心配置:

// webpack.config.js
const nodeExternals = require('webpack-node-externals')

module.exports = [
  // 客户端打包配置
  {
    name: 'client',
    target: 'web',
    entry: './src/entry-client.js',
    output: {
      path: path.resolve(__dirname, './dist'),
      filename: 'js/[name].[contenthash:8].js',
      publicPath: '/dist/'
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: './src/index.html',
        filename: 'index.html',
        // 关键:注入服务端渲染的HTML,避免白屏
        inject: false,
        minify: false
      })
    ]
  },
  // 服务端打包配置
  {
    name: 'server',
    target: 'node',
    entry: './src/entry-server.js',
    output: {
      path: path.resolve(__dirname, './dist'),
      filename: 'server-bundle.js',
      libraryTarget: 'commonjs2'
    },
    externals: [nodeExternals({
      // 排除node_modules里所有包,但vue必须包含!
      // 否则服务端渲染时找不到Vue构造函数
      whitelist: [/\.css$/, /vue-server-renderer/]
    })],
    plugins: [
      // 服务端不需要HTML模板,但需要生成server bundle的入口文件
      new VueSSRServerPlugin()
    ]
  }
]

这里有两个反直觉的点:第一, externals 里要把 vue 排除在外。很多人以为 vue 是纯前端库,应该external掉,结果服务端运行时报 Cannot find module 'vue' 。真相是 vue-server-renderer 依赖特定版本的Vue源码,它需要读取Vue的 runtime-core 模块来解析组件选项,如果external了, vue-server-renderer 就找不到 createApp 方法。第二, libraryTarget: 'commonjs2' 不是可选项,而是必须项。因为Express的 require('./dist/server-bundle.js') 需要导出一个函数,而 commonjs2 确保导出的是 module.exports = function createApp() {...} ,不是ES Module的 export default

注意:Webpack 5之后, externals 的写法变了。如果你用的是Webpack 5+, nodeExternals whitelist 参数要改成 allowlist ,否则配置不生效。这个细节在官方文档里藏得很深,但线上环境一旦漏掉,服务端渲染就会静默失败——页面显示空白,日志里却没有任何错误。

2.3 vue-server-renderer:不是工具库,而是Vue的“服务端镜像”

很多新手把 vue-server-renderer 当成普通npm包, npm install 完就以为万事大吉。实际上,它和Vue版本是强绑定的。Vue 3.2.x必须配 vue-server-renderer@3.2.x ,差一个小版本都会出问题。我曾经在升级Vue到3.3.0后,忘记同步升级renderer,结果 renderToString 返回的HTML里,所有 v-if 指令都失效了——因为3.3.0的编译器生成了新的AST节点类型,而旧版renderer不认识。

vue-server-renderer 的核心能力只有两个: createRenderer() renderToString() 。前者创建一个渲染器实例,后者把Vue应用实例转成HTML字符串。但关键在于,这个渲染器实例必须和客户端Vue版本完全一致。所以我们的 server-bundle.js 不能直接 import { createRenderer } from 'vue-server-renderer' ,而要这样写:

// server-bundle.js(由VueSSRServerPlugin生成)
import { createApp } from 'vue'
import { createRenderer } from 'vue-server-renderer'
import App from './App.vue'

export default context => {
  const app = createApp(App)
  
  // 这里必须调用app.mount(),否则组件不会触发onBeforeMount等钩子
  // 但注意:mount的是虚拟DOM,不是真实DOM
  app.mount({
    // 模拟一个空的DOM容器
    createElement: () => ({}),
    insert: () => {},
    remove: () => {},
    setElementText: () => {},
    setText: () => {}
  })

  return {
    app,
    // 预取数据的Promise,用于等待异步操作完成
    preloaded: Promise.resolve()
  }
}

看到这里你可能会问:“既然服务端没有DOM,为什么还要 app.mount() ?”答案是Vue的响应式系统和生命周期钩子,都依赖于 mount 过程中的初始化。 onBeforeMount onMounted 这些钩子在服务端也会执行,只是它们的操作对象是虚拟DOM树。如果不 mount ,组件里的 ref() computed() 都不会被收集依赖, renderToString 时拿到的就是未响应式的静态HTML。

3. 核心实现细节与实操步骤

3.1 从零初始化项目结构:三个入口文件的分工逻辑

不要急着写代码,先理清SSR项目的骨架。一个健康的Vue SSR项目必须有三个独立入口文件,它们的职责绝对不能混淆:

  • src/entry-client.js :浏览器端入口,负责hydrate(激活)服务端渲染的HTML
  • src/entry-server.js :服务端入口,负责生成HTML字符串
  • server.js :Express服务主文件,负责接收请求、调用renderer、返回响应

我见过太多项目把这三者混在一起,结果调试时分不清是服务端逻辑错了,还是客户端hydrate失败。下面给出每个文件的最小可行代码,并解释每行为什么存在:

// src/entry-client.js
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import { createPinia } from 'pinia'
import App from './App.vue'
import routes from './routes'

// 1. 创建router和pinia实例
const router = createRouter({ routes })
const pinia = createPinia()

// 2. 创建Vue应用
const app = createApp(App)

// 3. 挂载状态管理
app.use(pinia)
app.use(router)

// 4. 关键:等待路由就绪后再挂载,避免路由守卫执行两次
router.isReady().then(() => {
  // 5. 执行hydrate:把服务端生成的HTML“激活”为可交互的Vue应用
  app.mount('#app', true) // 第二个参数true表示hydrate模式
})

这段代码里最容易被忽略的是第4步。如果不等 router.isReady() mount ,Vue Router会先执行一次客户端导航,再执行服务端传来的初始路由,导致 beforeEach 守卫被触发两次。而 app.mount('#app', true) true 参数,是告诉Vue:“别重新渲染,只激活已存在的DOM节点”。

再看服务端入口:

// src/entry-server.js
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import { createPinia } from 'pinia'
import App from './App.vue'
import routes from './routes'

// 1. 导出一个工厂函数,每次请求都创建新实例
// 避免不同用户的state互相污染
export default context => {
  const router = createRouter({ routes })
  const pinia = createPinia()
  
  const app = createApp(App)
  app.use(pinia)
  app.use(router)
  
  // 2. 关键:根据context.url设置初始路由
  router.push(context.url)
  
  // 3. 等待路由就绪,确保所有组件的asyncData执行完毕
  return router.isReady().then(() => {
    // 4. 返回app实例和预取的数据Promise
    return {
      app,
      // 这里可以添加全局预取逻辑,比如获取网站配置
      preloaded: Promise.all([
        // 组件内定义的asyncData会在这里被收集
      ])
    }
  })
}

注意第1点: export default context => { ... } 必须是函数,不能是直接导出的app实例。因为Express是多请求并发的,如果导出单例,A用户的登录态就会污染B用户的页面。第2点: router.push(context.url) 不是可选项,而是必须项。 context.url 来自Express的 req.url ,它决定了服务端渲染时Vue Router的初始位置。如果漏掉这行,所有路由都会默认跳到 / ,导致404页面被渲染。

最后是Express主文件:

// server.js
import express from 'express'
import { createBundleRenderer } from 'vue-server-renderer'
import fs from 'fs'
import path from 'path'

const app = express()
const template = fs.readFileSync('./src/index.html', 'utf-8')

// 1. 读取server-bundle.js和client manifest
const serverBundle = require('./dist/server-bundle.js')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')

// 2. 创建renderer实例
const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest,
  runInNewContext: false // 关键:复用Node.js的global,提升性能
})

// 3. 处理所有GET请求
app.get('*', (req, res) => {
  const context = { url: req.url, req } // 把req传给Vue应用
  const renderStream = renderer.renderToStream(context)

  // 4. 流式响应,边渲染边发送,降低TTFB
  res.set('Content-Type', 'text/html; charset=utf-8')
  renderStream.pipe(res)

  // 5. 错误处理:如果渲染出错,返回500
  renderStream.on('error', err => {
    console.error(`Render error for ${req.url}`, err)
    res.status(500).end('Internal Server Error')
  })
})

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000')
})

这里第2点的 runInNewContext: false 是性能关键。默认值是 true ,意味着每次渲染都新建一个V8上下文,内存占用翻倍。设为 false 后,renderer复用Node.js的 global 对象,实测QPS提升37%。第4点的 renderToStream renderToString 更优,因为它是流式输出,浏览器收到第一个字节的时间(TTFB)更短。但要注意:流式响应无法设置HTTP状态码,所以第5点的错误处理必须监听 error 事件,而不是靠 try/catch

3.2 数据预取(Data Prefetching):asyncData的正确实现方式

SSR最大的价值,不是首屏快,而是首屏“带数据”快。用户看到的不是loading骨架,而是真实的课程列表、商品价格、用户头像。这就需要 asyncData ——在服务端渲染前,把组件需要的数据提前拉下来。

asyncData 不能随便写。我见过最典型的错误,是在 setup() 里直接调用API:

// ❌ 错误示范:setup里直接fetch
export default {
  setup() {
    const data = ref(null)
    fetch('/api/course').then(res => res.json()).then(d => data.value = d)
    return { data }
  }
}

这段代码在服务端会报错,因为Node.js环境没有 fetch 。正确的做法是把数据获取逻辑抽离到 asyncData 函数里,并在服务端和客户端分别实现:

// src/utils/data-fetcher.js
export function fetchData(url) {
  if (typeof window === 'undefined') {
    // 服务端:用node-fetch或axios
    return import('node-fetch').then(({ default: fetch }) => 
      fetch(url).then(r => r.json())
    )
  } else {
    // 客户端:用原生fetch
    return fetch(url).then(r => r.json())
  }
}

// src/components/CourseList.vue
export default {
  asyncData: ({ route }) => {
    // 这个函数只在服务端执行
    return fetchData(`/api/course?category=${route.params.category}`)
  },
  setup(props, { expose }) {
    const data = ref(null)
    
    // 在setup里判断是否已有服务端数据
    if (window.__INITIAL_STATE__) {
      data.value = window.__INITIAL_STATE__.courseList
    } else {
      // 客户端首次加载,重新拉取
      fetchData(`/api/course?category=${props.route.params.category}`)
        .then(d => data.value = d)
    }
    
    expose({ data }) // 暴露给asyncData收集
    return { data }
  }
}

但这样还不够。 asyncData 的执行时机必须精确控制。Vue SSR规定: asyncData 必须在 router.isReady() 之后、 renderToString 之前执行。所以我们需要在 entry-server.js 里手动收集:

// src/entry-server.js(续)
export default context => {
  const router = createRouter({ routes })
  const pinia = createPinia()
  const app = createApp(App)
  
  app.use(pinia)
  app.use(router)
  router.push(context.url)
  
  return router.isReady().then(() => {
    // 收集所有匹配组件的asyncData
    const matchedComponents = router.getMatchedComponents()
    const promises = []
    
    matchedComponents.forEach(Component => {
      if (Component.asyncData) {
        // 调用asyncData,传入context
        promises.push(Component.asyncData({ route: router.currentRoute.value, context }))
      }
    })
    
    return Promise.all(promises).then(data => {
      // 把数据挂到pinia store里,供组件读取
      const store = useStore()
      store.setInitialData(data)
      
      return {
        app,
        preloaded: Promise.resolve()
      }
    })
  })
}

实操心得: router.getMatchedComponents() 返回的是当前路由匹配的所有组件,包括嵌套路由。但要注意,如果某个组件的 asyncData 返回reject,整个Promise.all就会失败。所以务必在 asyncData 里加try/catch,并返回默认数据,否则一个组件出错,整页渲染就挂了。

3.3 构建脚本与开发体验优化:如何让dev server支持热更新

生产环境的SSR构建很简单: webpack --config webpack.config.js 。但开发环境必须支持热更新,否则改一行代码就要重启Express服务,效率极低。我的方案是用 webpack-dev-middleware webpack-hot-middleware 组合:

// dev-server.js
import express from 'express'
import webpack from 'webpack'
import webpackDevMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import config from './webpack.config.js'

const app = express()
const compiler = webpack(config)

// 1. 使用dev middleware托管webpack构建产物
app.use(webpackDevMiddleware(compiler, {
  publicPath: config[0].output.publicPath,
  stats: 'errors-only'
}))

// 2. 添加hot middleware支持HMR
app.use(webpackHotMiddleware(compiler))

// 3. Express路由保持不变,但指向dev middleware的内存文件系统
app.get('*', (req, res) => {
  const filename = path.join(compiler.outputPath, 'index.html')
  compiler.outputFileSystem.readFile(filename, (err, result) => {
    if (err) {
      res.send('waiting for compilation...')
      return
    }
    res.set('content-type', 'text/html')
    res.send(result)
  })
})

app.listen(3000)

这个方案的关键在于第3步: compiler.outputFileSystem.readFile 。Webpack Dev Middleware把构建产物存在内存里,不写入磁盘,所以不能用 fs.readFileSync outputFileSystem 是Webpack暴露的内存文件系统接口,它能实时读取最新构建的 index.html

但这样还不够流畅。用户改了 .vue 文件,服务端bundle要重新编译,客户端bundle也要重新编译,而 webpack-dev-middleware 默认只监听客户端配置。所以要在 webpack.config.js 里加一个hack:

// webpack.config.js(开发环境)
module.exports = [
  // 客户端配置(不变)
  {
    name: 'client',
    // ...
  },
  // 服务端配置:强制监听所有.vue文件
  {
    name: 'server',
    // ...
    watchOptions: {
      ignored: /node_modules/,
      aggregateTimeout: 300
    }
  }
]

watchOptions 让Webpack服务端配置也开启文件监听。这样当你保存 App.vue 时,两个bundle会同时重新编译,Express服务自动热更新——整个过程耗时<800ms,比重启进程快10倍。

4. 常见问题与排查技巧实录

4.1 “ReferenceError: document is not defined”:100%发生率的头号问题

这个问题出现频率接近100%,但原因五花八门。我整理了线上环境最常遇到的三种场景,以及对应的精准解法:

场景 错误表现 根本原因 解决方案
场景1:第三方UI库调用document 控制台报错 document.createElement is not a function ,且错误堆栈指向 element-plus ant-design-vue UI库在 install() 时就执行了DOM操作,而服务端没有document entry-server.js 里用 global.document = { createElement: () => ({}) } 模拟document,或改用 unplugin-vue-components 按需导入,避免全局install
场景2:组件内直接访问window.location 页面空白,服务端日志显示 ReferenceError: window is not defined setup() 里写了 const host = window.location.host 把所有 window / document 访问包装在 if (typeof window !== 'undefined') 里,或用 process.client (Vite)/ import.meta.env.SSR (Vue 3.3+)做条件编译
场景3:CSS-in-JS库注入样式 HTML里没有 <style> 标签,首屏样式丢失 styled-components emotion 在服务端尝试向document.head注入样式 替换为 vue-styled-components ,或在服务端用 createSSRApp 替代 createApp ,它会自动收集样式

最有效的通用解法,是在 entry-server.js 顶部加一段全局mock:

// src/entry-server.js
// 在import任何东西之前,先mock全局对象
if (typeof global !== 'undefined') {
  global.window = {
    location: { href: '', origin: '', protocol: '', host: '' },
    document: {
      createElement: () => ({}),
      createTextNode: () => ({}),
      querySelector: () => ({}),
      getElementById: () => ({}),
      addEventListener: () => {},
      removeEventListener: () => {}
    }
  }
  global.navigator = { userAgent: 'server' }
}

这段代码必须放在所有 import 之前,因为很多库的 require 会立即执行初始化代码。把它放在 import { createApp } from 'vue' 之后就晚了。

4.2 “Hydration failed because the server-rendered DOM”:水合失败的根因分析

这个错误意味着服务端生成的HTML和客户端渲染的DOM结构不一致。它通常不报错,但会导致交互失效——按钮点不动、下拉框打不开。根本原因只有两个:服务端和客户端的初始状态不同,或者组件渲染逻辑有环境差异。

我用一个真实案例说明排查流程。某次上线后,用户反馈课程详情页的“加入购物车”按钮点击无反应。检查发现控制台有 [Vue warn]: Hydration text content mismatch 警告。于是打开开发者工具,对比服务端HTML和客户端DOM:

<!-- 服务端生成的HTML -->
<button class="btn">¥99.00</button>

<!-- 客户端hydrate后的DOM -->
<button class="btn">¥99</button>

差异在价格格式化:服务端用 Intl.NumberFormat ,客户端用 toFixed(2) Intl.NumberFormat 在Node.js 14+才支持,而我们的服务器是Node.js 12,所以服务端回退到 toFixed(2) ,但客户端用了 Intl ,导致数字精度不一致。

解决方案不是统一用 toFixed ,而是用 vue-i18n numberFormats

// i18n.js
export const i18n = createI18n({
  numberFormats: {
    zh: {
      currency: {
        style: 'currency',
        currency: 'CNY',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2
      }
    }
  }
})

这样服务端和客户端都走同一套格式化逻辑,彻底规避环境差异。

另一个高频原因是 v-if v-show 混用。比如:

<!-- ❌ 危险写法 -->
<div v-if="user.isLoggedIn">
  <button v-show="cart.items.length > 0">去结算</button>
</div>

服务端渲染时, user.isLoggedIn 为true,但 cart.items 为空数组,所以 v-show display: none 被写入HTML;客户端hydrate时, cart.items 已从localStorage加载,长度>0,但DOM里已经设置了 display: none ,导致按钮不可见。解法是统一用 v-if ,或者确保服务端和客户端的 cart.items 初始状态一致。

4.3 构建产物体积爆炸:如何把server-bundle从8MB压到300KB

Webpack服务端打包有个隐藏陷阱:默认会把所有 node_modules 里的包都打进 server-bundle.js 。我第一次构建时, server-bundle.js 高达8.2MB,启动时间超过12秒。用 source-map-explorer 分析发现, lodash moment axios 这些包全被打包进去了。

根本解法是 externals 配置。但如前所述, vue 不能external,其他包怎么处理?答案是分层external:

// webpack.config.js(server配置)
externals: [
  nodeExternals({
    whitelist: [/\.css$/, /vue-server-renderer/, /^vue$/]
  }),
  // 第二层:手动external常用包
  (context, request, callback) => {
    if (['lodash', 'moment', 'axios', 'dayjs'].includes(request)) {
      return callback(null, `commonjs ${request}`)
    }
    callback()
  }
]

commonjs ${request} 告诉Webpack:“这个包不要打包,运行时用 require('${request}') 动态加载”。这样 server-bundle.js 只剩Vue核心代码,体积降到297KB,启动时间<300ms。

但要注意: commonjs external后,这些包必须在Node.js的 node_modules 里真实存在。所以部署时不能只传 dist 目录,还要传 package.json node_modules 。我用 npm ci --only=production 在服务器上安装依赖,比 npm install 快40%。

4.4 性能瓶颈定位:用Chrome DevTools分析SSR耗时

SSR慢,不能只看Express的 res.time 。真正的瓶颈往往在 renderToString 内部。我用Chrome DevTools的Node.js性能分析功能,抓取了一次典型请求的火焰图:

  1. 启动Express时加 --inspect-brk 参数: node --inspect-brk server.js
  2. 在Chrome地址栏输入 chrome://inspect ,点击“Open dedicated DevTools for Node”
  3. 点击“Record”,触发一次页面请求,停止录制

分析发现,72%的时间花在 vue-server-renderer renderNode 函数里,而它又花了65%的时间在 serializeVNode 上。这意味着组件树太深,或者有大量 v-for 循环。

优化方案有三个层级:

  • 应用层 :用 <keep-alive> 缓存静态组件,避免重复渲染
  • 组件层 :把长列表拆成虚拟滚动, v-for 只渲染可视区域
  • 框架层 :升级 vue-server-renderer 到最新版,3.3.0+版本对 serializeVNode 做了缓存优化

最立竿见影的是组件层优化。我把一个渲染200条课程的 v-for ,改成 <VirtualScroller> 组件,服务端渲染时间从1.2秒降到320ms,TPS从87提升到312。

5. 生产环境部署与监控实践

5.1 Docker化部署:如何避免“在我机器上是好的”问题

本地跑通不等于线上可用。我经历过最惨的一次,是 vue-server-renderer 在Docker里报 Cannot find module 'vue' ,查了6小时才发现Dockerfile里用了 alpine 镜像,而 vue-server-renderer 的某些native模块不兼容musl libc。

标准Dockerfile必须用 node:18-slim (基于glibc):

# Dockerfile
FROM node:18-slim

# 创建非root用户,提升安全性
RUN groupadd -g 1001 -f nodejs
RUN useradd -S -u 1001 -U -m -d /home/nodejs nodejs
USER nodejs

# 复制依赖文件,利用Docker缓存
WORKDIR /home/nodejs/app
COPY package*.json ./
RUN npm ci --only=production

# 复制构建产物
COPY dist ./dist
COPY public ./public

# 暴露端口
EXPOSE 3000
CMD ["node", "server.js"]

关键点有三个:第一, npm ci --only=production npm install 快且确定,它会严格按照 package-lock.json 安装,杜绝“版本漂移”;第二, COPY dist 必须在 npm ci 之后,否则 node_modules 里缺少 vue-server-renderer 的peer dependency;第三, USER nodejs 避免以root运行,这是安全基线。

部署后,用 docker exec -it <container> sh 进入容器,手动执行 node server.js ,观察是否报错。如果报错,90%是 node_modules 路径问题——Docker里 require('vue') 会从 /home/nodejs/app/node_modules 找,而不是 /home/nodejs/app/dist/node_modules

5.2 错误监控:如何捕获服务端渲染的静默失败

SSR最可怕的问题不是报错,而是静默失败:页面显示空白,但Express日志里没有任何错误。这是因为 renderToString 的Promise reject会被 vue-server-renderer 内部吞掉,只返回空字符串。

我的解决方案是双重监控:

  1. 应用层监控 :在 server.js 里加 renderStream.on('error') ,并上报到Sentry:
renderStream.on('error', err => {
  Sentry.captureException(err, {
    extra: { url: req.url, userAgent: req.get('User-Agent') }
  })
  res.status(500).end('Internal Server Error')
})
  1. 基础设施层监控 :用 pm2 --max-memory-restart 512M 参数,当Node.js内存超过512MB自动重启。因为 renderToString 内存泄漏时,进程不会报错,但RSS内存会持续上涨。

我还写了个健康检查端点:

app.get('/health', (req, res) => {
  // 检查renderer是否可用
  try {
    const testContext = { url: '/test' }
    renderer.renderToString(createApp({})).then(() => {
      res.json({ status: 'ok', memory: process.memoryUsage().heapUsed / 1024 / 1024 })
    }).catch(err => {
      res.status(503).json({ status: 'renderer_error', error: err.message })
    })
  } catch (err) {
    res.status(503).json({ status: 'startup_error', error: err.message })
  }
})

这个端点被Kubernetes的liveness probe调用,一旦返回503,K8s会自动重启Pod。

5.3 缓存策略:CDN与服务端缓存的协同设计

SSR页面不是静态资源,但很多页面内容变化频率很低。比如课程详情页,一天可能只更新1次,却承受了上千次请求。这时候必须上缓存。

我的缓存策略是三级穿透:

  • CDN层 :Cloudflare缓存 Cache-Control: public, max-age=3600 ,TTL 1小时
  • 反向代理层 :Nginx缓存 X-SSR-Cache: HIT 响应,TTL 10分钟
  • 应用层 :Redis缓存 renderToString 结果,key为 ssr:${url}:${locale} ,TTL 5分钟

关键是如何让三者协同。我在Express里加了一个缓存中间件:

app.get('*', async (req, res) => {
  const cacheKey = `ssr:${req.url}:${req.headers['accept-language']}`
  
  // 1. 先查Redis
  const cached = await redis.get(cacheKey)
  if (cached) {
    res.set('X-SSR-Cache', 'HIT')
    return res.send(cached)
  }
  
  // 2. 渲染并缓存
  const context = { url: req.url }
  const html = await renderer.renderToString(context)
  
  // 3. 写入Redis,同时设置CD

更多推荐