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(
window、document);部分第三方库没有做同构适配,服务端会直接报错。 - 部署成本变高:不能直接托管静态 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'))
常见问题
生命周期执行差异
- 服务端会执行:
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。
组件示例:
<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() 改成了回调形式。
SSR 下 Cookie 处理
浏览器请求携带 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 之后仍然要遵守。