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

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

告別「webpack配置工程師」

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

顯然不是。在學習如何使用webpack以外,咱們更須要深刻webpack內部,探索各部分的設計與實現。萬變不離其宗,即便有一天webpack「過氣」了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。所以,在學習webpack過程當中,我會總結一系列【webpack進階】的文章和你們分享。前端

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

1. 引言

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

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

可是咱們要知道,<script type="module">還存在必定的兼容性與使用問題。git

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

2. NodeJS中的模塊化

在探究webpack打包後代碼的模塊化實現前,咱們先來看一下Node中的模塊化。web

NodeJS(如下簡稱爲Node)在模塊化上基本是遵循的CommonJS規範,而webpack打包出來的代碼所實現模塊化的方式,也相似於CommonJS。所以,咱們先以熟悉的Node(這裏主要參考Node v10)做爲引子,簡單介紹它的模塊化實現,幫助咱們接下來理解webpack的實現。json

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感興趣的朋友可以相互交流,關注個人系列文章。

參考資料

相關文章
相關標籤/搜索