深入理解 webpack 檔案打包機制

發表於2018-01-15

前言

最近在重拾 webpack 一些知識點,希望對前端模組化有更多的理解,以前對 webpack 打包機制有所好奇,沒有理解深入,淺嘗則止,最近透過對 webpack 打包後的檔案進行查閱,對其如何打包 JS 檔案有了更深的理解,希望透過這篇文章,能夠幫助讀者你理解:

  1. webpack 單檔案如何進行打包?
  2. webpack 多檔案如何進行程式碼切割?
  3. webpack1 和 webpack2 在檔案打包上有什麼區別?
  4. webpack2 如何做到 tree shaking?
  5. webpack3 如何做到 scope hoisting?

本文所有示例程式碼全部放在我的 Github 上,看興趣的可以看看:

webpack 單檔案如何打包?

首先現在 webpack 作為當前主流的前端模組化工具,在 webpack 剛開始流行的時候,我們經常透過 webpack 將所有處理檔案全部打包成一個 bundle 檔案, 先透過一個簡單的例子來看:

透過 npm run build:single 可看到打包效果,打包內容大致如下(經過精簡):

將相對無關的程式碼剔除掉後,剩下主要的程式碼:

  1. 首先 webpack 將所有模組(可以簡單理解成檔案)包裹於一個函式中,並傳入預設引數,這裡有三個檔案再加上一個入口模組一共四個模組,將它們放入一個陣列中,取名為 modules,並透過陣列的下標來作為 moduleId。
  2. 將 modules 傳入一個自執行函式中,自執行函式中包含一個 installedModules 已經載入過的模組和一個模組載入函式,最後載入入口模組並返回。
  3. __webpack_require__ 模組載入,先判斷 installedModules 是否已載入,載入過了就直接返回 exports 資料,沒有載入過該模組就透過 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__) 執行模組並且將 module.exports 給返回。

很簡單是不是,有些點需要注意的是:

  1. 每個模組 webpack 只會載入一次,所以重複載入的模組只會執行一次,載入過的模組會放到 installedModules,下次需要需要該模組的值就直接從裡面拿了。
  2. 模組的 id 直接透過陣列下標去一一對應的,這樣能保證簡單且唯一,透過其它方式比如檔名或檔案路徑的方式就比較麻煩,因為檔名可能出現重名,不唯一,檔案路徑則會增大檔案體積,並且將路徑暴露給前端,不夠安全。
  3. modules[moduleId].call(module.exports, module, module.exports, __webpack_require__) 保證了模組載入時 this 的指向 module.exports 並且傳入預設引數,很簡單,不過多解釋。

webpack 多檔案如何進行程式碼切割?

webpack 單檔案打包的方式應付一些簡單場景就足夠了,但是我們在開發一些複雜的應用,如果沒有對程式碼進行切割,將第三方庫(jQuery)或框架(React) 和業務程式碼全部打包在一起,就會導致使用者訪問頁面速度很慢,不能有效利用快取,你的老闆可能就要找你談話了。

那麼 webpack 多檔案入口如何進行程式碼切割,讓我先寫一個簡單的例子:

這裡我們定義了兩個入口 pageA 和 pageB 和三個庫 util,我們希望程式碼切割做到:

  1. 因為兩入口都是用到了 utilB,我們希望把它抽離成單獨檔案,並且當使用者訪問 pageA 和 pageB 的時候都能去載入 utilB 這個公共模組,而不是存在於各自的入口檔案中。
  2. pageB 中 utilC 不是頁面一開始載入時候就需要的內容,假如 utilC 很大,我們不希望頁面載入時就直接載入 utilC,而是當使用者達到某種條件(如:點選按鈕)才去非同步載入 utilC,這時候我們需要將 utilC 抽離成單獨檔案,當使用者需要的時候再去載入該檔案。

那麼 webpack 需要怎麼配置呢?

單單配置多 entry 是不夠的,這樣只會生成兩個 bundle 檔案,將 pageA 和 pageB 所需要的內容全部放入,跟單入口檔案並沒有區別,要做到程式碼切割,我們需要藉助 webpack 內建的外掛 CommonsChunkPlugin。

首先 webpack 執行存在一部分執行時程式碼,即一部分初始化的工作,就像之前單檔案中的 __webpack_require__,這部分程式碼需要載入於所有檔案之前,相當於初始化工作,少了這部分初始化程式碼,後面載入過來的程式碼就無法識別並工作了。

這段程式碼的含義是,在這些入口檔案中,找到那些引用兩次的模組(如:utilB),幫我抽離成一個叫 vendor 檔案,此時那部分初始化工作的程式碼會被抽離到 vendor 檔案中。

這段程式碼的含義是在 vendor 檔案中幫我把初始化程式碼抽離到 mainifest 檔案中,此時 vendor 檔案中就只剩下 utilB 這個模組了。你可能會好奇為什麼要這麼做?

因為這樣可以給 vendor 生成穩定的 hash 值,每次修改業務程式碼(pageA),這段初始化時程式碼就會發生變化,那麼如果將這段初始化程式碼放在 vendor 檔案中的話,每次都會生成新的 vendor.xxxx.js,這樣不利於持久化快取,如果不理解也沒關係,下次我會另外寫一篇文章來講述這部分內容。

另外 webpack 預設會抽離非同步載入的程式碼,這個不需要你做額外的配置,pageB 中非同步載入的 utilC 檔案會直接抽離為 chunk.xxxx.js 檔案。

所以這時候我們頁面載入檔案的順序就會變成:

執行 npm run build:multiple 即可檢視打包內容,首先來看下 manifest 如何做初始化工作(精簡版)?

與單檔案內容一致,定義了一個自執行函式,因為它不包含任何模組,所以傳入一個空陣列。除了定義了 __webpack_require__,還另外定義了兩個函式用來進行載入模組。

首先講解程式碼前需要理解兩個概念,分別是 module 和 chunk

  1. chunk 代表生成後 js 檔案,一個 chunkId 對應一個打包好的 js 檔案(一共五個),從這段程式碼可以看出,manifest 的 chunkId 為 4,並且從程式碼中還可以看到:0-3 分別對應 pageA, pageB, 非同步 utilC, vendor 公共模組檔案,這也就是我們為什麼不能將這段程式碼放在 vendor 的原因,因為檔案的 hash 值會變。內容變了,vendor 生成的 hash 值也就變了。
  2. module 對應著模組,可以簡單理解為打包前每個 js 檔案對應一個模組,也就是之前 __webpack_require__ 載入的模組,同樣的使用陣列下標作為 moduleId 且是唯一不重複的。

那麼為什麼要區分 chunk 和 module 呢?

首先使用 installedChunks 來儲存每個 chunkId 是否被載入過,如果被載入過,則說明該 chunk 中所包含的模組已經被放到了 modules 中,注意是 modules 而不是 installedModules。我們先來簡單看一下 vendor chunk 打包出來的內容。

在執行完 manifest 後就會先執行 vendor 檔案,結合上面 webpackJsonp 的定義,我們可以知道 [3, 4] 代表 chunkId,當載入到 vendor 檔案後,installedChunks[3] 和 installedChunks[4] 將會被置為 0,這表明 chunk3,chunk4 已經被載入過了。

webpackJsonpCallback 一共有兩個引數,chuckIds 一般包含該 chunk 檔案依賴的 chunkId 以及自身 chunkId,moreModules 代表該 chunk 檔案帶來新的模組。

簡單說說 webpackJsonpCallback 做了哪些事,首先判斷 chunkIds 在 installedChunks 裡有沒有回撥函式函式未執行完,有的話則放到 callbacks 裡,並且等下統一執行,並將 chunkIds 在 installedChunks 中全部置為 0, 然後將 moreModules 合併到 modules。

這裡面只有 modules[0] 是不固定的,其它 modules 下標都是唯一的,在打包的時候 webpack 已經為它們統一編號,而 0 則為入口檔案即 pageA,pageB 各有一個 module[0]。

然後將 callbacks 執行並清空,保證了該模組載入開始前所以前置依賴內容已經載入完畢,最後判斷 moreModules[0], 有值說明該檔案為入口檔案,則開始執行入口模組 0。

上面解釋了一大堆,但是像 pageA 這種同步載入 manifest, vendor 以及 pageA 檔案來說,每次載入的時候 callbacks 都是為空的,因為它們在 installedChunks 中的值要嘛為 undefined(未載入), 要嘛為 0(已被載入)。installedChunks[chunkId] 的值永遠為 false,所以在這種情況下 callbacks 里根本不會出現函式,如果僅僅是考慮這樣的場景,上面的 webpackJsonpCallback 完全可以寫成下面這樣:

但是考慮到非同步載入 js 檔案的時候(比如 pageB 非同步載入 utilC 檔案),就沒那麼簡單,我們先來看下 webpack 是如何載入非同步指令碼的:

大致分為三種情況,(已經載入過,正在載入中以及從未載入過)

  1. 已經載入過該 chunk 檔案,那就不用再重新載入該 chunk 了,直接執行回撥函式即可,可以理解為假如頁面有兩種操作需要載入載入非同步指令碼,但是兩個指令碼都依賴於公共模組,那麼第二次載入的時候發現之前第一次操作已經載入過了該 chunk,則不用再去獲取非同步指令碼了,因為該公共模組已經被執行過了。
  2. 從未載入過,則動態地去插入 script 指令碼去請求 js 檔案,這也就為什麼取名 webpackJsonpCallback,因為跟 jsonp 的思想很類似,所以這種非同步載入指令碼在做指令碼錯誤監控時經常出現 Script error,具體原因可以檢視我之前寫的文章:前端程式碼異常監控實戰
  3. 正在載入中代表該 chunk 檔案已經在載入中了,比如說點選按鈕觸發非同步指令碼,使用者點太快了,連點兩次就可能出現這種情況,此時將回撥函式放入 installedChunks。

我們透過 utilC 生成的 chunk 來進行講解:

pageB 需要非同步載入這個 chunk:

當 pageB 進行某種操作需要載入 utilC 時就會執行 __webpack_require__.e(2, callback) 2,代表需要載入的模組 chunkId(utilC),非同步載入 utilC 並將 callback 新增到 installedChunks[2] 中,然後當 utilC 的 chunk 檔案載入完畢後,chunkIds 包含 2,發現 installedChunks[2] 是個陣列,裡面還有之前還未執行的 callback 函式。

既然這樣,那我就將我自己帶來的模組先放到 modules 中,然後再統一執行之前未執行完的 callbacks 函式,這裡指的是存放於 installedChunks[2] 中的回撥函式 (可能存在多個),這也就是說明這裡的先後順序:

webpack1 和 webpack2 在檔案打包上有什麼區別?

經過我對打包檔案的觀察,從 webpack1 到 webpack2 在打包檔案上有下面這些主要的改變:

首先,moduleId[0] 不再為入口執行函式做保留,所以說不用傻傻看到 moduleId[0] 就認為是打包檔案的入口模組,取而代之的是 window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {} 傳入了第三個引數 executeModules,是個陣列,如果引數存在則說明它是入口模組,然後就去執行該模組。

其次,webpack2 中會預設載入 OccurrenceOrderPlugin 這個外掛,即你不用 plugins 中新增這個配置它也會預設執行,那它有什麼用途呢?主要是在 webpack1 中 moduleId 的不確定性導致的,在 webpack1 中 moduleId 取決於引入檔案的順序,這就會導致這個 moduleId 可能會時常發生變化, 而 OccurrenceOrderPlugin 外掛會按引入次數最多的模組進行排序,引入次數的模組的 moduleId 越小,比如說上面引用的 utilB 模組引用次數為 2(最多),所以它的 moduleId 為 0。

最後說下在非同步載入模組時, webpack2 是基於 Promise 的,所以說如果你要相容低版本瀏覽器,需要引入 Promise-polyfill,另外為引入請求新增了錯誤處理。

可以看出,原本基於回撥函式的方式已經變成基於 Promise 做非同步處理,另外新增了 onScriptComplete 用於做指令碼載入失敗處理。

在 webpack1 的時候,如果由於網路原因當你載入指令碼失敗後,即使網路恢復了,你再次進行某種操作需要同個 chunk 時候都會無效,主要原因是失敗之後沒把 installedChunks[chunkId] = undefined; 導致之後不會再對該 chunk 檔案發起非同步請求。

而在 webpack2 中,當指令碼請求超時了(2min)或者載入失敗,會將 installedChunks[chunkId] 清空,當下次重新請求該 chunk 檔案會重新載入,提高了頁面的容錯性。

這些是我在打包檔案中看到主要的區別,難免有所遺漏,如果你有更多的見解,歡迎在評論區留言。

webpack2 如何做到 tree shaking?

什麼是 tree shaking,即 webpack 在打包的過程中會將沒用的程式碼進行清除(dead code)。一般 dead code 具有一下的特徵:

  1. 程式碼不會被執行,不可到達
  2. 程式碼執行的結果不會被用到
  3. 程式碼只會影響死變數(只寫不讀)

是不是很神奇,那麼需要怎麼做才能使 tree shaking 生效呢?

首先,模組引入要基於 ES6 模組機制,不再使用 commonjs 規範,因為 es6 模組的依賴關係是確定的,和執行時的狀態無關,可以進行可靠的靜態分析,然後清除沒用的程式碼。而 commonjs 的依賴關係是要到執行時候才能確定下來的。

其次,需要開啟 UglifyJsPlugin 這個外掛對程式碼進行壓縮。

我們先寫一個例子來說明:

打包的配置也很簡單:

透過 npm run build:es6 對壓縮的檔案進行分析:

引入但是沒用的變數,函式都會清除,未執行的程式碼也會被清除。但是類方法是不會被清除的。因為 webpack 不會區分不了是定義在 classC 的 prototype 還是其它 Array 的 prototype 的,比如 classC 寫成下面這樣:

webpack 無法保證 prototype 掛載的物件是 classC,這種程式碼,靜態分析是分析不了的,就算能靜態分析程式碼,想要正確完全的分析也比較困難。所以 webpack 乾脆不處理類方法,不對類方法進行 tree shaking。

更多的 tree shaking 的副作用可以查閱:Tree shaking class methods

webpack3 如何做到 scope hoisting?

scope hoisting,顧名思義就是將模組的作用域提升,在 webpack 中不能將所有所有的模組直接放在同一個作用域下,有以下幾個原因:

  1. 按需載入的模組
  2. 使用 commonjs 規範的模組
  3. 被多 entry 共享的模組

在 webpack3 中,這些情況生成的模組不會進行作用域提升,下面我就舉個例子來說明:

這個例子比較典型,utilA 被 pageA 和 pageB 所共享,utilB 被 pageB 單獨載入,utilC 被 pageB 非同步載入。

想要 webpack3 生效,則需要在 plugins 中新增 ModuleConcatenationPlugin。

webpack 配置如下:

執行 npm run build:hoist 進行編譯,簡單看下生成的 pageB 程式碼:

透過程式碼分析,可以得出下面的結論:

  1. 因為我們配置了共享模組抽離,所以 utilA 被抽出為單獨模組,故這部分內容不會進行作用域提升。
  2. utilB 無牽無掛,被 pageB 單獨載入,所以這部分不會生成新的模組,而是直接作用域提升到 pageB 中。
  3. utilC 被非同步載入,需要抽離成單獨模組,很明顯沒辦法作用域提升。

結尾

好了,講到這差不多就完了,理解上面的內容對前端模組化會有更多的認知,如果有什麼寫的不對或者不完整的地方,還望補充說明,希望這篇文章能幫助到你。

相關文章