記一次對webpack打包後程式碼的失敗探究

linzx發表於2019-03-04

記得4月新出了webpack4,這個月剛好沒什麼事情,用webpack4又重新去搭了一遍自己的專案。在搭專案的途中,忽然對webpack模組化之後的程式碼起了興趣,於是想搞清楚我們引入的檔案到底是怎麼執行的。

1、基本版——單入口引入一個js檔案

所謂的基本版,就是我只引入了一個test.js,程式碼只有一行var a = 1。打包之後,發現生成的檔案main.js並沒有多少程式碼,只有90行不到。

擷取出真正執行的程式碼就更加少了,只有下面4行。我們接下去就從這幾行程式碼中看下打包出來的檔案的執行流程是怎麼樣的。

(function(modules) {
    //新建一個物件,記錄匯入了哪些模組
    var installedModules = {};
    
    // The require function 核心執行方法
    function __webpack_require__(moduleId){/*內容暫時省略*/}
    
    // expose the modules object (__webpack_modules__) 記錄傳入的modules作為私有屬性
    __webpack_require__.m = modules;
    
    // expose the module cache 快取物件,記錄了匯入哪些模組
    __webpack_require__.c = installedModules;
    
    
    // Load entry module and return exports 預設將傳入的陣列第一個元素作為引數傳入,這個s應該是start的意思了
    return __webpack_require__(__webpack_require__.s = 0);
})([(function(module, exports, __webpack_require__) {
/* 0 */
    var a = 1;
/***/ })
/******/ ])
複製程式碼

首先很明顯,整個檔案是個自執行函式。傳入了一個陣列引數modules

這個自執行函式內部一開始新建了一個物件installedModules,用來記錄打包了哪些模組。

然後新建了函式__webpack_require__,可以說整個自執行函式最核心的就是__webpack_require____webpack_require__有許多私有屬性,其中就有剛剛新建的installedModules

最後自執行函式return__webpack_require__,並傳入了一個引數0。因為__webpack_require__的傳參變數名稱叫做moduleId,那麼傳參傳進來的也就是*模組id**。所以我大膽猜測這個0可能是某個模組的id。

這時候我瞄到下面有一行註釋/* 0 */。可以發現webpack會在每一個模組匯入的時候,會在打包模組的頂部寫上一個id的註釋。那麼剛才那個0就能解釋了,就是我們引入的那個模組,由於是第一個模組,所以它的id是0。

那麼當傳入了moduleId之後,__webpack_require__內部發生了什麼?

__webpack_require__解析

function __webpack_require__(moduleId) {
    // Check if module is in cache 
    // 檢查快取物件中是否有這個id,判斷是否首次引入
    if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache) 新增到.c快取裡面
    var module = installedModules[moduleId] = {
    	i: moduleId,
    	l: false,
    	exports: {}
    };
    // Execute the module function 執行通過moduleId獲取到的函式
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // Flag the module as loaded
    // 表示module物件裡面的模組載入了
    module.l = true;
    // Return the exports of the module
    return module.exports;
}
複製程式碼

首先通過moduleId判斷這個模組是否引入過。如果已經引入過的話,則直接返回。否則installedModules去記錄下這次引入。這樣子如果別的檔案也要引入這個模組的話,避免去重複執行相同的程式碼。

然後通過modules[moduleId].call去執行了引入的JS檔案。

看完這個函式之後,大家可以發現其實webpack打包之後的檔案並沒有什麼很複雜的內容嘛。當然這很大一部分原因是因為我們的場景太簡單了,那麼接下來就增加一點複雜性。

2、升級版——單入口引入多個檔案

接下來我修改一下webpack入口,單個入口同時下引入三個個檔案

entry: [path.resolve(__dirname, `../src/test.js`),path.resolve(__dirname, `../src/test2.js`),path.resolve(__dirname, `../src/test3.js`)],
複製程式碼

三個檔案的內容分別為var a = 1,var b = 2,var c = 3。接下來我們可以看看打包之後的程式碼

打包之後的檔案main.js核心內容並沒有發生變化,和上面一模一樣。但是這個自執行函式傳入的引數卻發生了變化。

(function(modules) {
    /*這部分內容省略,和前面一模一樣*/
})([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
        __webpack_require__(1);
        __webpack_require__(2);
        module.exports = __webpack_require__(3);
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
        var a = 1;
/***/ }),
/* 2 */
/***/ (function(module, exports, __webpack_require__) {
        var b = 2;
/***/ })
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
        var c = 3;
/***/ })
/******/ ]);
複製程式碼

前面說過,自執行函式預設將傳入的引數陣列的第一個元素傳入__webpack_require__執行程式碼。

我們可以看一下傳入第一個引數的內容,在上一章中是我們引入的檔案內容var a = 1,但是這裡卻不是了。而是按模組引入順序執行函式__webpack_require__(1),__webpack_require__(2),__webpack_require__(3),通過__webpack_require__函式去執行了我們引入的程式碼。

大家可以先想一下這裡的1,2,3是怎麼來的,為什麼可以函式呼叫的時候,直接傳參1,2,3

不過到這裡還不明白,module.exports到底起了什麼作用,如果起作用,為什麼又只取最後一個呢?

3.升級版——多入口,多檔案引入方式

因為好奇如果多入口多檔案是怎麼樣的,接下去我又將入口改了一下,變成了下面這樣

entry: {
    index1: [path.resolve(__dirname, `../src/test1.js`)],
    index2: [path.resolve(__dirname, `../src/test2.js`),path.resolve(__dirname, `../src/test3.js`)],
},
複製程式碼

打包生成了index1.jsindex2.js。發現index1.js和第一章講的一樣,index2.js和第二個檔案一樣。並沒有什麼讓我很意外的東西。

4、進階版——引入公共模組

在前面的打包檔案中,我們發現每個模組id似乎是和引入順序有關的。而在我們日常開發環境中,必然會引入各種公共檔案,那麼webpack會怎麼處理這些id呢

於是我們在配置檔案中新增了webpack.optimize.SplitChunksPlugin外掛。

webpack2和3版本中是webpack.optimize.CommonsChunkPlugin外掛。但是在webpack4進行了一次優化改進,想要了解的可以看一下這篇文章webpack4:程式碼分割CommonChunkPlugin的壽終正寢。所以這裡的程式碼將是使用webpack4打包出來的。

然後修改一下配置檔案中的入口,我們開了兩個入口,並且兩個入口都引入了test3.js這個檔案

entry: {
        index1: [path.resolve(__dirname, `../src/test.js`),path.resolve(__dirname, `../src/test3.js`)],
        index2: [path.resolve(__dirname, `../src/test2.js`),path.resolve(__dirname, `../src/test3.js`)],
    },
複製程式碼

可以看到,打包後生成了3個檔案。

<script type="text/javascript" src="scripts/bundle.4474bdd2169853ce33a7.js"></script>
<script type="text/javascript" src="scripts/index1.4474bdd2169853ce33a7.js"></script>
<script type="text/javascript" src="scripts/index2.4474bdd2169853ce33a7.js"></script>
複製程式碼

首先bundle.js(檔名自己定義的)很明顯是一個公共檔案,裡面應該有我們提取test3.js出來的內容。開啟檔案後,發現裡面的程式碼並不多,只有下面幾行。

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[2],{
/***/ 2:
/***/ (function(module, exports, __webpack_require__) {
var c = 1;
/***/ })
}]);
複製程式碼

單純看檔案內容,我們大概能推測出幾點:

  • window全域性環境下有一個名為webpackJsonp的陣列
  • 陣列的第一個元素仍然是陣列,記錄了數字2,應該是這個模組的id
  • 陣列第二個元素是一個記錄了形式為{模組id:模組內容}的物件。
  • 物件中的模組內容就是我們test3.js,被一個匿名函式包裹

webpack2中,採用的是{檔案路徑:模組內容}的物件形式。不過在升級到webpack3中優化採用了數字形式,為了方便提取公共模組。

注意到一點,這個檔案中的2並不像之前一樣作為註釋的形式存在了,而是作為屬性名。但是它為什麼直接就將這個模組id命名為2呢,目前來看,應該是這個模組是第二個引入的。帶著這個想法,我接下去看了打包出來的index1.js檔案

擷取出了真正執行並且有用的程式碼出來。

// index1.js
(function(modules) { // webpackBootstrap
    // install a JSONP callback for chunk loading
    function webpackJsonpCallback(){
        /*暫時省略內容*/
        return checkDeferredModules
    }
    
    function checkDeferredModules(){/*暫時省略內容*/}
    
    // The module cache
    var installedModules = {};
    
    // object to store loaded and loading chunks
    // undefined = chunk not loaded, null = chunk preloaded/prefetched
    // Promise = chunk loading, 0 = chunk loaded
    var installedChunks = {
    	0: 0
    };
    
    var deferredModules = [];   //
    
    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;
    
    
    // add entry module to deferred list
    deferredModules.push([0,2]);
    // run deferred modules when ready
    return checkDeferredModules();
    
})([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
    __webpack_require__(1);
    module.exports = __webpack_require__(2);
    /***/ }),
    /* 1 */
    /***/ (function(module, exports, __webpack_require__) {
        var a = 1;
    /***/ })
/******/ ]);
複製程式碼

在引入webpack.optimize.SplitChunksPlugin之後,核心程式碼在原來基礎上新增了兩個函式webpackJsonpCallbackcheckDeferredModules。然後在原來的installedModules基礎上,多了一個installedModules,用來記錄了模組的執行狀態;一個deferredModules,暫時不知道幹嘛,看名字像是儲存待執行的模組,等到後面用到時再看。

此外,還有這個自執行函式最後一行程式碼呼叫形式不再像之前一樣。之前是通過呼叫__webpack_require__(0),現在則變成了checkDeferredModules。那麼我們便順著它現在的呼叫順序再去分析一下現在的程式碼。

在分析了不同之後,接下來就按照執行順序來檢視程式碼,首先能看到一個熟悉的變數名字webpackJsonp。沒錯,就是剛才bundle.js中暴露到全域性的那個陣列。由於在html中先引入了bundle.js檔案,所以我們可以直接從全域性變數中獲取到這個陣列。

前面已經簡單分析過window["webpackJsonp"]了,就不細究了。接下來這個陣列進行了一次for迴圈,將陣列中的每一個元素傳參給了方法webpackJsonpCallback。而在這裡的演示中,傳入就是我們bundle.js中一個包含模組資訊的陣列[[2],{2:fn}}]

接下來就看webpackJsonpCallback如何處理傳進來的引數了

webpackJsonpCallback簡析

/******/ 	function webpackJsonpCallback(data) {
/******/ 		var chunkIds = data[0]; // 模組id
/******/ 		var moreModules = data[1];  // 提取出來的公共模組,也就是檔案內容
/******/ 		var executeModules = data[2];   // 需要執行的模組,但演示中沒有
/******/ 		// add "moreModules" to the modules object,
/******/ 		// then flag all "chunkIds" as loaded and fire callback
/******/ 		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;
/******/ 		}
/******/ 		for(moduleId in moreModules) {
/******/ 			if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ 				modules[moduleId] = moreModules[moduleId];
/******/ 			}
/******/ 		}
/******/ 		if(parentJsonpFunction) parentJsonpFunction(data);
/******/
/******/ 		while(resolves.length) {
/******/ 			resolves.shift()();
/******/ 		}
/******/
/******/ 		// add entry modules from loaded chunk to deferred list
/******/ 		deferredModules.push.apply(deferredModules, executeModules || []);
/******/
/******/ 		// run deferred modules when all chunks ready
/******/ 		return checkDeferredModules();
/******/ 	};
複製程式碼

這個函式中主要乾了兩件事情,分別是在那兩個for迴圈中。

一是在installedChunks物件記錄引入的公共模組id,並且將這個模組標為已經匯入的狀態0

installedChunks[chunkId] = 0;
複製程式碼

然後在另一個for迴圈中,設定傳引數組modules的資料。我們公共模組的id是2,那麼便設定modules陣列中索引為2的位置為引入的公共模組函式。

modules[moduleId] = moreModules[moduleId];
//這段程式碼在我們的例子中等同於 modules[2] = (function(){/*test3.js公共模組中的程式碼*/})
複製程式碼

其實當看到這段程式碼時,心裡就有個疑問了。因為index1.js中設定modulesp[2]這個操作並不是一個push操作,如果說陣列索引為2的位置已經有內容了呢?暫時保留著心中的疑問,繼續走下去。心中隱隱感覺到這個打包後的程式碼其實並不是一個獨立的產物了。

我們知道modules是傳進來的一個陣列引數,在第二個章節中可以看到,我們會在最後執行函式__webpack_require__(0),然後依順序去執行所有引入模組。

不過這次卻和以前不一樣了,可以看到webpackJsonpCallback最後返回的程式碼是checkDeferredModules。前面也說了整個自執行函式最後返回的函式也是checkDeferredModules,可以說它替代了__webpack_require__(0)。接下去就去看看checkDeferredModules發生了什麼

checkDeferredModules簡析

/******/ 	function checkDeferredModules() {
/******/ 		var result;
/******/ 		for(var i = 0; i < deferredModules.length; i++) {
/******/ 			var deferredModule = deferredModules[i];
/******/ 			var fulfilled = true;
/******/ 			for(var j = 1; j < deferredModule.length; j++) {
/******/ 				var depId = deferredModule[j];
/******/ 				if(installedChunks[depId] !== 0) fulfilled = false;
/******/ 			}
/******/ 			if(fulfilled) {
/******/ 				deferredModules.splice(i--, 1);
/******/ 				result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
/******/ 			}
/******/ 		}
/******/ 		return result;
/******/ 	}
複製程式碼

這個函式關鍵點似乎是在deferredModules,但是我們剛才webpackJsonpCallback唯一涉及到這個的只有這麼一句,並且executeModules其實是沒有內容的,所以可以說是空陣列。

deferredModules.push.apply(deferredModules, executeModules || []);
複製程式碼

既然沒有內容,那麼webpackJsonpCallback就只能結束函式了。回到主執行緒,發現下面馬上是兩句程式碼,得,又繞回來了。

// add entry module to deferred list
deferredModules.push([0,2]);
// run deferred modules when ready
return checkDeferredModules();
複製程式碼

不過現在就有deferredModules這個陣列終於有內容了,一次for迴圈下來,最後去執行我們模組的程式碼仍然是這一句

result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
複製程式碼

很熟悉,有木有,最後還是回到了__webpack_require__,然後就是熟悉的流程了

__webpack_require__(1);
module.exports = __webpack_require__(2);
複製程式碼

但是當我看到這個內容竟然有這行程式碼時__webpack_require__(2);還是有點崩潰的。為什麼?因為它程式碼明確直接執行了__webpack_require__(2)。但是2這個模組id是通過在全域性屬性webpackJsonp獲得的,程式碼不應該明確知道的啊。

我原來以為的執行過程是,每個js檔案通過全域性變數webpackJsonp獲得到公共模組id,然後push到自執行函式傳引數組modules。那麼等到真正執行的時候,會按照for迴圈依次執行陣列內的每個函式。它不會知道有1,2這種明確的id的。

為什麼我會這麼想呢?因為我一開始認為每個js檔案都是獨立的,想互動只能通過全域性變數來。既然是獨立的,我自然不知道公共模組id是2事實上,webpackJsonp的確是驗證了我的想法。

可惜結果跟我想象的完全不一樣,在index1.js直接指定執行哪些模組。這隻能說明一個事情,其實webpack內部已經將所有的程式碼順序都確定好了,而不是在js檔案中通過程式碼來確定的。事實上,當我去檢視index2.js檔案時,更加確定了我的想法。

/******/ (function(modules) {/*內容和index1.js一樣*/})
/************************************************************************/
/******/ ([
/* 0 */,
/* 1 */,
/* 2 */,
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
    __webpack_require__(4);
    module.exports = __webpack_require__(2);
/***/ }),
/* 4 */
/***/ (function(module, exports, __webpack_require__) {
    var b = 2;
/***/ })
/******/ ]);
//# sourceMappingURL=index2.19eeab4e90ee99ee1ce4.js.map
複製程式碼

仔細檢視自執行函式的傳引數組,發現它的第0,1,2位都是undefined。我們知道這幾個數字其實就是每個模組本身的Id。而這幾個id恰恰就是index1.jsbundle.js中的模組。理論上來說在瀏覽器下執行,index2.js應該無法得知的,但是事實卻完全相反。

走到這一步,我對webpack打包後的程式碼也沒有特別大的慾望了,webpack內部實現才是更重要的了。好了,不說了,我先去看網上webpack的原始碼解析了,等我搞明白了,再回來寫續集。

文章首發於我的github上,覺得不錯的可以去點個贊

相關文章