> For the complete documentation index, see [llms.txt](https://lizh.gitbook.io/knowledge/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://lizh.gitbook.io/knowledge/research/05vuessr-fu-wu-duan-xuan-ran-de-jian-dan-shi-xian.md).

# Vue SSR（服务端渲染）的简单实现

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

## 什么是服务端渲染 SSR

### CSR 客户端渲染（普通SPA）

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

```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（`window`、`document`）；部分第三方库没有做同构适配，服务端会直接报错。
* **部署成本变高**：不能直接托管静态 CDN，需要运行 Node.js 服务。
* **服务器压力增大**：Node 需要执行 Vue 组件渲染逻辑，CPU 开销高于返回静态文件；高并发场景需要做缓存优化。

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

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

## 极简Demo快速体验

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

```javascript
// 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`（通用入口，服务端、客户端共用）。

```javascript
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，激活水合。

```javascript
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 执行，每次请求调用工厂。

```javascript
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 配置，分别对应客户端、服务端构建。

```javascript
// 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 渲染页面

```javascript
// 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'))
```

## 常见问题

### 生命周期执行差异

* **服务端会执行**：`beforeCreate`、`created`
* **服务端不会执行**：`mounted`、`updated`、`beforeDestroy`、`destroyed`

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

### 不能直接使用浏览器 API

服务端是 Node 环境，不存在 `window`、`document`、`navigator`。

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

### 服务端数据预取 asyncData

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

组件示例：

```html
<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__` 给客户端还原状态。

```javascript
// 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()` 改成了回调形式。

### SSR 下 Cookie 处理

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

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

```javascript
// 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`

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

组件中接收，传给 action

```javascript
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）](https://ssr.vuejs.org/zh/)

[vue-server-renderer API 参考](https://ssr.vuejs.org/zh/api/#createrenderer)
