簡(jiǎn)介
使用
Vue.js構(gòu)建客戶端應(yīng)用程序時(shí),默認(rèn)情況下是在瀏覽器中輸出Vue組件,進(jìn)行生成DOM和操作DOM。而使用SSR可以將同一個(gè)組件渲染為服務(wù)器端的HTML字符串,然后將它們直接發(fā)送到瀏覽器,最后將靜態(tài)標(biāo)記 "混合" 為客戶端上完全交互的應(yīng)用程序。
如何看一個(gè)網(wǎng)頁(yè)是否是服務(wù)器端渲染?
簡(jiǎn)單的方式是在
Chrome瀏覽器打開(kāi)控制臺(tái)/開(kāi)發(fā)者工具,查看Network中加載的資源,如下圖所示segmentfault網(wǎng)站,可以看到第一個(gè)文件總是document類型,這是服務(wù)器發(fā)送過(guò)來(lái)的完整的HTML文檔,瀏覽器只需要加載css/js進(jìn)行視圖渲染即可。
看 Vue SSR 官網(wǎng)也是服務(wù)器端渲染:

這里說(shuō)的渲染,就是指生成 HTML 文檔的過(guò)程,和之前瀏覽器的 CSS+HTML 渲染沒(méi)有關(guān)系。簡(jiǎn)單來(lái)說(shuō),瀏覽器端渲染,指的是用 JS 去生成 HTML,例如 React, Vue 等前端框架做的路由。服務(wù)器端渲染,指的是用后臺(tái)語(yǔ)言通過(guò)一些模版引擎生成 HTML,例如 Java 配合 VM 模版引擎、NodeJS配合Jade 等,將數(shù)據(jù)與視圖整理輸出為完整的 HTML 文檔發(fā)送給瀏覽器。
一、入門(mén)配置
1. 起步
新建項(xiàng)目,安裝
Vue與SSR依賴包vue-server-renderer
$ mkdir testSSR // 新建空文件夾 vueSSR
$ cd testSSR // 進(jìn)入 vueSSR 目錄
$ npm init // 初始化,生成 package.json
$ npm install vue vue-server-renderer --save-dev // 安裝
先使用 vue-server-renderer 渲染一個(gè)簡(jiǎn)單的 Vue 組件
$ touch test.js // 新建 test.js
// test.js
const Vue = require('vue')
const app = new Vue({ // 創(chuàng)建一個(gè) Vue 實(shí)例
template: `<div>Hello World</div>`
})
const vueRenderer = require('vue-server-renderer')
const renderer = vueRenderer.createRenderer() // 創(chuàng)建一個(gè) renderer
// 通過(guò) renderToString 將 Vue 實(shí)例渲染為 HTML
// 函數(shù)簽名: renderer.renderToString(vm, context?, callback?): ?Promise<string>
renderer.renderToString(app, (err, doc) => {
if (err) throw err
console.log(doc)
})
運(yùn)行 test.js,輸出渲染后的 HTML

注意到應(yīng)用程序的根元素上添加了一個(gè)特殊的屬性 data-server-rendered,這是讓客戶端 Vue 知道這
部分 HTML 是由 Vue 在服務(wù)端渲染的。
2. 引入模板
上例只是渲染一個(gè) vue 組件,通常應(yīng)用程序都會(huì)抽象出一個(gè)或多個(gè)模板來(lái)嵌入不同的組件。
Render 的 template 選項(xiàng)為整個(gè)頁(yè)面的 HTML 提供一個(gè)模板。此模板應(yīng)包含注釋 <\!--vue-ssr-outlet-->作為渲染應(yīng)用程序內(nèi)容的占位符。
首先創(chuàng)建一個(gè) HTML 模板 index.template.html
<!--index.template.html -->
<!doctype html>
<html lang="en">
<head>
<title></title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
這里的 <\!--vue-ssr-outlet--> 注釋就是應(yīng)用程序 HTML 標(biāo)記注入的地方。
將此模板通過(guò) fs 讀取, 然后在 createRenderer( ) 時(shí)注入,修改 test.js 如下:
// test.js
//const renderer = vueRenderer.createRenderer()
const fs = require('fs')
const renderer = vueRenderer.createRenderer({
template: fs.readFileSync('./index.template.html', 'utf-8') // 同步讀取文件
})
運(yùn)行 test.js 可以看到之前定義的 hello world 組件已嵌入模板中。

二、服務(wù)器端整合
選取基于 node.js 的 express 作為服務(wù)器,示例 vue ssr 在服務(wù)器端的工作。
1. 啟動(dòng) express server
$ cd testSSR // 進(jìn)入項(xiàng)目
$ npm install express --save-dev // 安裝 express
$ touch server.js // 新建 server.js
引入 express 并設(shè)置一個(gè)測(cè)試路由
// server.js
const express = require('express')
const server = express()
server.get('/mytest', (request, response) => {
response.send("hello world "+request.url)
})
server.listen(8000)
運(yùn)行$ node server.js 后打開(kāi)瀏覽器訪問(wèn) http://localhost:8000/mytest

服務(wù)器啟動(dòng)成功。
2. Renderer 渲染
首先創(chuàng)建一個(gè)可以重復(fù)執(zhí)行的工廠函數(shù),為每個(gè)請(qǐng)求創(chuàng)建新的 Vue 實(shí)例,如果創(chuàng)建一個(gè)單例對(duì)象,它將在每個(gè)傳入的請(qǐng)求之間共享,很容易導(dǎo)致交叉請(qǐng)求狀態(tài)污染。
$ cd testSSR // 進(jìn)入項(xiàng)目
$ touch app.js // 新建 app.js
// app.js
const Vue = require('vue')
module.exports = function createApp (context) {
return new Vue({
data: {
url: context.url
},
template: `<div>Vue SSR URL: {{ url }}</div>`
})
}
然后在 server.js 中引入 app.js 創(chuàng)建實(shí)例,并配置路由與請(qǐng)求渲染。
// server.js
const createApp = require('./app')
const vueRenderer = require('vue-server-renderer')
const renderer = vueRenderer.createRenderer()
server.get('/ssr', (request, response) => {
const context = { url: request.url }
const app = createApp(context)
renderer.renderToString(app, (err, doc) => {
if (err) throw err
response.send(doc)
})
})
運(yùn)行$ node server.js 后打開(kāi)瀏覽器訪問(wèn) http://localhost:8000/ssr?sadas=2222

3. 插入模板
增加頁(yè)面模板,使用之前定義的 index.template.html 作為模板,注入到一個(gè)新的 renderer
// server.js
const fs = require('fs')
const rendererTmp = vueRenderer.createRenderer({
template: fs.readFileSync('./index.template.html', 'utf-8') // 同步讀取文件
})
server.get('/template', (request, response) => {
const context = { url: request.url }
const app = createApp(context)
rendererTmp.renderToString(app, (err, doc) => {
if (err) throw err
response.send(doc)
})
})
運(yùn)行$ node server.js 后打開(kāi)瀏覽器訪問(wèn) http://localhost:8000/template

可以看到一個(gè)簡(jiǎn)單的服務(wù)器端渲染已經(jīng)完成。
三、項(xiàng)目工程化
1. SSR 項(xiàng)目結(jié)構(gòu)
通常 Vue 應(yīng)用程序是由 webpack 和 vue-loader 構(gòu)建,并且許多 webpack 特定功能不能直接在Node.js 中運(yùn)行(例如通過(guò) file-loader 導(dǎo)入文件,通過(guò) css-loader 導(dǎo)入 CSS)。
對(duì)于客戶端應(yīng)用程序和服務(wù)器應(yīng)用程序,我們都要使用 webpack 打包 - 服務(wù)器需要「服務(wù)器bundle」然后用于服務(wù)器端渲染(SSR),而「客戶端 bundle」會(huì)發(fā)送給瀏覽器,用于混合靜態(tài)標(biāo)記?;玖鞒倘缦聢D。

所以一個(gè)基本的項(xiàng)目目錄可能如下:
src
├── config
│ ├── webpack.base.config.js
│ ├── webpack.client.config.js
│ └── webpack.server.config.js
├── components
│ ├── Foo.vue
│ └── xxx.vue
├── build
│ ├── index.js
│ └── xxx.js
├── template
│ ├── index.template.html
│ └── xxx.html
├── route.js # vue-router 路由
├── App.vue # 根實(shí)例
├── app.js # 通用 entry
├── entry-client.js # 配置 僅運(yùn)行于瀏覽器
├── entry-server.js # 配置 僅運(yùn)行于服務(wù)器
├── server.js # 服務(wù)器
├── webpack.config.js
└── package.json
2. 配置路由
使用 vue-router
$ npm intall vue-router --save-dev
$ touch route.js
在新建的 router.js 中創(chuàng)建 router,類似于 createApp,我們也需要給每個(gè)請(qǐng)求一個(gè)新的 router 實(shí)例,
所以文件導(dǎo)出一個(gè) createRouter 函數(shù)
// router.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export function createRouter () {
return new Router({
mode: 'history',
routes: [
// ...
]
})
}
修改 app.js,添加路由
// app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
export function createApp () {
// 創(chuàng)建 router 實(shí)例
const router = createRouter()
const app = new Vue({
// 注入 router 到根 Vue 實(shí)例
router,
render: h => h(App)
})
// 返回 app 和 router
return { app, router }
}
3. 配置 webpack
新建 entry-server.js,實(shí)現(xiàn)服務(wù)器端路由邏輯:
// entry-server.js
import { createApp } from './app'
export default context => {
// 因?yàn)橛锌赡軙?huì)是異步路由鉤子函數(shù)或組件,所以我們將返回一個(gè) Promise,
// 以便服務(wù)器能夠等待所有的內(nèi)容在渲染前,
// 就已經(jīng)準(zhǔn)備就緒。
return new Promise((resolve, reject) => {
const { app, router } = createApp()
// 設(shè)置服務(wù)器端 router 的位置
router.push(context.url)
// 等到 router 將可能的異步組件和鉤子函數(shù)解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,執(zhí)行 reject 函數(shù),并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Promise 應(yīng)該 resolve 應(yīng)用程序?qū)嵗员闼梢凿秩? resolve(app)
}, reject)
})
}
在生成 vue-ssr-server-bundle.json 之后,只需將文件路徑傳遞給 createBundleRenderer:
// webpack.server.config.js
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
// 將 entry 指向應(yīng)用程序的 server entry 文件
entry: '/path/to/entry-server.js',
// 這允許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動(dòng)態(tài)導(dǎo)入(dynamic import),
// 并且還會(huì)在編譯 Vue 組件時(shí),
// 告知 `vue-loader` 輸送面向服務(wù)器代碼(server-oriented code)。
target: 'node',
// 對(duì) bundle renderer 提供 source map 支持
devtool: 'source-map',
// 此處告知 server bundle 使用 Node 風(fēng)格導(dǎo)出模塊(Node-style exports)
output: {
libraryTarget: 'commonjs2'
},
// https://webpack.js.org/configuration/externals/#function
// https://github.com/liady/webpack-node-externals
// 外置化應(yīng)用程序依賴模塊??梢允狗?wù)器構(gòu)建速度更快,
// 并生成較小的 bundle 文件。
externals: nodeExternals({
// 不要外置化 webpack 需要處理的依賴模塊。
// 你可以在這里添加更多的文件類型。例如,未處理 *.vue 原始文件,
// 你還應(yīng)該將修改 `global`(例如 polyfill)的依賴模塊列入白名單
whitelist: /\.css$/
}),
// 這是將服務(wù)器的整個(gè)輸出
// 構(gòu)建為單個(gè) JSON 文件的插件。
// 默認(rèn)文件名為 `vue-ssr-server-bundle.json`
plugins: [
new VueSSRServerPlugin()
]
})
在生成 vue-ssr-server-bundle.json 之后,只需將文件路徑傳遞給 createBundleRenderer:
// server.js
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {
// ……renderer 的其他選項(xiàng)
})
除了 server bundle 之外,我們還可以生成客戶端構(gòu)建清單(client build manifest)。使用客戶端清單(client manifest)和服務(wù)器bundle(server bundle),renderer 現(xiàn)在具有了服務(wù)器和客戶端的構(gòu)建信息,因此它可以自動(dòng)推斷和注入資源預(yù)加載 / 數(shù)據(jù)預(yù)取指令(preload / prefetch directive),以及 css 鏈接 / script 標(biāo)簽到所渲染的 HTML。
// webpack.client.config.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, {
entry: '/path/to/entry-client.js',
plugins: [
// 重要信息:這將 webpack 運(yùn)行時(shí)分離到一個(gè)引導(dǎo) chunk 中,
// 以便可以在之后正確注入異步 chunk。
// 這也為你的 應(yīng)用程序/vendor 代碼提供了更好的緩存。
new webpack.optimize.CommonsChunkPlugin({
name: "manifest",
minChunks: Infinity
}),
// 此插件在輸出目錄中
// 生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
這樣就可以生成客戶端構(gòu)建清單(client build manifest)。
4. Bundle Renderer
到目前為止,我們假設(shè)打包的服務(wù)器端代碼,將由服務(wù)器通過(guò) require 直接使用:
const createApp = require('/path/to/built-server-bundle.js')
然而在每次編輯過(guò)應(yīng)用程序源代碼之后,都必須停止并重啟服務(wù)。這在開(kāi)發(fā)過(guò)程中會(huì)影響開(kāi)發(fā)效率。
此外,Node.js 本身不支持 source map。
vue-server-renderer 提供一個(gè)名為 createBundleRenderer 的 API,用于處理此問(wèn)題,通過(guò)使用
webpack 的自定義插件,server bundle 將生成為可傳遞到 bundle renderer 的特殊 JSON 文件。
// server.js
const { createBundleRenderer } = require('vue-server-renderer')
const template = require('fs').readFileSync('/path/to/template.html', 'utf-8')
const serverBundle = require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('/path/to/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推薦
template, // (可選)頁(yè)面模板
clientManifest // (可選)客戶端構(gòu)建 manifest
})
// 在服務(wù)器處理函數(shù)中……
server.get('/', (req, res) => {
const context = { url: req.url }
// 這里無(wú)需傳入一個(gè)應(yīng)用程序,因?yàn)樵趫?zhí)行 bundle 時(shí)已經(jīng)自動(dòng)創(chuàng)建過(guò)。
// 現(xiàn)在我們的服務(wù)器與應(yīng)用程序已經(jīng)解耦!
renderer.renderToString(context, (err, html) => {
// 處理異?!? res.end(html)
})
})
