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

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

脚手架主要解决哪些问题

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

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

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

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

目录结构

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

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了,同时,也会致使热更新失败,由于跨域了。react

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

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方法结合才能够达到热更新的效果。最简单的方法就是在入口文件添加以下代码:nginx

if (module.hot) {  
  module.hot.accept();
}
复制代码

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

// 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文件。github

// 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吧。传送门

相关文章
相关标签/搜索