Webpack 性能系列五:使用 Scope Hoisting
一、什么是 Scope Hoisting
默認情況下,經過 Webpack 打包后的模塊資源會被組織成一個個函數形式,例如:
關于打包產物形態的更多知識,可參考前文《Webpack 原理系列八:產物轉譯打包邏輯》
- // common.js
- export default "common";
- // index.js
- import common from './common';
- console.log(common);
上例最終會被打包出形如下面結構的產物:
- "./src/common.js":
- ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
- const __WEBPACK_DEFAULT_EXPORT__ = ("common");
- __webpack_require__.d(__webpack_exports__, {
- /* harmony export */
- "default": () => (__WEBPACK_DEFAULT_EXPORT__)
- /* harmony export */
- });
- }),
- "./src/index.js":
- ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
- var _common__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*! ./common */ "./src/common.js");
- console.log(_common__WEBPACK_IMPORTED_MODULE_0__)
- })
這種結構存在兩個影響到運行性能的問題:
- 重復的函數模板代碼會增大產物體積,消耗更多網絡流量
- 函數的出棧入棧需要創建、銷毀作用域空間,影響運行性能
針對這些問題,自 Webpack 3 開始引入 Scope Hoisting 功能,本質上就是將符合條件的多個模塊合并到同一個函數空間內,減少函數聲明的模板代碼與運行時頻繁出入棧操作,從而打包出「體積更小」、「運行性能」更好的包。例如上述示例經過 Scope Hoisting 優化后,生成代碼:
- ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
- ;// CONCATENATED MODULE: ./src/common.js
- /* harmony default export */ const common = ("common");
- ;// CONCATENATED MODULE: ./src/index.js
- console.log(common);
- })
二、使用 Scope Hoisting
2.1 開啟 Scope Hoisting 特性
Webpack 提供了三種方法開啟 Scope Hoisting 功能的方法:
- 開啟 Production 模式
- 使用 optimization.concatenateModules 配置項
- 直接使用 ModuleConcatenationPlugin 插件
分別對應下述代碼:
- const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
- module.exports = {
- // 方法1: 將 `mode` 設置為 production,即可開啟
- mode: "production",
- // 方法2: 將 `optimization.concatenateModules` 設置為 true
- optimization: {
- concatenateModules: true,
- usedExports: true,
- providedExports: true,
- },
- // 方法3: 直接使用 `ModuleConcatenationPlugin` 插件
- plugins: [new ModuleConcatenationPlugin()]
- };
三種方法的作用原理相似,最終都會用到 ModuleConcatenationPlugin 完成模塊分析與合并操作,唯一需要注意的是使用 optimization.concatenateModules 時需要將 usedExports、providedExports 同時設置為 true,標記模塊的導入導出變量,才能完成合并操作。
2.2 模塊合并規則
開啟 Scope Hoisting 后,Webpack 會將盡可能多的模塊合并到同一個函數作用域下,但合并功能一方面依賴于 ESM 靜態分析能力;一方面需要確保合并操作不會造成代碼冗余。因此開發者需要注意 Scope Hoisting 會在以下場景下失效:
2.2.1 非 ESM 模塊
對于 AMD、CMD 一類的模塊,由于模塊導入導出內容的動態性,Webpack 無法確保模塊合并后不會對原有的代碼語義產生副作用,導致 Scope Hoisting 失效,例如:
- // common.js
- module.exports = 'common';
- // index.js
- import common from './common';
上例中,由于 common.js 使用 CommonJS 導入模塊內容,Scope Hoisting 失效,兩個模塊無法合并。
這一問題在導入 NPM 包尤其常見,由于大部分框架都會自行打包后再上傳到 NPM,并且默認導出的是兼容性更佳的 CommonJS 模塊方案,因而無法使用 Scope Hoisting 功能,此時可通過 mainFileds 屬性嘗試引入框架的 ESM 版本:
- module.exports = {
- resolve: {
- // 優先使用 jsnext:main 中指向的 ES6 模塊化語法的文件
- mainFields: ['jsnext:main', 'browser', 'main']
- },
- };
2.2.2 模塊被多個 Chunk 引用
如果一個模塊被多個 Chunk 同時引用,為避免重復打包,Scope Hoisting 同樣會失效,例如:
- // common.js
- export default "common"
- // async.js
- import common from './common';
- // index.js
- import common from './common';
- import("./async");
上例中,入口 index.js 以異步引用方式導入 async.js 模塊,同時 async.js 與 index.js 都依賴于 common.js 模塊,根據 Chunk 的運行規則, async.js 會被處理為單獨的 Chunk ,這就意味著 common.js 模塊同時被 index.js 對應的 Initial Chunk 與 async.js 對應的 Async Chunk 引用,此時 Scope Hoisting 失效,common.js 無法被合并入任一 Chunk,而是作為生成為單獨的作用域,最終打包結果:
- "./src/common.js":
- (() => {
- var __WEBPACK_DEFAULT_EXPORT__ = ("common");
- }),
- "./src/index.js":
- (() => {
- var _common__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*! ./common */ "./src/common.js");
- __webpack_require__.e( /*! import() */ "src_async_js").then(__webpack_require__.bind(__webpack_require__, /*! ./async */ "./src/async.js"));
- }),
關于 Chunk 的更多內容,請參考:
- 《Webpack 性能系列四:分包優化》
- 《有點難的知識點:Webpack Chunk 分包規則詳解》
三、總結
默認情況下,Webpack 會將模塊打包成一個一個分離的函數,這會造成一定程度上的代碼冗余與運行性能問題,這一情況自 Webpack 3.0 引入 ModuleConcatenationPlugin 后,開發者可使用 Scope Hoisting 技術將多個模塊合并成一個函數,減少性能問題。