Vue SSR(服务端渲染)简单实现

Vue 的 SSR 不是把组件「搬到服务器上跑一遍」那么简单:同构代码要避开浏览器 API,每次请求还得新建实例,避免状态串台。本文按 Vue 2 + vue-server-renderer 走通最小实现;Vue 3 换成 vue/server-renderer,工厂函数和水合思路仍然适用。

什么是服务端渲染 SSR

CSR 客户端渲染(普通SPA)

常规 Vue SPA 属于客户端渲染 CSR。浏览器请求返回的 HTML 只有空挂载节点,没有业务内容。

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <title>标题</title>
  <link href=/css/chunk-vendors.css rel=stylesheet>
</head>
<body>
  <!-- 无任何业务DOM内容 -->
  <div id="app"></div>
  <script src=/js/app.js></script>
</body>
</html>

浏览器下载 JS,在本地执行 JS 生成 DOM,再挂载到 #app,页面才展示内容。全部渲染逻辑运行在浏览器。

SSR 服务端渲染

SSR(Server-Side Rendering,服务端渲染):在 Node.js 服务端把 Vue 组件预先编译成完整 HTML 字符串,浏览器请求时直接返回带完整业务 DOM 的 HTML。浏览器拿到 HTML 直接渲染,之后再执行 JS 做注水(hydrate,水合),把静态 HTML 激活为可交互 Vue 应用。

同构、通用应用(isomorphic、universal):同一套 Vue 业务代码,既可以跑在 Node.js 服务端,也可以跑在浏览器客户端。

为什么要使用 SSR

优势

  • 更好的首屏体验(Time-to-Content):首屏不需要等待 JS 下载、解析执行,服务端直接返回渲染完成的 HTML,弱网、低端设备上首屏效果提升明显。
  • SEO 友好:爬虫可以直接拿到完整 HTML 内容。

弊端与成本

  • 代码约束:通用代码不能随意使用浏览器 API(windowdocument);部分第三方库没有做同构适配,服务端会直接报错。
  • 部署成本变高:不能直接托管静态 CDN,需要运行 Node.js 服务。
  • 服务器压力增大:Node 需要执行 Vue 组件渲染逻辑,CPU 开销高于返回静态文件;高并发场景需要做缓存优化。

备注:现代谷歌、必应爬虫可以执行 JS 索引 SPA 页面,但不会等待异步 Ajax 请求完成。页面内容依赖接口异步拉取的场景,SPA 会导致爬虫拿不到有效内容,此时 SSR 更合适。

选型判断:内部后台系统首屏不敏感,不需要 SSR;面向公网营销页、内容站点,对首屏速度、SEO 有强需求,适合 SSR。

极简Demo快速体验

vue-server-renderer 提供核心渲染能力,createRenderer 接收 Vue 实例,输出 HTML 字符串。

// index.js
const Vue = require('vue')
const express = require('express')
const { createRenderer } = require('vue-server-renderer')

const app = express()
const renderer = createRenderer()

app.get('*', (req, res) => {
  const vueApp = new Vue({
    data() {
      return { url: req.url }
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })

  renderer.renderToString(vueApp, (err, html) => {
    if(err) {
      return res.status(500).end('Internal Server Error')
    }
    res.end(`
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>SSR Demo</title></head>
<body>
${html}
</body>
</html>
    `)
  })
})

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

运行:node index.js 访问即可看到页面。

实际项目不会直接传入 Vue 实例,业务组件、SFC 单文件组件需要 webpack 打包,使用 createBundleRenderer 读取打包后的 vue-ssr-server-bundle.json

SSR项目核心改造

解决交叉请求状态污染

浏览器打开页面,每次访问都会新建 JS 执行环境,Vue 实例、store 都是全新。 但 Node 是常驻进程,如果全局只创建一份 Vue、Router、Store 实例,多个用户请求会共用同一份状态,发生交叉请求状态污染

解决方案:编写工厂函数,每次请求调用工厂,生成全新实例。

src/main.js(通用入口,服务端、客户端共用)。

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'

Vue.config.productionTip = false

// 工厂函数:每次调用返回全新实例
export function createApp () {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })
  return { app, router, store }
}

客户端入口 entry-client.js:浏览器执行,挂载 DOM,激活水合。

import { createApp } from './main'
const { app, router, store } = createApp()

// 还原服务端序列化下发的状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  app.$mount('#app')
})

服务端入口 entry-server.js:Node 执行,每次请求调用工厂。

import { createApp } from './main'

// 每次http请求执行,返回全新实例
export default function createAppWithContext () {
  const { app, router, store } = createApp()
  return { app, router, store }
}

webpack 双份构建

SSR 项目需要输出两套产物:

  • Server Bundle:给 Node 渲染用,target=node,输出 vue-ssr-server-bundle.json
  • Client Bundle:浏览器执行,包含业务 JS、CSS,输出 vue-ssr-client-manifest.json,帮助服务端自动注入 script、preload 标签。

备注:vue-cli 项目需要两份 webpack 配置,分别对应客户端、服务端构建。

// vue.config.js 关键配置示意
const merge = require('lodash.merge')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')

// 基础配置
const baseConfig = {}

// 服务端构建配置
const serverConfig = merge({}, baseConfig, {
  outputDir: './dist/server',
  css: { extract: false },
  configureWebpack: {
    entry: './src/entry-server.js',
    target: 'node',
    output: {
      libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({ whitelist: [/\.css$/] }),
    optimization: { splitChunks: false },
    plugins: [ new VueSSRServerPlugin() ]
  },
  chainWebpack(config) {
    // 服务端不需要输出css,使用null-loader过滤样式
    const langs = ["css", "postcss", "scss", "sass", "less", "stylus"];
    const types = ["vue-modules", "vue", "normal-modules", "normal"];
    for (const lang of langs) {
      for (const type of types) {
        let rule = config.module.rule(lang).oneOf(type)
        rule.uses.clear();
        rule.use('null-loader').loader('null-loader');
      }
    }
  }
})

// 客户端构建配置
const clientConfig = merge({}, baseConfig, {
  outputDir: './dist/client',
  configureWebpack: {
    entry: './src/entry-client.js',
    target: 'web',
    optimization: { runtimeChunk: { name: 'manifest' } },
    plugins: [ new VueSSRClientPlugin() ]
  }
})

module.exports = [clientConfig, serverConfig]

Node 服务读取 bundle 渲染页面

// server/index.js
const path = require('path')
const fs = require('fs')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')

const app = express()
// 读取打包产物
const serverBundle = require('../dist/server/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/client/vue-ssr-client-manifest.json')
const template = fs.readFileSync(path.resolve(__dirname, '../src/template.html'), 'utf-8')

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐,不每次新建V8上下文,提升性能
  template,
  clientManifest
})

app.get('*', (req, res) => {
  const context = { url: req.url }
  renderer.renderToString(context, (err, html) => {
    if(err) {
      // 渲染失败可降级到SPA
      return res.status(500).send('服务器异常')
    }
    res.end(html)
  })
})

app.listen(8080, ()=> console.log('SSR服务启动 8080'))

常见问题

生命周期执行差异

  • 服务端会执行beforeCreatecreated
  • 服务端不会执行mountedupdatedbeforeDestroydestroyed

注意:不要在 created 写定时器、事件监听等副作用代码。服务端执行完不会触发销毁钩子,定时器会内存泄漏。浏览器 DOM 相关逻辑放到 mounted。Vue 3 对应的销毁钩子是 beforeUnmountunmounted

不能直接使用浏览器 API

服务端是 Node 环境,不存在 windowdocumentnavigator

  • DOM、BOM 相关代码放到 mounted
  • 引入第三方库优先确认是否支持同构;不能同构的库可以在客户端钩子动态 import

服务端数据预取 asyncData

SSR 渲染页面前必须把页面依赖的异步接口数据全部请求完毕,再渲染组件。 Vue 2 SSR 通常约定 asyncData 作为页面组件静态方法,服务端执行,填充 store。

组件示例:

<template>
  <div>{{ pageData.title }}</div>
</template>
<script>
export default {
  async asyncData({ store }) {
    // 服务端渲染阶段执行,拿接口数据存入vuex
    await store.dispatch('fetchPageData')
  },
  computed:{
    pageData(){
      return this.$store.state.page
    }
  }
}
</script>

服务端入口处理逻辑:拿到路由匹配组件,批量执行 asyncData,完成后再渲染,并且把 store.state 挂载到渲染 context,最终注入 HTML 成为 window.__INITIAL_STATE__ 给客户端还原状态。

// entry-server.js
export default function createAppWithContext(context) {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    router.push(context.url)
    router.onReady(async () => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) return reject({ code: 404 })

      // 批量执行页面组件 asyncData
      await Promise.all(matchedComponents.map(comp => {
        return comp.asyncData ? comp.asyncData({ store }) : Promise.resolve()
      }))
      // 将 state 交给 renderer,注入页面全局变量
      context.state = store.state
      resolve(app)
    }, reject)
  })
}

注意:Promise 的 executor 不要写成 async 函数,否则内部抛错不会走到外层 reject。上面把 await router.onReady() 改成了回调形式。

浏览器请求携带 Cookie,Node 服务端发起接口请求的时候不会自动带上浏览器 Cookie;登录态依赖 Cookie 的业务,接口会拿不到用户身份。

推荐方案:HTTP 请求拿到 req.cookies,传入 render 的 context,再传递给组件 asyncData,再传递给 vuex action,最终传给 axios 请求头。

// server/index.js
app.get('*', (req, res)=>{
  const context = {
    url: req.url,
    cookies: req.headers.cookie
  }
  renderer.renderToString(context, (err, html)=>{})
})

entry-server 将 cookies 传入组件 asyncData

await Promise.all(matchedComponents.map(comp=>{
  return comp.asyncData ? comp.asyncData({ store, cookies: context.cookies }) : Promise.resolve()
}))

组件中接收,传给 action

async asyncData({ store, cookies }) {
  await store.dispatch('fetchPageData', { cookies })
}

axios 请求封装:把传入的 cookies 设置到 request headers。

水合(Hydration)不匹配(hydration mismatch)

服务端输出 HTML 和客户端挂载后生成 DOM 结构不一致,Vue 抛出水合报错。

常见诱因:

  • HTML 嵌套非法,浏览器自动修复 DOM 结构;例如 <p> 里面嵌套 <div>
  • 服务端客户端生成随机数不一致。
  • 服务端和客户端时区不同,时间格式化结果不同。

Nuxt.js 框架

原生手写 SSR 配置繁琐,生产项目一般使用 Nuxt:Vue 2 对应 Nuxt 2,Vue 3 对应 Nuxt 3。它把路由、构建、服务端渲染、静态生成 SSG 全部封装。

主要特性:

  • 约定式 pages 目录自动生成路由;
  • 内置服务端渲染、SPA 模式、generate 静态站点预渲染(SSG);
  • 封装数据获取、中间件、布局、meta 标签管理;
  • 内部处理 webpack 双份构建、开发热更新、cookie、水合等大量底层细节。

Nuxt 支持三种模式:SSR 服务端渲染、SPA 单页应用、SSG 静态站点生成。静态生成适合内容不经常变动的博客、文档站点,不需要持续运行 Node 服务,可以直接部署静态 CDN。

Vue 2 的 SSR 指南仍可参考 ssr.vuejs.org;Vue 3 请看官方文档的 SSR 章节。工厂函数、数据预取和水合匹配,换 API 之后仍然要遵守。

参考资料

Vue SSR 官方指南(Vue 2)

vue-server-renderer API 参考

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-09-03 01:39:37

results matching ""

    No results matching ""