Vue.js 服务器端渲染
注意:vue ssr 需要最低为如下版本的 Vue,以及以下 Library 支持
- vue & vue-server-render 2.3.0+
- vue-router 2.5.0+
- vue-loader 12.0.0+ & vue-style-loader 3.0.0+
vue-server-renderer
和vue
必须匹配版本
服务器端渲染(SSR)的一些权衡之处
- 开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数(lifecycle hook)中使用;一些外部扩展库(external library)可能需要特殊处理,才能在服务器渲染应用程序中运行。
- 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序(SPA)不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。
- 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源(CPU-intensive - CPU 密集),因此如果你预料在高流量环境(high traffic)下使用,请准备相应的服务器负载,并明智地采用缓存策略。
渲染步骤
- 用 Webpack 的
node
模式把整个应用单独打一个包,通过vue-ssr-webpack-plugin
插件,server bundle 将生可传递到 bundle renderer 的特殊 JSON 文件。 - Node 环境下通过
vue-server-renderer
提供的名为createBundleRenderer
这个 API 加载 server bundle 到vm
上下文环境中。 - 应用在 server 内部启动 HTTP 请求抓取当前路由依赖的数据【非必要】
- 生成网页模板,嵌入 HTML 和 初始数据
vue-ssr-webpack-plugin
当我们使用 webpack 的按需代码分割(
require.ensure
或 动态import
)功能的时候,生成的 server bundle 将会包含多个单独文件,这个插件能自动将这些文件打包成一个可传递给 bundle render 的 JSON 文件来简化工作流程。
基本用法
源码结构
这里使用的项目结构是基于 vue-cli 生成的使用 webpack 为打包工具的默认代码结构。
├── build
| ├── build.js
| ├── check-version.js
| ├── setup-dev-server.js
| ├── utils.js
| ├── vue-loader.conf.js
| ├── webpack.base.conf.js
| ├── webpack.client.conf.js
| └── webpack.server.conf.js
├── config
│ ├── dev.env.js
│ ├── index.js
│ └── prod.env.js
├── src
│ ├── router
│ │ ├── index.js
│ │ └── routes.js
│ ├── entry-client.js
│ ├── entry-server.js
│ ├── index.template.html
│ └── main.js
├── server.js
└── package.json
服务端代码打包
首先需要为服务端打包新增一个 webpack 配置
webpack.server.conf.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseWebpackConfig, {
target: 'node', // 因为代码是为了在服务端运行打包,需要设置 target 为 node
devtool: '#source-map',
entry: './src/entry-server.js',
output: {
filename: '[name].js',
libraryTarget: 'commonjs2' // commonjs 是 node 的模块规范
},
// https://webpack.js.org/configuration/externals/#externals
// https://github.com/liady/webpack-node-externals
externals: nodeExternals({
// 不外部化 CSS 文件,以防我们需要从依赖引入
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin() // 生成的可传递给 bundle renderer 的 JSON 文件
]
})
避免状态单例
纯客户端的时候,每个客户端都有一个自己的上下文环境,不会有数据共享的情况。但是 Node.js 服务器是一个长期运行的进程,如果我们在多个请求之间使用一个共享的实例,它将在每个传入的请求之间共享存放在内存中的数据,很容易导致交叉请求状态污染(cross-request state pollution)。所以要为每个请求创建一个新的根 Vue 实例,需要暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例。
// main.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import { createRouter } from './router'
import Meta from 'vue-meta'
Vue.use(VueRouter)
// manage page meta info in vue component, support SSR
Vue.use(Meta)
export function createApp() {
// create router instance
const router = createRouter()
// create the app instance
const app = new Vue({
el: '#app',
router,
render: h => h('div', { attrs: { id: 'app' }, [h('router-view')] })
})
return { app, router }
}
使用 vue-router 路由
使用官方提供的 vue-router
创建一个 router,前后端都将复用这个相同的路由配置。与 createApp 类似,我们也需要给每个请求创建一个新的 router 实例。
// router/index.js
import VueRouter from 'vue-router'
import routes from './routes'
export function createRouter() {
return new VueRouter({
routes,
mode: 'history',
fallback: false,
scrollBehavior (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
}
})
}
客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中,
src/entry-client.js
import { createApp } from './main'
// client side bootstrap
const { app, router } = createApp()
app.$mount('#app')
这里需要注意的是,如果在 SSR 应用里使用了代码分割或惰性加载,则需要在启动渲染之前在服务器上解析所有的异步组件。Vue 提供异步组件,将其与 webpack 2 支持的按模块动态导入来进行代码分割相结合。
router/routes.js
export default [
{ path: '/', component: () => import(/* webpackChunkName: "home" */'../page/home.vue') }
]
build/webpack.client.conf.js
const webpackConfig = merge(baseWebpackConfig, {
output: {
// ...
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
}
})
src/entry-client.js 修改为如下:
import { createApp } from './main'
// client side bootstrap
const { app, router } = createApp()
router.onReady(() => {
app.$mount('#app')
})
src/entry-server.js
在 entry-server.js
中实现服务器端路由逻辑(server-side routing logic):
import { createApp } from './main'
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router } = createApp()
// 设置服务端 router 的位置
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Promise 应该 resolve 应用程序实例,以便它可以渲染
resolve(app)
}, reject)
})
}
使用一个页面模板
在渲染 Vue 应用程序时,renderer 只从应用程序生成 HTML 标记(markup)。因此还需要一个额外的 HTML 页面包裹容器,来包裹生成的 HTML 标记。
为了简化,可以在创建 renderer 时提供一个页面模板文件,例如 index.template.html
:
<!Doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<!-- title -->
<!-- meta -->
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
注意 <!--vue-ssr-outlet-->
注释的位置,就是 HTML 标记注入的地方。另外值得注意的是,为了实现不同路由对应不同的 title 和 keyword, <!-- title -->
和 <!-- meta -->
注释的位置,分别是渲染时 title 标记和 meta 标记注入的占位符。
服务端渲染
假设现在已经经过编译,得到了由 vue-ssr-webpack-plugin
生成的可传递给 bundle renderer 的 JSON 文件,在 server.js
中通过 require
引入使用。这样创建的 bundle renderer,有什么好处呢?
- 内置 source map 支持(在 webpack 配置中使用
devtool: 'source-map'
) - 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
- 关键 CSS(critical CSS) 注入(在使用
*.vue
文件时):自动内联在渲染过程中用到的组件所需的 CSS - 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。
server.js
const fs = require('fs')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENV === 'production'
const app = express()
const serverInfo =
`express/${require('express/package.json').version} ` +
`vue-server-renderer/${require('vue-server-renderer/package.json').version}`
function createRenderer(bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
basedir: resolve('./dist'),
runInNewContext: false
}))
}
let renderer
let readyPromise
const templatePath = resolve('./src/index.template.html')
if (isProd) {
// 生成环境:用 server bundle 和 template 创建渲染器
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('./dist/vue-ssr-server-bundle.json')
// client manifest 是可选项,但是它能让 renderer 自动推断需要 preload/prefetch 的链接
// 并且将任何渲染期间的异步代码添加为 <script> 标签,避免请求瀑布流
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
template,
clientManifest
})
} else {
// 开发环境:启动 dev server 并监控代码的热加载
// 并为 bundle 和 模板更新创建一个渲染器
readyPromise = require('./build/setup-dev-server')(
app,
templatePath,
(bundle, options) => {
renderer = createRenderer(bundle, options)
}
)
}
function render (req, res) {
const s = Date.now()
res.setHeader("Content-Type", "text/html")
res.setHeader("Server", serverInfo)
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if(err.code === 404) {
res.redirect(err.url)
} else {
res.status(500).send('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
}
const context = {
url: req.url
}
// 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
renderer.renderToString(context, (err, html) => {
// 处理异常
if (err) {
return handleError(err)
}
res.send(html)
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = 8000
app.listen(port, function (err) {
if (err) {
console.error(err)
process.exit(1)
}
console.log(`server listen on http://localhost:${port}`)
})
到这里服务端渲染脚本已经实现了基本的功能。
动态 HEAD 管理
HTML Head 的管理是 SPA 里常见的问题,在 SSR 中我们同样也要处理这个问题。一个比较方便的解决办法是使用 vue-mate,它能同时支持客户端和服务端渲染。假设 Example.vue
是一个需要单独设置 title 和 meta 的页面
用 Vue.use 引入 vue-meta
,合适的位置是与 vue-router
放在放在一起
src/main.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Meta from 'vue-meta'
Vue.use(Router)
Vue.use(Meta)
在 *.vue
文件中添加 metaInfo
src/pages/Example.vue
<template>
...
</template>
<script>
export default {
metaInfo: {
title: 'My Example App', // 设置标题
titleTemplate: '%s - Yay!', // 标题现在变成了 "My Example App - Yay!"
htmlAttrs: { l{ charset: 'utf-8' },ang: 'en' },
meta: {
{ name: 'keywords', content: 'keywords', id: 'kw' },
{ name: 'description', content: 'description', id: 'desc' }
}
}
}
</script>
还需要修改 server 的入口文件,在 server.js
注入 meta 信息前将 $meta
暴露给 bundle Renderer
。
src/entry-server.js
import { createApp } from './main'
export default context => {
return new Promise((resolve, reject) => {
const { app, router } = createApp()
// ...
const meta = app.$meta()
context.meta = meta
router.onReady(() => {
// ...
}, reject)
})
}
最后在渲染时将 index.template.html
里的占位符替换为 $meta
输出的信息。
server.js
function render(req, res) {
// ...
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
// 替换占位符为 meta 信息
const { title, meta } = context.meta.inject()
const replacedHtml = html.replace('<!-- title -->', title.text())
.replace('<!-- meta -->', meta.text())
res.send(replacedHtml)
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
HTML API 报错
我们知道现在这套代码将同时运行在浏览器端和服务端,但是在服务端,是无法访问到浏览器端提供的 API,例如window
或document
,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是一样的。
对于仅浏览器可用的 API,通常做法是在「纯客户端(client-only)」的生命周期钩子函数中惰性访问(lazily access)它们。
但是如果你有一些第三方的 Library,用到了这些 API,那么问题就会有点棘手,但是我们依然可以通过 Mock 全局变量来让他正常运行。
server.js
// ...
// 为服务端模拟了部分 HTML API
const html = '<!doctype html><html><body></body></html>'
require('jsdom-global')(html, {
url: isProd ? 'https://domain.com' : 'http://localhost:8000'
// 由于模拟了 document,需要为 document 设置默认路由
})
// 接下来可以在代码中访问使用 document,window
// ...
我们知道现在这套代码将同时运行在浏览器端和服务端,但是在服务端,是无法访问到浏览器端提供的 API,例如 window
或 document
,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是一样的。
对于仅浏览器可用的 API,通常做法是在「纯客户端(client-only)」的生命周期钩子函数中惰性访问(lazily access)它们。
但是如果你有一些第三方的 Library,用到了这些 API,那么问题就会有点棘手,但是我们依然可以通过 Mock 全局变量来让他正常运行。
server.js
// ...
// 为服务端模拟了部分 HTML API
const html = '<!doctype html><html><body></body></html>'
require('jsdom-global')(html, {
url: isProd ? 'https://domain.com' : 'http://localhost:8000'
// 由于模拟了 document,需要为 document 设置默认路由
})
// 接下来可以在代码中访问使用 document,window
// ...