Webpack 模組打包原理

lq782655835發表於2019-03-22

在使用webpack的過程中,你是否好奇webpack打包的程式碼為什麼可以直接在瀏覽器中跑?為什麼webpack可以支援各種ES6最新語法?為什麼在webpack中可以書寫import ES6模組,也支援require CommonJS模組?

模組規範

關於模組,我們先來認識下目前主流的模組規範(自從有了ES6 Module及Webpack等工具,AMD/CMD規範生存空間已經很小了):

  • CommonJS
  • UMD
  • ES6 Module

CommonJS

ES6前,js沒有屬於自己的模組規範,所以社群制定了 CommonJS規範。而NodeJS所使用的模組系統就是基於CommonJS規範實現的。

// CommonJS 匯出
module.exports = { age: 1, a: 'hello', foo:function(){} }

// CommonJS 匯入
const foo = require('./foo.js')
複製程式碼

UMD

根據當前執行環境的判斷,如果是 Node 環境 就是使用 CommonJS 規範, 如果不是就判斷是否為 AMD 環境, 最後匯出全域性變數。這樣程式碼可以同時執行在Node和瀏覽器環境中。目前大部分庫都是打包成UMD規範,Webpack也支援UMD打包,配置API是output.libraryTarget。詳細案例可以看筆者封裝的npm工具包:cache-manage-js

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
    typeof define === 'function' && define.amd ? define(factory) :
    (global.libName = factory());
}(this, (function () { 'use strict';})));
複製程式碼

ES6 Module

ES6 模組的設計思想是儘量的靜態化,使得編譯時就能確定模組的依賴關係,以及輸入和輸出的變數。具體思想和語法可以看筆者的另外一篇文章:ES6-模組詳解

// es6模組 匯出
export default { age: 1, a: 'hello', foo:function(){} }

// es6模組 匯入
import foo from './foo'
複製程式碼

Webpack模組打包

既然模組規範有這麼多,那webpack是如何去解析不同的模組呢?

webpack根據webpack.config.js中的入口檔案,在入口檔案裡識別模組依賴,不管這裡的模組依賴是用CommonJS寫的,還是ES6 Module規範寫的,webpack會自動進行分析,並通過轉換、編譯程式碼,打包成最終的檔案。最終檔案中的模組實現是基於webpack自己實現的webpack_require(es5程式碼),所以打包後的檔案可以跑在瀏覽器上。

同時以上意味著在webapck環境下,你可以只使用ES6 模組語法書寫程式碼(通常我們都是這麼做的),也可以使用CommonJS模組語法,甚至可以兩者混合使用。因為從webpack2開始,內建了對ES6、CommonJS、AMD 模組化語句的支援,webpack會對各種模組進行語法分析,並做轉換編譯

我們舉個例子來分析下打包後的原始碼檔案,例子原始碼在 webpack-module-example

// webpack.config.js
const path = require('path');

module.exports = {
    mode: 'development',
  // JavaScript 執行入口檔案
  entry: './src/main.js',
  output: {
    // 把所有依賴的模組合併輸出到一個 bundle.js 檔案
    filename: 'bundle.js',
    // 輸出檔案都放到 dist 目錄下
    path: path.resolve(__dirname, './dist'),
  }
};
複製程式碼
// src/add
export default function(a, b) {
    let { name } = { name: 'hello world,'} // 這裡特意使用了ES6語法
    return name + a + b
}

// src/main.js
import Add from './add'
console.log(Add, Add(1, 2))
複製程式碼

打包後精簡的bundle.js檔案如下:

// modules是存放所有模組的陣列,陣列中每個元素儲存{ 模組路徑: 模組匯出程式碼函式 }
(function(modules) {
// 模組快取作用,已載入的模組可以不用再重新讀取,提升效能
var installedModules = {};

// 關鍵函式,載入模組程式碼
// 形式有點像Node的CommonJS模組,但這裡是可跑在瀏覽器上的es5程式碼
function __webpack_require__(moduleId) {
  // 快取檢查,有則直接從快取中取得
  if(installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }
  // 先建立一個空模組,塞入快取中
  var module = installedModules[moduleId] = {
    i: moduleId,
    l: false, // 標記是否已經載入
    exports: {} // 初始模組為空
  };

  // 把要載入的模組內容,掛載到module.exports上
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
  module.l = true; // 標記為已載入

  // 返回載入的模組,呼叫方直接呼叫即可
  return module.exports;
}

// __webpack_require__物件下的r函式
// 在module.exports上定義__esModule為true,表明是一個模組物件
__webpack_require__.r = function(exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
};

// 啟動入口模組main.js
return __webpack_require__(__webpack_require__.s = "./src/main.js");
})
({
  // add模組
  "./src/add.js": (function(module, __webpack_exports__, __webpack_require__) {
    // 在module.exports上定義__esModule為true
    __webpack_require__.r(__webpack_exports__);
    // 直接把add模組內容,賦給module.exports.default物件上
    __webpack_exports__["default"] = (function(a, b) {
      let { name } = { name: 'hello world,'}
      return name + a + b
    });
  }),

  // 入口模組
  "./src/main.js": (function(module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__)
    // 拿到add模組的定義
    // _add__WEBPACK_IMPORTED_MODULE_0__ = module.exports,有點類似require
    var _add__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/add.js");
    // add模組內容: _add__WEBPACK_IMPORTED_MODULE_0__["default"]
    console.log(_add__WEBPACK_IMPORTED_MODULE_0__["default"], Object(_add__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2))
  })
});
複製程式碼

以上核心程式碼中,能讓打包後的程式碼直接跑在瀏覽器中,是因為webpack通過__webpack_require__ 函式模擬了模組的載入(類似於node中的require語法),把定義的模組內容掛載到module.exports上。同時__webpack_require__函式中也對模組快取做了優化,防止模組二次重新載入,優化效能。

再讓我們看下webpack的原始碼:

// webpack/lib/MainTemplate.js

// 主檔案模板
// webpack生成的最終檔案叫chunk,chunk包含若干的邏輯模組,即為module
this.hooks.render.tap( "MainTemplate",
(bootstrapSource, chunk, hash, moduleTemplate, dependencyTemplates) => {
  const source = new ConcatSource();
  source.add("/******/ (function(modules) { // webpackBootstrap\n");
  // 入口內容,__webpack_require__就在bootstrapSource中
  source.add(new PrefixSource("/******/", bootstrapSource));
  source.add("/******/ })\n");
  source.add(
    "/************************************************************************/\n"
  );
  source.add("/******/ (");
  source.add(
    // 依賴的module都會寫入對應陣列
    this.hooks.modules.call(
      new RawSource(""),
      chunk,
      hash,
      moduleTemplate,
      dependencyTemplates
    )
  );
  source.add(")");
  return source;
}
複製程式碼

Webpack ES6語法支援

可能細心的讀者看到,以上打包後的add模組程式碼中依然還是ES6語法,在低端的瀏覽器中不支援。這是因為沒有對應的loader去解析js程式碼,webpack把所有的資源都視作模組,不同的資源使用不同的loader進行轉換。

這裡需要使用babel-loader及其外掛@babel/preset-env進行處理,把ES6程式碼轉換成可在瀏覽器中跑的es5程式碼。

// webpack.config.js
module.exports = {
  ...,
  module: {
    rules: [
      {
        // 對以js字尾的檔案資源,用babel進行處理
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  }
};
複製程式碼
// 經過babel處理es6語法後的程式碼
__webpack_exports__["default"] = (function (a, b) {
  var _name = {    name: 'hello world,'  }, name = _name.name;
  return name + a + b;
});
複製程式碼

Webpack 模組非同步載入

以上webpack把所有模組打包到主檔案中,所以模組載入方式都是同步方式。但在開發應用過程中,按需載入(也叫懶載入)也是經常使用的優化技巧之一。按需載入,通俗講就是程式碼執行到非同步模組(模組內容在另外一個js檔案中),通過網路請求即時載入對應的非同步模組程式碼,再繼續接下去的流程。那webpack是如何執行程式碼時,判斷哪些程式碼是非同步模組呢?webpack又是如何載入非同步模組呢?

webpack有個require.ensure api語法來標記為非同步載入模組,最新的webpack4推薦使用新的import() api(需要配合@babel/plugin-syntax-dynamic-import外掛)。因為require.ensure是通過回撥函式執行接下來的流程,而import()返回promise,這意味著可以使用最新的ES8 async/await語法,使得可以像書寫同步程式碼一樣,執行非同步流程。

現在我們從webpack打包後的原始碼來看下,webpack是如何實現非同步模組載入的。修改入口檔案main.js,引入非同步模組async.js:

// main.js
import Add from './add'
console.log(Add, Add(1, 2), 123)

// 按需載入
// 方式1: require.ensure
// require.ensure([], function(require){
//     var asyncModule = require('./async')
//     console.log(asyncModule.default, 234)
// })

// 方式2: webpack4新的import語法
// 需要加@babel/plugin-syntax-dynamic-import外掛
let asyncModuleWarp = async () => await import('./async')
console.log(asyncModuleWarp().default, 234)
複製程式碼
// async.js
export default function() {
    return 'hello, aysnc module'
}
複製程式碼

以上程式碼打包會生成兩個chunk檔案,分別是主檔案main.bundle.js以及非同步模組檔案0.bundle.js。同樣,為方便讀者快速理解,精簡保留主流程程式碼。

// 0.bundle.js

// 非同步模組
// window["webpackJsonp"]是連線多個chunk檔案的橋樑
// window["webpackJsonp"].push = 主chunk檔案.webpackJsonpCallback
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  [0], // 非同步模組標識chunkId,可判斷非同步程式碼是否載入成功
  // 跟同步模組一樣,存放了{模組路徑:模組內容}
  {
  "./src/async.js": (function(module, __webpack_exports__, __webpack_require__) {
      __webpack_require__.r(__webpack_exports__);
      __webpack_exports__["default"] = (function () {
        return 'hello, aysnc module';
      });
    })
  }
]);
複製程式碼

以上知道,非同步模組打包後的檔案中儲存著非同步模組原始碼,同時為了區分不同的非同步模組,還儲存著該非同步模組對應的標識:chunkId。以上程式碼主動呼叫window["webpackJsonp"].push函式,該函式是連線非同步模組與主模組的關鍵函式,該函式定義在主檔案中,實際上window["webpackJsonp"].push = webpackJsonpCallback,詳細原始碼我們們看看主檔案打包後的程式碼:

// main.bundle.js

(function(modules) {
// 獲取到非同步chunk程式碼後的回撥函式
// 連線兩個模組檔案的關鍵函式
function webpackJsonpCallback(data) {
  var chunkIds = data[0]; //data[0]存放了非同步模組對應的chunkId
  var moreModules = data[1]; // data[1]存放了非同步模組程式碼

  // 標記非同步模組已載入成功
  var moduleId, chunkId, i = 0, resolves = [];
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(installedChunks[chunkId]) {
      resolves.push(installedChunks[chunkId][0]);
    }
    installedChunks[chunkId] = 0;
  }

  // 把非同步模組程式碼都存放到modules中
  // 此時萬事俱備,非同步程式碼都已經同步載入到主模組中
  for(moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId];
  }

  // 重點:執行resolve() = installedChunks[chunkId][0]()返回promise
  while(resolves.length) {
    resolves.shift()();
  }
};

// 記錄哪些chunk已載入完成
var installedChunks = {
  "main": 0
};

// __webpack_require__依然是同步讀取模組程式碼作用
function __webpack_require__(moduleId) {
  ...
}

// 載入非同步模組
__webpack_require__.e = function requireEnsure(chunkId) {
  // 建立promise
  // 把resolve儲存到installedChunks[chunkId]中,等待程式碼載入好再執行resolve()以返回promise
  var promise = new Promise(function(resolve, reject) {
    installedChunks[chunkId] = [resolve, reject];
  });

  // 通過往head頭部插入script標籤非同步載入到chunk程式碼
  var script = document.createElement('script');
  script.charset = 'utf-8';
  script.timeout = 120;
  script.src = __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle.js"
  var onScriptComplete = function (event) {
    var chunk = installedChunks[chunkId];
  };
  script.onerror = script.onload = onScriptComplete;
  document.head.appendChild(script);

  return promise;
};

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// 關鍵程式碼: window["webpackJsonp"].push = webpackJsonpCallback
jsonpArray.push = webpackJsonpCallback;

// 入口執行
return __webpack_require__(__webpack_require__.s = "./src/main.js");
})
({
"./src/add.js": (function(module, __webpack_exports__, __webpack_require__) {...}),

"./src/main.js": (function(module, exports, __webpack_require__) {
  // 同步方式
  var Add = __webpack_require__("./src/add.js").default;
  console.log(Add, Add(1, 2), 123);

  // 非同步方式
  var asyncModuleWarp =function () {
    var _ref = _asyncToGenerator( regeneratorRuntime.mark(function _callee() {
      return regeneratorRuntime.wrap(function _callee$(_context) {
        // 執行到非同步程式碼時,會去執行__webpack_require__.e方法
        // __webpack_require__.e其返回promise,表示非同步程式碼都已經載入到主模組了
        // 接下來像同步一樣,直接載入模組
        return __webpack_require__.e(0)
              .then(__webpack_require__.bind(null, "./src/async.js"))
      }, _callee);
    }));

    return function asyncModuleWarp() {
      return _ref.apply(this, arguments);
    };
  }();
  console.log(asyncModuleWarp().default, 234)
})
});
複製程式碼

從上面原始碼可以知道,webpack實現模組的非同步載入有點像jsonp的流程。在主js檔案中通過在head中構建script標籤方式,非同步載入模組資訊;再使用回撥函式webpackJsonpCallback,把非同步的模組原始碼同步到主檔案中,所以後續操作非同步模組可以像同步模組一樣。 原始碼具體實現流程:

  1. 遇到非同步模組時,使用__webpack_require__.e函式去把非同步程式碼載入進來。該函式會在html的head中動態增加script標籤,src指向指定的非同步模組存放的檔案。
  2. 載入的非同步模組檔案會執行webpackJsonpCallback函式,把非同步模組載入到主檔案中。
  3. 所以後續可以像同步模組一樣,直接使用__webpack_require__("./src/async.js")載入非同步模組。

注意原始碼中的primose使用非常精妙,主模組載入完成非同步模組才resolve()

總結

  1. webpack對於ES模組/CommonJS模組的實現,是基於自己實現的webpack_require,所以程式碼能跑在瀏覽器中。
  2. 從 webpack2 開始,已經內建了對 ES6、CommonJS、AMD 模組化語句的支援。但不包括新的ES6語法轉為ES5程式碼,這部分工作還是留給了babel及其外掛。
  3. 在webpack中可以同時使用ES6模組和CommonJS模組。因為 module.exports很像export default,所以ES6模組可以很方便相容 CommonJS:import XXX from 'commonjs-module'。反過來CommonJS相容ES6模組,需要額外加上default:require('es-module').default。
  4. webpack非同步載入模組實現流程跟jsonp基本一致。

參考文章

相關文章