Webpack 實現(xiàn) Tree shaking 的前世今生
前言
如果看過 rollup 系列的這篇文章 - 無用代碼去哪了?項目減重之 rollup 的 Tree-shaking,那你一定對 tree-shaking 不陌生了。如果對 tree-shaking 相關(guān)知識不熟悉,請先點開上面這篇文章花 5 分鐘了解一下:什么是 tree-shaking。
眾所周知,原本不支持 tree-shaking 的 Webpack 在它的 2.x 版本也實現(xiàn)了 tree-shaking,好奇心又來了,rollup 從一開始就自實現(xiàn)了 tree-shaking,而 Webpack 則是看到 rollup 的打包瘦身效果之后,到了 2.x 才實現(xiàn),那么二者實現(xiàn) tree-shaking 的原理是一樣的嗎?
因為這樣的疑問,就有了眼前這篇文章。
Tree-shaking 實現(xiàn)機制
快速瀏覽完官方文檔和一眾文章后,發(fā)現(xiàn) webpack 實現(xiàn) tree-shaking 的方式還不止一種!但是,都與 rollup 不同。
早期 webpack 的配置使用并不簡單,也因此曾有 webpack 配置工程師的戲稱,雖然現(xiàn)在 webpack 的配置被極大簡化了,webpack4 也宣稱 0 配置,但如果涉及復(fù)雜全面的打包功能,并非是 0 配置可以實現(xiàn)的。了解其功能原理及配置還是極為有用的,接下來就來了解一下 webpack 實現(xiàn) tree-shaking 的原理吧。
Tree-shaking -- rollup VS Webpack
- rollup 是在編譯打包過程中分析程序流,得益于于 ES6 靜態(tài)模塊(exports 和 imports 不能在運行時修改),我們在打包時就可以確定哪些代碼時我們需要的。
- webpack 本身在打包時只能標記未使用的代碼而不移除,而識別代碼未使用標記并完成 tree-shaking 的 其實是 UglifyJS、babili、terser 這類壓縮代碼的工具。簡單來說,就是壓縮工具讀取 webpack 打包結(jié)果,在壓縮之前移除 bundle 中未使用的代碼。
我們提到了標記未使用代碼,也提到了 UglifyJS、babili、terser 等壓縮工具,那么 webpack 與壓縮工具是怎么實現(xiàn) tree-shaking 的呢?先來了解下 webpack 中實現(xiàn) tree-shaking 的前世今生吧!
Webpack 實現(xiàn) tree-shaking 的 3 個階段
第一階段:UglifyJS
webpack 標記代碼 + babel 轉(zhuǎn)譯 ES5 --> UglifyJS 壓縮刪除無用代碼 關(guān)于最早版本的 Webpack 實現(xiàn) tree-shaking 可以參考這篇文章 如何在 Webpack 2 中使用 tree-shaking(鏈接地址見文末參考),掘金也有翻譯版,當然如果不愿意花時間考古,也可以看下面這一段總結(jié):
- UglifyJS 不支持 ES6 及以上,需要用 Babel 將代碼編譯為 ES5,然后再用 UglifyJS 來清除無用代碼;
- 通過 Babel 將代碼編譯為 ES5,但又要讓 ES6 模塊不受 Babel 預(yù)設(shè)(preset)的影響:配置 Babel 預(yù)設(shè)不轉(zhuǎn)換 module,對應(yīng)地配置 Webpack 的 plugins 配置;
- 為避免副作用,將其標記為 pure(無副作用),以便 UglifyJS 能夠處理,主要是 webpack 的編譯過程阻止了對類進行 tree-shaking,它僅對函數(shù)起作用,后來通過支持將類編譯后的賦值標記為 @__PURE__解決了這個問題。
- // .babelrc
- {
- "presets": [
- ["env", {
- "loose": true, // 寬松模式
- "modules": false // 不轉(zhuǎn)換 module,保持 ES6 語法
- }]
- ]
- }
- // webpack.config.js
- module: {
- rules: [
- { test: /\.js$/, loader: 'babel-loader' }
- ]
- },
- plugins: [
- new webpack.LoaderOptionsPlugin({
- minimize: true,
- debug: false
- }),
- new webpack.optimize.UglifyJsPlugin({
- compress: {
- warnings: true
- },
- output: {
- comments: false
- },
- sourceMap: false
- })
- ]
第二階段:BabelMinify
webpack 標記代碼 --> Babili(即 BabelMinify)壓縮刪除無用代碼 Babili 后來被重命名為 BabelMinify,是基于 Babel 的代碼壓縮工具,而 Babel 已經(jīng)通過我們的解析器 Babylon 理解了新語法,同時又在 babili 中集成了 UglifyJS 的壓縮功能,本質(zhì)上實現(xiàn)了和 UglifyJS 一樣的功能,但使用 babili 插件又不必再轉(zhuǎn)譯,而是直接壓縮,使代碼體積更小。
一般使用 Babili 替代 uglify 有 Babili 插件式和 babel-loader 預(yù)設(shè)兩種方式。在官方文檔最后有說明,Babel Minify 最適合針對最新的瀏覽器(具有完整的 ES6+ 支持),也可以與通常的 Babel es2015 預(yù)設(shè)一起使用,以首先向下編譯代碼。
在 webpack 中使用 babel-loader,然后再引入 minify 作為一個 preset 會比直接使用 BabelMinifyWebpackPlugin 插件(下一個就講到)執(zhí)行得更快。因為 babel-minify 處理的文件體積會更小。
第三階段:Terser
webpack 標記代碼 --> Terser 壓縮刪除無用代碼 (webpack5 已內(nèi)置) terser 是一個用于 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包。如果你看過這個 issue(https://github.com/webpack-contrib/terser-webpack-plugin/issues/15),就會知道放棄 uglify 而投向 terser 懷抱的人越來越多,其原因也很清楚:
- uglify 不再進行維護且不支持 ES6+ 語法
- webpack 默認內(nèi)置配置了 terser 插件實現(xiàn)代碼壓縮 關(guān)于副作用,從 webpack 4 正式版本擴展了未使用模塊檢測能力,通過 package.json 的 "sideEffects" 屬性作為標記,向 compiler 提供提示,表明項目中的哪些文件是 "pure(純正 ES2015 模塊)",由此可以安全地刪除文件中未使用的部分。
webpack4 的時候還要手動配置一下壓縮插件,但最新的 webpack5 已經(jīng)內(nèi)置實現(xiàn) tree-shaking 啦!在生產(chǎn)環(huán)境下無需配置即可實現(xiàn) tree-shaking !
Webpack 的 Tree-shaking 流程
Webpack 標記代碼
總的來說,webpack 對代碼進行標記,主要是對 import & export 語句標記為 3 類:
- 所有 import 標記為 /* harmony import */
- 所有被使用過的 export 標記為/* harmony export ([type]) */,其中 [type] 和 webpack 內(nèi)部有關(guān),可能是 binding, immutable 等等
- 沒被使用過的 export 標記為/* unused harmony export [FuncName] */,其中 [FuncName] 為 export 的方法名稱
首先我們要知道,為了正常運行業(yè)務(wù)項目,Webpack 需要將開發(fā)者編寫的業(yè)務(wù)代碼以及支撐、調(diào)配這些業(yè)務(wù)代碼的運行時一并打包到產(chǎn)物(bundle)中。落到 Webpack 源碼實現(xiàn)上,運行時的生成邏輯可以劃分為打包階段中的兩個步驟:
- 依賴收集:遍歷代碼模塊并收集模塊的特性依賴,從而確定整個項目對 Webpack runtime 的依賴列表;
- 生成:合并 runtime 的依賴列表,打包到最終輸出的 bundle。
顯然,對代碼的語句標記就發(fā)生在依賴收集的過程中。
在運行時環(huán)境標記所有 import:
- const exportsType = module.getExportsType(
- chunkGraph.moduleGraph,
- originModule.buildMeta.strictHarmonyModule
- );
- runtimeRequirements.add(RuntimeGlobals.require);
- const importContent = `/* harmony import */ ${optDeclaration}${importVar} = __webpack_require__(${moduleId});\n`;
- // 動態(tài)導(dǎo)入語法分析
- if (exportsType === "dynamic") {
- runtimeRequirements.add(RuntimeGlobals.compatGetDefaultExport);
- return [
- importContent, // 標記/* harmony import */
- `/* harmony import */ ${optDeclaration}${importVar}_default = /*#__PURE__*/${RuntimeGlobals.compatGetDefaultExport}(${importVar});\n` // 通過 /*#__PURE__*/ 注釋可以告訴 webpack 一個函數(shù)調(diào)用是無副作用的
- ]; // 返回 import 語句和 compat 語句
- }
在運行時環(huán)境標記所有被使用過的和未被使用的 export:
- // 在運行時狀態(tài)定義 property getters
- generate() {
- const { runtimeTemplate } = this.compilation;
- const fn = RuntimeGlobals.definePropertyGetters;
- return Template.asString([
- "// define getter functions for harmony exports",
- `${fn} = ${runtimeTemplate.basicFunction("exports, definition", [
- `for(var key in definition) {`,
- Template.indent([
- `if(${RuntimeGlobals.hasOwnProperty}(definition, key) && !${RuntimeGlobals.hasOwnProperty}(exports, key)) {`,
- Template.indent([
- "Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });"
- ]),
- "}"
- ]),
- "}"
- ])};`
- ]);
- }
- // 輸入為 generate 上下文
- getContent({ runtimeTemplate, runtimeRequirements }) {
- runtimeRequirements.add(RuntimeGlobals.exports);
- runtimeRequirements.add(RuntimeGlobals.definePropertyGetters);
- const unusedPart =
- this.unusedExports.size > 1
- ? `/* unused harmony exports ${joinIterableWithComma(
- this.unusedExports
- )} */\n`
- : this.unusedExports.size > 0
- ? `/* unused harmony export ${first(this.unusedExports)} */\n`
- : "";
- const definitions = [];
- for (const [key, value] of this.exportMap) {
- definitions.push(
- `\n/* harmony export */ ${JSON.stringify(
- key
- )}: ${runtimeTemplate.returningFunction(value)}`
- );
- }
- const definePart =
- this.exportMap.size > 0
- ? `/* harmony export */ ${RuntimeGlobals.definePropertyGetters}(${
- this.exportsArgument
- }, {${definitions.join(",")}\n/* harmony export */ });\n`
- : "";
- return `${definePart}${unusedPart}`; // 作為初始化代碼包含的源代碼
- }
壓縮清除大法
UglifyJS
以 UglifyJS 為例,UglifyJS 是一個 js 解釋器、最小化器、壓縮器、美化器工具集(parser, minifier, compressor or beautifier toolkit)。具體介紹可以查看下 UglifyJS 中文手冊。
如果不想瀏覽這么一大長篇文檔,可以看干凈利落、直指 tree-shaking 的壓縮配置參數(shù)總結(jié)吧!
- dead_code -- 移除沒被引用的代碼 // 是不是很眼熟!無用代碼!
- drop_debugger -- 移除 debugger
- unused -- 干掉沒有被引用的函數(shù)和變量。(除非設(shè)置"keep_assign",否則變量的簡單直接賦值也不算被引用。)
- toplevel -- 干掉頂層作用域中沒有被引用的函數(shù) ("funcs")和/或變量("vars") (默認是 false , true 的話即函數(shù)變量都干掉)
- warnings -- 當刪除沒有用處的代碼時,顯示警告 // 還挺貼心有么有~
- pure_getters -- 默認是 false. 如果你傳入 true,UglifyJS 會假設(shè)對象屬性的引用(例如 foo.bar 或 foo["bar"])沒有函數(shù)副作用。
- pure_funcs -- 默認 null. 你可以傳入一個名字的數(shù)組,UglifyJS 會假設(shè)這些函數(shù)沒有函數(shù)副作用。
舉個栗子:
- plugins: [
- new UglifyJSPlugin({
- uglifyOptions: {
- compress: {
- // 這樣該函數(shù)會被認為沒有函數(shù)副作用,整個聲明會被廢棄。在目前的執(zhí)行情況下,會增加開銷(壓縮會變慢)。
- pure_funcs: ['Math.floor']
- }
- }
- })
- ],
Tip:假如名字在作用域中重新定義,不會再次檢測。例如 var q = Math.floor(a/b),假如變量 q 沒有被引用,UglifyJS 會干掉它,但 Math.floor(a/b)會被保留,沒有人知道它是干嘛的。
- side_effects -- 默認 true. 傳 false 禁用丟棄純函數(shù)。如果一個函數(shù)被調(diào)用前有一段/@PURE/ or /#PURE/ 注釋,該函數(shù)會被標注為純函數(shù)。例如 /@PURE/foo();
事實上,在這么多的壓縮配置中,除了要解決副作用問題要手動配置以外,僅使用 UglifyJS 默認配置即可去除無用標記代碼以實現(xiàn) tree-shaking。
terser
以 terser 為例,terser 是一個用于 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包。具體可查看官方文檔。雖然沒有中文文檔,但是一眼掃過去也可以看出來配置參數(shù)和 UglifyJS 沒有太大區(qū)別。當然很明顯地多了一些參數(shù):
- arrows -- 如果轉(zhuǎn)換后的代碼更短,類和對象字面量方法也將被轉(zhuǎn)換為箭頭表達式
- ecma -- 通過 ES2015 或 更高版本來啟用壓縮選項,將 ES5 代碼轉(zhuǎn)換為更小的 ES6+等效形式 顯然是因為 terser 支持 ES6+ 語法,這也是它淘汰 UglifyJS 的優(yōu)勢之一。
壓縮性能 PK
目前 Webpack 已經(jīng)更新到了版本 5.X,已經(jīng)將 terser 插件默認內(nèi)置且無需配置,雖然生產(chǎn)環(huán)境下默認使用 TerserPlugin ,并且也是代碼壓縮方面比較好的選擇,但是還有一些其他可選擇項。等等,我們的主題不是 tree-shaking 嗎?怎么在壓縮工具的路上突然越走越遠...
本質(zhì)上,實現(xiàn) tree-shaking 的還是壓縮工具,所以我們來看壓縮工具的性能好像也沒毛病!
TIP:壓縮是在生產(chǎn)環(huán)境中生效的,所以生產(chǎn)環(huán)境下才能 tree-shaking。下面 3 個可配置插件要求 webpack 版本至少在 V4+。
UglifyjsWebpackPlugin
基本的使用方式也更加簡單:
- // webpack.config.js
- const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
- module.exports = {
- optimization: {
- minimizer: [new UglifyJsPlugin()],
- },
- };
- const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
- module.exports = {
- plugins: [
- new UglifyJsPlugin()
- ]
- }
BabelMinifyWebpackPlugin
一般使用 babili 替代 UglifyJS 有 Babili 插件式和 babel-loader 預(yù)設(shè)兩種方式。
Babili 插件式
只要用 Babili 插件替代 uglify 即可,此時也不需要 babel-loader 了:
- // webpack.config.js
- const MinifyPlugin = require("babel-minify-webpack-plugin");
- module.exports = {
- plugins: [
- new MinifyPlugin(minifyOpts, pluginOpts)
- ]
- }
babel-loader 預(yù)設(shè)
在官方文檔最后有說明,Babel Minify 最適合針對最新的瀏覽器(具有完整的 ES6+ 支持),也可以與通常的 Babel es2015 預(yù)設(shè)一起使用,以首先向下編譯代碼。
在 webpack 中使用 babel-loader,然后再引入 minify 作為一個 preset 會比直接使用 BabelMinifyWebpackPlugin 插件執(zhí)行得更快。因為 babel-minify 處理的文件體積會更小。
即在.babelrc 中配置如下:
- {
- "presets": ["es2015"],
- "env": {
- "production": {
- "presets": ["minify"]
- }
- }
- }
但 BabelMinifyWebpackPlugin 插件存在必定有其無法替代的作用:
- webpack loader 對單個文件進行操作, minify preset 作為一個 webpack loader 會把每個文件視為在瀏覽器全局范圍內(nèi)直接執(zhí)行(默認情況下),并不會優(yōu)化頂級作用域內(nèi)的某些內(nèi)容;
- 當排除 node_modules 不通過 babel-loader 運行時,babel-minify 優(yōu)化不會應(yīng)用于被排除的文件;
- 當使用 babel-loader 時,由 webpack 為模塊系統(tǒng)生成的代碼不會通過 babel-minify 進行優(yōu)化;
- webpack 插件可以在整個 chunk/bundle 輸出上運行,并且可以優(yōu)化整個 bundle。
采用第一種方式:
TerserWebpackPlugin
同 uglify 和 babelMinify 插件一樣,terser 插件配置使用也十分簡單。
- webpack.config.js
- const TerserPlugin = require("terser-webpack-plugin");
- module.exports = {
- optimization: {
- minimize: true,
- minimizer: [new TerserPlugin()],
- },
- };
企業(yè)微信截圖_16247735356260.png
看上去結(jié)果是符合預(yù)期的,又因為我的文件代碼本身體積就小,所以壓縮包體積上的優(yōu)勢其實并不明顯,但壓縮時間上還是比較明顯的。
官方數(shù)據(jù)性能對比
再來康康 bableMinify 文檔 中給出的對比吧:
打包 react:

打包 vue:
打包 lodash:

打包 three.js:

小結(jié)
先讓我們來看看 issue 區(qū)網(wǎng)友們是怎么說的:

大意是 terser 壓縮性能相較于 uglify 提升了三倍!Nice!
大意是說:鑒于 terser-webpack-plugin 得到維護并且有更多的正確性修復(fù),絕對是首選 -- 即使沒有性能改進(事實上還是有所改進的),也值得切換。最后一句話總結(jié):webpack 打包 + terser 壓縮才是最終的不二之選!webpack5 內(nèi)置 terser 說明了一切!
處理 Side Effects
「副作用」的定義是,在導(dǎo)入時會執(zhí)行特殊行為的代碼,而不是僅僅暴露一個 export 或多個 export。舉例說明,例如 polyfill,它影響全局作用域,并且通常不提供 export。
關(guān)于副作用在 rollup 中也已經(jīng)介紹過。有些模塊導(dǎo)入,只要被引入,就會對應(yīng)用程序產(chǎn)生重要的影響。比如全局樣式表,或者設(shè)置全局配置的 JavaScript 文件就是很好的例子。
Webpack 認為這樣的文件有“副作用”,具有副作用的文件不應(yīng)該做 tree-shaking,因為這將破壞整個應(yīng)用程序。webpack 的 tree-shaking 在副作用處理方面稍顯遜色,它可以簡單的判斷變量后續(xù)是否被引用、修改,但是不能判斷一個變量完整的修改過程,不知道它是否已經(jīng)指向了外部變量,所以很多有可能會產(chǎn)生副作用的代碼,都只能保守的不刪除。
幸運的是,我們可以通過配置項目,告訴 Webpack 哪些代碼是沒有副作用的,可以進行 tree-shaking。
配置參數(shù)
在項目的 package.json 文件中,添加 "sideEffects" 屬性。package.json 有一個特殊的屬性 sideEffects,就是為處理副作用而存在的 -- 向 webpack 的 compiler 提供提示哪些代碼是“純粹部分”。它有三個可能的值:
- true 是默認值,如果不指定其他值的話。這意味著所有的文件都有副作用,也就是沒有一個文件可以 tree-shaking。
- false 告訴 Webpack 沒有文件有副作用,所有文件都可以 tree-shaking。
- 第三個值 […] 是文件路徑數(shù)組。它告訴 webpack,除了數(shù)組中包含的文件外,你的任何文件都沒有副作用。因此,除了指定的文件之外,其他文件都可以安全地進行 tree-shaking。
- {
- "name": "your-project",
- "sideEffects": false
- // "sideEffects": [ // 數(shù)組方式支持相關(guān)文件的相對路徑、絕對路徑和 glob 模式
- // "./src/some-side-effectful-file.js",
- // "*.css"
- //]
- }
每個項目都必須將 sideEffects 屬性設(shè)置為 false 或文件路徑數(shù)組,如果你的代碼確實有一些副作用,那么可以改為提供一個數(shù)組,在工作中需要正確配置 sideEffects 標記。
代碼中標記
可以通過 /#PURE/ 注釋可以告訴 webpack 一個函數(shù)調(diào)用是無副作用的。在函數(shù)調(diào)用之前,用來標記它們是無副作用的(pure)。傳到函數(shù)中的入?yún)⑹菬o法被剛才的注釋所標記,需要單獨每一個標記才可以。如果一個沒被使用的變量定義的初始值被認為是無副作用的(pure),它會被標記為死代碼,不會被執(zhí)行且會被壓縮工具清除掉。當 optimization.innerGraph 被設(shè)置成 true 這個行為被會開啟,而在 webpack5.x 中optimization.innerGraph 默認為 true。
語法使用層面
- 首先,mode 為 production 模式下才會啟用更多優(yōu)化項,包括我們本文講的壓縮代碼與 tree shaking;
- 使用 ES2015 模塊語法(即 import 和 export);
- 確保沒有編譯器將 ES2015 模塊語法轉(zhuǎn)換為 CommonJS 的,把 presets 中的 modules 設(shè)置為 false,告訴 babel 不要編譯模塊代碼。
總結(jié)
- 如果是開發(fā) JavaScript 庫,使用 rollup!并且提供 ES6 module 的版本,入口文件地址設(shè)置到 package.json 的 module 字段;
- 使用 webpack 哪怕是舊版本可以優(yōu)先考慮 terser 插件作為壓縮工具;
- 為避免副作用,盡量不寫帶有副作用的代碼,使用 ES2015 模塊語法;
- 在項目 package.json 文件中,添加一個 sideEffects 入口,設(shè)置 sideEffects 屬性為 false,也可以通過 /#PURE/ 注釋強制刪除一些認為不會產(chǎn)生副作用的代碼;
- 在 Webpack 中還要額外引入一個能夠刪除未引用代碼(dead code)的壓縮工具(eg. Terser)。
參考資料
- 如何在 Webpack 2 中使用 tree-shaking()https://blog.craftlab.hu/how-to-do-proper-tree-shaking-in-webpack-2-e27852af8b21
- 你的 Tree-Shaking 并沒什么卵用(https://zhuanlan.zhihu.com/p/32831172)
- UglifyJS 中文手冊(https://github.com/LiPinghai/UglifyJSDocCN/blob/master/README.md)
- Webpack 4 Tree Shaking 終極優(yōu)化指南(https://juejin.cn/post/6844903998634328072#heading-5)
- Webpack 中文文檔 Tree-shaking(https://www.webpackjs.com/guides/tree-shaking/)