在實現 egg + vue 服務端渲染工程化實現以前,咱們先來看看前面兩篇關於Webpack構建和Egg的文章:javascript
從 Vue 的官方支持咱們知道,Vue 是支持服務端渲染的,並且還提供了官方渲染插件 vue-server-renderer 提供了基於 JSBundle 或 JSON 文件渲染模式和流渲染模式。這裏咱們主要講基於 JSBundle 的服務端渲染實現,流渲染模式目前在 Egg 框架裏面與 Egg 部分插件有衝突(Header寫入時機問題), 後續做爲單獨的研究課題。另外基於 Vue JSON 文件字符串構建渲染請移步 VueSSRPlugin 這種方案目前基於 Vue 官方的Plugin在構建上面只能構建單頁面(生成一個json manfiest,多個會有衝突),完善的解決方案須要繼續研究。css
首先,咱們來看看 vue-server-renderer 提供的 createBundleRenderer 和 renderToString 怎麼把 JSBundle 編譯成 HTML。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 渲染的速度,同時也要知足實際的項目須要。vue
緩存java
基於以上兩點, 咱們實現了 egg-view-vue 插件, 提供了 Vue 渲染引擎。在 Egg 項目裏面,咱們能夠經過 this.app.vue 拿到 Vue 渲染引擎的實例,而後就能夠根據提供的方法進行 Vue 編譯成 HTML。node
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]; }, };
'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;
資源依賴android
基於以上兩點, 咱們實現了 egg-view-vue-ssr 插件, 解決資源依賴和數據問題。該插件是基於 egg-view-vue 擴展而來, 會覆蓋 render 方法。 目前的實現方式會產生一個問題,具體請看 多引擎問題 。webpack
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); }
在開頭咱們提到了 easywebpack-vue 構建方案,咱們能夠經過該解決方案完成 Webpack + Vue 的構建方案。具體實現請看 Webpack工程化解決方案easywebpack 和 easywebpack-vue插件。 這裏咱們直接提供 webpack.config.js 配置,根據該配置便可完成 Vue 前端渲染構建和 Node 層構建。git
'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-vue-ssr
// choose Simple egg app
npm i easywebpack-vue --save-dev
npm i egg-webpack --save-dev
npm i egg-view-vue --save
npm i egg-view-vue-ssr --save
exports.webpack = { enable: true, package: 'egg-webpack' }; exports.webpackvue = { enable: true, package: 'egg-webpack-vue' };
2. 在 ${app_root}/config/config.local.js 添加以下配置
const EasyWebpack = require('easywebpack-vue'); // 用於本地開發時,讀取 Webpack 配置,而後構建 exports.webpack = { webpackConfigList: EasyWebpack.getWebpackConfig() };
'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
// 首先安裝 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
<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>
exports.index = function* (ctx) { yield ctx.render('home/home.js', { message: 'vue server side render!' }); };
app.get('/home', app.controller.home.home.index);
前端渲染
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
本地運行模式
本地開發服務端渲染頁面訪問
頁面能夠直接使用 /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>
頁面必須使用 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"> <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應用地址是兩回事
發佈模式構建流程和運行模式