1. 簡介
代碼分離是 webpack 中最引人注目的特性之一。此特性能夠把代碼分離到不同的 bundle 中,然后可以按需加載或并行加載這些文件。代碼分離可以用于獲取更小的 bundle,以及控制資源加載優先級,如果使用合理,會極大影響加載時間。
2. 入口分離
我們看下面這種情況:
// index.js
import _ from 'lodash';
import './another-module';
console.log(
_.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js
import _ from 'lodash';
import $ from 'jquery';
console.log(
_.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
$('body').css('background', 'green')
});
npm run dev 打包后如下:
可以看到,雖然 index 展示的時候不需要 another-module,但兩者最終被打包到同一個文件輸出,這樣的話有兩個缺點:
- index 和 another-module 邏輯混合到一起,增大了需要下載的包的體積。如果此時 index 是首屏必須的邏輯,那么由于包體增大,延遲了首屏展示時間。
- 修改 index 或者 another-module 邏輯,都會導致最終輸出的文件被改變,用戶需要重新下載和當前改動無關的模塊內容。
解決這兩個問題,最好的辦法,就是將無關的 index 和 another-module 分離。如下:
entry: {
index: "./src/index.js",
another: "./src/another-module.js"
},
// index.js
// index.js
import _ from 'lodash';
console.log(
_.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
打包后如下:
可以看到,首屏加載的資源 index 明顯變小了,可是加載時間反而延長了。這是由于 another 被并行加載,而且 index 和 another 的總體大小增大了很多。仔細分析,可以發現 lodash 模塊被分別打包到了 index 和 another。我們按照上面的思路,繼續將三方庫 lodash 和 jquery 也分離出來:
// index.js
console.log(
_.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js
console.log(
_.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
$('body').css('background', 'green')
});
// jquery.js
import $ from 'jquery';
window.$ = $;
// lodash.js
import _ from 'lodash';
window._ = _;
可以看到,jquery 和 lodash 被分離后,index 和 another 顯著變小,而第三方模塊基本上是很少改變的,也就是當某個業務模塊改變時,我們只需要重新上傳新的業務模塊代碼,用戶更新的時候也只需要更新較小的業務模塊代碼。不過可以看到,這里仍然有兩個缺點:
- 手動做代碼抽取非常麻煩,我們需要自己把握分離的先后順序,以及手動指定入口。
- 首次進入且沒有緩存的時候,由于并行的資源較多,并沒有減少首屏加載的時間,反而可能延長了這個時間。
下面我們來嘗試解決這兩個問題。
3. 代碼自動抽取
SplitChunksPlugin
插件可以將公共的依賴模塊提取到已有的入口 chunk 中,或者提取到一個新生成的 chunk。
3.1 代碼自動抽取
讓我們使用這個插件,將之前的示例中重復的 lodash 模塊 和 jquery 模塊抽取出來。(ps: 這里 webpack4 已經移除了 CommonsChunkPlugin 插件,改為 SplitChunksPlugin 插件了)。
// index.js
import _ from 'lodash';
console.log(
_.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js
import _ from 'lodash';
import $ from 'jquery';
console.log(
_.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
$('body').css('background', 'green')
});
optimization: {
splitChunks: {
chunks: 'all'
}
}
可以看到,兩個公共模塊各自被自動抽取到了新生成的 chunk 中。
3.2 SplitChunksPlugin 配置參數詳解
SplitChunksPlugin 默認配置如下:
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async',
minSize: 30000,
minRemainingSize: 0,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 6,
maxInitialRequests: 4,
automaticNameDelimiter: '~',
automaticNameMaxLength: 30,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
};
各項缺省時會自動取默認值,也就是如果傳入:
module.exports = {
//...
optimization: {
splitChunks: {}
}
};
等同于全部取默認值。下面我們來看一下每一項的含義。首先修改一下源文件,抽取 log-util 模塊:
// log-util.js
export const log = (info) => {
console.log(info);
};
export const err = (info) => {
console.log(info);
};
// index.js
import _ from 'lodash';
import { log } from './log-util';
log(
_.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js
import _ from 'lodash';
import $ from 'jquery';
import { log } from './log-util';
log(
_.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
$('body').css('background', 'green')
});
3.2.1 splitChunks.chunks
chunks 有三個值,分別是:
async: 異步模塊(即按需加載模塊,默認值)
initial: 初始模塊(即初始存在的模塊)
all: 全部模塊(異步模塊 + 初始模塊)
因為更改初始塊會影響 HTML 文件應該包含的用于運行項目的腳本標簽。我們可以修改該配置項如下(這里對 cacheGroups 做了簡單的修改,是為了方便后續的比較,大家簡單理解為,node_modules 的模塊,會放在 verdors 下,其他的會放在 default 下即可,后面會有更詳細的解釋):
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
3.2.2 splitChunks.minSize
生成塊的最小大小(以字節為單位)。
optimization: {
splitChunks: {
chunks: 'all',
minSize: 800000,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
可以看到 lodash 并沒有從 index 中拆出,lodash 和 jquery 從another 拆出后一起被打包在一個公共的 vendors~another 中。這是由于如果 lodash 和 jquery 單獨拆出后 jquery 是不到 800k 的,無法拆成單獨的兩個 chunk。
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
可以看到每個模塊都被分離了出來。
3.2.3 splitChunks.minRemainingSize
在 webpack 5 中引入了該選項,通過確保分割后剩余塊的最小大小超過指定限制,從而避免了零大小的模塊。在“開發”模式下默認為0。對于其他情況,該選項默認為 minSize 的值。所以它不需要手動指定,除非在需要采取特定的深度控制的情況下。
3.2.4 splitChunks.maxSize
使用 maxSize 告訴 webpack 嘗試將大于 maxSize 字節的塊分割成更小的部分。每塊至少是 minSize 大小。該算法是確定性的,對模塊的更改只會產生局部影響。因此,它在使用長期緩存時是可用的,并且不需要記錄。maxSize只是一個提示,當模塊大于 maxSize 時可能不會分割也可能分割后大小小于 minSize。
當塊已經有一個名稱時,每個部分將從該名稱派生出一個新名稱。取決于值optimization.splitChunks.hidePathInfo,它將從第一個模塊名或其散列派生一個
key。
需要注意:
- maxSize比maxInitialRequest/ maxasyncrequest具有更高的優先級。實際的優先級是maxInitialRequest/maxAsyncRequests < maxSize < minSize。
- 設置maxSize的值將同時設置maxAsyncSize和maxInitialSize的值。
maxSize選項用于HTTP/2和長期緩存。它增加了請求數,以便更好地進行緩存。它還可以用來減小文件大小,以便更快地重建。
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
maxSize: 30000,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
可以看到,defaultVendorsanotherindex~ 又分離出了 defaultVendorsanotherindex._node_modules_lodash_lodash.js2ef0e502.js 和 defaultVendorsanotherindex~._node_modules_webpack_buildin_g.js。
3.2.5 splitChunks.minChunks
代碼分割前共享一個模塊的最小 chunk 數,我們來看一下:
optimization: {
splitChunks: {
chunks: 'all',
minSize: 10,
minChunks: 2,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
可以看到, jquery 由于引用次數小于 2,沒有被單獨分離出來。如果改為 3,
optimization: {
splitChunks: {
chunks: 'all',
minSize: 10,
minChunks: 3,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
可以看到, jquery 和 lodash 由于引用次數小于 3,都沒有被單獨分離出來。
3.2.6 splitChunks.maxAsyncRequests
按需加載時的最大并行請求數。
3.2.7 splitChunks.maxInitialRequests
一個入口點的最大并行請求數。
3.2.8 splitChunks.automaticNameDelimiter
默認情況下,webpack將使用塊的來源和名稱來生成名稱(例如: vendors~main.js)。此選項允許您指定用于生成的名稱的分隔符。。
3.2.9 splitChunks.automaticNameMaxLength
插件生成的 chunk 名稱所允許的最大字符數。防止名稱過長,增大代碼和傳輸包體,保持默認即可。
3.2.10 splitChunks.cacheGroups
緩存組可以繼承和/或覆蓋splitChunks中的任何選項。但是test、priority和reuseExistingChunk只能在緩存組級配置。若要禁用任何缺省緩存組,請將它們設置為false。
3.2.10.1 splitChunks.cacheGroups.{cacheGroup}.test
控制此緩存組選擇哪些模塊。省略它將選擇所有模塊。它可以匹配絕對模塊資源路徑或塊名稱。當一個 chunk 名匹配時,chunk 中的所有模塊都被選中。
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
minChunks: 1,
cacheGroups: {
log: {
test(module, chunks) {
// `module.resource` contains the absolute path of the file on disk.
// Note the usage of `path.sep` instead of / or \, for cross-platform compatibility.
return module.resource &&
module.resource.indexOf('log') > -1;
}
},
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
可以看到,log-util 模塊被匹配到了 loganotherindex chunk。
3.2.10.2 splitChunks.cacheGroups.{cacheGroup}.priority
一個模塊可以屬于多個緩存組。該優化將優先選擇具有較高優先級的緩存組。默認組具有負優先級,以允許自定義組具有更高的優先級(默認值為0的自定義組)。
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
minChunks: 1,
cacheGroups: {
log: {
test(module, chunks) {
// `module.resource` contains the absolute path of the file on disk.
// Note the usage of `path.sep` instead of / or \, for cross-platform compatibility.
return module.resource &&
module.resource.indexOf('log') > -1;
},
priority: -20,
},
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -15,
reuseExistingChunk: true
}
}
}
}
可以看到 log 緩存組下不會輸出了,事實上,比 default 的 prioity 低的緩存組都是不會輸出的。
3.2.10.3 splitChunks.cacheGroups.{cacheGroup}.reuseExistingChunk
如果當前 chunk 包含已經從主包中分離出來的模塊,那么它將被重用,而不是生成一個新的 chunk。這可能會影響 chunk 的結果文件名。
3.3 小結
可以看到,提取公共代碼單獨輸出后,我們加載資源的時間并沒有變短,因為帶寬是一定的,并行資源過多,反而會增加 http 耗時。我們獲得的主要好處是,充分利用了緩存,這對于用戶資源更新時有很大的好處,不過也需要衡量公共代碼提取的條件,防止負優化。這里一般使用默認的四個條件即可(至于作用的模塊我們可以改為 all):
- 新的 chunk 可以被共享,或者是來自 node_modules 文件夾
- 新的 chunk 大于30kb(在 min + gz 壓縮之前)
- 當按需加載 chunk 時,并行請求的最大數量小于或等于 6
- 初始頁面加載時并行請求的最大數量將小于或等于 4
4. 動態引入和懶加載
我們進一步考慮,初始的時候并行了這么多資源,導致加載時間變慢,那么其中是否所有的資源都是需要的呢。顯然不是的。這里我們其實是想先加載首屏邏輯,然后點擊 body 時才去加載 another-module 的邏輯。
首先,webpack 資源是支持動態引入的。當涉及到動態代碼拆分時,webpack 提供了兩個類似的技術。對于動態導入,第一種,也是優先選擇的方式是,使用符合 ECMAScript 提案 的 import()
語法。第二種,則是使用 webpack 特定的 require.ensure
。更推薦使用第一種,適應范圍更大。
而在用戶真正需要的時候才去動態引入資源,也就是所謂的懶加載了。
我們作如下修改:
// index.js
import _ from 'lodash';
import { log } from './log-util';
log(
_.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
document.body.addEventListener('click', () => {
import ('./another-module').then(anotherModule => {
anotherModule.default.run();
});
});
// another-module.js
import _ from 'lodash';
import $ from 'jquery';
import { log } from './log-util';
const anotherModule = {
run() {
log(
_.join(['another', 'module', 'loaded!'], ' ')
);
$('body').css('background', 'green');
}
};
export default anotherModule;
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
minChunks: 1,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
}
打包后如下:
可以看到,another 的輔助加載和 log,lodash 邏輯被提前加載,但是模塊內部邏輯和 jquery 模塊都被單獨拎出來了,且并沒有加載。
點擊body后,該部分內容才被加載并執行。這樣就能有效提升首屏加載速度。
如果我們想改變異步加載包的名稱,可以使用 magic-comment,如下:
document.body.addEventListener('click', () => {
import (/* webpackChunkName: "anotherModule" */ './another-module').then(anotherModule => {
anotherModule.default.run();
});
});
打包發現:
但是尷尬地是,由于新增了 another-module,和 another 相同的部分被打包并且提前加載了,導致我們的懶加載策略失效了,這個坑大家要注意。
5. 預拉取和預加載
我們考慮一下這個問題,懶加載雖然減少了首屏加載時間,但是在交互操作或者其他異步渲染的響應。我們該如何解決這個問題呢?
webpack 4.6.0+增加了對預拉取和預加載的支持。
預拉取: 將來某些導航可能需要一些資源
預加載: 在當前導航可能需要一些資源
假設有一個主頁組件,它呈現一個LoginButton組件,然后在單擊后按需加載一個LoginModal組件。
// LoginButton.js
//...
import(/* webpackPrefetch: true */ 'LoginModal');
這將導致 <link rel="prefetch" href="login-modal-chunk.js"> 被附加在頁面的頭部,指示瀏覽器在空閑時間預拉取login-modal-chunk.js文件。
ps:webpack將在加載父模塊后立即添加預拉取提示。
Preload 不同于 prefetch:
- 一個預加載的塊開始與父塊并行加載。預拉取的塊在父塊完成加載后啟動。
- 預加載塊具有中等優先級,可以立即下載。在瀏覽器空閑時下載預拉取的塊。
- 一個預加載的塊應該被父塊立即請求。預拉取的塊可以在將來的任何時候使用。
- 瀏覽器支持是不同的。
讓我們想象一個組件 ChartComponent,它需要一個巨大的圖表庫。它在渲染時顯示一個 LoadingIndicator,并立即按需導入圖表庫:
// ChartComponent.js
//...
import(/* webpackPreload: true */ 'ChartingLibrary');
當使用 ChartComponent 的頁面被請求時,還會通過請求圖表庫塊。假設頁面塊更小,完成速度更快,那么頁面將使用 LoadingIndicator 顯示,直到已經請求的圖表庫塊完成。這將對加載時間有一定優化,因為它只需要一次往返而不是兩次。特別是在高延遲環境中。
ps: 不正確地使用 webpackPreload 實際上會損害性能,所以在使用它時要小心。
對于本文所列的例子,顯然更符合預拉取的情況,如下:
document.body.addEventListener('click', () => {
import (/* webpackPrefetch: true */ './another-module').then(anotherModule => {
anotherModule.default.run();
});
});
圖示資源,提前被下載好,在點擊的時候再去下載資源時就可以直接使用緩存。
document.body.addEventListener('click', () => {
import (/* webpackLoad: true */ './another-module').then(anotherModule => {
anotherModule.default.run();
});
});
6. 小結
本文內容比較多,統合了多個章節,而且內容上有很大的不一致。如果大家有同步看視屏,應該也會發現之前也有很多不一致的地方。學習記錄切忌照本宣科,多查資料,多實踐,才能有更多收獲。
參考
https://webpack.js.org/guides/code-splitting/#root
https://www.webpackjs.com/guides/code-splitting/
Webpack 的 Bundle Split 和 Code Split 區別和應用
https://webpack.js.org/plugins/split-chunks-plugin/
手摸手,帶你用合理的姿勢使用webpack4
webpack4 splitChunks的reuseExistingChunk選項有什么作用