一個極簡版本的 VUE SSR demo
我本人在剛開始看 VUE SSR 官方文件的時候遇到很多問題,它一開始是建立在你有一個可執行的構建環境的,所以它直接講程式碼的實現,但是對於剛接觸的開發者來說並沒有一個執行環境,所以所有的程式碼片段都無法執行。那為什麼作者不先講構建,再講程式實現呢?我覺得可能是因為構建、執行又重度依賴具體的程式碼實現,先講構建也不利於理解整體過程,所以是一個不太好平衡的事。
我們這個 demo 將先講構建過程,其中有些問題可能需要在後面講完以後回頭再看,但力求能將整體過程交待清楚。同時,文章中的每一步都會在這個 ofollow,noindex">DEMO 有體現,通過這個 demo 的不同 commit ,可以快速定位到不同階段,具體的 commit id 如下:
* e06aee792a59ffd9018aea1e3601e220c37fedbd (HEAD -> master, origin/master) 優化:新增快取 * c65f08beaff1dea1eaf05d02fb30a7e8776ce289 程式開發:初步完成demo * 2fb0d28ee6d84d2b1bdbbe419c744efdad3227de 程式開發:完成store定義,api編寫和程式同步 * 9604aec0de526726f4fe435385f7c2fa4009fa63 程式開發:第一個可獨立執行版本,無store * 7d567e254fc9dc5a1655d2f0abbb4b8d53bccfce 構建配置:webpack配置、server.js後端入口檔案編寫 * 969248b64af82edd07214a621dfd19cf357d6c53 構建配置:babel 配置 * a5453fdeb20769e8c9e9ee339b624732ad14658a 初始化專案,完成第一個可執行demo 複製程式碼
在閱讀、測試的時候,可以通過 git reset --hard commitid
來切換不同的階段,看具體的實現。
什麼是伺服器端渲染(SSR)?
Vue.js 是構建客戶端應用程式的框架。預設情況下,可以在瀏覽器中輸出 Vue 元件,進行生成 DOM 和操作 DOM。然而,也可以將同一個元件渲染為伺服器端的 HTML 字串,將它們直接傳送到瀏覽器,最後將這些靜態標記"啟用"為客戶端上完全可互動的應用程式。
伺服器渲染的 Vue.js 應用程式也可以被認為是"同構"或"通用",因為應用程式的大部分程式碼都可以在 伺服器 和 客戶端 上執行。
為什麼使用伺服器端渲染(SSR)?
與傳統 SPA(Single-Page Application - 單頁應用程式)相比,伺服器端渲染(SSR)的優勢主要在於:
- 更好的 SEO,由於搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面。
- 更快的內容到達時間(time-to-content),特別是對於緩慢的網路情況或執行緩慢的裝置。
基本用法
安裝需要用到的模板
npm install vue vue-server-renderer express --save
新建 /server.js
、 /src/index.template.html
const server = require('express')() const Vue = require('vue') const fs = require('fs') const Renderer = require('vue-server-renderer').createRenderer({ template:fs.readFileSync('./src/index.template.html', 'utf-8') }) server.get('*', (req, res) => { const app = new Vue({ data: { name: 'vue app~', url: req.url }, template:'<div>hello from {{name}}, and url is: {{url}}</div>' }) const context = { title: 'SSR test#' } Renderer.renderToString(app, context, (err, html) => { if(err) { console.log(err) res.status(500).end('server error') } res.end(html) }) }) server.listen(4001) console.log('running at: http://localhost:4001'); 複製程式碼
通過以上程式,可以看到通過 vue-server-renderer 將VUE例項進行編譯,最終通過 express 輸出到瀏覽器。
但同時也能看到,輸出的是一個靜態的純html頁面,由於沒有載入任何 javascript 檔案,前端的使用者互動也無所實現,所以上面的 demo 只是一個極簡的例項,要想實現一個完整的 VUE ssr 程式,還需要藉助 VueSSRClientPlugin (vue-server-renderer/client-plugin) 將檔案編譯成前端瀏覽器可執行的 vue-ssr-client-manifest.json 檔案和 js、css 等檔案, VueSSRServerPlugin (vue-server-renderer/server-plugin) 將檔案編譯成可供node呼叫的 vue-ssr-server-bundle.json
真正開始之前,需要了解一些概念
編寫通用程式碼
"通用"程式碼時的約束條件 - 即執行在伺服器和客戶端的程式碼,由於用例和平臺 API 的差異,當執行在不同環境中時,我們的程式碼將不會完全相同。
伺服器上的資料響應
每個請求應該都是全新的、獨立的應用程式例項,以便不會有交叉請求造成的狀態汙染(cross-request state pollution)
元件生命週期鉤子函式
由於沒有動態更新,所有的生命週期鉤子函式中,只有 beforeCreate 和 created 會在伺服器端渲染(SSR)過程中被呼叫
訪問特定平臺(Platform-Specific) API
通用程式碼不可接受特定平臺的 API,因此如果你的程式碼中,直接使用了像 window 或 document,這種僅瀏覽器可用的全域性變數,則會在 Node.js 中執行時丟擲錯誤,反之也是如此。
構建配置
如何將相同的 Vue 應用程式提供給服務端和客戶端。為了做到這一點,我們需要使用 webpack 來打包 Vue 應用程式。
-
通常 Vue 應用程式是由 webpack 和 vue-loader 構建,並且許多 webpack 特定功能不能直接在 Node.js 中執行(例如通過 file-loader 匯入檔案,通過 css-loader 匯入 CSS)。
-
儘管 Node.js 最新版本能夠完全支援 ES2015 特性,我們還是需要轉譯客戶端程式碼以適應老版瀏覽器。這也會涉及到構建步驟。
所以基本看法是,對於客戶端應用程式和伺服器應用程式,我們都要使用 webpack 打包 - 伺服器需要「伺服器 bundle」然後用於伺服器端渲染(SSR),而「客戶端 bundle」會發送給瀏覽器,用於混合靜態標記。

下面看具體實現過程
Babel配置
新建 /.babelrc 配置
// es6 compile to es5 相關配置 { "presets": [ [ "env", { "modules": false } ] ], "plugins": ["syntax-dynamic-import"] } npm i -D babel-loader@7 babel-core babel-plugin-syntax-dynamic-import babel-preset-env 複製程式碼
webpack 配置
新建一個 build 資料夾,用於存放 webpack
相關的配置檔案
/ ├── build │├── setup-dev-server.js# 設定 webpack-dev-middleware 開發環境 │├── webpack.base.config.js # 基礎通用配置 │├── webpack.client.config.js# 編譯出 vue-ssr-client-manifest.json 檔案和 js、css 等檔案,供瀏覽器呼叫 │└── webpack.server.config.js# 編譯出 vue-ssr-server-bundle.json 供 nodejs 呼叫 複製程式碼
先把相關的包安裝
安裝 webpack 相關的包
npm i -D webpack webpack-cli webpack-dev-middleware webpack-hot-middleware webpack-merge webpack-node-externals
安裝構建依賴的包
npm i -D chokidar cross-env friendly-errors-webpack-plugin memory-fs rimraf vue-loader
接下來看每個檔案的具體內容:
webpack.base.config.js
const path = require('path') const { VueLoaderPlugin } = require('vue-loader') const isProd = process.env.NODE_ENV === 'production' module.exports = { context: path.resolve(__dirname, '../'), devtool: isProd ? 'source-map' : '#cheap-module-source-map', output: { path: path.resolve(__dirname, '../dist'), publicPath: '/dist/', filename: '[name].[chunkhash].js' }, resolve: { // ... }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', options: { compilerOptions: { preserveWhitespace: false } } } // ... ] }, plugins: [new VueLoaderPlugin()] } 複製程式碼
webpack.base.config.js
這個是通用配置,和我們之前SPA開發配置基本一樣。
webpack.client.config.js
const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const config = merge(base, { mode: 'development', entry: { app: './src/entry-client.js' }, resolve: {}, plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify( process.env.NODE_ENV || 'development' ), 'process.env.VUE_ENV': '"client"' }), new VueSSRClientPlugin() ] }) module.exports = config 複製程式碼
webpack.client.config.js
主要完成了兩個工作
- 定義入口檔案
entry-client.js
- 通過外掛
VueSSRClientPlugin
生成vue-ssr-client-manifest.json
這個 manifest.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, { template, clientManifest }) 複製程式碼
通過以上設定,使用程式碼分割特性構建後的伺服器渲染的 HTML 程式碼,所有都是自動注入。
webpack.server.config.js
const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled. const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') module.exports = merge(base, { mode: 'production', target: 'node', devtool: '#source-map', entry: './src/entry-server.js', output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }, resolve: {}, externals: nodeExternals({ whitelist: /\.css$/ // 防止將某些 import 的包(package)打包到 bundle 中,而是在執行時(runtime)再去從外部獲取這些擴充套件依賴 }), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"' }), new VueSSRServerPlugin() ] }) 複製程式碼
webpack.server.config.js
主要完成的工作是:
- 通過
target: 'node'
告訴 webpack 編譯的目錄程式碼是 node 應用程式 - 通過
VueSSRServerPlugin
外掛,將程式碼編譯成vue-ssr-server-bundle.json
在生成 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 的其他選項 }) 複製程式碼
至此,基本已經完成構建
完成第一個可執行例項
安裝 VUE 相關的依賴包
npm i axios vue-template-compiler vue-router vuex vuex-router-sync
新增並完善如下檔案:
/ ├── server.js # 實現長期執行的 node 程式 ├── src │├── app.js # 新增 │├── router.js # 新增 定義路由 │├── App.vue # 新增 │├── entry-client.js # 瀏覽器端入口 │├── entry-server.js # node程式端入口 └── views └── Home.vue # 首頁 複製程式碼
接下來逐個看這些檔案:
server.js
const fs = require('fs'); const path = require('path'); const express = require('express'); const { createBundleRenderer } = require('vue-server-renderer'); const devServer = require('./build/setup-dev-server') const resolve = file => path.resolve(__dirname, file); const isProd = process.env.NODE_ENV === 'production'; const app = express(); const serve = (path, cache) => express.static(resolve(path), { maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0 }); app.use('/dist', serve('./dist', true)); function createRenderer(bundle, options) { return createBundleRenderer( bundle, Object.assign(options, { basedir: resolve('./dist'), runInNewContext: false }) ); } function render(req, res) { const startTime = Date.now(); res.setHeader('Content-Type', 'text/html'); const context = { title: 'SSR 測試', // default title url: req.url }; renderer.renderToString(context, (err, html) => { res.send(html); }); } let renderer; let readyPromise; const templatePath = resolve('./src/index.template.html'); if (isProd) { const template = fs.readFileSync(templatePath, 'utf-8'); const bundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json') // 將js檔案注入到頁面中 renderer = createRenderer(bundle, { template, clientManifest }); } else { readyPromise = devServer( app, templatePath, (bundle, options) => { renderer = createRenderer(bundle, options); } ); } app.get('*',isProd? render : (req, res) => { readyPromise.then(() => render(req, res)); } ); const port = process.env.PORT || 8088; app.listen(port, () => { console.log(`server started at localhost:${port}`); }); 複製程式碼
server.js
主要完成了以下工作
- 當執行
npm run dev
的時候,呼叫/build/setup-dev-server.js
啟動 'webpack-dev-middleware' 開發中間件 - 通過
vue-server-renderer
呼叫之前編譯生成的vue-ssr-server-bundle.json
啟動 node 服務 - 將
vue-ssr-client-manifest.json
注入到createRenderer
中實現前端資源的t自動注入 - 通過
express
處理http
請求
server.js
是整個站點的入口程式,通過他呼叫編譯過後的檔案,最終輸出到頁面,是整個專案中很關鍵的一部分
app.js
import Vue from 'vue' import App from './App.vue'; import { createRouter } from './router'; export function createApp(context) { const router = createRouter(); const app = new Vue({ router, render: h => h(App) }); return { app, router }; }; 複製程式碼
app.js
暴露一個可以重複執行的工廠函式,為每個請求建立新的應用程式例項,提交給 'entry-client.js' 和 entry-server.js
呼叫
entry-client.js
import { createApp } from './app'; const { app, router } = createApp(); router.onReady(() => { app.$mount('#app'); }); 複製程式碼
entry-client.js
常規的例項化 vue 物件並掛載到頁面中
entry-server.js
import { createApp } from './app'; export default context => { // 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise, // 以便伺服器能夠等待所有的內容在渲染前, // 就已經準備就緒。 return new Promise((resolve, reject) => { const { app, router } = createApp(context); // 設定伺服器端 router 的位置 router.push(context.url); // 等到 router 將可能的非同步元件和鉤子函式解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,執行 reject 函式,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }); } resolve(app); }); }); }; 複製程式碼
entry-server.js
作為伺服器入口,最終經過 VueSSRServerPlugin
外掛,編譯成 vue-ssr-server-bundle.json
供 vue-server-renderer
呼叫
router.js
和 Home.vue
為常規 vue
程式,這裡不進一步展開了。
至此,我們完成了第一個可以完整編譯和執行的 vue ssr
例項
資料預取和狀態管理
在此之前完成的程式,只是將預想定義的變數渲染成html返回給客戶端,但如果要實現一個真正可用的web程式,是要有動態資料的支援的,現在我們開始看如何從遠端獲取資料,然後渲染成html輸出到客戶端。
在伺服器端渲染(SSR)期間,我們本質上是在渲染我們應用程式的"快照",所以如果應用程式依賴於一些非同步資料,那麼在開始渲染過程之前,需要先預取和解析好這些資料。
資料預取儲存容器(Data Store)
先定義一個獲取資料的 api.js
,使用 axios
:
import axios from 'axios'; export function fetchItem(id) { return axios.get('https://api.mimei.net.cn/api/v1/article/' + id); } export function fetchList() { return axios.get('https://api.mimei.net.cn/api/v1/article/'); } 複製程式碼
我們將使用官方狀態管理庫 Vuex。我們先建立一個 store.js 檔案,裡面會獲取一個檔案列表、根據 id 獲取文章內容:
import Vue from 'vue'; import Vuex from 'vuex'; import { fetchItem, fetchList } from './api.js' Vue.use(Vuex); export function createStore() { return new Vuex.Store({ state: { items: {}, list: [] }, actions: { fetchItem({commit}, id) { return fetchItem(id).then(res => { commit('setItem', {id, item: res.data}) }) }, fetchList({commit}){ return fetchList().then(res => { commit('setList', res.data.list) }) } }, mutations: { setItem(state, {id, item}) { Vue.set(state.items, id, item) }, setList(state, list) { state.list = list } } }); } 複製程式碼
然後修改 app.js
:
import Vue from 'vue' import App from './App.vue'; import { createRouter } from './router'; import { createStore } from './store' import { sync } from 'vuex-router-sync' export function createApp(context) { const router = createRouter(); const store = createStore(); sync(store, router) const app = new Vue({ router, store, render: h => h(App) }); return { app, router, store }; }; 複製程式碼
帶有邏輯配置的元件
store action
定義好了以後,現在來看如何觸發請求,官方建議是放在路由元件裡,接下來看 Home.vue
:
<template> <div> <h3>文章列表</h3> <div class="list" v-for="i in list"> <router-link :to="{path:'/item/'+i.id}">{{i.title}}</router-link> </div> </div> </template> <script> export default { asyncData ({store, route}){ return store.dispatch('fetchList') }, computed: { list () { return this.$store.state.list } }, data(){ return { name:'wfz' } } } </script> 複製程式碼
伺服器端資料預取
在 entry-server.js
中,我們可以通過路由獲得與 router.getMatchedComponents()
相匹配的元件,如果元件暴露出 asyncData
,我們就呼叫這個方法。然後我們需要將解析完成的狀態,附加到渲染上下文(render context)中。
// entry-server.js import { createApp } from './app'; export default context => { // 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise, // 以便伺服器能夠等待所有的內容在渲染前, // 就已經準備就緒。 return new Promise((resolve, reject) => { const { app, router, store } = createApp(context); // 設定伺服器端 router 的位置 router.push(context.url); // 等到 router 將可能的非同步元件和鉤子函式解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,執行 reject 函式,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }); } Promise.all( matchedComponents.map(component => { if (component.asyncData) { return component.asyncData({ store, route: router.currentRoute }); } }) ).then(() => { context.state = store.state // Promise 應該 resolve 應用程式例項,以便它可以渲染 resolve(app); }); }); }); }; 複製程式碼
當使用 template
時, context.state
將作為 window.__INITIAL_STATE__
狀態,自動嵌入到最終的 HTML 中。而在客戶端,在掛載到應用程式之前,store 就應該獲取到狀態:
// entry-client.js const { app, router, store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } 複製程式碼
客戶端資料預取
在客戶端,處理資料預取有兩種不同方式: 在路由導航之前解析資料
和 匹配要渲染的檢視後,再獲取資料
,我們的 demo 裡用第一種方案:
// entry-client.js import { createApp } from './app'; const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } router.onReady(() => { router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to); const prevMatched = router.getMatchedComponents(from); let diffed = false; const activated = matched.filter((c, i) => { return diffed || (diffed = prevMatched[i] !== c); }); if (!activated.length) { return next(); } Promise.all( activated.map(component => { if (component.asyncData) { component.asyncData({ store, route: to }); } }) ) .then(() => { next(); }) .catch(next); }); app.$mount('#app'); }); 複製程式碼
通過檢查匹配的元件,並在全域性路由鉤子函式中執行 asyncData
函式獲取介面資料。
由於這個 demo
是兩個頁面,還需要的 router.js
新增一個路由資訊、新增一個路由元件 Item.vue
,至此已經完成了一個基本的 VUE SSR
例項。
快取優化
由於服務端渲染屬於計算密集型,如果併發較大的話,很有可能有效能問題。適當的使用快取策略可以大幅提高響應速度。
const microCache = LRU({ max: 100, maxAge: 1000 // 重要提示:條目在 1 秒後過期。 }) const isCacheable = req => { // 實現邏輯為,檢查請求是否是使用者特定(user-specific)。 // 只有非使用者特定(non-user-specific)頁面才會快取 } server.get('*', (req, res) => { const cacheable = isCacheable(req) if (cacheable) { const hit = microCache.get(req.url) if (hit) { return res.end(hit) } } renderer.renderToString((err, html) => { res.end(html) if (cacheable) { microCache.set(req.url, html) } }) }) 複製程式碼
基本上,通過 nginx
和快取,可能很大程度上解決效能瓶頸問題。