Webpack 面試題
面試題希沃 ENOW 大前端
公司官網:CVTE(廣州視源股份)
團隊:CVTE 旗下未來教育希沃軟件平臺中心 enow 團隊
本文作者:
溫廣名片 2.png
前言
在前端工程化日趨複雜的今天,模塊打包工具在我們的開發中起到了越來越重要的作用,其中webpack
就是最熱門的打包工具之一。
說到webpack
,可能很多小夥伴會覺得既熟悉又陌生,熟悉是因爲幾乎在每一個項目中我們都會用上它,又因爲webpack
複雜的配置和五花八門的功能感到陌生。尤其當我們使用諸如umi.js
之類的應用框架還幫我們把 webpack 配置再封裝一層的時候,webpack
的本質似乎離我們更加遙遠和深不可測了。
當面試官問你是否瞭解webpack
的時候,或許你可以說出一串耳熟能詳的webpack loader
和plugin
的名字,甚至還能說出插件和一系列配置做按需加載和打包優化,那你是否瞭解他的運行機制以及實現原理呢,那我們今天就一起探索webpack
的能力邊界,嘗試瞭解webpack
的一些實現流程和原理,拒做API
工程師。
你知道 webpack 的作用是什麼嗎?
從官網上的描述我們其實不難理解,webpack
的作用其實有以下幾點:
-
編譯兼容。在前端的 “上古時期”,手寫一堆瀏覽器兼容代碼一直是令前端工程師頭皮發麻的事情,而在今天這個問題被大大的弱化了,通過
webpack
的Loader
機制,不僅僅可以幫助我們對代碼做polyfill
,還可以編譯轉換諸如.less, .vue, .jsx
這類在瀏覽器無法識別的格式文件,讓我們在開發的時候可以使用新特性和新語法做開發,提高開發效率。 -
能力擴展。通過
webpack
的Plugin
機制,我們在實現模塊化打包和編譯兼容的基礎上,可以進一步實現諸如按需加載,代碼壓縮等一系列功能,幫助我們進一步提高自動化程度,工程效率以及打包輸出的質量。
說一下模塊打包運行原理?
如果面試官問你Webpack
是如何把這些模塊合併到一起,並且保證其正常工作的,你是否瞭解呢?
首先我們應該簡單瞭解一下webpack
的整個打包流程:
-
1、讀取
webpack
的配置參數; -
2、啓動
webpack
,創建Compiler
對象並開始解析項目; -
3、從入口文件(
entry
)開始解析,並且找到其導入的依賴模塊,遞歸遍歷分析,形成依賴關係樹; -
4、對不同文件類型的依賴模塊文件使用對應的
Loader
進行編譯,最終轉爲Javascript
文件; -
5、整個過程中
webpack
會通過發佈訂閱模式,向外拋出一些hooks
,而webpack
的插件即可通過監聽這些關鍵的事件節點,執行插件任務進而達到干預輸出結果的目的。
其中文件的解析與構建是一個比較複雜的過程,在webpack
源碼中主要依賴於compiler
和compilation
兩個核心對象實現。
compiler
對象是一個全局單例,他負責把控整個webpack
打包的構建流程。compilation
對象是每一次構建的上下文對象,它包含了當次構建所需要的所有信息,每次熱更新和重新構建,compiler
都會重新生成一個新的compilation
對象,負責此次更新的構建過程。
而每個模塊間的依賴關係,則依賴於AST
語法樹。每個模塊文件在通過Loader
解析完成之後,會通過acorn
庫生成模塊代碼的AST
語法樹,通過語法樹就可以分析這個模塊是否還有依賴的模塊,進而繼續循環執行下一個模塊的編譯解析。
最終Webpack
打包出來的bundle
文件是一個IIFE
的執行函數。
// webpack 5 打包的bundle文件內容
(() => { // 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 做了相當的精簡。在上面的打包demo
中,整個立即執行函數里邊只有三個變量和一個函數方法,__webpack_modules__
存放了編譯後的各個文件模塊的 JS 內容,__webpack_module_cache__
用來做模塊緩存,__webpack_require__
是Webpack
內部實現的一套依賴引入函數。最後一句則是代碼運行的起點,從入口文件開始,啓動整個項目。
其中值得一提的是__webpack_require__
模塊引入函數,我們在模塊化開發的時候,通常會使用ES Module
或者CommonJS
規範導出 / 引入依賴模塊,webpack
打包編譯的時候,會統一替換成自己的__webpack_require__
來實現模塊的引入和導出,從而實現模塊緩存機制,以及抹平不同模塊規範之間的一些差異性。
你知道 sourceMap 是什麼嗎?
提到sourceMap
,很多小夥伴可能會立刻想到Webpack
配置裏邊的devtool
參數,以及對應的eval
,eval-cheap-source-map
等等可選值以及它們的含義。除了知道不同參數之間的區別以及性能上的差異外,我們也可以一起了解一下sourceMap
的實現方式。
sourceMap
是一項將編譯、打包、壓縮後的代碼映射回源代碼的技術,由於打包壓縮後的代碼並沒有閱讀性可言,一旦在開發中報錯或者遇到問題,直接在混淆代碼中debug
問題會帶來非常糟糕的體驗,sourceMap
可以幫助我們快速定位到源代碼的位置,提高我們的開發效率。sourceMap
其實並不是Webpack
特有的功能,而是Webpack
支持sourceMap
,像JQuery
也支持souceMap
。
既然是一種源碼的映射,那必然就需要有一份映射的文件,來標記混淆代碼裏對應的源碼的位置,通常這份映射文件以.map
結尾,裏邊的數據結構大概長這樣:
{
"version" : 3, // Source Map版本
"file": "out.js", // 輸出文件(可選)
"sourceRoot": "", // 源文件根目錄(可選)
"sources": ["foo.js", "bar.js"], // 源文件列表
"sourcesContent": [null, null], // 源內容列表(可選,和源文件列表順序一致)
"names": ["src", "maps", "are", "fun"], // mappings使用的符號名稱列表
"mappings": "A,AAAB;;ABCDE;" // 帶有編碼映射數據的字符串
}
其中mappings
數據有如下規則:
-
生成文件中的一行的每個組用 “;” 分隔;
-
每一段用 “,” 分隔;
-
每個段由 1、4 或 5 個可變長度字段組成;
有了這份映射文件,我們只需要在我們的壓縮代碼的最末端加上這句註釋,即可讓 sourceMap 生效:
//# sourceURL=/path/to/file.js.map
有了這段註釋後,瀏覽器就會通過sourceURL
去獲取這份映射文件,通過解釋器解析後,實現源碼和混淆代碼之間的映射。因此 sourceMap 其實也是一項需要瀏覽器支持的技術。
如果我們仔細查看 webpack 打包出來的 bundle 文件,就可以發現在默認的development
開發模式下,每個_webpack_modules__
文件模塊的代碼最末端,都會加上//# sourceURL=webpack://file-path?
,從而實現對 sourceMap 的支持。
sourceMap 映射表的生成有一套較爲複雜的規則,有興趣的小夥伴可以看看以下文章,幫助理解 soucrMap 的原理實現:
Source Map 的原理探究
Source Maps under the hood – VLQ, Base64 and Yoda
是否寫過 Loader?簡單描述一下編寫 loader 的思路?
從上面的打包代碼我們其實可以知道,Webpack
最後打包出來的成果是一份Javascript
代碼,實際上在Webpack
內部默認也只能夠處理JS
模塊代碼,在打包過程中,會默認把所有遇到的文件都當作 JavaScript
代碼進行解析,因此當項目存在非JS
類型文件時,我們需要先對其進行必要的轉換,才能繼續執行打包任務,這也是Loader
機制存在的意義。
Loader
的配置使用我們應該已經非常的熟悉:
// webpack.config.js
module.exports = {
// ...other config
module: {
rules: [
{
test: /^your-regExp$/,
use: [
{
loader: 'loader-name-A',
},
{
loader: 'loader-name-B',
}
]
},
]
}
}
通過配置可以看出,針對每個文件類型,loader
是支持以數組的形式配置多個的,因此當Webpack
在轉換該文件類型的時候,會按順序鏈式調用每一個loader
,前一個loader
返回的內容會作爲下一個loader
的入參。因此loader
的開發需要遵循一些規範,比如返回值必須是標準的JS
代碼字符串,以保證下一個loader
能夠正常工作,同時在開發上需要嚴格遵循 “單一職責”,只關心loader
的輸出以及對應的輸出。
loader
函數中的this
上下文由webpack
提供,可以通過this
對象提供的相關屬性,獲取當前loader
需要的各種信息數據,事實上,這個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 參數:
* error:Error | null,當 loader 出錯時向外拋出一個 error
* content:String | Buffer,經過 loader 編譯後需要導出的內容
* sourceMap:爲方便調試生成的編譯後內容的 source map
* ast:本次編譯生成的 AST 靜態語法樹,之後執行的 loader 可以直接使用這個 AST,進而省去重複生成 AST 的過程
*/
this.callback(null, content);
// or return content;
}
更詳細的開發文檔可以直接查看官網的 Loader API。
是否寫過 Plugin?簡單描述一下編寫 plugin 的思路?
如果說Loader
負責文件轉換,那麼Plugin
便是負責功能擴展。Loader
和Plugin
作爲Webpack
的兩個重要組成部分,承擔着兩部分不同的職責。
上文已經說過,webpack
基於發佈訂閱模式,在運行的生命週期中會廣播出許多事件,插件通過監聽這些事件,就可以在特定的階段執行自己的插件任務,從而實現自己想要的功能。
既然基於發佈訂閱模式,那麼知道Webpack
到底提供了哪些事件鉤子供插件開發者使用是非常重要的,上文提到過compiler
和compilation
是Webpack
兩個非常核心的對象,其中compiler
暴露了和 Webpack
整個生命週期相關的鉤子(compiler-hooks),而compilation
則暴露了與模塊和依賴有關的粒度更小的事件鉤子(Compilation Hooks)。
Webpack
的事件機制基於webpack
自己實現的一套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();
// 通過調用tap方法即可增加一個消費者,訂閱對應的鉤子事件了
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());
Plugin
的開發和開發Loader
一樣,需要遵循一些開發上的規範和原則:
-
插件必須是一個函數或者是一個包含
apply
方法的對象,這樣才能訪問compiler
實例; -
傳給每個插件的
compiler
和compilation
對象都是同一個引用,若在一個插件中修改了它們身上的屬性,會影響後面的插件; -
異步的事件需要在插件處理完任務時調用回調函數通知
Webpack
進入下一個流程,不然會卡住;
瞭解了以上這些內容,想要開發一個 Webpack Plugin
,其實也並不困難。
class MyPlugin {
apply (compiler) {
// 找到合適的事件鉤子,實現自己的插件功能
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation: 當前打包構建流程的上下文
console.log(compilation);
// do something...
})
}
}
更詳細的開發文檔可以直接查看官網的 Plugin API。
最後
本文也是結合一些優秀的文章和webpack
本身的源碼,大概地說了幾個相對重要的概念和流程,其中的實現細節和設計思路還需要結合源碼去閱讀和慢慢理解。
Webpack
作爲一款優秀的打包工具,它改變了傳統前端的開發模式,是現代化前端開發的基石。這樣一個優秀的開源項目有許多優秀的設計思想和理念可以借鑑,我們自然也不應該僅僅停留在API
的使用層面,嘗試帶着問題閱讀源碼,理解實現的流程和原理,也能讓我們學到更多知識,理解得更加深刻,在項目中才能遊刃有餘的應用。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wm_7RvwIQxSow2K5IxZDvw