Webpack 是怎樣執行的?(一)

凱大胳膊發表於2018-12-26

Webpack 是時下最流行的前端打包工具,它打包開發程式碼,輸出能在各種瀏覽器執行的程式碼,提升了開發至釋出過程的效率。

你可能已經知道,這種便捷是由 Webpack 的外掛系統帶來的,但我們今天先把這些概念放在一邊,從簡單的實踐開始,探索 Webpack 打包出的程式碼是如何在瀏覽器環境執行的。

簡單配置

配置檔案是使用 Webpack 的關鍵,一份配置檔案主要包含入口(entry)、輸出檔案(output)、模式、Loader、外掛(Plugin)等幾個部分,但如果只需要組織 JS 檔案的話,指定入口和輸出檔案路徑即可完成一個迷你專案的打包:

專案目錄:

  • build
    • webpack.config.js -- 存放 webpack 配置物件
  • src
    • index.js -- 原始檔
  • package.json -- 本文使用 webpack ^4.23.0 作示例

為了更好地觀察產出的檔案,我們將模式設定為 development 關閉程式碼壓縮,再開啟 source-map 支援原始原始碼除錯。

配置檔案 build/webpack.config.js

const path = require('path');
const resolve = relativePath => path.resolve(__dirname, relativePath);

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry: resolve('../src/index.js'),
  output: {
    path: resolve('../dist'),
  }
};
複製程式碼

原始檔 src/index.js

document.writeln('Hello webpack!');
複製程式碼

現在我們執行命令 webpack --config build/webpack.config.js ,打包完成後會多出一個輸出目錄 dist:

  • build
    • webpack.config.js
  • dist
    • main.js
  • src
    • index.js
  • package.json

main 是 webpack 預設設定的輸出檔名,我們快速瞄一眼這個檔案:

dist/main.js

(function(modules){
  // ...
})({
  "./src/index.js": (function(){
    // ...
  })
});
複製程式碼

整個檔案只含一個 立即執行函式(IIFE),我們稱它為 webpackBootstrap,它僅接收一個物件 —— 未載入的 模組集合(modules),這個 modules 物件的 key 是一個路徑,value 是一個函式。你也許會問,這裡的模組是什麼?它們又是如何載入的呢?

模組

彆著急,在細看產出程式碼前,我們先豐富一下原始碼:

專案目錄:

  • build
    • webpack.config.js
  • src
    • utils
      • math.js
    • index.js
  • package.json

新檔案 src/utils/math.js

export const plus = (a, b) => {
  return a + b;
};

export const minus = (a, b) => {
  return a - b;
};
複製程式碼

src/index.js

import {plus, minus} from './utils/math.js';

document.writeln('Hello webpack!');
document.writeln('1 + 2: ', plus(1, 2));
document.writeln('1 - 2: ', minus(1, 2));
複製程式碼

我們按照 ES 規範的模組化語法寫了一個簡單的模組 src/utils/math.js,給 src/index.js 引用。目前,雖然各大瀏覽器開始支援通過 <script type="module"> 的方式支援 ES6 Module,但還需時間覆蓋。Webpack 用自己的方式支援了 ES6 Module 規範,前面提到的 module 就是和 ES6 module 對應的概念。

接下來我們看一下這些模組是如何通 ES5 程式碼實現的。再次執行命令 webpack --config build/webpack.config.js 後檢視輸出檔案:

dist/main.js

(function(modules){
  // ...
})({
  "./src/index.js": (function(){
    // ...
  }),
  "./src/utils/math.js": (function() {
    // ...
  })
});
複製程式碼

IIFE 傳入的 modules 物件裡多了一個鍵值對,對應著新模組 src/utils/math.js,這和我們在原始碼中拆分的模組互相呼應。然而,有了 modules 只是第一步,這份檔案最終達到的效果應該是讓各個模組按開發者編排的順序執行。

探究 webpackBootstrap

接下來看看 webpackBootstrap 函式中有些什麼:

// webpackBootstrap
(function(modules){

  // 快取 __webpack_require__ 函式載入過的模組
  var installedModules = {};
  
  /**
   * Webpack 載入函式,用來載入 webpack 定義的模組
   * @param {String} moduleId 模組 ID,一般為模
            塊的原始碼路徑,如 "./src/index.js"
   * @returns {Object} exports 匯出物件
   */
  function __webpack_require__(moduleId) {
    // ...
  }

  // 在 __webpack_require__ 函式物件上掛載一些變數
  // 及函式 ...

  // 傳入表示式的值為 "./src/index.js"
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})(/* modules */);
複製程式碼

可以看到其實主要做了兩件事:

  1. 定義一個模組載入函式 __webpack_require__
  2. 使用載入函式載入入口模組 "./src/index.js"

整個 webpackBootstrap 中只出現了入口模組的影子,那其他模組又是如何載入的呢?我們順著 __webpack_require__("./src/index.js") 細看載入函式的內部邏輯:

// ...

function __webpack_require__(moduleId) {
  // 重複載入則利用快取
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }

  // 如果是第一次載入,則初始化模組物件,並快取
  var module = installedModules[moduleId] = {
    i: moduleId,  // 模組 ID
    l: false,     // 模組載入標識
    exports: {}   // 模組匯出物件
  };

  /**
    * 執行模組
    * @param module.exports -- 模組匯出物件引用,改變模組包裹函式內部的 this 指向
    * @param module -- 當前模組物件引用
    * @param module.exports -- 模組匯出物件引用
    * @param __webpack_require__ -- 用於在模組中載入其他模組
    */
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  // 模組載入標識置為已載入
  module.l = true;

  // 返回當前模組的匯出物件引用
  return module.exports;
}

// ...
複製程式碼

首先,載入函式使用了閉包變數 installedModules,用來將已載入過的模組儲存在記憶體中。 接著是初始化模組物件,並把它掛載到快取裡。然後是模組的執行過程,載入入口檔案時 modules[moduleId] 其實就是 ./src/index.js 對應的模組函式。執行模組函式前傳入了跟模組相關的幾個實參,讓模組可以匯出內容,以及載入其他模組的匯出。最後標識該模組載入完成,返回模組的匯出內容。

根據 __webpack_require__ 的快取和匯出邏輯,我們得知在整個 IIFE 執行過程中,載入已快取的模組時,都會直接返回 installedModules[moduleId].exports,換句話說,相同的模組只有在第一次引用的時候才會執行模組本身。

模組執行函式

__webpack_require__ 中通過 modules[moduleId].call() 執行了模組執行函式,下面我們就進入到 webpackBootstrap 的引數部分,看看模組的執行函式。


// webpackBootstrap
(function(modules){

  // ...

})({

  /*** 入口模組 ./src/index.js ***/
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
    "use strict";

    // 用於區分 ES 模組和其他模組規範,不影響理解 demo,戰略跳過。
    __webpack_require__.r(__webpack_exports__);

    // 源模組程式碼中,`import {plus, minus} from './utils/math.js';` 語句被 loader 解析轉化。
    // 載入 "./src/utils/math.js" 模組,
    /* harmony import */ var _utils_math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/math.js */ "./src/utils/math.js");

    document.writeln('Hello webpack!');
    document.writeln('1 + 2: ', Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__["plus"])(1, 2));
    document.writeln('1 - 2: ', Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__["minus"])(1, 2));
  }),

  /*** 工具模組 ./src/utils/math.js ***/
  "./src/utils/math.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";

    // 同 "./src/index.js"
    __webpack_require__.r(__webpack_exports__);

    // 源模組程式碼中,`export` 語句被 loader 解析轉化。
    // 匯出 __webpack_exports__
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "plus", function() { return plus; });
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "minus", function() { return minus; });
    const plus = (a, b) => {
      return a + b;
    };

    const minus = (a, b) => {
      return a - b;
    };
  })
});

複製程式碼

執行順序是:入口模組 -> 工具模組 -> 入口模組。入口模組中首先就通過 __webpack_require__("./src/utils/math.js") 拿到了工具模組的 exports 物件。再看工具模組,ES 匯出語法轉化成了__webpack_require__.d(__webpack_exports__, [key], [getter]),而 __webpack_require__.d 函式的定義在 webpackBootstrap 內:

// ...

  // 定義 exports 物件匯出的屬性。
  __webpack_require__.d = function (exports, name, getter) {

    // 如果 exports (不含原型鏈上)沒有 [name] 屬性,定義該屬性的 getter。
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter
      });
    }
  };

  // 包裝 Object.prototype.hasOwnProperty 函式。
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };

// ...
複製程式碼

可見 __webpack_require__.d 其實就是 Object.defineProperty 的簡單包裝(怪不得叫 d 呢)。

回顧一下,__webpack_exports__ 原本在 __webpack_require__ 中建立,初始值為 {}。這個匯出物件一路傳到工具模組 math.js 中,被新增上 plusminus,然後又在 __webpack_require__ 函式最後匯出,為入口模組 index.js 的執行函式所用。

exports 的一生:

exports 的一生

引用工具模組匯出的變數後,入口模組再執行它剩餘的部分。至此,Webpack 基本的模組執行過程就結束了。

以上內容可克隆示例程式碼庫除錯,分支為 demo1

除了 ES6 Module 規範,Webpack 同樣支援 CommonJS 與 AMD 規範,你可以替換模組化規範,重新打包來觀察它們的區別。

小結

好了,我們用流程圖總結一下 Webpack 模組的載入思路:

webpack-module-implementation-sync

參考

Webpack 術語表 - Module

相關文章