Webpack 實現 Tree shaking 的前世此生

掘金引流終版.gif

構建專欄系列目錄入口css

左琳,微醫前端技術部前端開發工程師。身處互聯網浪潮之中,熱愛生活與技術。前端

前言

若是看過 rollup 系列的這篇文章 - 無用代碼去哪了?項目減重之 rollup 的 Tree-shaking,那你必定對 tree-shaking 不陌生了。若是對 tree-shaking 相關知識不熟悉,請先點開上面這篇文章花 5 分鐘瞭解一下:什麼是 tree-shaking。vue

衆所周知,本來不支持 tree-shaking 的 Webpack 在它的 2.x 版本也實現了 tree-shaking,好奇心又來了,rollup 從一開始就自實現了 tree-shaking,而 Webpack 則是看到 rollup 的打包瘦身效果以後,到了 2.x 才實現,那麼兩者實現 tree-shaking 的原理是同樣的嗎?node

由於這樣的疑問,就有了眼前這篇文章。react

Tree-shaking 實現機制

快速瀏覽完官方文檔和一衆文章後,發現 webpack 實現 tree-shaking 的方式還不止一種!可是,都與 rollup 不一樣。webpack

早期 webpack 的配置使用並不簡單,也所以曾有 webpack 配置工程師的戲稱,雖然如今 webpack 的配置被極大簡化了,webpack4 也宣稱 0 配置,但若是涉及複雜全面的打包功能,並不是是 0 配置能夠實現的。瞭解其功能原理及配置仍是極爲有用的,接下來就來了解一下 webpack 實現 tree-shaking 的原理吧。 ​git

Tree-shaking -- rollup VS Webpack

  • rollup 是在編譯打包過程當中分析程序流,得益於於 ES6 靜態模塊(exports 和 imports 不能在運行時修改),咱們在打包時就能夠肯定哪些代碼時咱們須要的。github

  • webpack 自己在打包時只能標記未使用的代碼而不移除,而識別代碼未使用標記並完成 tree-shaking 的 實際上是 UglifyJS、babili、terser 這類壓縮代碼的工具。簡單來講,就是壓縮工具讀取 webpack 打包結果,在壓縮以前移除 bundle 中未使用的代碼。web

咱們提到了標記未使用代碼,也提到了 UglifyJS、babili、terser 等壓縮工具,那麼 webpack 與壓縮工具是怎麼實現 tree-shaking 的呢?先來了解下 webpack 中實現 tree-shaking 的前世此生吧! ​json

Webpack 實現 Tree-shaking 的 3 個階段

第一階段: UglifyJS

webpack 標記代碼 + babel 轉譯 ES5 --> UglifyJS 壓縮刪除無用代碼 ​ 關於最先版本的 Webpack 實現 tree-shaking 能夠參考這篇文章 如何在 Webpack 2 中使用 tree-shaking,掘金也有翻譯版,固然若是不肯意花時間考古,也能夠看下面這一段總結:

  • UglifyJS 不支持 ES6 及以上,須要用 Babel 將代碼編譯爲 ES5,而後再用 UglifyJS 來清除無用代碼;
  • 經過 Babel 將代碼編譯爲 ES5,但又要讓 ES6 模塊不受 Babel 預設(preset)的影響:配置 Babel 預設不轉換 module,對應地配置 Webpack 的 plugins 配置;
  • 爲避免反作用,將其標記爲 pure(無反作用),以便 UglifyJS 可以處理,主要是 webpack 的編譯過程阻止了對類進行 tree-shaking,它僅對函數起做用,後來經過支持將類編譯後的賦值標記爲 @__PURE__解決了這個問題。
// .babelrc
{
  "presets": [
    ["env", {
      "loose": true, // 寬鬆模式
      "modules": false // 不轉換 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 已經經過咱們的解析器 Babylon 理解了新語法,同時又在 babili 中集成了 UglifyJS 的壓縮功能,本質上實現了和 UglifyJS 同樣的功能,但使用 babili 插件又沒必要再轉譯,而是直接壓縮,使代碼體積更小。

通常使用 Babili 替代 uglify 有 Babili 插件式和 babel-loader 預設兩種方式。在官方文檔最後有說明,Babel Minify 最適合針對最新的瀏覽器(具備完整的 ES6+ 支持),也能夠與一般的 Babel es2015 預設一塊兒使用,以首先向下編譯代碼。

在 webpack 中使用 babel-loader,而後再引入 minify 做爲一個 preset 會比直接使用 BabelMinifyWebpackPlugin 插件(下一個就講到)執行得更快。由於 babel-minify 處理的文件體積會更小。 ​

第三階段: Terser

webpack 標記代碼 --> Terser 壓縮刪除無用代碼 (webpack5 已內置) ​ terser 是一個用於 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包。若是你看過這個 issue,就會知道放棄 uglify 而投向 terser 懷抱的人愈來愈多,其緣由也很清楚:

  • uglify 再也不進行維護且不支持 ES6+ 語法
  • webpack 默認內置配置了 terser 插件實現代碼壓縮

​ 關於反作用,從 webpack 4 正式版本擴展了未使用模塊檢測能力,經過 package.json 的 "sideEffects" 屬性做爲標記,向 compiler 提供提示,代表項目中的哪些文件是 "pure(純正 ES2015 模塊)",由此能夠安全地刪除文件中未使用的部分。

webpack4 的時候還要手動配置一下壓縮插件,但最新的 webpack5 已經內置實現 tree-shaking 啦!在生產環境下無需配置便可實現 tree-shaking !

Webpack 的 Tree-shaking 流程

Webpack 標記代碼

總的來講,webpack 對代碼進行標記,主要是對 import & export 語句標記爲 3 類:

  • 全部 import 標記爲 /* harmony import */
  • 全部被使用過的 export 標記爲/* harmony export ([type]) */,其中 [type] 和 webpack 內部有關,多是 binding, immutable 等等
  • 沒被使用過的 export 標記爲/* unused harmony export [FuncName] */,其中 [FuncName] 爲 export 的方法名稱

首先咱們要知道,爲了正常運行業務項目,Webpack 須要將開發者編寫的業務代碼以及支撐、調配這些業務代碼的運行時一併打包到產物(bundle)中。 ​ 落到 Webpack 源碼實現上,運行時的生成邏輯能夠劃分爲打包階段中的兩個步驟:

  • 依賴收集:遍歷代碼模塊並收集模塊的特性依賴,從而肯定整個項目對 Webpack runtime 的依賴列表;
  • 生成:合併 runtime 的依賴列表,打包到最終輸出的 bundle。

顯然,對代碼的語句標記就發生在依賴收集的過程當中。

在運行時環境標記全部 import:

const exportsType = module.getExportsType(
	chunkGraph.moduleGraph,
	originModule.buildMeta.strictHarmonyModule
);
runtimeRequirements.add(RuntimeGlobals.require);
const importContent = `/* harmony import */ ${optDeclaration}${importVar} = __webpack_require__(${moduleId});\n`;

// 動態導入語法分析
if (exportsType === "dynamic") {
	runtimeRequirements.add(RuntimeGlobals.compatGetDefaultExport);
	return [
		importContent, // 標記/* harmony import */
		`/* harmony import */ ${optDeclaration}${importVar}_default = /*#__PURE__*/${RuntimeGlobals.compatGetDefaultExport}(${importVar});\n` // 經過 /*#__PURE__*/ 註釋能夠告訴 webpack 一個函數調用是無反作用的
	]; // 返回 import 語句和 compat 語句
}
複製代碼

在運行時環境標記全部被使用過的和未被使用的 export:

// 在運行時狀態定義 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 的壓縮配置參數總結吧!

  • dead_code -- 移除沒被引用的代碼 // 是否是很眼熟!無用代碼!
  • drop_debugger -- 移除 debugger
  • unused -- 幹掉沒有被引用的函數和變量。(除非設置"keep_assign",不然變量的簡單直接賦值也不算被引用。)
  • toplevel -- 幹掉頂層做用域中沒有被引用的函數 ("funcs")和/或變量("vars") (默認是 false , true 的話即函數變量都幹掉)
  • warnings -- 當刪除沒有用處的代碼時,顯示警告 // 還挺貼心有麼有~
  • pure_getters -- 默認是 false. 若是你傳入 true,UglifyJS 會假設對象屬性的引用(例如 foo.bar 或 foo["bar"])沒有函數反作用。
  • pure_funcs -- 默認 null. 你能夠傳入一個名字的數組,UglifyJS 會假設這些函數沒有函數反作用。

舉個栗子:

plugins: [
  new UglifyJSPlugin({
    uglifyOptions: {
      compress: {
          // 這樣該函數會被認爲沒有函數反作用,整個聲明會被廢棄。在目前的執行狀況下,會增長開銷(壓縮會變慢)。
          pure_funcs: ['Math.floor']
      }
    }
  })
],
複製代碼

Tip:假如名字在做用域中從新定義,不會再次檢測。例如 var q = Math.floor(a/b),假如變量 q 沒有被引用,UglifyJS 會幹掉它,但 Math.floor(a/b)會被保留,沒有人知道它是幹嗎的。

  • side_effects -- 默認 true. 傳 false 禁用丟棄純函數。若是一個函數被調用前有一段/@PURE/ or /#PURE/ 註釋,該函數會被標註爲純函數。例如 /@PURE/foo();

事實上,在這麼多的壓縮配置中,除了要解決反作用問題要手動配置之外,僅使用 UglifyJS 默認配置便可去除無用標記代碼以實現 tree-shaking。

terser

以 terser 爲例,terser 是一個用於 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包。具體可查看官方文檔。 ​ 雖然沒有中文文檔,可是一眼掃過去也能夠看出來配置參數和 UglifyJS 沒有太大區別。固然很明顯地多了一些參數:

  • arrows -- 若是轉換後的代碼更短,類和對象字面量方法也將被轉換爲箭頭表達式
  • ecma -- 經過 ES2015 或 更高版原本啓用壓縮選項,將 ES5 代碼轉換爲更小的 ES6+等效形式

​ 顯然是由於 terser 支持 ES6+ 語法,這也是它淘汰 UglifyJS 的優點之一。

壓縮性能 PK

目前 Webpack 已經更新到了版本 5.X,已經將 terser 插件默認內置且無需配置,雖然生產環境下默認使用 TerserPlugin ,而且也是代碼壓縮方面比較好的選擇,可是還有一些其餘可選擇項。等等,咱們的主題不是 tree-shaking 嗎?怎麼在壓縮工具的路上忽然越走越遠...

本質上,實現 tree-shaking 的仍是壓縮工具,因此咱們來看壓縮工具的性能好像也沒毛病!

TIP:壓縮是在生產環境中生效的,因此生產環境下才能 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 預設兩種方式。

Babili 插件式

只要用 Babili 插件替代 uglify 便可,此時也不須要 babel-loader 了:

// webpack.config.js
const MinifyPlugin = require("babel-minify-webpack-plugin");
module.exports = {
  plugins: [
    new MinifyPlugin(minifyOpts, pluginOpts)
  ]
}
複製代碼

babel-loader 預設

官方文檔最後有說明,Babel Minify 最適合針對最新的瀏覽器(具備完整的 ES6+ 支持),也能夠與一般的 Babel es2015 預設一塊兒使用,以首先向下編譯代碼。

在 webpack 中使用 babel-loader,而後再引入 minify 做爲一個 preset 會比直接使用 BabelMinifyWebpackPlugin 插件執行得更快。由於 babel-minify 處理的文件體積會更小。

即在.babelrc 中配置以下:

{
  "presets": ["es2015"],
  "env": {
    "production": {
      "presets": ["minify"]
    }
  }
}
複製代碼

但 BabelMinifyWebpackPlugin 插件存在一定有其沒法替代的做用:

  • webpack loader 對單個文件進行操做, minify preset 做爲一個 webpack loader 會把每一個文件視爲在瀏覽器全局範圍內直接執行(默認狀況下),並不會優化頂級做用域內的某些內容;
  • 當排除 node_modules 不經過 babel-loader 運行時,babel-minify 優化不會應用於被排除的文件;
  • 當使用 babel-loader 時,由 webpack 爲模塊系統生成的代碼不會經過 babel-minify 進行優化;
  • webpack 插件能夠在整個 chunk/bundle 輸出上運行,而且能夠優化整個 bundle。

採用第一種方式:

TerserWebpackPlugin

同 uglify 和 babelMinify 插件同樣,terser 插件配置使用也十分簡單。

webpack.config.js
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};
複製代碼

企業微信截圖_16247735356260.png

看上去結果是符合預期的,又由於個人文件代碼自己體積就小,因此壓縮包體積上的優點其實並不明顯,但壓縮時間上仍是比較明顯的。 ​

官方數據性能對比

再來康康 bableMinify 文檔 中給出的對比吧:

打包 react: react.png 打包 vue: vue.png

打包 lodash: lodash.png 打包 three.js: threejs.png

小結

先讓咱們來看看 issue 區網友們是怎麼說的: up.png

大意是 terser 壓縮性能相較於 uglify 提高了三倍!Nice! no-up.png

大意是說:鑑於 terser-webpack-plugin 獲得維護而且有更多的正確性修復,絕對是首選 -- 即便沒有性能改進(事實上仍是有所改進的),也值得切換。 ​ 最後一句話總結:webpack 打包 + terser 壓縮纔是最終的不二之選!webpack5 內置 terser 說明了一切!

處理 Side Effects

「反作用」的定義是,在導入時會執行特殊行爲的代碼,而不是僅僅暴露一個 export 或多個 export。舉例說明,例如 polyfill,它影響全局做用域,而且一般不提供 export。

關於反作用在 rollup 中也已經介紹過。有些模塊導入,只要被引入,就會對應用程序產生重要的影響。好比全局樣式表,或者設置全局配置的 JavaScript 文件就是很好的例子。

Webpack 認爲這樣的文件有「反作用」,具備反作用的文件不該該作 tree-shaking,由於這將破壞整個應用程序。webpack 的 tree-shaking 在反作用處理方面稍顯遜色,它能夠簡單的判斷變量後續是否被引用、修改,可是不能判斷一個變量完整的修改過程,不知道它是否已經指向了外部變量,因此不少有可能會產生反作用的代碼,都只能保守的不刪除。

幸運的是,咱們能夠經過配置項目,告訴 Webpack 哪些代碼是沒有反作用的,能夠進行 tree-shaking。

配置參數

在項目的 package.json 文件中,添加 "sideEffects" 屬性。package.json 有一個特殊的屬性 sideEffects,就是爲處理反作用而存在的 -- 向 webpack 的 compiler 提供提示哪些代碼是「純粹部分」。它有三個可能的值:

  • true 是默認值,若是不指定其餘值的話。這意味着全部的文件都有反作用,也就是沒有一個文件能夠 tree-shaking。
  • false 告訴 Webpack 沒有文件有反作用,全部文件均可以 tree-shaking。
  • 第三個值 […] 是文件路徑數組。它告訴 webpack,除了數組中包含的文件外,你的任何文件都沒有反作用。所以,除了指定的文件以外,其餘文件均可以安全地進行 tree-shaking。
{
  "name": "your-project",
  "sideEffects": false
  // "sideEffects": [ // 數組方式支持相關文件的相對路徑、絕對路徑和 glob 模式
  // "./src/some-side-effectful-file.js",
  // "*.css"
  //]
}
複製代碼

​ 每一個項目都必須將 sideEffects 屬性設置爲 false 或文件路徑數組,若是你的代碼確實有一些反作用,那麼能夠改成提供一個數組,在工做中須要正確配置 sideEffects 標記。

代碼中標記

能夠經過 /#PURE/ 註釋能夠告訴 webpack 一個函數調用是無反作用的。在函數調用以前,用來標記它們是無反作用的(pure)。 ​ 傳到函數中的入參是沒法被剛纔的註釋所標記,須要單獨每個標記才能夠。 ​ 若是一個沒被使用的變量定義的初始值被認爲是無反作用的(pure),它會被標記爲死代碼,不會被執行且會被壓縮工具清除掉。當 optimization.innerGraph 被設置成 true 這個行爲被會開啓,而在 webpack5.x 中optimization.innerGraph 默認爲 true。 ​

語法使用層面

  • 首先,mode 爲 production 模式下才會啓用更多優化項,包括咱們本文講的壓縮代碼與 tree shaking;
  • 使用 ES2015 模塊語法(即 import 和 export);
  • 確保沒有編譯器將 ES2015 模塊語法轉換爲 CommonJS 的,把 presets 中的 modules 設置爲 false,告訴 babel 不要編譯模塊代碼。

總結

  • 若是是開發 JavaScript 庫,使用 rollup!而且提供 ES6 module 的版本,入口文件地址設置到 package.json 的 module 字段;
  • 使用 webpack 哪怕是舊版本能夠優先考慮 terser 插件做爲壓縮工具;
  • 爲避免反作用,儘可能不寫帶有反作用的代碼,使用 ES2015 模塊語法;
  • 在項目 package.json 文件中,添加一個 sideEffects 入口,設置 sideEffects 屬性爲 false,也能夠經過 /#PURE/ 註釋強制刪除一些認爲不會產生反作用的代碼;
  • 在 Webpack 中還要額外引入一個可以刪除未引用代碼(dead code)的壓縮工具(eg. Terser)。

參考資料

保大人.gif

相關文章
相關標籤/搜索