在實現 egg + vue 服務端渲染工程化實現之前,我們先來看看前面兩篇關于Webpack構建和Egg的文章:
在 Webpack工程化解決方案easywebpack 文章中我們提到了基于 Vue 構建的解決方案 easywebpack-vue. easywebpack-vue 支持純前端模式和Node層構建,這為 Vue 服務端渲染提供了支持,我們只需要簡單的配置關鍵的 entry 和 alias 就可以完成 Vue 前端渲染構建和 Node 層構建, 極大的簡化了 Vue 服務端渲染構建的工作,可以讓我們把中心放到 Vue 服務端渲染的實現上面。
在 Egg + Webpack 熱更新實現 文章中我們通過 Egg 框架的 Message 通信機制實現了 Webpack 內存編譯熱更新實現插件 egg-webpack,保證 Node 層代碼修改重啟時,Webpack 編譯實例依然存在, 為本地開發Node層代碼修改和熱更新提供了支持。
Vue 服務端(Node)渲染機制
從 Vue 的官方支持我們知道,Vue 是支持服務端渲染的,而且還提供了官方渲染插件 vue-server-renderer 提供了基于 JSBundle 或 JSON 文件渲染模式和流渲染模式。這里我們主要講基于 JSBundle 的服務端渲染實現,流渲染模式目前在 Egg 框架里面與 Egg 部分插件有沖突(Header寫入時機問題), 后續作為單獨的研究課題。另外基于 Vue JSON 文件字符串構建渲染請移步 VueSSRPlugin 這種方案目前基于 Vue 官方的Plugin在構建上面只能構建單頁面(生成一個json manfiest,多個會有沖突),完善的解決方案需要繼續研究。
首先,我們來看看 vue-server-renderer 提供的 createBundleRenderer 和 renderToString 怎么把 JSBundle 編譯成 HTML。
基于 vue-server-renderer 實現 JSBundle 主要代碼如下:
const renderer = require('vue-server-renderer');
// filepath 為 Webpack 構建的服務端代碼
const bundleRenderer = renderer.createBundleRenderer(filepath, renderOptions);
// data 為 Node端獲取到的數據
const context = { state: data };
return new Promise((resolve, reject) => {
bundleRenderer.renderToString(context, (err, html) => {
if (err) {
reject(err);
} else {
resolve(html);
}
});
這里面僅僅簡單考慮了編譯,對于緩存,資源依賴都沒有考慮。其實在做 Vue 服務端渲染時,關鍵的地方就在于這里,如何保證 Vue 渲染的速度,同時也要滿足實際的項目需要。
緩存
目前 createBundleRenderer 方法提供了 options 擴展參數,提供了 cache 的接口,支持組件級別緩存,我們這里再近一步支持頁面緩存,也就是根據文件把 createBundleRenderer 緩存起來。
runInNewContext:默認情況下,對于每次渲染,bundle renderer 將創建一個新的 V8 上下文并重新執行整個 bundle。這具有一些好處 - 例如,應用程序代碼與服務器進程隔離,我們無需擔心文檔中提到的狀態單例問題。然而,這種模式有一些相當大的性能開銷,因為重新創建上下文并執行整個 bundle 還是相當昂貴的,特別是當應用很大的時候。出于向后兼容的考慮,此選項默認為 true,但建議你盡可能使用 runInNewContext: false 或 runInNewContext: 'once'(這段信息來自 Vue 官網:https://ssr.vuejs.org/zh/api.html#runinnewcontext)。從實際項目統計分析也印證了這里所說的性能開銷問題:runInNewContext=false 能顯著提高 render 速度,從線上實際統計來看,runInNewContext=false 能顯著提高 render速度 3 倍以上(一個多模塊的5屏的列表頁面,runInNewContext = true 時的render時間平均在60-80ms,runInNewContext = false 時的render時間平均在20-30ms)。
基于以上兩點, 我們實現了 egg-view-vue 插件, 提供了 Vue 渲染引擎。在 Egg 項目里面,我們可以通過 this.app.vue 拿到 Vue 渲染引擎的實例,然后就可以根據提供的方法進行 Vue 編譯成 HTML。
- egg-view-vue 暴露的 vue 實例
const Engine = require('../../lib/engine');
const VUE_ENGINE = Symbol('Application#vue');
module.exports = {
get vue() {
if (!this[VUE_ENGINE]) {
this[VUE_ENGINE] = new Engine(this);
}
return this[VUE_ENGINE];
},
};
- Vue View Engine 設計實現
'use strict';
const Vue = require('vue');
const LRU = require('lru-cache');
const vueServerRenderer = require('vue-server-renderer');
class Engine {
constructor(app) {
this.app = app;
this.config = app.config.vue;
this.vueServerRenderer = vueServerRenderer;
this.renderer = this.vueServerRenderer.createRenderer();
this.renderOptions = this.config.renderOptions;
if (this.config.cache === true) {
this.bundleCache = LRU({
max: 1000,
maxAge: 1000 * 3600 * 24 * 7,
});
} else if (typeof this.config.cache === 'object') {
if (this.config.cache.set && this.config.cache.get) {
this.bundleCache = this.config.cache;
} else {
this.bundleCache = LRU(this.config.cache);
}
}
}
createBundleRenderer(name, renderOptions) {
if (this.bundleCache) {
const bundleRenderer = this.bundleCache.get(name);
if (bundleRenderer) {
return bundleRenderer;
}
}
const bundleRenderer = this.vueServerRenderer.createBundleRenderer(name, Object.assign({}, this.renderOptions, renderOptions));
if (this.bundleCache) {
this.bundleCache.set(name, bundleRenderer);
}
return bundleRenderer;
}
renderBundle(name, context, options) {
context = context || /* istanbul ignore next */ {};
options = options || /* istanbul ignore next */ {};
return new Promise((resolve, reject) => {
this.createBundleRenderer(name, options.renderOptions).renderToString(context, (err, html) => {
if (err) {
reject(err);
} else {
resolve(html);
}
});
});
}
renderString(tpl, locals, options) {
const vConfig = Object.assign({ template: tpl, data: locals }, options);
const vm = new Vue(vConfig);
return new Promise((resolve, reject) => {
this.renderer.renderToString(vm, (err, html) => {
if (err) {
reject(err);
} else {
resolve(html);
}
});
});
}
}
module.exports = Engine;
資源依賴
關于頁面資源依賴我們可以結合 Webpack 的 webpack-manifest-plugin 插件 生成每個頁面資源依賴表。 然后在 render 時, 我們根據文件名找到對應的資源依賴,然后摻入到HTML的指定位置。
Vue 服務端渲染時,我們知道服務端渲染時,只是把Vue 編譯成HTML文本,至于頁面的事件綁定和一些瀏覽器端初始化工作還需要我們自己處理,而處理這些,我們還需要 Vue模板文件數據綁定的原始數據,所以我們這里還需要統一處理 INIT_STATE 數據問題。這里我們在 render 后,統一通過 script 標簽把數據輸出到頁面。這里我們通過 serialize-javascript 會進行統一的序列化。注意: 一些敏感數據請不要輸出到頁面,一般建議通過 API 拿到原始數據時,進行數據清洗,只把 Vue 模板文件需要的數據丟給 render 函數。
基于以上兩點, 我們實現了 egg-view-vue-ssr 插件, 解決資源依賴和數據問題。該插件是基于 egg-view-vue 擴展而來, 會覆蓋 render 方法。 目前的實現方式會產生一個問題,具體請看 多引擎問題
inject(html, context, name, config, options) {
const fileKey = name;
const fileManifest = this.resourceDeps[fileKey];
if (fileManifest) {
const headInject = [];
const bodyInject = [];
const publicPath = this.buildConfig.publicPath;
if (config.injectCss && (options.injectCss === undefined || options.injectCss)) {
fileManifest.css.forEach(item => {
headInject.push(this.createCssLinkTag(publicPath + item));
});
} else {
headInject.push(context.styles);
}
if (config.injectJs) {
fileManifest.script.forEach(item => {
bodyInject.push(this.createScriptSrcTag(publicPath + item));
});
if (!/window.__INITIAL_STATE__/.test(html)) {
bodyInject.unshift(`<script> window.__INITIAL_STATE__= ${serialize(context.state, { isJSON: true })};</script>`);
}
}
this.injectHead(headInject);
html = html.replace(this.headRegExp, match => {
return headInject.join('') + match;
});
this.injectBody(bodyInject);
html = html.replace(this.bodyRegExp, match => {
return bodyInject.join('') + match;
});
}
return config.afterRender(html, context);
}
Vue 服務端(Node) 構建
在開頭我們提到了 easywebpack-vue 構建方案,我們可以通過該解決方案完成 Webpack + Vue 的構建方案。具體實現請看 Webpack工程化解決方案easywebpack 和 easywebpack-vue 插件。 這里我們直接提供 webpack.config.js 配置,根據該配置即可完成 Vue 前端渲染構建和 Node 層構建。
'use strict';
const path = require('path');
module.exports = {
egg: true,
framework: 'vue',
entry: {
include: ['app/web/page', { 'app/app': 'app/web/page/app/app.js?loader=false' }],
exclude: ['app/web/page/[a-z]+/component', 'app/web/page/test', 'app/web/page/html', 'app/web/page/app'],
loader: {
client: 'app/web/framework/vue/entry/client-loader.js',
server: 'app/web/framework/vue/entry/server-loader.js',
}
},
alias: {
server: 'app/web/framework/vue/entry/server.js',
client: 'app/web/framework/vue/entry/client.js',
app: 'app/web/framework/vue/app.js',
asset: 'app/web/asset',
component: 'app/web/component',
framework: 'app/web/framework',
store: 'app/web/store'
}
};
本地開發與線上解耦
我們知道,在本地開發時,大家都會用 Webpack 熱更新功能. 而 Webpack 熱更新實現是基于內存編譯實現的。
在線上運行時,我們可以直接讀取構建好的JSBundle文件,那么在本地開發時,在 Egg 服務端渲染時,如何獲取到 JSBundle文件 內容時, 同時又不耦合線上代碼。
這里我們結合 Egg + Webpack 熱更新實現 里面提到插件 egg-webpack ,該插件在 egg app上下文提供了 app.webpack.fileSystem 實例,我們可以根據文件名獲取到 Webpack編譯的內存文件內容。有了這一步,為我們本地開發從 Webpack 內存里面實時讀取文件內容提供了支持。至于不耦合線上代碼線上代碼的問題我們可以單獨編寫一下插件,覆蓋 egg-view-vue 暴露的 engine renderBundle 方法。具體實現請看如下實現。
if (app.vue) {
const renderBundle = app.vue.renderBundle;
app.vue.renderBundle = (name, context, options) => {
const filePath = path.isAbsolute(name) ? name : path.join(app.config.view.root[0], name);
const promise = app.webpack.fileSystem.readWebpackMemoryFile(filePath, name);
return co(function* () {
const content = yield promise;
if (!content) {
throw new Error(`read webpack memory file[${filePath}] content is empty, please check if the file exists`);
}
return renderBundle.bind(app.vue)(content, context, options);
});
};
}
基于以上實現,我們封裝了 egg-webpack-vue 插件,用于 Egg + Webpack + Vue 本地開發模式。
項目搭建
有了上面的 3 個渲染相關的 Egg 插件和 easywepback-vue 構建插件, 該如何搭建一個基于 Egg + Webpack + Vue 的服務端渲染工程項目呢?
項目你可以通過 easywebpack-cli 直接初始化即可完成或者clone egg-vue-webpack-boilerplate。下面說明一下從零如何搭建一個Egg + Webpack + Vue 的服務端渲染工程項目。
- 通過 egg-init 初始化 egg 項目
egg-init egg-vue-ssr
// choose Simple egg app
- 安裝 easywebpack-vue 和 egg-webpack
npm i easywebpack-vue --save-dev
npm i egg-webpack --save-dev
- 安裝 egg-view-vue 和 egg-view-vue-ssr
npm i egg-view-vue --save
npm i egg-view-vue-ssr --save
- 添加配置
在 ${app_root}/config/plugin.local.js 添加如下配置
exports.webpack = {
enable: true,
package: 'egg-webpack'
};
exports.webpackvue = {
enable: true,
package: 'egg-webpack-vue'
};
- 在 ${app_root}/config/config.local.js 添加如下配置
const EasyWebpack = require('easywebpack-vue');
// 用于本地開發時,讀取 Webpack 配置,然后構建
exports.webpack = {
webpackConfigList: EasyWebpack.getWebpackConfig()
};
- 配置 ${app_root}/webpack.config.js
'use strict';
const path = require('path');
module.exports = {
egg: true,
framework: 'vue',
entry: {
include: ['app/web/page', { 'app/app': 'app/web/page/app/app.js?loader=false' }],
exclude: ['app/web/page/[a-z]+/component', 'app/web/page/test', 'app/web/page/html', 'app/web/page/app'],
loader: {
client: 'app/web/framework/vue/entry/client-loader.js',
server: 'app/web/framework/vue/entry/server-loader.js',
}
},
alias: {
server: 'app/web/framework/vue/entry/server.js',
client: 'app/web/framework/vue/entry/client.js',
app: 'app/web/framework/vue/app.js',
asset: 'app/web/asset',
component: 'app/web/component',
framework: 'app/web/framework',
store: 'app/web/store'
},
loaders: {
eslint: false,
less: false, // 沒有使用, 禁用可以減少npm install安裝時間
stylus: false // 沒有使用, 禁用可以減少npm install安裝時間
},
plugins: {
provide: false,
define: {
args() { // 支持函數, 這里僅做演示測試,isNode無實際作用
return {
isNode: this.ssr
};
}
},
commonsChunk: {
args: {
minChunks: 5
}
},
uglifyJs: {
args: {
compress: {
warnings: false
}
}
}
}
};
- 本地運行
node index.js 或 npm start
- Webpack 編譯文件到磁盤
// 首先安裝 easywebpack-cli 命令行工具
npm i easywebpack-cli -g
// Webpack 編譯文件到磁盤
easywebpck build dev/test/prod
項目開發
服務端渲染
在app/web/page 目錄下面創建 home 目錄, home.vue 文件, Webpack自動根據 .vue 文件創建entry入口, 具體實現請見 webpack.config.js
- home.vue 編寫界面邏輯, 根元素為layout(自定義組件, 全局注冊, 統一的html, meta, header, body)
<template>
<layout title="基于egg-vue-webpack-dev和egg-view-vue插件的工程示例項目" description="vue server side render" keywords="egg, vue, webpack, server side render">
{{message}}
</layout>
</template>
<style>
@import "home.css";
</style>
<script type="text/babel">
export default {
components: {
},
computed: {
},
methods: {
},
mounted() {
}
}
</script>
- 創建controller文件home.js
exports.index = function* (ctx) {
yield ctx.render('home/home.js', { message: 'vue server side render!' });
};
- 添加路由配置
app.get('/home', app.controller.home.home.index);
前端渲染
- 創建controller文件home.js
exports.client = function* (ctx) {
yield ctx.renderClient('home/home.js', { message: 'vue server side render!' });
};
- 添加路由配置
app.get('/client', app.controller.home.home.client);
更多實踐請參考骨架項目:egg-vue-webpack-boilerplate
運行原理
本地運行模式
- 首先執行node index.js 或者 npm start 啟動 Egg應用
- 在Egg Agent 里面啟動koa服務, 同時在koa服務里面啟動Webpack編譯服務
- 掛載Webpack內存文件讀取方法覆蓋本地文件讀取的邏輯
- Worker 監聽Webpack編譯狀態, 檢測Webpack 編譯是否完成, 如果未完成, 顯示Webpack 編譯Loading, 如果編譯完成, 自動打開瀏覽器
- Webpack編譯完成, Agent 發送消息給Worker, Worker檢測到編譯完成, 自動打開瀏覽器, Egg服務正式可用
本地開發服務端渲染頁面訪問
- 瀏覽器輸入URL請求地址, 然后Egg接收到請求, 然后進入Controller
- Node層獲取數據后(Node通過http/rpc方式調用Java后端API數據接口), 進入模板render流程
- 進入render流程后, 通過 worker 進程通過調用 app.messenger.sendToAgent 發送文件名給Agent進程, 同時通過 app.messenger.on 啟動監聽監聽agent發送過來的消
- Agent進程獲取到文件名后, 從 Webpack 編譯內存里面獲取文件內容, 然后Agent 通過 agent.messenger.sendToApp 把文件內容發送給Worker進程
- Worker進程獲取到內容以后, 進行Vue編譯HTML, 編譯成HTML后, 進入jss/css資源依賴流程
- 如果啟動代理模式(見easywebpack的setProxy), HTML直接注入相對路徑的JS/CSS, 如下
頁面可以直接使用 /public/client/js/vendor.js 相對路徑, /public/client/js/vendor.js 由后端框架代理轉發到webpack編譯服務, 然后返回內容給后端框架, 這里涉及兩個應用通信. 如下:
<link rel="stylesheet" href="/public/client/css/home/android/home.css">
<script type="text/javascript" src="/public/client/js/vendor.js"></script>
<script type="text/javascript" src="/public/client/js/home.js"></script>
如果非代理模式(見easywebpack的setProxy), HTML直接注入必須是絕對路徑的JS/CSS, 如下:
頁面必須使用 http://127.0.0.1:9001/public/client/js/vendor.js 絕對路徑
<link rel="stylesheet" >
<script type="text/javascript" src="http://127.0.0.1:9001/public/client/js/vendor.js"></script>
<script type="text/javascript" src="http://127.0.0.1:9001/public/client/js/home.js"></script>
其中 http://127.0.0.1:9001 是 Agent里面啟動的Webpack編譯服務地址, 與Egg應用地址是兩回事
最后, 模板渲染完成, 服務器輸出HTML內容給瀏覽器
- 發布模式構建流程和運行模式
- Webpack通過本地構建或者ci直接構建好服務端和客戶端渲染文件到磁盤
- Egg render直接讀取本地文件, 然后渲染成HTML
- 根據manfifest.json 文件注入 jss/css資源依賴注入
- 模板渲染完成, 服務器輸出HTML內容給瀏覽器.
egg-vue-webpack-boilerplate 基于egg-view-vue, egg-view-vue-ssr, egg-webpack, egg-webpack-vue插件的多頁面和單頁面服務器渲染同構工程骨架項目, 貼兩張截圖:
支持特性
1.特性
支持服務端渲染, 前端渲染, 靜態頁面渲染三種方式,
支持單頁面, 多頁面服務端渲染,前端渲染模式
支持 server 和 client 端代碼修改, Webpack 時時編譯和熱更新,
npm start
一鍵啟動應用基于 vue + axios 多頁面服務端渲染, 客戶端渲染同構實現
基于 vue + vuex + vue-router + axios 單頁面服務器客戶端同構實現
基于easywebpack基礎配置, 使用es6 class 繼承方式編寫webpack配置 和 cli 構建
支持Js/Css/Image資源依賴, 內置支持CDN特性
支持css/sass/less樣式編寫
支持根據.vue文件自動創建entry入口文件
支持Vue組件異步加載, 具體實例請看app/web/page/dynamic
支持vue 2.3 官方VueSSRPlugin實現方案,代碼分支feature/VueSSRPlugin
支持Node 4+ 以上版本, 包括Node 8 版本的async和await特性
相關插件和工程
- easywebpack Webpack 基礎配置骨架
- easywebpack-vue Vue構建解決方案
- egg-view-vue egg view plugin for vue.
- egg-view-vue-ssr vue server side render solution for egg-view-vue.
- egg-webpack webpack dev server plugin for egg, support read file in memory and hot reload.
- egg-webpack-vue egg webpack building solution for vue.
- egg-vue-webpack-boilerplate 基于egg-view-vue, egg-view-vue-ssr, egg-webpack, egg-webpack-vue插件的多頁面和單頁面服務器渲染同構工程骨架項目