簡介
幾個月前怔昨,我的任務是將我們組的 React 構(gòu)建配置升級到 Webpack 4。我們的主要目標之一是利用 tree-shaking 的優(yōu)勢宿稀,即 Webpack 去掉了實際上并沒有使用的代碼來減少包的大小〕靡ǎ現(xiàn)在,tree-shaking 的好處將根據(jù)你的代碼庫而有所不同祝沸。由于我們的幾個架構(gòu)決策矮烹,我們從公司內(nèi)部的其他庫中提取了大量代碼越庇,而我們只使用了其中的一小部分。
我寫這篇文章是因為恰當?shù)貎?yōu)化 Webpack 并不簡單奉狈。一開始我以為這是一種簡單的魔法卤唉,但后來我花了一個月的時間在網(wǎng)上搜索我遇到的一系列問題的答案。我希望通過這篇文章嘹吨,其他人會更容易地處理類似問題搬味。
先說好處
在討論技術(shù)細節(jié)之前,讓我先總結(jié)一下好處蟀拷。不同的應用程序?qū)⒖吹讲煌潭鹊暮锰幣鑫场V饕臎Q定因素是應用程序中死代碼的數(shù)量。如果你沒有多少死代碼问芬,那么你就看不到 tree-shaking 的多少好處悦析。我們項目里有很多死代碼。
在我們部門此衅,最大的問題是共享庫的數(shù)量强戴。從簡單的自定義組件庫,到企業(yè)標準組件庫挡鞍,再到莫名其妙地塞到一個庫中的大量代碼骑歹。很多都是技術(shù)債務,但一個大問題是我們所有的應用程序都在導入所有這些庫墨微,而實際上每個應用程序都只需要其中的一小部分
總的來說道媚,一旦實現(xiàn)了 tree-shaking,我們的應用程序就會根據(jù)應用程序的不同翘县,縮減率從25%到75%最域。平均縮減率為52%,主要是由這些龐大的共享庫驅(qū)動的锈麸,它們是小型應用程序中的主要代碼镀脂。
同樣,具體情況會有所不同忘伞,但是如果你覺得你打的包中可能有很多不需要的代碼薄翅,這就是如何消除它們的方法。
沒有示例代碼倉庫
對不住了各位老鐵氓奈,我做的項目是公司的財產(chǎn)翘魄,所以我不能分享代碼到 GitHub 倉庫了。但是探颈,我將在本文中提供簡化的代碼示例來說明我的觀點熟丸。
因此,廢話少說伪节,讓我們來看看如何編寫可實現(xiàn) tree-shaking 的最佳 webpack 4 配置光羞。
什么是死代碼
很簡單:就是 Webpack 沒看到你使用的代碼绩鸣。Webpack 跟蹤整個應用程序的 import/export 語句,因此纱兑,如果它看到導入的東西最終沒有被使用呀闻,它會認為那是“死代碼”,并會對其進行 tree-shaking 潜慎。
死代碼并不總是那么明確的捡多。下面是一些死代碼和“活”代碼的例子,希望能讓你更明白铐炫。請記住垒手,在某些情況下,Webpack 會將某些東西視為死代碼倒信,盡管它實際上并不是科贬。請參閱《副作用》一節(jié),了解如何處理鳖悠。
// 導入并賦值給 JavaScript 對象榜掌,然后在下面的代碼中被用到
// 這會被看作“活”代碼,不會做 tree-shaking
import Stuff from './stuff';
doSomething(Stuff);
// 導入并賦值給 JavaScript 對象乘综,但在接下來的代碼里沒有用到
// 這就會被當做“死”代碼憎账,會被 tree-shaking
import Stuff from './stuff';
doSomething();
// 導入但沒有賦值給 JavaScript 對象,也沒有在代碼里用到
// 這會被當做“死”代碼卡辰,會被 tree-shaking
import './stuff';
doSomething();
// 導入整個庫胞皱,但是沒有賦值給 JavaScript 對象,也沒有在代碼里用到
// 非常奇怪看政,這竟然被當做“活”代碼朴恳,因為 Webpack 對庫的導入和本地代碼導入的處理方式不同抄罕。
import 'my-lib';
doSomething();
用支持 tree-shaking 的方式寫 import
在編寫支持 tree-shaking 的代碼時允蚣,導入方式非常重要。你應該避免將整個庫導入到單個 JavaScript 對象中呆贿。當你這樣做時嚷兔,你是在告訴 Webpack 你需要整個庫, Webpack 就不會搖它做入。
以流行的庫 Lodash 為例冒晰。一次導入整個庫是一個很大的錯誤,但是導入單個的模塊要好得多竟块。當然壶运,Lodash 還需要其他的步驟來做 tree-shaking,但這是個很好的起點浪秘。
// 全部導入 (不支持 tree-shaking)
import _ from 'lodash';
// 具名導入(支持 tree-shaking)
import { debounce } from 'lodash';
// 直接導入具體的模塊 (支持 tree-shaking)
import debounce from 'lodash/lib/debounce';
基本的 Webpack 配置
使用 Webpack 進行 tree-shaking 的第一步是編寫 Webpack 配置文件蒋情。你可以對你的 webpack 做很多自定義配置埠况,但是如果你想要對代碼進行 tree-shaking,就需要以下幾項棵癣。
首先辕翰,你必須處于生產(chǎn)模式。Webpack 只有在壓縮代碼的時候會 tree-shaking狈谊,而這只會發(fā)生在生產(chǎn)模式中喜命。
其次,必須將優(yōu)化選項 “usedExports” 設置為true河劝。這意味著 Webpack 將識別出它認為沒有被使用的代碼壁榕,并在最初的打包步驟中給它做標記。
最后赎瞎,你需要使用一個支持刪除死代碼的壓縮器护桦。這種壓縮器將識別出 Webpack 是如何標記它認為沒有被使用的代碼,并將其剝離煎娇。TerserPlugin 支持這個功能二庵,推薦使用。
下面是 Webpack 開啟 tree-shaking 的基本配置:
// Base Webpack Config for Tree Shaking
const config = {
mode: 'production',
optimization: {
usedExports: true,
minimizer: [
new TerserPlugin({...})
]
}
};
有什么副作用
僅僅因為 Webpack 看不到一段正在使用的代碼缓呛,并不意味著它可以安全地進行 tree-shaking催享。有些模塊導入,只要被引入哟绊,就會對應用程序產(chǎn)生重要的影響因妙。一個很好的例子就是全局樣式表,或者設置全局配置的JavaScript 文件票髓。
Webpack 認為這樣的文件有“副作用”攀涵。具有副作用的文件不應該做 tree-shaking,因為這將破壞整個應用程序洽沟。Webpack 的設計者清楚地認識到不知道哪些文件有副作用的情況下打包代碼的風險以故,因此默認地將所有代碼視為有副作用。這可以保護你免于刪除必要的文件裆操,但這意味著 Webpack 的默認行為實際上是不進行 tree-shaking怒详。
幸運的是,我們可以配置我們的項目踪区,告訴 Webpack 它是沒有副作用的昆烁,可以進行 tree-shaking。
如何告訴 Webpack 你的代碼是無副作用的
package.json
有一個特殊的屬性 sideEffects
缎岗,就是為此而存在的静尼。它有三個可能的值:
true
是默認值,如果不指定其他值的話。這意味著所有的文件都有副作用鼠渺,也就是沒有一個文件可以 tree-shaking蜗元。
false
告訴 Webpack 沒有文件有副作用,所有文件都可以 tree-shaking系冗。
第三個值 […]
是文件路徑數(shù)組奕扣。它告訴 webpack,除了數(shù)組中包含的文件外掌敬,你的任何文件都沒有副作用惯豆。因此,除了指定的文件之外奔害,其他文件都可以安全地進行 tree-shaking楷兽。
每個項目都必須將 sideEffects
屬性設置為 false
或文件路徑數(shù)組。在我公司的工作中华临,我們的基本應用程序和我提到的所有共享庫都需要正確配置 sideEffects
標志芯杀。
下面是 sideEffects
標志的一些代碼示例。盡管有 JavaScript 注釋雅潭,但這是 JSON 代碼:
// 所有文件都有副作用揭厚,全都不可 tree-shaking
{
"sideEffects": true
}
// 沒有文件有副作用,全都可以 tree-shaking
{
"sideEffects": false
}
// 只有這些文件有副作用扶供,所有其他文件都可以 tree-shaking筛圆,但會保留這些文件
{
"sideEffects": [
"./src/file1.js",
"./src/file2.js"
]
}
全局 CSS 與副作用
首先,讓我們在這個上下文中定義全局 CSS椿浓。全局 CSS 是直接導入到 JavaScript 文件中的樣式表(可以是CSS太援、SCSS等)。它沒有被轉(zhuǎn)換成 CSS 模塊或任何類似的東西扳碍√岵恚基本上,import 語句是這樣的:
// 導入全局 CSS
import './MyStylesheet.css';
因此笋敞,如果你做了上面提到的副作用更改碱蒙,那么在運行 webpack 構(gòu)建時,你將立即注意到一個棘手的問題液样。以上述方式導入的任何樣式表現(xiàn)在都將從輸出中刪除振亮。這是因為這樣的導入被 webpack 視為死代碼巧还,并被刪除鞭莽。
幸運的是,有一個簡單的解決方案可以解決這個問題麸祷。Webpack 使用它的模塊規(guī)則系統(tǒng)來控制各種類型文件的加載。每種文件類型的每個規(guī)則都有自己的 sideEffects
標志喷面。這會覆蓋之前為匹配規(guī)則的文件設置的所有 sideEffects
標志。
所以琳状,為了保留全局 CSS 文件念逞,我們只需要設置這個特殊的 sideEffects
標志為 true
翎承,就像這樣:
// 全局 CSS 副作用規(guī)則相關(guān)的 Webpack 配置
const config = {
module: {
rules: [
{
test: /regex/,
use: [loaders],
sideEffects: true
}
]
}
};
Webpack 的所有模塊規(guī)則上都有這個屬性叨咖。處理全局樣式表的規(guī)則必須用上它甸各,包括但不限于 CSS/SCSS/LESS/等等痴晦。
什么是模塊誊酌,模塊為什么重要
現(xiàn)在我們開始進入秘境露乏。表面上看,編譯出正確的模塊類型似乎是一個簡單的步驟箱锐,但是正如下面幾節(jié)將要解釋的劳较,這是一個會導致許多復雜問題的領(lǐng)域观蜗。這是我花了很長時間才弄明白的部分。
首先抖仅,我們需要了解一下模塊撤卢。多年來放吩,JavaScript 已經(jīng)發(fā)展出了在文件之間以“模塊”的形式有效導入/導出代碼的能力渡紫。有許多不同的 JavaScript 模塊標準已經(jīng)存在了多年腻惠,但是為了本文的目的集灌,我們將重點關(guān)注兩個標準欣喧。一個是 “commonjs”,另一個是 “es2015”益涧。下面是它們的代碼形式:
// Commonjs
const stuff = require('./stuff');
module.exports = stuff;
// es2015
import stuff from './stuff';
export default stuff;
默認情況下,Babel 假定我們使用 es2015 模塊編寫代碼驯鳖,并轉(zhuǎn)換 JavaScript 代碼以使用 commonjs 模塊闲询。這樣做是為了與服務器端 JavaScript 庫的廣泛兼容性浅辙,這些 JavaScript 庫通常構(gòu)建在 NodeJS 之上(NodeJS 只支持 commonjs 模塊)扭弧。但是记舆,Webpack 不支持使用 commonjs 模塊來完成 tree-shaking鸽捻。
現(xiàn)在泽腮,有一些插件(如 common-shake-plugin)聲稱可以讓 Webpack 有能力對 commonjs 模塊進行 tree-shaking御蒲,但根據(jù)我的經(jīng)驗,這些插件要么不起作用诊赊,要么在 es2015 模塊上運行時,對 tree-shaking 的影響微乎其微豪筝。我不推薦這些插件摘能。
因此敲街,為了進行 tree-shaking像吻,我們需要將代碼編譯到 es2015 模塊峻黍。
為 es2015 模塊配置 Babel
據(jù)我所知,Babel 不支持將其他模塊系統(tǒng)編譯成 es2015 模塊惭每。但是骨饿,如果你是前端開發(fā)人員,那么你可能已經(jīng)在使用 es2015 模塊編寫代碼了台腥,因為這是全面推薦的方法宏赘。
因此,為了讓我們編譯的代碼使用 es2015 模塊黎侈,我們需要做的就是告訴 babel 不要管它們察署。為了實現(xiàn)這一點,我們只需將以下內(nèi)容添加到我們的 babel.config.js 中(在本文中峻汉,你會看到我更喜歡JavaScript 配置而不是 JSON 配置):
// es2015 模塊的基本 Babel 配置
const config = {
presets: [
[
'[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: false
}
]
]
};
把 modules
設置為 false
贴汪,就是告訴 babel 不要編譯模塊代碼。這會讓 Babel 保留我們現(xiàn)有的 es2015 import/export 語句休吠。
劃重點:所有可需要 tree-shaking 的代碼必須以這種方式編譯嘶是。因此,如果你有要導入的庫蛛碌,則必須將這些庫編譯為 es2015 模塊以便進行 tree-shaking 聂喇。如果它們被編譯為 commonjs,那么它們就不能做 tree-shaking 蔚携,并且將會被打包進你的應用程序中希太。許多庫支持部分導入,lodash 就是一個很好的例子酝蜒,它本身是 commonjs 模塊誊辉,但是它有一個 lodash-es 版本,用的是 es2015模塊亡脑。
此外堕澄,如果你在應用程序中使用內(nèi)部庫邀跃,也必須使用 es2015 模塊編譯。為了減少應用程序包的大小蛙紫,必須將所有這些內(nèi)部庫修改為以這種方式編譯拍屑。
然而 Jest 又跪了
其他測試框架情況類似,我們用的是 Jest坑傅。
不管怎么樣僵驰,如果你走到了這一步,你會發(fā)現(xiàn) Jest 測試開始失敗了唁毒。你會像我當時一樣蒜茴,看到日志里出現(xiàn)各種奇怪的錯誤,慌的一批浆西。別慌粉私,我會帶你一步一步解決。
出現(xiàn)這個結(jié)果的原因很簡單:NodeJS近零。Jest 是基于 NodeJS 開發(fā)的诺核,而 NodeJS 不支持 es2015 模塊。為此有一些方法可以配置 Node秒赤,但是在 jest 上行不通猪瞬。因此,我們卡在這里了:Webpack 需要 es2015 進行 tree shaking入篮,但是 Jest 無法在這些模塊上執(zhí)行測試陈瘦。
就是為什么我說進入了模塊系統(tǒng)的“秘境”。這是整個過程中耗費我最多時間來搞清楚的部分潮售。建議你仔細閱讀這一節(jié)和后面幾節(jié)痊项,因為我會給出解決方案。
解決方案有兩個主要部分酥诽。第一部分針對項目本身的代碼鞍泉,也就是跑測試的代碼。這部分比較容易肮帐。第二部分針對庫代碼咖驮,也就是來自其他項目,被編譯成 es2015 模塊并引入到當前項目的代碼训枢。這部分比較復雜托修。
解決項目本地 Jest 代碼
針對我們的問題,babel 有一個很有用的特性:環(huán)境選項恒界。通過配置可以運行在不同環(huán)境睦刃。在這里,開發(fā)和生產(chǎn)環(huán)境我們需要 es2015 模塊十酣,而測試環(huán)境需要 commonjs 模塊涩拙。還好际长,Babel 配置起來非常容易:
// 分環(huán)境配置Babel
const config = {
env: {
development: {
presets: [
[
'[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: false
}
]
]
},
production: {
presets: [
[
'[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: false
}
]
]
},
test: {
presets: [
[
'[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: 'commonjs'
}
]
],
plugins: [
'transform-es2015-modules-commonjs' // Not sure this is required, but I had added it anyway
]
}
}
};
設置好之后,所有的項目本地代碼能夠正常編譯兴泥,Jest 測試能運行了工育。但是,使用 es2015 模塊的第三方庫代碼依然不能運行郁轻。
解決 Jest 中的庫代碼
庫代碼運行出錯的原因非常明顯翅娶,看一眼node_modules 目錄就明白了文留。這里的庫代碼用的是 es2015 模塊語法好唯,為了進行 tree-shaking。這些庫已經(jīng)采用這種方式編譯過了燥翅,因此當 Jest 在單元測試中試圖讀取這些代碼時骑篙,就炸了。注意到?jīng)]有森书,我們已經(jīng)讓 Babel 在測試環(huán)境中啟用 commonjs 模塊了呀靶端,為什么對這些庫不起作用呢?這是因為凛膏,Jest (尤其是 babel-jest) 在跑測試之前編譯代碼的時候杨名,默認忽略任何來自node_modules 的代碼。
這實際上是件好事猖毫。如果 Jest 需要重新編譯所有庫的話台谍,將會大大增加測試處理時間。然而吁断,雖然我們不想讓它重新編譯所有代碼趁蕊,但我們希望它重新編譯使用 es2015 模塊的庫,這樣才能在單元測試里使用仔役。
幸好掷伙,Jest 在它的配置中為我們提供了解決方案。我想說又兵,這部分確實讓我想了很久任柜,并且我感覺沒必要搞得這么復雜,但這是我能想到的唯一解決方案沛厨。
配置 Jest 重新編譯庫代碼
// 重新編譯庫代碼的 Jest 配置
const path = require('path');
const librariesToRecompile = [
'Library1',
'Library2'
].join('|');
const config = {
transformIgnorePatterns: [
`[\\\/]node_modules[\\\/](?!(${librariesToRecompile})).*$`
],
transform: {
'^.+\.jsx?$': path.resolve(__dirname, 'transformer.js')
}
};
以上配置是 Jest 重新編譯你的庫所需要的宙地。有兩個主要部分,我會一一解釋俄烁。
transformIgnorePatterns
是 Jest 配置的一個功能绸栅,它是一個正則字符串數(shù)組。任何匹配這些正則表達式的代碼页屠,都不會被 babel-jest 重新編譯粹胯。默認是一個字符串“node_modules”蓖柔。這就是為什么Jest 不會重新編譯任何庫代碼。
當我們提供了自定義配置风纠,就是告訴 Jest 重新編譯的時候如何忽略代碼况鸣。也就是為什么你剛才看到的變態(tài)的正則表達式有一個負向先行斷言在里面,目的是為了匹配除了庫以外的所有代碼竹观。換句話說镐捧,我們告訴 Jest 忽略 node_modules 中除了指定庫之外的所有代碼。
這又一次證明了 JavaScript 配置比 JSON 配置要好臭增,因為我可以輕松地通過字符串操作懂酱,往正則表達式里插入庫名字的數(shù)組拼接。
第二個是 transform 配置誊抛,他指向一個自定義的 babel-jest 轉(zhuǎn)換器列牺。我不敢100%確定這個是必須的,但我還是加上了拗窃。設置它用于在重新編譯所有代碼時加載我們的 Babel 配置瞎领。
// Babel-Jest 轉(zhuǎn)換器
const babelJest = require('babel-jest');
const path = require('path');
const cwd = process.cwd();
const babelConfig = require(path.resolve(cwd, 'babel.config'));
module.exports = babelJest.createTransformer(babelConfig);
這些都配置好后,你在測試代碼應該又能跑了随夸。記住了九默,任何使用庫的 es2015 模塊都需要這樣配置,不然測試代碼跑不動宾毒。
Npm/Yarn Link 就是魔鬼
接下來輪到另一個痛點了:鏈接庫驼修。使用 npm/yarn 鏈接的過程就是創(chuàng)建一個指向本地項目目錄的符號鏈接。結(jié)果表明伍俘,Babel 在重新編譯通過這種方式鏈接的庫時邪锌,會拋出很多錯誤。我之所以花了這么長時間才弄清楚 Jest 這檔子事兒癌瘾,原因之一就是我一直通過這種方式鏈接我的庫觅丰,出現(xiàn)了一堆錯誤。
解決辦法就是:不要使用 npm/yarn link妨退。用類似 “yalc” 這樣的工具妇萄,它可以連接本地項目,同時能模擬正常的 npm 安裝過程咬荷。它不但沒有 Babel 重編譯的問題冠句,還能更好地處理傳遞性依賴。
針對特定庫的優(yōu)化幸乒。
如果完成了以上所有步驟懦底,你的應用基本上實現(xiàn)了比較健壯的 tree shaking。不過罕扎,為了進一步減少文件包大小聚唐,你還可以做一些事情竟痰。我會列舉一些特定庫的優(yōu)化方法宝磨,但這絕對不是全部。它尤其能為我們提供靈感糠聪,做出一些更酷的事情兔沃。
MomentJS 是出了名的大體積庫务热。幸好它可以剔除多語言包來減少體積殉摔。在下面的代碼示例中焰雕,我排除了 momentjs 所有的多語言包,只保留了基本部分客峭,體積明顯小了很多豫领。
// 用 IgnorePlugin 移除多語言包
const { IgnorePlugin } from 'webpack';
const config = {
plugins: [
new IgnorePlugin(/^\.\/locale$/, /moment/)
]
};
Moment-Timezone 是 MomentJS 的老表,也是個大塊頭桃笙。它的體積基本上是一個帶有時區(qū)信息的超大 JSON 文件導致的氏堤。我發(fā)現(xiàn)只要保留本世紀的年份數(shù)據(jù)沙绝,就可以將體積縮小90%搏明。這種情況需要用到一個特殊的 Webpack 插件。
// MomentTimezone Webpack Plugin
const MomentTimezoneDataPlugin = require('moment-timezone-data-webpack-plugin');
const config = {
plugins: [
new MomentTimezoneDataPlugin({
startYear: 2018,
endYear: 2100
})
]
};
Lodash 是另一個導致文件包膨脹的大塊頭闪檬。幸好有一個替代包 Lodash-es星著,它被編譯成 es2015 模塊,并帶有 sideEffects 標志粗悯。用它替換 Lodash 可以進一步縮減包的大小虚循。
另外,Lodash-es样傍,react-bootstrap 以及其他庫可以在 Babel transform imports 插件的幫助下實現(xiàn)瘦身横缔。該插件從庫的 index.js 文件讀取 import 語句,并使其指向庫中特定文件衫哥。這樣就使 webpack 在解析模塊樹時更容易對庫做 tree shaking茎刚。下面的例子演示了它是如何工作的。
// Babel Transform Imports
// Babel config
const config = {
plugins: [
[
'transform-imports',
{
'lodash-es': {
transform: 'lodash/${member}',
preventFullImport: true
},
'react-bootstrap': {
transform: 'react-bootstrap/es/${member}', // The es folder contains es2015 module versions of the files
preventFullImport: true
}
}
]
]
};
// 這些庫不再支持全量導入撤逢,否則會報錯
import _ from 'lodash-es';
// 具名導入依然支持
import { debounce } from 'loash-es';
// 不過這些具名導入會被babel編譯成這樣子
// import debounce from 'lodash-es/debounce';
總結(jié)
全文到此結(jié)束膛锭。這樣的優(yōu)化可以極大地縮減打包后的大小。隨著前端架構(gòu)開始有了新的方向(比如微前端)蚊荣,保持包大小最優(yōu)化變得比以往更加重要初狰。希望本文能給那些正在給應用程序做tree shaking的同學帶來一些幫助。
交流
歡迎關(guān)注微信公眾號“1024譯站”互例,為你奉上更多技術(shù)干貨奢入。