Webpack 是怎樣運行的?

在平時開發中我們經常會用到Webpack這個時下最流行的前端打包工具。它打包開發代碼,輸出能在各種瀏覽器運行的代碼,提升了開發至發佈過程的效率。

我們知道一份Webpack配置文件主要包含入口(entry)、輸出文件(output)、模式、加載器(Loader)、插件(Plugin)等幾個部分。但如果只需要組織 JS 文件的話,指定入口和輸出文件路徑即可完成一個迷你項目的打包。下面我們來通過一個簡單的項目來看一下Webpack是怎樣運行的。

同步加載

本文使用 webpack ^4.30.0 作示例.爲了更好地觀察產出的文件,我們將模式設置爲 development 關閉代碼壓縮,再開啓 source-map 支持原始源代碼調試。除此之外。我們還簡單的寫了一個插件MyPlugin來去除源碼中的註釋。

新建src/index.js

console.log('Hello webpack!');

新建webpack配置文件webpack.config.js

const path = require('path');
const MyPlugin = require('./src/MyPlugin.js')

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist')
  },
  plugins:[
    new MyPlugin()
  ]
};

新建src/MyPlugin.js瞭解webpack插件更多信息

class MyPlugin {
  constructor(options) {
    this.options = options
    this.externalModules = {}
  }

  apply(compiler) {
    var reg = /("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g
    compiler.hooks.emit.tap('CodeBeautify', (compilation)=> {
      Object.keys(compilation.assets).forEach((data)=> {
        let content = compilation.assets[data].source() // 欲處理的文本
        content = content.replace(reg, function (word) { // 去除註釋後的文本
          return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word;
        });
        compilation.assets[data] = {
          source(){
            return content
          },
          size(){
            return content.length
          }
        }
      })
    })
  }
}
module.exports = MyPlugin

現在我們運行命令 webpack --config webpack.config.js ,打包完成後會多出一個輸出目錄 distdist/main.jsmainwebpack 默認設置的輸出文件名,我們快速瞄一眼這個文件:

(function(modules){
  // ...
})({
  "./src/index.js": (function(){
    // ...
  })
});

整個文件只含一個立即執行函數(IIFE),我們稱它爲 webpackBootstrap,它僅接收一個對象 —— 未加載的 模塊集合(modules),這個 modules 對象的 key 是一個路徑,value 是一個函數。你也許會問,這裏的模塊是什麼?它們又是如何加載的呢?
在細看產出代碼前,我們先豐富一下源代碼:
新文件 src/utils/math.js

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

修改src/index.js

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

console.log('Hello webpack!');
console.log('1 + 2: ', plus(1, 2));

我們按照 ES 規範的模塊化語法寫了一個簡單的模塊 src/utils/math.js,給 src/index.js 引用。Webpack 用自己的方式支持了 ES6 Module 規範,前面提到的 module 就是和 ES6 module 對應的概念。

接下來我們看一下這些模塊是如何通 ES5 代碼實現的。再次運行命令 webpack --config webpack.config.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 的參數部分,看看模塊的執行函數。

/*** 入口模塊 ./src/index.js ***/
"./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
  "use strict";
// 用於區分 ES 模塊和其他模塊規範,不影響理解 demo,戰略跳過。
  __webpack_require__.r(__webpack_exports__);
  /* harmony import */
 // 源模塊代碼中,`import {plus} from './utils/math.js';` 語句被 loader 解析轉化。
    // 加載 "./src/utils/math.js" 模塊,
  var _utils_math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/utils/math.js");
  console.log('Hello webpack!');
  console.log('1 + 2: ', Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__["plus"])(1, 2));
}),

"./src/utils/math.js": (function (module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_require__.r(__webpack_exports__);
  /* harmony export (binding) */
// 源模塊代碼中,`export` 語句被 loader 解析轉化。
  __webpack_require__.d(__webpack_exports__, "plus", function () {
    return plus;
  });
  const plus = (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 的簡單包裝.
引用工具模塊導出的變量後,入口模塊再執行它剩餘的部分。至此,Webpack 基本的模塊執行過程就結束了。

好了,我們用流程圖總結一下 Webpack 模塊的加載思路:
圖片描述

異步加載

有上面的打包我們發現將不同的打包進一個 main.js 文件。main.js 會集中消耗太多網絡資源,導致用戶需要等待很久纔可以開始與網頁交互。

一般的解決方式是:根據需求降低首次加載文件的體積,在需要時(如切換前端路由器,交互事件回調)異步加載其他文件並使用其中的模塊。

Webpack 推薦用 ES import() 規範來異步加載模塊,我們根據 ES 規範修改一下入口模塊的 import 方式,讓其能夠異步加載模塊:

src/index.js

console.log('Hello webpack!');

window.setTimeout(() => {
  import('./utils/math').then(mathUtil => {
  console.log('1 + 2: ' + mathUtil.plus(1, 2));
  });
}, 2000);

工具模塊(src/utils/math.js)依然不變,在webpack 配置裏,我們指定一下資源文件的公共資源路徑(publicPath),後面的探索過程中會遇到。

const path = require('path');
const MyPlugin = require('./src/MyPlugin.js')

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  },
  plugins:[
    new MyPlugin()
  ]
};

接着執行一下打包,可以看到除了 dist/main.js 外,又多了一個 dist/0.js ./src/utils/math.js。模塊從main chunk 遷移到了 0 chunk 中。而與 demo1 不同的是,main chunk 中添加了一些用於異步加載的代碼,我們概覽一下:

// webpackBootstrap
(function (modules) {
  // 加載其他 chunk 後的回調函數
  function webpackJsonpCallback(data) {
    // ...
  }

  // ...

  // 用於緩存 chunk 的加載狀態,0 爲已加載
  var installedChunks = {
    "main": 0
  };

  // 拼接 chunk 的請求地址
  function jsonpScriptSrc(chunkId) {
    // ...
  }

  // 同步 require 函數,內容不變
  function __webpack_require__(moduleId) {
    // ...
  }

  // 異步加載 chunk,返回封裝加載過程的 promise
  __webpack_require__.e = function requireEnsure(chunkId) {
    // ...
  }

  // ...

  // defineProperty 的包裝,內容不變
  __webpack_require__.d = function (exports, name, getter) {}

  // ...

  // 根據配置文件確定的 publicPath
  __webpack_require__.p = "/dist/";

  /**** JSONP 初始化 ****/
  var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
  var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
  jsonpArray.push = webpackJsonpCallback;
  jsonpArray = jsonpArray.slice();
  for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
  var parentJsonpFunction = oldJsonpFunction;
  /**** JSONP 初始化 ****/

  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  "./src/index.js": (function(module, exports, __webpack_require__) {

    document.write('Hello webpack!\n');

    window.setTimeout(() => {
      __webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, /*! ./utils/math */ "./src/utils/math.js")).then(mathUtil => {
        console.log('1 + 2: ' + mathUtil.plus(1, 2));
      });
    }, 2000);

  })
})

可以看到 webpackBootstrap 的函數體部分增加了一些內容,參數部分移除了 "./src/utils/math.js" 模塊。跟着包裹函數的執行順序,我們先聚焦到「JSONP 初始化」部分:

// 存儲 jsonp 的數組,首次運行爲 []
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];

// 保存 jsonpArray 的 push 函數,首次運行爲 Array.prototype.push
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);

// 將 jsonpArray 的 push 重寫爲 webpackJsonpCallback (加載其他 chunk 後的回調函數)
jsonpArray.push = webpackJsonpCallback;

// 將 jsonpArray 重置爲正常數組,push 重置爲 Array.prototype.push
jsonpArray = jsonpArray.slice();

// 由於 jsonpArray 爲 [],不做任何事
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);

// Array.prototype.push
var parentJsonpFunction = oldJsonpFunction;

初始化結束後,變化就是 window 上掛載了一個 webpackJsonp 數組,它的值爲 [];此外,這個數組的 push 被改寫爲 webpackJsonpCallback 函數,我們在後面會提到這些準備工作的作用。

接着是 __webpack_require__ 入口模塊,由於 __webpack_require__ 函數沒有改變,我們繼續觀察入口模塊執行函數有了什麼變化。

顯然,import('../utils/math.js') 被轉化爲__webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/utils/math.js"))。0 是 ./src/utils/math.js 所在 chunk id,「同步加載模塊」的邏輯拆分成了「先加載 chunk,完成後再加載模塊」。

我們翻到 __webpack_require__.e 的定義位置:

__webpack_require__.e = function requireEnsure(chunkId) {
  var promises = [];

  // installedChunks 是在 webpackBootstrap 中維護的 chunk 緩存
  var installedChunkData = installedChunks[chunkId];

  // chunk 未加載
  if(installedChunkData !== 0) {

    // installedChunkData 爲 promise 表示 chunk 加載中
    if(installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      /*** 首次加載 chunk: ***/
      // 初始化 promise 對象
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);

      // 創建 script 標籤加載 chunk
      var head = document.getElementsByTagName('head')[0];
      var script = document.createElement('script');
      var onScriptComplete;

      // ... 省略一些 script 屬性設置

      // src 根據 publicPath 和 chunkId 拼接
      script.src = jsonpScriptSrc(chunkId);

      // 加載結束回調函數,處理 script 加載完成、加載超時、加載失敗的情況
      onScriptComplete = function (event) {
        script.onerror = script.onload = null; // 避免 IE 內存泄漏問題
        clearTimeout(timeout);
        var chunk = installedChunks[chunkId];

        // 處理 script 加載完成,但 chunk 沒有加載完成的情況
        if(chunk !== 0) {
          // chunk 加載中
          if(chunk) {
            var errorType = event && (event.type === 'load' ? 'missing' : event.type);
            var realSrc = event && event.target && event.target.src;
            var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
            error.type = errorType;
            error.request = realSrc;

            // reject(error)
            chunk[1](error);
          }

          // 統一將沒有加載的 chunk 標記爲未加載
          installedChunks[chunkId] = undefined;
        }
      };

      // 設置 12 秒超時時間
      var timeout = setTimeout(function(){
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);

      script.onerror = script.onload = onScriptComplete;
      head.appendChild(script);

      /*** 首次加載 chunk ***/
    }
  }
  return Promise.all(promises);
};

看起來有點長,我們一步步剖析,先從第一行和最後一行來看,整個函數將異步加載的過程封裝到了 promise 中,最終導出。

接着從第二行開始,installedChunkData 從緩存中取值,顯然首次加載 chunk 時此處是 undefined。接下來,installedChunkDataundefined 值觸發了第一層 if 語句的判斷條件。緊接着進行到第二層 if 語句,此時根據判斷條件走入 else 塊,這裏 if 塊裏的內容我們先戰略跳過,else 裏主要有兩塊內容,一是 chunk 腳本加載過程,這個過程創建了一個 script 標籤,使其請求 chunk所在地址並執行 chunk 內容;二是初始化 promise ,並用 promise 控制 chunk 文件加載過程。

不過,我們只在這段 else 代碼塊中找到了 reject 的使用處,也就是在 chunk 加載異常時 chunk[1](error) 的地方,但並沒發現更重要的 resolve 的使用地點,僅僅是把 resolve 掛在了緩存上(installedChunks[chunkId] = [resolve, reject])

這裏的 chunk 文件加載下來會發生什麼呢?讓我們打開dist/0.js 一探究竟:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0], {
  "./src/utils/math.js":
    (function (module, __webpack_exports__, __webpack_require__) {

      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony export (binding) */
      __webpack_require__.d(__webpack_exports__, "plus", function () {
        return plus;
      });
      const plus = (a, b) => {
        return a + b;
      };
    })

}]);

我們發現了:

  1. 久違的 ./src/utils/math.js 模塊
  2. window["webpackJsonp"] 數組的使用地點

這段代碼開始執行,把異步加載相關的 chunk id 與模塊傳給 push 函數。而前面已經提到過,window["webpackJsonp"] 數組的 push 函數已被重寫爲 webpackJsonpCallback 函數,它的定義位置在 webpackBootstrap 中:

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];

  // then flag all "chunkIds" as loaded and fire callback
  var moduleId, chunkId, i = 0, resolves = [];

  // 將 chunk 標記爲已加載
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(installedChunks[chunkId]) {
      resolves.push(installedChunks[chunkId][0]);
    }
    installedChunks[chunkId] = 0;
  }

  // 把 "moreModules" 加到 webpackBootstrap 中的 modules 閉包變量中。
  for(moduleId in moreModules) {
    if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      modules[moduleId] = moreModules[moduleId];
    }
  }

  // parentJsonpFunction 是 window["webpackJsonp"] 的原生 push
  // 將 data 加入全局數組,緩存 chunk 內容
  if(parentJsonpFunction) parentJsonpFunction(data);

  // 執行 resolve 後,加載 chunk 的 promise 狀態變爲 resolved,then 內的函數開始執行。
  while(resolves.length) {
    resolves.shift()();
  }

};

走進這個函數中,意味着異步加載的 chunk 內容已經拿到,這個時候我們要完成兩件事,一是讓依賴這次異步加載結果的模塊繼續執行,二是緩存加載結果。

關於第一點,我們回憶一下之前 __webpack_require__.e 的內容,此時 chunk 還處於「加載中」的狀態,也就是說對應的 installedChunks[chunkId] 的值此時爲 [resolve, reject, promise]。 而這裏,chunk 已經加載,但 promise 還未決議,於是 webpackJsonpCallback 內部定義了一個 resolves 變量用來收集 installedChunks 上的 resolve 並執行它。

接下來說到第二點,就要涉及幾個層面的緩存了。

首先是 chunk 層面,這裏有兩個相關操作,操作一將 installedChunks[chunkId] 置爲 0 可以讓 __webpack_require__.e 在第二次加載同一 chunk 時返回一個立即決議的 promise(Promise.all([]));操作二將 chunk data 添加進 window["webpackJsonp"] 數組,可以在多入口模式時,方便地拿到已加載過的 chunk 緩存。通過以下代碼實現:

/*** 緩存執行部分 ***/
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// ...
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;
/*** 緩存執行部分 ***/

/*** 緩存添加部分 ***/
function webpackJsonpCallback(data) {
  //...
    // 此處的 parentJsonpFunction 是 window["webpackJsonp"] 數組的原生 push
    if (parentJsonpFunction) parentJsonpFunction(data);
  //...
}
/*** 緩存添加部分 ***/

而在 modules 層面,chunk 中的 moreModules 被合入入口文件的 modules 中,可供下一個微任務中的 __webpack_require__ 同步加載模塊。


({

  "./src/index.js":
    (function (module, exports, __webpack_require__) {
      console.log('Hello webpack!');
      window.setTimeout(() => {
        __webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/utils/math.js")).then(mathUtil => {
          console.log('1 + 2: ' + mathUtil.plus(1, 2));
        });
      }, 2000);
    })
});

__webpack_require__.e(0) 返回的 promise 決議後,__webpack_require__.bind(null, "./src/utils/math.js") 可以加載到 chunk 攜帶的模塊,並返回模塊作爲下一個微任務函數的入參,接下來就是 Webpack Loader 翻譯過的其他業務代碼了。

現在讓我們把異步流程梳理一下:
圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章