當(dāng)面試官問Webpack的時候他想知道什么
希沃ENOW大前端
公司官網(wǎng):CVTE(廣州視源股份)
團隊:CVTE旗下未來教育希沃軟件平臺中心enow團隊
本文作者:

前言
在前端工程化日趨復(fù)雜的今天,模塊打包工具在我們的開發(fā)中起到了越來越重要的作用,其中webpack就是最熱門的打包工具之一。
說到webpack,可能很多小伙伴會覺得既熟悉又陌生,熟悉是因為幾乎在每一個項目中我們都會用上它,又因為webpack復(fù)雜的配置和五花八門的功能感到陌生。尤其當(dāng)我們使用諸如umi.js之類的應(yīng)用框架還幫我們把webpack配置再封裝一層的時候,webpack的本質(zhì)似乎離我們更加遙遠和深不可測了。
當(dāng)面試官問你是否了解webpack的時候,或許你可以說出一串耳熟能詳?shù)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">webpack loader和plugin的名字,甚至還能說出插件和一系列配置做按需加載和打包優(yōu)化,那你是否了解他的運行機制以及實現(xiàn)原理呢,那我們今天就一起探索webpack的能力邊界,嘗試了解webpack的一些實現(xiàn)流程和原理,拒做API工程師。

你知道webpack的作用是什么嗎?
從官網(wǎng)上的描述我們其實不難理解,webpack的作用其實有以下幾點:
模塊打包??梢詫⒉煌K的文件打包整合在一起,并且保證它們之間的引用正確,執(zhí)行有序。利用打包我們就可以在開發(fā)的時候根據(jù)我們自己的業(yè)務(wù)自由劃分文件模塊,保證項目結(jié)構(gòu)的清晰和可讀性。
編譯兼容。在前端的“上古時期”,手寫一堆瀏覽器兼容代碼一直是令前端工程師頭皮發(fā)麻的事情,而在今天這個問題被大大的弱化了,通過
webpack的Loader機制,不僅僅可以幫助我們對代碼做polyfill,還可以編譯轉(zhuǎn)換諸如.less, .vue, .jsx這類在瀏覽器無法識別的格式文件,讓我們在開發(fā)的時候可以使用新特性和新語法做開發(fā),提高開發(fā)效率。能力擴展。通過
webpack的Plugin機制,我們在實現(xiàn)模塊化打包和編譯兼容的基礎(chǔ)上,可以進一步實現(xiàn)諸如按需加載,代碼壓縮等一系列功能,幫助我們進一步提高自動化程度,工程效率以及打包輸出的質(zhì)量。
說一下模塊打包運行原理?
如果面試官問你Webpack是如何把這些模塊合并到一起,并且保證其正常工作的,你是否了解呢?
首先我們應(yīng)該簡單了解一下webpack的整個打包流程:
1、讀取 webpack的配置參數(shù);2、啟動 webpack,創(chuàng)建Compiler對象并開始解析項目;3、從入口文件( entry)開始解析,并且找到其導(dǎo)入的依賴模塊,遞歸遍歷分析,形成依賴關(guān)系樹;4、對不同文件類型的依賴模塊文件使用對應(yīng)的 Loader進行編譯,最終轉(zhuǎn)為Javascript文件;5、整個過程中 webpack會通過發(fā)布訂閱模式,向外拋出一些hooks,而webpack的插件即可通過監(jiān)聽這些關(guān)鍵的事件節(jié)點,執(zhí)行插件任務(wù)進而達到干預(yù)輸出結(jié)果的目的。
其中文件的解析與構(gòu)建是一個比較復(fù)雜的過程,在webpack源碼中主要依賴于compiler和compilation兩個核心對象實現(xiàn)。
compiler對象是一個全局單例,他負責(zé)把控整個webpack打包的構(gòu)建流程。compilation對象是每一次構(gòu)建的上下文對象,它包含了當(dāng)次構(gòu)建所需要的所有信息,每次熱更新和重新構(gòu)建,compiler都會重新生成一個新的compilation對象,負責(zé)此次更新的構(gòu)建過程。
而每個模塊間的依賴關(guān)系,則依賴于AST語法樹。每個模塊文件在通過Loader解析完成之后,會通過acorn庫生成模塊代碼的AST語法樹,通過語法樹就可以分析這個模塊是否還有依賴的模塊,進而繼續(xù)循環(huán)執(zhí)行下一個模塊的編譯解析。
最終Webpack打包出來的bundle文件是一個IIFE的執(zhí)行函數(shù)。
// webpack 5 打包的bundle文件內(nèi)容
(() => { // webpackBootstrap
var __webpack_modules__ = ({
'file-A-path': ((modules) => { // ... })
'index-file-path': ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => { // ... })
})
// The module cache
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}
// startup
// Load entry module and return exports
// This entry module can't be inlined because the eval devtool is used.
var __webpack_exports__ = __webpack_require__("./src/index.js");
})
和webpack4相比,webpack5打包出來的bundle做了相當(dāng)?shù)木?。在上面的打?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">demo中,整個立即執(zhí)行函數(shù)里邊只有三個變量和一個函數(shù)方法,__webpack_modules__存放了編譯后的各個文件模塊的JS內(nèi)容,__webpack_module_cache__用來做模塊緩存,__webpack_require__是Webpack內(nèi)部實現(xiàn)的一套依賴引入函數(shù)。最后一句則是代碼運行的起點,從入口文件開始,啟動整個項目。
其中值得一提的是__webpack_require__模塊引入函數(shù),我們在模塊化開發(fā)的時候,通常會使用ES Module或者CommonJS規(guī)范導(dǎo)出/引入依賴模塊,webpack打包編譯的時候,會統(tǒng)一替換成自己的__webpack_require__來實現(xiàn)模塊的引入和導(dǎo)出,從而實現(xiàn)模塊緩存機制,以及抹平不同模塊規(guī)范之間的一些差異性。
你知道sourceMap是什么嗎?
提到sourceMap,很多小伙伴可能會立刻想到Webpack配置里邊的devtool參數(shù),以及對應(yīng)的eval,eval-cheap-source-map等等可選值以及它們的含義。除了知道不同參數(shù)之間的區(qū)別以及性能上的差異外,我們也可以一起了解一下sourceMap的實現(xiàn)方式。
sourceMap是一項將編譯、打包、壓縮后的代碼映射回源代碼的技術(shù),由于打包壓縮后的代碼并沒有閱讀性可言,一旦在開發(fā)中報錯或者遇到問題,直接在混淆代碼中debug問題會帶來非常糟糕的體驗,sourceMap可以幫助我們快速定位到源代碼的位置,提高我們的開發(fā)效率。sourceMap其實并不是Webpack特有的功能,而是Webpack支持sourceMap,像JQuery也支持souceMap。
既然是一種源碼的映射,那必然就需要有一份映射的文件,來標(biāo)記混淆代碼里對應(yīng)的源碼的位置,通常這份映射文件以.map結(jié)尾,里邊的數(shù)據(jù)結(jié)構(gòu)大概長這樣:
{
"version" : 3, // Source Map版本
"file": "out.js", // 輸出文件(可選)
"sourceRoot": "", // 源文件根目錄(可選)
"sources": ["foo.js", "bar.js"], // 源文件列表
"sourcesContent": [null, null], // 源內(nèi)容列表(可選,和源文件列表順序一致)
"names": ["src", "maps", "are", "fun"], // mappings使用的符號名稱列表
"mappings": "A,AAAB;;ABCDE;" // 帶有編碼映射數(shù)據(jù)的字符串
}
其中mappings數(shù)據(jù)有如下規(guī)則:
生成文件中的一行的每個組用“;”分隔; 每一段用“,”分隔; 每個段由1、4或5個可變長度字段組成;
有了這份映射文件,我們只需要在我們的壓縮代碼的最末端加上這句注釋,即可讓sourceMap生效:
//# sourceURL=/path/to/file.js.map
有了這段注釋后,瀏覽器就會通過sourceURL去獲取這份映射文件,通過解釋器解析后,實現(xiàn)源碼和混淆代碼之間的映射。因此sourceMap其實也是一項需要瀏覽器支持的技術(shù)。
如果我們仔細查看webpack打包出來的bundle文件,就可以發(fā)現(xiàn)在默認的development開發(fā)模式下,每個_webpack_modules__文件模塊的代碼最末端,都會加上//# sourceURL=webpack://file-path?,從而實現(xiàn)對sourceMap的支持。
sourceMap映射表的生成有一套較為復(fù)雜的規(guī)則,有興趣的小伙伴可以看看以下文章,幫助理解soucrMap的原理實現(xiàn):
Source Map的原理探究
Source Maps under the hood – VLQ, Base64 and Yoda
是否寫過Loader?簡單描述一下編寫loader的思路?
從上面的打包代碼我們其實可以知道,Webpack最后打包出來的成果是一份Javascript代碼,實際上在Webpack內(nèi)部默認也只能夠處理JS模塊代碼,在打包過程中,會默認把所有遇到的文件都當(dāng)作 JavaScript代碼進行解析,因此當(dāng)項目存在非JS類型文件時,我們需要先對其進行必要的轉(zhuǎn)換,才能繼續(xù)執(zhí)行打包任務(wù),這也是Loader機制存在的意義。
Loader的配置使用我們應(yīng)該已經(jīng)非常的熟悉:
// webpack.config.js
module.exports = {
// ...other config
module: {
rules: [
{
test: /^your-regExp$/,
use: [
{
loader: 'loader-name-A',
},
{
loader: 'loader-name-B',
}
]
},
]
}
}
通過配置可以看出,針對每個文件類型,loader是支持以數(shù)組的形式配置多個的,因此當(dāng)Webpack在轉(zhuǎn)換該文件類型的時候,會按順序鏈?zhǔn)秸{(diào)用每一個loader,前一個loader返回的內(nèi)容會作為下一個loader的入?yún)?。因?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">loader的開發(fā)需要遵循一些規(guī)范,比如返回值必須是標(biāo)準(zhǔn)的JS代碼字符串,以保證下一個loader能夠正常工作,同時在開發(fā)上需要嚴(yán)格遵循“單一職責(zé)”,只關(guān)心loader的輸出以及對應(yīng)的輸出。
loader函數(shù)中的this上下文由webpack提供,可以通過this對象提供的相關(guān)屬性,獲取當(dāng)前loader需要的各種信息數(shù)據(jù),事實上,這個this指向了一個叫loaderContext的loader-runner特有對象。有興趣的小伙伴可以自行閱讀源碼。
module.exports = function(source) {
const content = doSomeThing2JsString(source);
// 如果 loader 配置了 options 對象,那么this.query將指向 options
const options = this.query;
// 可以用作解析其他模塊路徑的上下文
console.log('this.context');
/*
* this.callback 參數(shù):
* error:Error | null,當(dāng) loader 出錯時向外拋出一個 error
* content:String | Buffer,經(jīng)過 loader 編譯后需要導(dǎo)出的內(nèi)容
* sourceMap:為方便調(diào)試生成的編譯后內(nèi)容的 source map
* ast:本次編譯生成的 AST 靜態(tài)語法樹,之后執(zhí)行的 loader 可以直接使用這個 AST,進而省去重復(fù)生成 AST 的過程
*/
this.callback(null, content);
// or return content;
}
更詳細的開發(fā)文檔可以直接查看官網(wǎng)的 Loader API。
是否寫過Plugin?簡單描述一下編寫plugin的思路?
如果說Loader負責(zé)文件轉(zhuǎn)換,那么Plugin便是負責(zé)功能擴展。Loader和Plugin作為Webpack的兩個重要組成部分,承擔(dān)著兩部分不同的職責(zé)。
上文已經(jīng)說過,webpack基于發(fā)布訂閱模式,在運行的生命周期中會廣播出許多事件,插件通過監(jiān)聽這些事件,就可以在特定的階段執(zhí)行自己的插件任務(wù),從而實現(xiàn)自己想要的功能。
既然基于發(fā)布訂閱模式,那么知道Webpack到底提供了哪些事件鉤子供插件開發(fā)者使用是非常重要的,上文提到過compiler和compilation是Webpack兩個非常核心的對象,其中compiler暴露了和 Webpack整個生命周期相關(guān)的鉤子(compiler-hooks),而compilation則暴露了與模塊和依賴有關(guān)的粒度更小的事件鉤子(Compilation Hooks)。
Webpack的事件機制基于webpack自己實現(xiàn)的一套Tapable事件流方案(github)
// Tapable的簡單使用
const { SyncHook } = require("tapable");
class Car {
constructor() {
// 在this.hooks中定義所有的鉤子事件
this.hooks = {
accelerate: new SyncHook(["newSpeed"]),
brake: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
}
/* ... */
}
const myCar = new Car();
// 通過調(diào)用tap方法即可增加一個消費者,訂閱對應(yīng)的鉤子事件了
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());
Plugin的開發(fā)和開發(fā)Loader一樣,需要遵循一些開發(fā)上的規(guī)范和原則:
插件必須是一個函數(shù)或者是一個包含 apply方法的對象,這樣才能訪問compiler實例;傳給每個插件的 compiler和compilation對象都是同一個引用,若在一個插件中修改了它們身上的屬性,會影響后面的插件;異步的事件需要在插件處理完任務(wù)時調(diào)用回調(diào)函數(shù)通知 Webpack進入下一個流程,不然會卡住;
了解了以上這些內(nèi)容,想要開發(fā)一個 Webpack Plugin,其實也并不困難。
class MyPlugin {
apply (compiler) {
// 找到合適的事件鉤子,實現(xiàn)自己的插件功能
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation: 當(dāng)前打包構(gòu)建流程的上下文
console.log(compilation);
// do something...
})
}
}
更詳細的開發(fā)文檔可以直接查看官網(wǎng)的 Plugin API。
最后
本文也是結(jié)合一些優(yōu)秀的文章和webpack本身的源碼,大概地說了幾個相對重要的概念和流程,其中的實現(xiàn)細節(jié)和設(shè)計思路還需要結(jié)合源碼去閱讀和慢慢理解。
Webpack作為一款優(yōu)秀的打包工具,它改變了傳統(tǒng)前端的開發(fā)模式,是現(xiàn)代化前端開發(fā)的基石。這樣一個優(yōu)秀的開源項目有許多優(yōu)秀的設(shè)計思想和理念可以借鑒,我們自然也不應(yīng)該僅僅停留在API的使用層面,嘗試帶著問題閱讀源碼,理解實現(xiàn)的流程和原理,也能讓我們學(xué)到更多知識,理解得更加深刻,在項目中才能游刃有余的應(yīng)用。
相關(guān)文檔鏈接
Webpack官網(wǎng)
「吐血整理」再來一打Webpack面試題
