Egg + Vue 服務端渲染工程化實現

在實現 egg + vue 服務端渲染工程化實現以前,咱們先來看看前面兩篇關於Webpack構建和Egg的文章:javascript

  • 在 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,多個會有衝突),完善的解決方案須要繼續研究。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

  • 目前 createBundleRenderer 方法提供了 options 擴展參數,提供了 cache 的接口,支持組件級別緩存,咱們這裏再近一步支持頁面緩存,也就是根據文件把 createBundleRenderer 緩存起來。
  • runInNewContext:默認狀況下,對於每次渲染,bundle renderer 將建立一個新的 V8 上下文並從新執行整個 bundle。這具備一些好處 - 例如,應用程序代碼與服務器進程隔離,咱們無需擔憂文檔中提到的狀態單例問題。然而,這種模式有一些至關大的性能開銷,由於從新建立上下文並執行整個 bundle 仍是至關昂貴的,特別是當應用很大的時候。出於向後兼容的考慮,此選項默認爲 true,但建議你儘量使用 runInNewContext: false 或 runInNewContext: 'once'(這段信息來自 Vue 官網:)。從實際項目統計分析也印證了這裏所說的性能開銷問題: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。node

  • 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;
 

 

資源依賴android

  • 關於頁面資源依賴咱們能夠結合 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 方法。 目前的實現方式會產生一個問題,具體請看 多引擎問題 。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);
  }
 

Vue 服務端(Node) 構建

在開頭咱們提到了 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 項目
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
  • 添加配置
  1. 在 ${app_root}/config/plugin.local.js 添加以下配置
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() }; 
  • 配置 ${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, 以下:

頁面必須使用  絕對路徑

<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> 

其中  是 Agent裏面啓動的Webpack編譯服務地址, 與Egg應用地址是兩回事

  • 最後, 模板渲染完成, 服務器輸出HTML內容給瀏覽器

 

發佈模式構建流程和運行模式

  • Webpack經過本地構建或者ci直接構建好服務端和客戶端渲染文件到磁盤
  • Egg render直接讀取本地文件, 而後渲染成HTML
  • 根據manfifest.json 文件注入 jss/css資源依賴注入
  • 模板渲染完成, 服務器輸出HTML內容給瀏覽器.

 

相關插件和工程

相關文章
相關標籤/搜索