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');
// 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'
};
  1. 在 ${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

运行原理

本地运行模式

egg-webpack.jpg
  • 首先执行node index.js 或者 npm start 启动 Egg应用
  • 在Egg Agent 里面启动koa服务, 同时在koa服务里面启动Webpack编译服务
  • 挂载Webpack内存文件读取方法覆盖本地文件读取的逻辑
  • Worker 监听Webpack编译状态, 检测Webpack 编译是否完成, 如果未完成, 显示Webpack 编译Loading, 如果编译完成, 自动打开浏览器
  • Webpack编译完成, Agent 发送消息给Worker, Worker检测到编译完成, 自动打开浏览器, Egg服务正式可用

本地开发服务端渲染页面访问

egg-webpack-dev.jpg
  • 浏览器输入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" 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应用地址是两回事
最后, 模板渲染完成, 服务器输出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插件的多页面和单页面服务器渲染同构工程骨架项目, 贴两张截图:

vue-mutil-page.png
vue-single-page.png

支持特性

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

相关插件和工程

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容

  • 1.概要 随着越来越多的项目采用 Vue, React, Weex 进行业务开发, 在前端构建方面大多数是用web...
    hubcarl阅读 6,237评论 3 18
  • 1. 项目初始化 1.1 通过 easywebpack-cli 脚手架初始化 安装脚手架 npm install ...
    hubcarl阅读 3,807评论 1 7
  • 上班第一天痛经 觉得肚子里有一台雪地里工作的绞肉机 平行空间里的我 可能是个男的 经都被我痛了 他一定每天风流快活...
    咸酥阅读 191评论 0 0
  • 今天梁先生微信我,要去老太家,唉,那个不愿意啊,不是不愿意见到她,而是不愿意配合他们做些自己不想做的事情。...
    恭华阅读 192评论 0 0
  • 你看到这个题目时,是不是就想骂人? 你是不是在心里骂:谁上班不是为赚钱呀? 是的,我也承认:钱是一个很现实的问题。...
    鑫欣妈阅读 1,591评论 0 0