【webpack進階】前端執行時的模組化設計與實現

AlienZHOU發表於2018-08-27

你真的瞭解前端模組化麼?

告別「webpack配置工程師」

webpack是一個強大而複雜的前端自動化工具。其中一個特點就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行?但是,難道你真的只滿足於玩轉webpack配置麼?

顯然不是。在學習如何使用webpack之外,我們更需要深入webpack內部,探索各部分的設計與實現。萬變不離其宗,即使有一天webpack“過氣”了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。因此,在學習webpack過程中,我會總結一系列【webpack進階】的文章和大家分享。

歡迎感興趣的同學多多交流與關注!

1. 引言

下面進入正題。一直以來,在前端領域,開發人員日益增長的語言能力需求和落後的JavaScript規範形成了一大矛盾。例如,我們會用babel來進行ES6到ES5的語法轉換,會使用各種polyfill來相容老式上的新特性……而我們本文的主角 —— 模組化也是如此。

由於JavaScript在設計之初就沒有考慮這一點,加之模組化規範的遲到,導致社群中湧現出一系列前端執行時的模組化方案,例如RequireJS、seaJS等。以及與之對應的編譯期模組依賴解決方案,例如browserify、rollup和本文的主角webpack。

但是我們要知道,<script type="module">還存在一定的相容性與使用問題。

【webpack進階】前端執行時的模組化設計與實現

在更通用的範圍內來講,瀏覽器原生實際是不支援所謂的CommonJS或ESM模組化規範的。那麼webpack是如何在打包出的程式碼中實現模組化的呢?

2. NodeJS中的模組化

在探究webpack打包後程式碼的模組化實現前,我們先來看一下Node中的模組化。

NodeJS(以下簡稱為Node)在模組化上基本是遵循的CommonJS規範,而webpack打包出來的程式碼所實現模組化的方式,也類似於CommonJS。因此,我們先以熟悉的Node(這裡主要參考Node v10)作為引子,簡單介紹它的模組化實現,幫助我們接下來理解webpack的實現。

Node中的模組引入會經歷下面幾個步驟:

  1. 路徑分析
  2. 檔案定位
  3. 編譯執行

在Node中,模組以檔案維度存在,並且在編譯後快取於記憶體中,通過require.cache可以檢視模組快取情況。在模組中新增console.log(require.cache)檢視輸出如下:

{ '/Users/alienzhou/programming/gitrepo/test.js':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/alienzhou/programming/gitrepo/test.js',
     loaded: false,
     children: [],
     paths:
      [ '/Users/alienzhou/programming/gitrepo/node_modules',
        '/Users/alienzhou/programming/node_modules',
        '/Users/alienzhou/node_modules',
        '/Users/node_modules',
        '/node_modules' ] } }
複製程式碼

上面就是模組物件的資料結構,也可以在Node原始碼中找到Module類的構造方法。其中exports屬性非常重要,它就是模組的匯出物件。因此,下面這行語句

var test = require('./test.js');
複製程式碼

其實就是把test.js模組的exports屬性賦值給test變數。

也許你還會好奇,當我們寫一個Node(JavaScript)模組時,模組裡的modulerequire__filename等這些變數是哪來的?如果你看過Node loader.js 部分原始碼,應該就大致能理解:

Module.wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];
複製程式碼

Node會自動將每個模組進行包裝(wrap),將其變為一個function。例如模組test.js原本為:

console.log(require.cache);
module.exports = 'test';
複製程式碼

包裝後大致會變為:

(function (exports, require, module, __filename, __dirname) {
    console.log(require.cache);
    module.exports = 'test';
});
複製程式碼

這下你應該明白modulerequire__filename這些變數都是哪來的了吧 —— 它們會被作為function的引數在模組編譯執行時注入進來。以一個副檔名為.js的模組為例,當你require它時,一個完整的方法呼叫大致包括下面幾個過程:

st=>start: require()引入模組
op1=>operation: 呼叫._load()載入模組
op2=>operation: new Module(filename, parent)建立模組物件
op3=>operation: 將模組物件存入快取
op4=>operation: 根據檔案型別呼叫Module._extensions
op5=>operation: 呼叫.compile()編譯執行js模組
cond=>condition: Module._cache是否無快取
e=>end: 返回module.exports結果
st->op1->cond
cond(yes)->op2->op3->op4->op5->e
cond(no)->e
複製程式碼

Node原始碼中能看到,模組執行時,包裝定義的幾個變數被注入了:

if (inspectorWrapper) {
    result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
                              require, this, filename, dirname);

} else {
    result = compiledWrapper.call(this.exports, this.exports, require, this,
                                  filename, dirname);
}

複製程式碼

題外話,從這裡你也可以看出,在模組內使用module.exportsexports的區別

3. webpack實現的前端模組化

之所以在介紹「webpack是如何在打包出的程式碼中實現模組化」之前,先用一定篇幅介紹了Node中的模組化,是因為兩者在同步依賴的設計與實現上有異曲同工之處。理解Node的模組化對學習webpack很有幫助。當然,由於執行環境的不同(webpack打包出的程式碼執行在客戶端,而Node是在服務端),實現上也有一定的差異。

下面就來看一下,webpack是如何在打包出的程式碼中實現前端(客戶端)模組化的。

3.1. 模組物件

和Node的模組化實現類似,在webpack打包出的程式碼中,每個模組也有一個對應的模組物件。在__webpack_require__()方法中,有這麼一段程式碼:

function __webpack_require__(moduleId) {
    // …… other code
    
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {},
        parents: null,
        children: []
    };
    
    // …… other code
}
複製程式碼

類似於Node,在webpack中各個模組的也有對應的模組物件,其資料結構基本遵循CommonJS規範;其中installedModules則是模組快取物件,類似於Node中的require.cache/Module._cache

2.2. 模組的require:__webpack_require__

__webpack_require__是webpack前端執行時模組化中非常重要的一個方法,相當於CommonJS規範中的require

根據第一部分的流程圖:在Node中,當我們require一個模組時,會先判斷該模組是否在快取之中,如果存在則直接返回該模組的exports屬性;否則會載入並執行該模組。webpack中的實現也類似:

function __webpack_require__(moduleId) {
    // 1.首先會檢查模組快取
    if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    
    // 2. 快取不存在時,建立並快取一個新的模組物件,類似Node中的new Module操作
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {},
        children: []
    };

    // 3. 執行模組,類似於Node中的:
    // result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    module.l = true;

    // 4. 返回該module的輸出
    return module.exports;
}
複製程式碼

如果你仔細對比webpack與Node,你會發現在__webpack_require__中有一個重要的區別:

在webpack中不存在像Node一樣呼叫._compile()這種方法的過程。即不會像Node那樣,對一個未載入快取的模組,通過「讀取模組路徑 -> 編譯模組程式碼 -> 執行模組」來載入模組。為什麼呢?

這是因為,Node作為服務端語言,模組都是本地檔案,載入時延低,可同步阻塞進行模組檔案定址、讀取、編譯和執行,這些過程在模組require的時候再“按需”執行即可;而webpack執行在客戶端(瀏覽器),顯然不能在需要時(即執行__webpack_require__時)再通過網路載入js檔案,並同步地等待載入完成後再返回__webpack_require__。這種網路時延,顯然不能滿足“同步依賴”的要求。

那麼webpack是如何解決這個問題的呢?

3.2. 如何解決前端的同步依賴

我們還是回來看下Node:

Node(v10)中載入、編譯與執行(js)模組的程式碼主要集中在Module._extensions['.js']Module.prototype._compile中。首先會通過fs.readFileSync讀取檔案內容,然後通過vm.runInThisContext來編譯和執行JavaScript程式碼。

The vm module provides APIs for compiling and running code within V8 Virtual Machine contexts.

但是,根據上面的分析,在前端runtime中肯定不能通過網路去同步獲取JavaScript指令碼檔案;那麼就需要我們換一個思路:有沒有什麼地方能夠預先放置我們“之後”可能會需要的模組,讓我們能夠在require時不需要同步等待過長的時間(當然,這裡的“之後”可能是幾秒、幾分鐘後,也可能是這次事件迴圈task的下幾行程式碼)。

記憶體就是一個不錯的選擇。我們可以把同步依賴的模組先“註冊”到記憶體中(模組暫存),等到require時,再執行該模組、快取模組物件、返回對應的exports。而webpack中,這個所謂的記憶體就是modules物件。

注意這裡指的模組暫存和模組快取概念完全不同。暫存可以粗略類比為將編譯好的模組程式碼先放到記憶體中,實際並沒有引入該模組。基於這個目的,我們也可以把“模組暫存”理解為“模組註冊”,因此後文中“模組暫存”與“模組註冊”具有相等的概念。

所以,過程大致是這樣的:

當我們已經獲取了模組內容後(但模組還未執行),我們就將其暫存在modules物件中,鍵就是webpack的moduleId;等到需要使用__webpack_require__引用模組時,發現快取中沒有,則從modules物件中取出暫存的模組並執行。

3.3. 如何”暫存“模組

思路已經清晰了,那麼我們就來看看,webpack是如何將模組“暫存”在modules物件上的。在實際上,webpack打包出來的程式碼可以簡單分為兩類:

  • 一類是webpack模組化的前端runtime,你可以簡單類比為RequireJS這樣的前端模組化類庫所實現的功能。它會控制模組的載入、快取,提供諸如__webpack_require__這樣的require方法等。
  • 另一類則是模組註冊與執行的程式碼,包含了原始碼中的模組程式碼。為了進一步理解,我們先來看一下這部分的程式碼是怎樣的。

為了便於學習與程式碼閱讀,建議你可以在webpack(v4)配置中加入optimization:{runtimeChunk: {name: 'runtime'}},這樣會讓webpack將runtime與模組註冊程式碼分開打包。

// webpack module chunk
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["home-0"],{

/***/ "module-home-0":
/***/ (function(module, exports, __webpack_require__) {

const myalert = __webpack_require__("module-home-1");

myalert('test');

/***/ }),

/***/ "module-home-1":
/***/ (function(module, exports) {

module.exports = function (a) {
    alert('hi:' + a);
};

/***/ })

},[["module-home-0","home-1"]]]);
複製程式碼

上面這是一個不包含runtime的chunk,我們不妨將其稱為module chunk(下面會沿用這個叫法)。簡化一下這部分程式碼,大致結構如下:

// webpack module chunk
window["webpackJsonp"].push([
    ["home-0"], // chunkIds
    {
        "module-home-0": (function(module, exports, __webpack_require__){ /* some logic */ }),
        "module-home-1": (function(module, exports, __webpack_require__){ /* some logic */ })
    },
    [["module-home-0","home-1"]]
])
複製程式碼

這裡,.push()方法引數為一個陣列,包含三個元素:

  • 第一個元素是一個陣列,["home-0"]表示該js檔案所包含的所有chunk的id(可以粗略理解為,webpack中module組成chunk,chunk又組成file);
  • 第二個元素是一個物件,鍵是各個模組的id,值則是一個被function包裝後的模組;
  • 第三個元素也是一個陣列,其又是由多個陣列組成。具體作用我們先按下不表,最後再說。

來看下引數陣列的第二個元素 —— 包含模組程式碼的物件,你會發現這裡方法簽名是不是很像Node中的通過Module.wrap()進行的模組程式碼包裝?沒錯,webpack原始碼中也有類似,會像Node那樣,將每個模組的程式碼用一個function包裝起來。

而當webpack配置了runtime分離後,打包出的檔案中會出現一個“純淨”的、不包含任何模組程式碼的runtime,其主要是一個自執行方法,其中暴露了一個全域性變數webpackJsonp

// webpack runtime chunk
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
複製程式碼

webpackJsonp變數名可以通過output.jsonpFunction進行配置

可以看到,window["webpackJsonp"]上的.push()方法已經被修改為了webpackJsonpCallback()方法。該方法如下:

// webpack runtime chunk
function webpackJsonpCallback(data) {
    var chunkIds = data[0];
    var moreModules = data[1];
    var executeModules = data[2];

    var moduleId, chunkId, i = 0, resolves = [];
    // webpack會在installChunks中儲存chunk的載入狀態,據此判斷chunk是否載入完畢
    for(;i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if(installedChunks[chunkId]) {
            resolves.push(installedChunks[chunkId][0]);
        }
        installedChunks[chunkId] = 0;
    }
    
    // 注意,這裡會進行“註冊”,將模組暫存入記憶體中
    // 將module chunk中第二個陣列元素包含的 module 方法註冊到 modules 物件裡
    for(moduleId in moreModules) {
        if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            modules[moduleId] = moreModules[moduleId];
        }
    }

    if(parentJsonpFunction) parentJsonpFunction(data);

    while(resolves.length) {
        resolves.shift()();
    }

    deferredModules.push.apply(deferredModules, executeModules || []);

    return checkDeferredModules();
};
複製程式碼

注意以上方法的這幾行,就是我們之前所說的「將模組“暫存”在modules物件上」

// webpackJsonpCallback
for(moduleId in moreModules) {
    if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
    modules[moduleId] = moreModules[moduleId];
    }
}
複製程式碼

配合__webpack_require__()中下面這一行程式碼,就實現了在需要引入模組時,同步地將模組從暫存區取出來執行,避免使用網路請求導致過長的同步等待時間。

// __webpack_require__
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
複製程式碼

3.4. 模組的自動執行

到目前為止,對於webpack的同步依賴實現已經介紹的差不多了,但還遺留一個小問題:webpack中的所有js原始檔都是模組,但如果都是不會自動執行的模組,那我們只是在前端引入了一堆“死”程式碼,怎麼讓程式碼“活”起來呢?

很多時候,我們引入一個script標籤載入指令碼檔案,至少希望其中一個模組的程式碼會自動執行,而不僅僅是註冊在modules物件上。一般來說,這就是webpack中所謂的入口模組。

webpack是如何讓這些入口模組自動執行的呢?不知道你是否還記得module chunk中那個按下不表的第三個引數:這個引數是一個陣列,而陣列裡面每個元素又是一個陣列

[["module-home-0","home-1"], ["module-home-2","home-3","home-5"]]
複製程式碼

對照上面這個例子,我們可以具體解釋下引數的含義。第一個元素["module-home-0","home-1"]表示,我希望自動執行moduleId為module-home-0的這個模組,但是該模組需要chunkId為home-1的chunk已經載入後才能執行;同理,["module-home-2","home-3","home-5"]表示自動執行module-home-2模組,但是需要檢查chunkhome-3home-5已經載入。

執行某些模組需要保證一些chunk已經載入是因為,該模組所依賴的其他模組可能並不在當前chunk中,而webpack在編譯期會通過依賴分析自動將依賴模組的所屬chunkId注入到此處。

這個模組“自動”執行的功能在runtime chunk的程式碼中主要是由checkDeferredModules()方法實現:

function checkDeferredModules() {
    var result;
    for(var i = 0; i < deferredModules.length; i++) {
        var deferredModule = deferredModules[i];
        var fulfilled = true;
        // 第一個元素是模組id,後面是其所需的chunk
        for(var j = 1; j < deferredModule.length; j++) {
            var depId = deferredModule[j];
            // 這裡會首先判斷模組所需chunk是否已經載入完畢
            if(installedChunks[depId] !== 0) fulfilled = false;
        }
        // 只有模組所需的chunk都載入完畢,該模組才會被執行(__webpack_require__)
        if(fulfilled) {
            deferredModules.splice(i--, 1);
            result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
        }
    }
    return result;
}
複製程式碼

4. 非同步依賴

如果你只是想學習webpack前端runtime中同步依賴的設計與實現,那麼到這裡主要內容基本已經結束了。不過我們知道,webpack支援使用動態模組引入的語法(程式碼拆分),例如:dynamic import和早期的require.ensure,這種方式與使用CommonJS的require和ESM的import最重要的區別在於,該類方法會非同步(或者說按需)載入依賴。

4.1. 程式碼轉換

就像在原始碼中使用require會在webpack打包時被替換為__webpack_require__一樣,在原始碼中使用的非同步依賴語法也會被webpack修改。以dynamic import為例,下面的程式碼

import('./test.js').then(mod => {
    console.log(mod);
});
複製程式碼

在產出後會被轉換為

__webpack_require__.e(/* import() */ "home-1")
    .then(__webpack_require__.bind(null, "module-home-3"))
    .then(mod => {
        console.log(mod);
    });
複製程式碼

上面程式碼是什麼意思呢?我們知道,webpack打包後會將一些module合併為一個chunk,因此上面的"home-1"就表示:包含./test.js模組的chunk的chunkId為"home-1"

webpack首先通過__webpack_require__.e載入指定chunk的script檔案(module chunk),該方法返回一個promise,當script載入並執行完成後resolve該promise。webpack打包時會保證非同步依賴的所有模組都已包含在該module chunk或當前上下文中。

既然module chunk已經執行,那麼表明非同步依賴已經就緒,於是在then方法中執行__webpack_require__引用test.js模組(webpack編譯後moduleId為module-home-3)並返回。這樣在第二個then方法中就可以正常使用該模組了。

4.2. __webpack_require__.e

非同步依賴的核心方法就是__webpack_require__.e。下面來分析一下該方法:

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    var installedChunkData = installedChunks[chunkId];
    
    // 判斷該chunk是否已經被載入,0表示已載入。installChunk中的狀態:
    // undefined:chunk未進行載入,
    // null:chunk preloaded/prefetched
    // Promise:chunk正在載入中
    // 0:chunk載入完畢
    if(installedChunkData !== 0) {
        // chunk不為null和undefined,則為Promise,表示載入中,繼續等待
        if(installedChunkData) {
            promises.push(installedChunkData[2]);
        } else {
            // 注意這裡installChunk的資料格式
            // 從左到右三個元素分別為resolve、reject、promise
            var promise = new Promise(function(resolve, reject) {
                installedChunkData = installedChunks[chunkId] = [resolve, reject];
            });
            promises.push(installedChunkData[2] = promise);

            // 下面程式碼主要是根據chunkId載入對應的script指令碼
            var head = document.getElementsByTagName('head')[0];
            var script = document.createElement('script');
            var onScriptComplete;

            script.charset = 'utf-8';
            script.timeout = 120;
            if (__webpack_require__.nc) {
                script.setAttribute("nonce", __webpack_require__.nc);
            }
            
            // jsonpScriptSrc方法會根據傳入的chunkId返回對應的檔案路徑
            script.src = jsonpScriptSrc(chunkId);

            onScriptComplete = function (event) {
                script.onerror = script.onload = null;
                clearTimeout(timeout);
                var chunk = installedChunks[chunkId];
                if(chunk !== 0) {
                    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;
                        chunk[1](error);
                    }
                    installedChunks[chunkId] = undefined;
                }
            };
            var timeout = setTimeout(function(){
                onScriptComplete({ type: 'timeout', target: script });
            }, 120000);
            script.onerror = script.onload = onScriptComplete;
            head.appendChild(script);
        }
    }
    return Promise.all(promises);
};
複製程式碼

該方法首先會根據chunkId在installChunks中判斷該chunk是否正在載入或已經被載入;如果沒有則會建立一個promise,將其儲存在installChunks中,並通過jsonpScriptSrc()方法獲取檔案路徑,通過sciript標籤載入,最後返回該promise。

jsonpScriptSrc()則可以理解為一個包含chunk map的方法,例如這個例子中:

function jsonpScriptSrc(chunkId) {
    return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"home-1":"0b49ae3b"}[chunkId] + ".js"
}
複製程式碼

其中包含一個map —— {"home-1":"0b49ae3b"},會根據home-1這個chunkId返回home-1.0b49ae3b.js這個檔名。

4.3. 更新chunk載入狀態

最後,你會發現,在onload中,並沒有呼叫promise的resolve方法。那麼是何時resolve的呢?

你還記得在介紹同步require時用於註冊module的webpackJsonpCallback()方法麼?我們之前說過,該方法引數陣列中的第一個元素是一個chunkId的陣列,代表了該指令碼所包含的chunk。

p.s. 當一個普通的指令碼被瀏覽器下載完畢後,會先執行該指令碼,然後觸發onload事件。

因此,在webpackJsonpCallback()方法中,有一段程式碼就是根據chunkIds的陣列,檢查並更新chunk的載入狀態:

// webpackJsonpCallback()
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;
}

// ……

while(resolves.length) {
    resolves.shift()();
}
複製程式碼

上面的程式碼先根據模組註冊時的chunkId,取出installedChunks對應的所有loading中的chunk,最後將這些chunk的promise進行resolve操作。

5. 寫在最後

至此,對於「webpack打包後是如何實現前端模組化」這個問題就差不多結束了。本文通過Node中的模組化為引子,介紹了webpack中的同步與非同步模組載入的設計與實現。

為了方便大家對照文中內容檢視webpack執行時原始碼,我把基礎的webpack runtime chunk和module chunk放在了這裡,有興趣的朋友可以對照著看。

最後還是歡迎對webpack感興趣的朋友能夠相互交流,關注我的系列文章。

參考資料

相關文章