分享一個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吧。傳送門

相關文章
相關標籤/搜索