Egg + Vue 服務端渲染工程化實現
在實現 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);n// filepath 為 Webpack 構建的服務端代碼nconst bundleRenderer = renderer.createBundleRenderer(filepath, renderOptions);n// data 為 Node端獲取到的數據nconst context = { state: data };nreturn new Promise((resolve, reject) => {n bundleRenderer.renderToString(context, (err, html) => {n if (err) {n reject(err);n } else {n resolve(html);n }n});n
這裡面僅僅簡單考慮了編譯,對於緩存,資源依賴都沒有考慮。其實在做 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);nconst VUE_ENGINE = Symbol(Application#vue);nnmodule.exports = {nn get vue() {n if (!this[VUE_ENGINE]) {n this[VUE_ENGINE] = new Engine(this);n }n return this[VUE_ENGINE];n },n};n
- Vue View Engine 設計實現
use strict;nnconst Vue = require(vue);nconst LRU = require(lru-cache);nconst vueServerRenderer = require(vue-server-renderer);nnclass Engine {n constructor(app) {n this.app = app;n this.config = app.config.vue;n this.vueServerRenderer = vueServerRenderer;n this.renderer = this.vueServerRenderer.createRenderer();n this.renderOptions = this.config.renderOptions;nn if (this.config.cache === true) {n this.bundleCache = LRU({n max: 1000,n maxAge: 1000 * 3600 * 24 * 7,n });n } else if (typeof this.config.cache === object) {n if (this.config.cache.set && this.config.cache.get) {n this.bundleCache = this.config.cache;n } else {n this.bundleCache = LRU(this.config.cache);n }n }n }nn createBundleRenderer(name, renderOptions) {n if (this.bundleCache) {n const bundleRenderer = this.bundleCache.get(name);n if (bundleRenderer) {n return bundleRenderer;n }n }n const bundleRenderer = this.vueServerRenderer.createBundleRenderer(name, Object.assign({}, this.renderOptions, renderOptions));n if (this.bundleCache) {n this.bundleCache.set(name, bundleRenderer);n }n return bundleRenderer;n }nn renderBundle(name, context, options) {n context = context || /* istanbul ignore next */ {};n options = options || /* istanbul ignore next */ {};nn return new Promise((resolve, reject) => {n this.createBundleRenderer(name, options.renderOptions).renderToString(context, (err, html) => {n if (err) {n reject(err);n } else {n resolve(html);n }n });n });n }nn renderString(tpl, locals, options) {n const vConfig = Object.assign({ template: tpl, data: locals }, options);n const vm = new Vue(vConfig);n return new Promise((resolve, reject) => {n this.renderer.renderToString(vm, (err, html) => {n if (err) {n reject(err);n } else {n resolve(html);n }n });n });n }n}nnmodule.exports = Engine;n
資源依賴
- 關於頁面資源依賴我們可以結合 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) {n const fileKey = name;n const fileManifest = this.resourceDeps[fileKey];n if (fileManifest) {n const headInject = [];n const bodyInject = [];n const publicPath = this.buildConfig.publicPath;n if (config.injectCss && (options.injectCss === undefined || options.injectCss)) {n fileManifest.css.forEach(item => {n headInject.push(this.createCssLinkTag(publicPath + item));n });n } else {n headInject.push(context.styles);n }n if (config.injectJs) {n fileManifest.script.forEach(item => {n bodyInject.push(this.createScriptSrcTag(publicPath + item));n });n if (!/window.__INITIAL_STATE__/.test(html)) {n bodyInject.unshift(`<script> window.__INITIAL_STATE__= ${serialize(context.state, { isJSON: true })};</script>`);n }n }n this.injectHead(headInject);n html = html.replace(this.headRegExp, match => {n return headInject.join() + match;n });nn this.injectBody(bodyInject);n html = html.replace(this.bodyRegExp, match => {n return bodyInject.join() + match;n });n }n return config.afterRender(html, context);n }n
Vue 服務端(Node) 構建
在開頭我們提到了 easywebpack-vue 構建方案,我們可以通過該解決方案完成 Webpack + Vue 的構建方案。具體實現請看 Webpack工程化解決方案easywebpack 和 easywebpack-vue 插件。 這裡我們直接提供 webpack.config.js 配置,根據該配置即可完成 Vue 前端渲染構建和 Node 層構建。
use strict;nconst path = require(path);nmodule.exports = {n egg: true,n framework: vue,n entry: {n include: [app/web/page, { app/app: app/web/page/app/app.js?loader=false }],n exclude: [app/web/page/[a-z]+/component, app/web/page/test, app/web/page/html, app/web/page/app],n loader: {n client: app/web/framework/vue/entry/client-loader.js,n server: app/web/framework/vue/entry/server-loader.js,n }n },n alias: {n server: app/web/framework/vue/entry/server.js,n client: app/web/framework/vue/entry/client.js,n app: app/web/framework/vue/app.js,n asset: app/web/asset,n component: app/web/component,n framework: app/web/framework,n store: app/web/storen }n};n
本地開發與線上解耦
我們知道,在本地開發時,大家都會用 Webpack 熱更新功能. 而 Webpack 熱更新實現是基於內存編譯實現的。
在線上運行時,我們可以直接讀取構建好的JSBundle文件,那麼在本地開發時,在 Egg 服務端渲染時,如何獲取到 JSBundle文件 內容時, 同時又不耦合線上代碼。
這裡我們結合 Egg + Webpack 熱更新實現 裡面提到插件 egg-webpack ,該插件在 egg app上下文提供了 app.webpack.fileSystem 實例,我們可以根據文件名獲取到 Webpack編譯的內存文件內容。有了這一步,為我們本地開發從 Webpack 內存裡面實時讀取文件內容提供了支持。至於不耦合線上代碼線上代碼的問題我們可以單獨編寫一下插件,覆蓋 egg-view-vue 暴露的 engine renderBundle 方法。具體實現請看如下實現。
if (app.vue) {n const renderBundle = app.vue.renderBundle;n app.vue.renderBundle = (name, context, options) => {n const filePath = path.isAbsolute(name) ? name : path.join(app.config.view.root[0], name);n const promise = app.webpack.fileSystem.readWebpackMemoryFile(filePath, name);n return co(function* () {n const content = yield promise;n if (!content) {n throw new Error(`read webpack memory file[${filePath}] content is empty, please check if the file exists`);n }n return renderBundle.bind(app.vue)(content, context, options);n });n };n }n
基於以上實現,我們封裝了 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-ssrn// choose Simple egg appn
- 安裝 easywebpack-vue 和 egg-webpack
npm i easywebpack-vue --save-devnnpm i egg-webpack --save-devn
- 安裝 egg-view-vue 和 egg-view-vue-ssr
npm i egg-view-vue --savennpm i egg-view-vue-ssr --saven
- 添加配置
- 在 ${app_root}/config/plugin.local.js 添加如下配置
exports.webpack = {n enable: true,n package: egg-webpackn};nnexports.webpackvue = {n enable: true,n package: egg-webpack-vuen};n
2. 在 ${app_root}/config/config.local.js 添加如下配置
const EasyWebpack = require(easywebpack-vue);n// 用於本地開發時,讀取 Webpack 配置,然後構建nexports.webpack = {n webpackConfigList: EasyWebpack.getWebpackConfig()n};n
- 配置 ${app_root}/webpack.config.js
use strict;nconst path = require(path);nmodule.exports = {n egg: true,n framework: vue,n entry: {n include: [app/web/page, { app/app: app/web/page/app/app.js?loader=false }],n exclude: [app/web/page/[a-z]+/component, app/web/page/test, app/web/page/html, app/web/page/app],n loader: {n client: app/web/framework/vue/entry/client-loader.js,n server: app/web/framework/vue/entry/server-loader.js,n }n },n alias: {n server: app/web/framework/vue/entry/server.js,n client: app/web/framework/vue/entry/client.js,n app: app/web/framework/vue/app.js,n asset: app/web/asset,n component: app/web/component,n framework: app/web/framework,n store: app/web/storen },n loaders: {n eslint: false,n less: false, // 沒有使用, 禁用可以減少npm install安裝時間n stylus: false // 沒有使用, 禁用可以減少npm install安裝時間n },n plugins: {n provide: false,n define: {n args() { // 支持函數, 這裡僅做演示測試,isNode無實際作用n return {n isNode: this.ssrn };n }n },n commonsChunk: {n args: {n minChunks: 5n }n },n uglifyJs: {n args: {n compress: {n warnings: falsen }n }n }n }n};n
- 本地運行
node index.js 或 npm startn
- Webpack 編譯文件到磁碟
// 首先安裝 easywebpack-cli 命令行工具nnpm i easywebpack-cli -gn// Webpack 編譯文件到磁碟neasywebpck build dev/test/prodn
項目開發
服務端渲染
在app/web/page 目錄下面創建 home 目錄, home.vue 文件, Webpack自動根據 .vue 文件創建entry入口, 具體實現請見 webpack.config.js
- home.vue 編寫界面邏輯, 根元素為layout(自定義組件, 全局註冊, 統一的html, meta, header, body)
<template>n <layout title="基於egg-vue-webpack-dev和egg-view-vue插件的工程示例項目" description="vue server side render" keywords="egg, vue, webpack, server side render">n {{message}}n </layout>n</template>n<style>n @import "home.css";n</style>n<script type="text/babel">nn export default {n components: {nn },n computed: {nn },n methods: {nn },n mounted() {nn }n }n</script>n
- 創建controller文件home.js
exports.index = function* (ctx) {n yield ctx.render(home/home.js, { message: vue server side render! });n};n
- 添加路由配置
app.get(/home, app.controller.home.home.index);n
前端渲染
- 創建controller文件home.js
exports.client = function* (ctx) {n yield ctx.renderClient(home/home.js, { message: vue server side render! });n};n
- 添加路由配置
app.get(/client, app.controller.home.home.client);n
更多實踐請參考骨架項目: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"> n<script type="text/javascript" src="/public/client/js/vendor.js"></script>n<script type="text/javascript" src="/public/client/js/home.js"></script>n
- 如果非代理模式(見easywebpack的setProxy), HTML直接注入必須是絕對路徑的JS/CSS, 如下:
頁面必須使用 http://127.0.0.1:9001/public/client/js/vendor.js 絕對路徑
<link rel="stylesheet" href="http://127.0.0.1:9001/public/client/css/home/android/home.css"> n<script type="text/javascript" src="http://127.0.0.1:9001/public/client/js/vendor.js"></script>n<script type="text/javascript" src="http://127.0.0.1:9001/public/client/js/home.js"></script>n
其中 http://127.0.0.1:9001 是 Agent裡面啟動的Webpack編譯服務地址, 與Egg應用地址是兩回事
- 最後, 模板渲染完成, 伺服器輸出HTML內容給瀏覽器
發布模式構建流程和運行模式
- Webpack通過本地構建或者ci直接構建好服務端和客戶端渲染文件到磁碟
- Egg render直接讀取本地文件, 然後渲染成HTML
- 根據manfifest.json 文件注入 jss/css資源依賴注入
- 模板渲染完成, 伺服器輸出HTML內容給瀏覽器.
相關插件和工程
- easywebpack Webpack 基礎配置骨架.
- 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.
- easywebpack-cli Webpack Building Command Line And Boilerplate Init Tool for easywebpack.
- egg-vue-webpack-boilerplate 基於egg-view-vue, egg-view-vue-ssr, egg-webpack, egg-webpack-vue插件的多頁面和單頁面伺服器渲染同構工程骨架項目
推薦閱讀:
※深入Webpack-編寫Loader
※用 webpack 構建 node 後端代碼,使其支持 js 新特性並實現熱重載
※編寫自己的Webpack Loader
※webpack之loader和plugin簡介