Vue 服務端渲染(SSR)
什麼是服務端渲染,簡單理解是將元件或頁面通過伺服器生成html字串,再傳送到瀏覽器,最後將靜態標記"混合"為客戶端上完全互動的應用程式。 於傳統的SPA(單頁應用)相比,服務端渲染能更好的有利於SEO,減少頁面首屏載入時間,當然對開發來講我們就不得不多學一些知識來支援服務端渲染。同時服務端渲染對伺服器的壓力也是相對較大的,和伺服器簡單輸出靜態檔案相比,通過node去渲染出頁面再傳遞給客戶端顯然開銷是比較大的,需要注意準備好相應的伺服器負載。
一、一個簡單的例子
// 第 1 步:建立一個 Vue 例項
const Vue = require('vue')
const app = new Vue({
template: `<div>Hello World</div>`
})
// 第 2 步:建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer()
// 第 3 步:將 Vue 例項渲染為 HTML
renderer.renderToString(app, (err, html) => {
if (err) throw err
console.log(html)
// => <div data-server-rendered="true">Hello World</div>
})
複製程式碼
上面例子利用 vue-server-renderer
npm 包將一個vue示例最後渲染出了一段 html。將這段html傳送給客戶端就輕鬆的實現了伺服器渲染了。
const server = require('express')()
server.get('*', (req, res) => {
// ... 生成 html
res.end(html)
})
server.listen(8080)
複製程式碼
二、官方渲染步驟
上面例子雖然簡單,但在實際專案中往往還需要考慮到路由,資料,元件化等等,所以服務端渲染不是隻用一個 vue-server-renderer
npm包就能輕鬆搞定的,下面給出一張Vue官方的伺服器渲染示意圖:
流程圖大致意思是:將 Source(原始碼)通過 webpack 打包出兩個 bundle,其中 Server Bundle 是給服務端用的,服務端通過渲染器 bundleRenderer 將 bundle 生成 html 給瀏覽器用;另一個 Client Bundle 是給瀏覽器用的,別忘了服務端只是生成前期首屏頁面所需的 html ,後期的互動和資料處理還是需要能支援瀏覽器指令碼的 Client Bundle 來完成。
三、具體怎麼實現
實現過程就是將上面的示意圖轉化成程式碼實現,不過這個過程還是有點小複雜的,需要多點耐心去推敲每個細節。
1、先實現一個基本版
專案結構示例:
├── build
│ ├── webpack.base.config.js # 基本配置檔案
│ ├── webpack.client.config.js # 客戶端配置檔案
│ ├── webpack.server.config.js # 服務端配置檔案
└── src
├── router
│ └── index.js # 路由
└── views
│ ├── comp1.vue # 元件
│ └── copm2.vue # 元件
├── App.vue # 頂級 vue 元件
├── app.js # app 入口檔案
├── client-entry.js # client 的入口檔案
├── index.template.html # html 模板
├── server-entry.js # server 的入口檔案
├── server.js # server 服務
複製程式碼
其中:
(1)、comp1.vue 和 copm2.vue 元件
<template>
<section>元件 1</section>
</template>
<script>
export default {
data () {
return {
msg: ''
}
}
}
</script>
複製程式碼
(2)、App.vue 頂級 vue 元件
<template>
<div id="app">
<h1>vue-ssr</h1>
<router-link class="link" to="/comp1">to comp1</router-link>
<router-link class="link" to="/comp2">to comp2</router-link>
<router-view class="view"></router-view>
</div>
</template>
<style lang="stylus">
.link
margin 10px
</style>
複製程式碼
(3)、index.template.html html 模板
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<title>{{ title }}</title>
<meta charset="utf-8"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
<meta name="renderer" content="webkit"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>
<meta name="theme-color" content="#f60"/>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
複製程式碼
(4)、上面基礎程式碼不解釋,接下來看
路由 router
import Vue from 'vue'
import Router from 'vue-router'
import comp1 from '../views/comp1.vue'
import comp2 from '../views/comp2.vue'
Vue.use(Router)
export function createRouter () {
return new Router({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: [
{
path: '/comp1',
component: comp1
},
{
path: '/comp2',
component: comp2
},
{ path: '/', redirect: '/comp1' }
]
})
}
複製程式碼
app.js app 入口檔案
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
export function createApp (ssrContext) {
const router = createRouter()
const app = new Vue({
router,
ssrContext,
render: h => h(App)
})
return { app, router }
}
複製程式碼
我們通過 createApp
暴露一個根 Vue 例項,這是為了確保每個使用者能得到一份新的例項,避免狀態汙染,所以我們寫了一個
可以重複執行的工廠函式 createApp
。 同樣路由 router 我們也是一樣的處理方式 createRouter
來暴露一個 router 例項
(5)client-entry.js client 的入口檔案
import { createApp } from './app'
const { app, router } = createApp()
router.onReady(() => {
app.$mount('#app')
})
複製程式碼
客戶端程式碼是在路由解析完成的時候講 app 掛載到 #app 標籤下
(7)server-entry.js server 的入口檔案
import { createApp } from './app'
export default context => {
// 因為這邊 router.onReady 是非同步的,所以我們返回一個 Promise
// 確保路由或元件準備就緒
return new Promise((resolve, reject) => {
const { app, router } = createApp(context)
router.push(context.url)
router.onReady(() => {
resolve(app)
}, reject)
})
}
複製程式碼
伺服器的入口檔案我們返回了一個 promise
2、打包
在第一步我們大費周章實現了一個帶有路由的日常功能模板程式碼,接著我們需要利用webpack將上面的程式碼打包出服務端和客戶端key的程式碼,入口檔案分別是 server-entry.js
和 client-entry.js
(1)、 webpack構建配置
一般配置分為三個檔案:base, client 和 server。基本配置(base config)包含在兩個環境共享的配置,例如,輸出路徑(output path),別名(alias)和 loader。伺服器配置(server config)和客戶端配置(client config),可以通過使用 webpack-merge 來簡單地擴充套件基本配置。
webpack.base.config.js 配置檔案
const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
module.exports = {
devtool: '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/',
filename: '[name]-[chunkhash].js'
},
resolve: {
alias: {
'public': path.resolve(__dirname, '../public'),
'components': path.resolve(__dirname, '../src/components')
},
extensions: ['.js', '.vue']
},
module: {
noParse: /es6-promise\.js$/,
rules: [
{
test: /\.(js|vue)/,
use: 'eslint-loader',
enforce: 'pre',
exclude: /node_modules/
},
{
test: /\.vue$/,
use: {
loader: 'vue-loader',
options: {
preserveWhitespace: false,
postcss: [
require('autoprefixer')({
browsers: ['last 3 versions']
})
]
}
}
},
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[hash:7].[ext]'
}
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
name: 'fonts/[name].[hash:7].[ext]'
}
}
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.json/,
use: 'json-loader'
}
]
},
performance: {
maxEntrypointSize: 300000,
hints: 'warning'
},
plugins: [
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false }
}),
new ExtractTextPlugin({
filename: 'common.[chunkhash].css'
})
]
}
複製程式碼
webpack.client.config.js 配置檔案
const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const glob = require('glob')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, {
entry: {
app: './src/client-entry.js'
},
resolve: {
alias: {
'create-api': './create-api-client.js'
}
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"',
'process.env.DEBUG_API': '"true"'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
return (
/node_modules/.test(module.context) && !/\.css$/.test(module.require)
)
}
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
// 這是將伺服器的整個輸出
// 構建為單個 JSON 檔案的外掛。
// 預設檔名為 `vue-ssr-server-bundle.json`
new VueSSRClientPlugin()
]
})
module.exports = config
複製程式碼
webpack.server.config.js 配置檔案
const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/server-entry.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
resolve: {
alias: {
'create-api': './create-api-server.js'
}
},
externals: nodeExternals({
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
複製程式碼
webpack 配置完成,其實東西也不多,都是常規配置。需要注意的是 webpack.server.config.js
配置,output是生成一個 commonjs 的 library, VueSSRServerPlugin
用於這是將伺服器的整個輸出構建為單個 JSON 檔案的外掛。
(2)、 webpack build poj
build 程式碼
webpack --config build/webpack.client.config.js
webpack --config build/webpack.server.config.js
複製程式碼
打包後會生成一些打包檔案,其中 server.config 打包後會生成 vue-ssr-server-bundle.json
檔案,這個檔案是給 createBundleRenderer
用的,用於服務端渲染出 html 檔案
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {
// ……renderer 的其他選項
})
複製程式碼
細心的你還會發現 client.config 不僅生成了一下客服端用的到 js 檔案,還會生成一份 vue-ssr-client-manifest.json
檔案,這個檔案是客戶端構建清單,服務端拿到這份構建清單找到一下用於初始化的js腳步或css注入到 html 一起發給瀏覽器。
(3)、 服務端渲染
其實上面都是準備工作,最重要的一步是將webpack構建後的資原始碼給服務端用來生成 html 。我們需要用node寫一個服務端應用,通過打包後的資源生成 html 併傳送給瀏覽器
server.js
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const KoaRuoter = require('koa-router')
const serve = require('koa-static')
const { createBundleRenderer } = require('vue-server-renderer')
const LRU = require('lru-cache')
const resolve = file => path.resolve(__dirname, file)
const app = new Koa()
const router = new KoaRuoter()
const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')
function createRenderer (bundle, options) {
return createBundleRenderer(
bundle,
Object.assign(options, {
template,
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
basedir: resolve('./dist'),
runInNewContext: false
})
)
}
let renderer
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
clientManifest
})
/**
* 渲染函式
* @param ctx
* @param next
* @returns {Promise}
*/
function render (ctx, next) {
ctx.set("Content-Type", "text/html")
return new Promise (function (resolve, reject) {
const handleError = err => {
if (err && err.code === 404) {
ctx.status = 404
ctx.body = '404 | Page Not Found'
} else {
ctx.status = 500
ctx.body = '500 | Internal Server Error'
console.error(`error during render : ${ctx.url}`)
console.error(err.stack)
}
resolve()
}
const context = {
title: 'Vue Ssr 2.3',
url: ctx.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
console.log(html)
ctx.body = html
resolve()
})
})
}
app.use(serve('/dist', './dist', true))
app.use(serve('/public', './public', true))
router.get('*', render)
app.use(router.routes()).use(router.allowedMethods())
const port = process.env.PORT || 8089
app.listen(port, '0.0.0.0', () => {
console.log(`server started at localhost:${port}`)
})
複製程式碼
這裡我們用到了最開始 demo 用到的 vue-server-renderer
npm 包,通過讀取 vue-ssr-server-bundle.json
和 vue-ssr-client-manifest.json
檔案 renderer 出 html,最後 ctx.body = html
傳送給瀏覽器, 我們試著
console.log(html)
出 html 看看服務端到底渲染出了何方神聖:
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<title>Vue Ssr 2.3</title>
<meta charset="utf-8"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
<meta name="renderer" content="webkit"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>
<meta name="theme-color" content="#f60"/>
<link rel="preload" href="/dist/manifest-56dda86c1b6ac68c0279.js" as="script"><link rel="preload" href="/dist/vendor-3504d51340141c3804a1.js" as="script"><link rel="preload" href="/dist/app-ae1871b21fa142b507e8.js" as="script"><style data-vue-ssr-id="41a1d6f9:0">
.link {
margin: 10px;
}
</style><style data-vue-ssr-id="7add03b4:0"></style></head>
<body>
<div id="app" data-server-rendered="true">
<h1>vue-ssr</h1>
<a href="/comp1" class="link router-link-exact-active router-link-active">to comp1</a>
<a href="/comp2" class="link">to comp2</a>
<section class="view">元件 1</section>
</div>
<script src="/dist/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/dit/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/dist/app-ae1871b21fa142b507e8.js" defer></script>
</body>
</html>
複製程式碼
可以看到服務端把路由下的 元件 1
也給渲染出來了,而不是讓客服端去動態載入,其次是 html 也被注入了一些 <script 標籤去載入對應的客戶端資源。這裡再多說一下,有的同學可能不理解,服務端渲染不就是最後輸出 html 讓瀏覽器渲染嗎,怎麼 html 還帶 js 指令碼,注意,服務端渲染出的 html 只是首次展示給使用者的頁面而已,使用者後期操作頁面處理資料還是需要 js 指令碼去跑的,也就是 webpack 為什麼要打包出一套服務端程式碼(用於渲染首次html用),一套客戶端程式碼(用於後期互動和資料處理用)
四、小結
本篇簡單瞭解了 vue ssr 的簡單流程,上面例子的demo放在github 歡迎提 issue 和 star 。服務端渲染還有比較重要的一部分是首屏資料的獲取渲染,一般頁面展示都會有一些網路資料初始化,服務端渲染可以將這些資料獲取到插入到 html ,由於這部份內容涉及到的知識點也不少,放在下次講。
2018-5-28 更新
評論區提到的bug已經修復,如果有其他問題可以到github對應的工程下提 issues,github 的 issues 有 markdown 格式,方便問題描述和討論,問題描述可以儘量清楚,方面作者排查問題原因
2018-7-14 更新
解決 Unexpected token < 報錯問題
報錯原因是 html 的 JavaScript 資源路徑錯誤導致
服務端渲染出的 html 帶有 script 標籤,對應到服務端請求的 .js 靜態資源路徑出錯了
<script src="/dist/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/dit/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/dist/app-ae1871b21fa142b507e8.js" defer></script>
複製程式碼
評論區有提到修改如下, 感謝指出錯誤 @wfz
//webpack.base.config.js 配置檔案
output: {
- publicPath: '/dit/',
+ publicPath: '/',
},
複製程式碼
//server.js
- app.use(serve('/dist', './dist', true))
- app.use(serve('/public', './public', true))
+ app.use(serve(__dirname + '/dist'))
複製程式碼
對應 html 輸出
<script src="/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/app-ae1871b21fa142b507e8.js" defer></script>
複製程式碼
執行專案
npm run install
npm run build:client // 生成 clientBundle
npm run build:server // 生成 serverBundle
npm run dev // 啟動 node 渲染服務
複製程式碼
open http://localhost:8089/