分享一个egg + webpack4多页面开发脚手架

2,547 阅读10分钟

脚手架包含egg、webpack、eslint、babel、happypack、sass、vue、lint-staged、热更新等特性。提供webpack构建的可配置化,扩展灵活,使用简单。

脚手架主要解决哪些问题

egg是一款优秀的企业级node框架,比较常用的使用场景是:1.用来做BFF层,2.用来做全栈应用,3.做服务器端渲染页面,SEO优化。理论上它属于服务器端的开发,浏览器端的代码还是需要有一套机制来进行组织,这样我们前后端开发起来才能比较好的进行融合,如html、css、js这些是跟egg无关的,我们可以使用webpack来对它进行模块打包。在使用了一段时间后,总结了一些问题如下:

  • 本地开发时,egg跟前端代码如何进行融合开发;
  • 前端代码如何进行热更新;
  • 如果是在做一些不需要seo优化的页面,如复杂的表单页面、个人用户中心等,这个时候若能引入一款MVVM的框架,那会极大地提高我们的开发效率,我们首选的是vue,因为它比较轻量级,而且容易上手。

思路的出发点是解决这些主要问题,当然还会有一些细节上的问题,当逐一解决后,我们也就基本实现了这个脚手架。

github: egg-multiple-page-example 欢迎star

目录结构

先看下脚手架的目录结构,目录结合注释阅读,更容易理解。

egg-multiple-page-example
|
├─app.js egg启动文件,可以在应用启动的时候做点事情
|  
│  
├─app 项目目录,主要存放node端的代码,跟常规的egg目录结构基本一致,具体参考egg的官方文档
│  │  router.js 路由总入口
│  │  
│  ├─controller 控制器模块
│  │  └─example 每个模块一个目录,模块下面还可以分目录
│  │          detail.js 一个页面一个js,里面包含有页面渲染和http接口的逻辑代码
│  │          home.js
│  │          vue.js
│  │          
│  ├─extend 自定义扩展模块
│  │      application.js
│  │      context.js
│  │      helper.js
│  │      request.js
│  │      response.js
│  │      
│  ├─middleware 中间件模块
│  │      errorHandler.js
│  │      
│  ├─router 每个模块的路由配置,一个模块一个文件
│  │      example.js
│  │      
│  └─service 后端服务模块,一个模块一个文件,里面是该模块下后端接口服务
│          music.js
│          
├─build webpack的配置目录
│  │  build.js
│  │  config.js webpack的可配置文件,可以在这里进行一些自定义的配置,简化配置
│  │  devServer.js
│  │  hotReload.js
│  │  utils.js
│  │  webpack.base.conf.js
│  │  webpack.dev.conf.js
│  │  webpack.prd.conf.js
│  │  
│  ├─loaders 自定义webpack loaders
│  │      hot-reload-loader.js
│  │      
│  └─plugins 自定义webpack plugins
│          compile-html-plugin.js
│          
├─config egg的配置文件,分环境配置
│      client.config.js
│      config.default.js
│      config.dev.js
│      config.local.js
│      config.prod.js
│      config.test.js
│      plugin.js
│   
├─dist webpack构建生产环境存放的文件目录
│
├─temp 本地开发时的临时目录,存放编译后的html文件
│
└─src 浏览器端的文件目录
    ├─assets 纯静态资源目录,如一些doc、excel、示例图片等,构建时会复制到dist/static目录下
    ├─common 公共模块,如公共的css和js,可自定义添加
    │  ├─css 公共样式
    │  │      common.scss
    │  │      
    │  └─js 公共js
    │          initRun.js 页面初始化执行的代码块,若有初始化执行的方法可放于此
    │          regex.js 统一正则管理
    │          utils.js 前端工具方法
    │          
    ├─images 图片目录,一个模块一个目录
    │  │  favicon.ico
    │  │  
    │  ├─common 公共图片,目录下面的图片不会转成base64,也不会添加md5,用于可复用的图片和对外提供的图片
    │  └─example 各个模块下面的图片,小图片会转成base64
    │          vue-logo.png
    │          
    └─templates 业务代码目录,存放每个页面和组件的代码,components为保留目录
        ├─components 自定义组件的目录,vue组件放在vue目录下
        │  ├─footer 如果组件包括html、js、css必须要用目录包起来,而且文件名要跟目录名一致
        │  │      footer.html
        │  │      footer.scss
        │  │      
        │  ├─header 如果组件只是html,可以直接html文件即可,这种一般是nunjucks模板
        │  │      header.html
        │  │      
        │  └─vue vue组件的专用目录
        │          helloWorld.vue
        │          
        └─example 各个模块的目录,目录下面还可以再分子目录
            ├─detail  一个目录一个页面,分别包含html、css、js文件,命名跟目录名一致
            │      detail.html
            │      detail.js
            │      detail.scss
            │      
            ├─home
            │      home.html
            │      home.js
            │      home.scss
            │      
            └─vue
                    app.vue
                    vue.html
                    vue.js
                    vue.scss


前后端代码交互图

上面是脚手架的一个前后端代码流向图,开发时,我们需要启动webpack和egg两个服务,webpack进程用来编译html、css、js等代码,其中html会写到本地的一个temp目录,让egg可以直接读取html模板,css和js会挂载到express服务器上,这样我们就可以通过http的方式来访问css和js代码了。可是这样会出现一个问题,就是egg和express两个服务器是不同端口的,而我们真正访问的页面是在egg上,express用来提供css和js的,而页面上引入css和js是用相对路径的,而不是express服务器上的路径,直接就404了,同时,也会导致热更新失败,因为跨域了。

这时,我们可以利用nginx来做反向代理,主服务器统一用的nginx,然后通过nginx来代理egg和express,把两个服务器打通,并解决跨域的问题。这样就解决上面提到的问题1,下面给出nginx的配置:

server {
        listen 80;
        server_name local.example.com;

        location / {
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:7113/;
        }

        #开发环境下使用,生产环境需要注释       
        location /static/ {
            proxy_pass http://127.0.0.1:7213/static/;
        }

    }

核心代码讲解

熟悉react或者vue开发的同学应该不陌生,让我们开发单页面应用时,js或者css代码修改后,会自动通知浏览器更新模块代码,并且不会刷新浏览器,整个开发过程是非常顺畅。可是在多页面时,如果更新呢?在webpack里,热更新是通过HotModuleReplacementPluginmodule.hot.accept方法结合才可以达到热更新的效果。最简单的方法就是在入口文件添加如下代码:

if (module.hot) {  
  module.hot.accept();
}

这样子模块或者自身模块代码更新了,webpack就会通知浏览器。
那多页面的情况其实也简单啦,也就是在每个页面的主js添加这段代码就可以了嘛...可是这样会不会有点傻,如果有50个页面就有50段这样的代码...囧。这里我想到一个方法,可以借助自定义loader,在每个js编译的时候自动加上这段代码不就可以了嘛。

// hot-reload-loader.js
module.exports = function (source) {
  // 在js源代码后面添加热更新代码
  let result = source + `
      if (module.hot) {
        module.hot.accept();
    }
`;
  return result;
};
// webpack.base.conf.js
// 开发环境,给js添加HMR代码
...
  {
    test: /\.js$/,
    loaders: devMode && reload ? [].concat(['hot-reload-loader']) : [],
    include: [path.join(__dirname, '../src/templates')]
  },
...

这时我又遇到了一个问题,一开始我使用的是htmlWebpackPlugin这个插件来编译html,主要是给html自动注入css和js,当页面越来越多的时候,html的编译就会越来越慢,后来我在html插件里面进行打印标记输出,一个页面的修改,会触发所有页面的编译,怪不得那么慢了。在网上扒了很久都没有找到解决方案,好吧,那就自己动手解决吧。
首先我们要做的是注入css和js,而且要一个页面的修改,不会触发所有页面的重新编译。 我们可以通过把html当做一个入口文件(像js文件那样),这样我们就能够让webpack来监听html文件。

// utils.js
/**
   * 初始化entry文件
   * @param globPath 遍历的文件路径
   * @returns {{}}  webpack entry入口对象
   */
  initEntries (globPath) {
    let files = glob.sync(globPath);
    let entries = {};
    files.forEach(function (file) {
      let ext = path.extname(file);
      /* 只需获取templates下面的目录 */
      let entryKey = file.split('/templates/')[1].split('.')[0];
      if (ext === '.js') {
        /* 组件不需要添加initRun.js */
        if (!(file.includes('/templates/components/'))) {
          entries[entryKey] = ['./src/common/js/initRun.js', file];
        } else {
          entries[entryKey] = file;
        }
      } else {
        entries[entryKey + ext] = file;
      }
    });

    return entries;
  }

然后再webpack里到所有的html、js都作为entry文件:

// webpack.base.conf.js
const webpackConfig = {
  entry: utils.initEntries('./src/templates/**/*.{js,html,nj}}'),
  output: {
    path: outputPath,
    filename: 'js/[name].js',
    publicPath: publicPath
  },
  ...

这样html就被webpack当做一个js文件,而经过我的一番研究,只要这个js文件进行自执行,它会的返回结果就是一串html的代码,而且里面的图片和静态资源都会自动编译为正确的路径(或者base64),这里发挥作用的是html-loader,会把html里面的img等标签进行编译。

接下来就是要解决如何插入css和js标签了。我们可以利用webpack的compiler和compilation的hooks钩子函数,在html模块编译完以后就可以对它插入css和js。为此我做了一个webpack插件:

// compile-html-plugin.js
/**
 * 自定义webpack插件,用于优化多页面html的编译的。
 * 为什么要编写这个插件:
 * htmlWebpackPlugin在多页面的情况下,一个页面的修改,会触发所有页面的编译(dev环境下),一旦项目的页面超过一定量(几十个吧)就会变得非常慢。
 * 使用该插件替换htmlWebpackPlugin不会触发所有页面的编译,只会编译你当前修改的页面,因此速度是非常快的,并且写入到temp目录。
 * 插件主要使用到自定义webpack plugin的一些事件和方法,具体可以参考文档:
 * https://doc.webpack-china.org/api/plugins/compiler
 * https://doc.webpack-china.org/api/plugins/compilation
 */

'use strict';
const vm = require('vm');
const fs = require('fs');
const _ = require('lodash');
const mkdirp = require('mkdirp');
const config = require('../config');

class CompileHtmlPlugin {
  constructor (options) {
    this.options = options || {};
  }
  // 将 `apply` 定义为其原型方法,此方法以 compiler 作为参数
  apply (compiler) {
    const self = this;
    self.isInit = false; // 是否已经第一次初始化编译了
    self.rawRequest = null; // 记录当前修改的html路径,单次编译html会用到

    /**
     * webpack4的插件添加compilation钩子方法附加到CompileHtmlPlugin插件上
     */
    compiler.hooks.compilation.tap('CompileHtmlPlugin', (compilation) => {
      /* 单次编译模块时会执行,试了很多方法,就只有这个方法能够监听单次文件的编译 */
      compilation.hooks.succeedModule.tap('CompileHtmlPlugin', function (module) {
        /* module.rawRequest属性可以获取到当前模块的路径,并且只有html和nj文件才进行编译 */
        if (self.isInit && module.rawRequest && /^\.\/src\/templates(.+)\.(html|nj)$/g.test(module.rawRequest)) {
          console.log('build module');
          self.rawRequest = module.rawRequest;
        }
      });
    });

    /**
     * 编译完成后,在发送资源到输出目录之前
     */
    compiler.hooks.emit.tapAsync('CompileHtmlPlugin', (compilation, cb) => {
      /* webpack首次执行 */
      if (!self.isInit) {
        /* 遍历所有的entry入口文件 */
        _.each(compilation.assets, function (asset, key) {
          if (/\.(html|nj)\.js$/.test(key)) {
            const filePath = key.replace('.js', '').replace('js/', 'temp/');
            const dirname = filePath.substr(0, filePath.lastIndexOf('/'));
            const source = asset.source();

            self.compileCode(compilation, source).then(function (result) {
              self.insertAssetsAndWriteFiles(key, result, dirname, filePath);
            });
          }
        });

        /* 单次修改html执行 */
      } else {
        /* rawRequest不为空,则表明这次修改的是html,可以执行编译 */
        if (self.rawRequest) {
          const assetKey = self.rawRequest.replace('./src/templates', 'js') + '.js';
          console.log(assetKey);
          const filePath = assetKey.replace('.js', '').replace('js/', 'temp/');
          const dirname = filePath.substr(0, filePath.lastIndexOf('/'));
          /* 获取当前的entry */
          const source = compilation.assets[assetKey].source();

          self.compileCode(compilation, source).then(function (result) {
            self.insertAssetsAndWriteFiles(assetKey, result, dirname, filePath, true);
          });
        }
      }

      cb();
    });

    /**
     * 编译完成,进行一些属性的重置
     */
    compiler.hooks.done.tap('CompileHtmlPlugin', (compilation) => {
      if (!self.isInit) {
        self.isInit = true;
      }
      self.rawRequest = null;
    });
  }

  /**
   * 用于把require进来的*.html.js进行沙箱执行,获取运行以后返回的html字符串
   * 使用vm模块,在V8虚拟机上下文中提供了编译和运行代码的API
   * @param compilation webpack compilation 对象
   * @param source 源代码
   * @returns {*}
   */
  compileCode (compilation, source) {
    if (!source) {
      return Promise.reject(new Error('请输入source'));
    }

    /* 定义vm的运行上下文,就是一些全局变量 */
    const vmContext = vm.createContext(_.extend({ require: require }, global));
    const vmScript = new vm.Script(source, {});
    // 编译后的代码
    let newSource;
    try {
      /* newSouce就是在沙箱执行js后返回的结果,这里用于获取编译后的html字符串 */
      newSource = vmScript.runInContext(vmContext);
      return Promise.resolve(newSource);
    } catch (e) {
      console.log('-------------compileCode error', e);
      return Promise.reject(e);
    }
  }

  /**
   * 把js和css插入到html模板,并写入到temp目录里面
   * @param assetKey  当前的html在entry对象中的key
   * @param result  html的模板字符串
   * @param dirname 写入的目录
   * @param filePath  写入的文件路径
   * @param isReload  是否需要通知浏览器刷新页面,前提是使用插件时必须传入hotMiddleware
   */
  insertAssetsAndWriteFiles (assetKey, result, dirname, filePath, isReload) {
    let self = this;
    let styleTag = `<link href="${config.publicPath}css/${assetKey.replace('.html.js', '.css').replace('js/', '')}" rel="stylesheet" />`;
    let scriptTag = `<script src="${config.publicPath}${assetKey.replace('.html.js', '.js')}"></script>`;

    result = result.replace('</head>', `${styleTag}</head>`);
    result = result.replace('</body>', `${scriptTag}</body>`);

    mkdirp(dirname, function (err) {
      if (err) {
        console.error(err);
      } else {
        fs.writeFile(filePath, result, function (err) {
          if (err) {
            console.error(err);
          }

          // 通知浏览器更新
          if (isReload) {
            self.options.hotMiddleware && self.options.hotMiddleware.publish({ action: 'reload' });
          }
        });
      }
    });
  }
}

module.exports = CompileHtmlPlugin;

代码不算复杂,关键的几个点就是:

  1. 使用了nodejs的vm模块,创建独立运行的沙箱对html的js代码自执行编译;
  2. 编译后需要在head和body标签里插入<link><script>标签;
  3. 把插入标签后的html代码写入到本地目录中;

这样就解决了html的编译问题了。

下面来解决问题3。问题3其实不是很难,关键是要分析出我们的需求,我们其实最需要的是vue的数据驱动,数据绑定还有组件的功能即可,上层工具,如vue-router、vuex、vue-cli这些其实都不是必须的,这些主要在做vue的单页应用或者ssr时才会排上用场。幸运的是vue是一个渐进式的框架,我们可以单纯引入vue.js即可。

在webpack里单纯引入vue,其实是比较简单的,主要用到VueLoaderPluginvue-loader即可:

// webpack.base.conf.js
...
const VueLoaderPlugin = require('vue-loader/lib/plugin');
...
module: {
    rules: [
      // 使用vue-loader将vue文件编译转换为js
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
  ]
}
...
plugins: [
    new VueLoaderPlugin(),
    ...
]

就是这么简单,我们就把vue引进我们的项目里,并不是所有vue项目都需要vue-cli哦。
在项目中使用vue,我们还可以利用一些技巧来提升我们的页面加载速度,如懒加载,下面是几种加载方式的例子:

// 传统的同步加载
import Vue from 'vue';
import app from './app.vue';
new Vue({
  el: '#app',
  render: h => h(app)
});

// 按顺序异步加载js
import('vue').then(async ({ default: Vue }) => {
  const { default: app } = await import('./app.vue');
  new Vue({
    el: '#app',
    render: h => h(app)
  });
});

// 多个异步js同时加载
Promise.all([
  // 打包时给异步的js添加命名
  import(/* webpackChunkName: 'async' */ 'vue'),
  import('./app.vue')
]).then(([{ default: Vue }, { default: app }]) => {
  new Vue({
    el: '#app',
    render: h => h(app)
  });
});

还有一点要注意的是你挂载到html中的根节点必须要和vue根节点的id(当然,你可以用class也行)是一样的,如#app,不然热更新的时候会找不到元素挂载,报错。

项目中还用到了一些webpack性能优化和公共代码抽取等,如happypackOptimizeCSSPluginsplitChunks等,这些都有现成的官方文档,这里就不做讲解了。

最后

如果有地方不明白可以在下面留言或者上github提issue,如果项目对你帮助,请给我个star吧。传送门