隨著項目的增大,webpack的打包速度已成前端工程師的“不可承受之重”。最近對團隊內某項目的打包速度進行了一些優(yōu)化,本文沒有具體的配置教程,只提供一些優(yōu)化思路,供啟發(fā)和參考。
更換更快的打包工具
1. bundler:代表webpack、parcel
parcel和webpack主要區(qū)別
- parcel采用了類似于webpack中thread-loader的方式進行并行構建
- parcel內建了類似于dll的緩存策略(webpack5中也內置了緩存策略)
- parcel的HTML、JS 和 CSS 分別是通過 posthtml、babel 和 postcss處理的
更新webpack版本
webpack5內置了持久性緩存和跟好的緩存策略,Tree Shaking性能提升,搖掉更多無用代碼。
嘗試改善與網(wǎng)絡平臺的兼容性。
2. noBundler:代表snowpack、vite
主流的瀏覽器版本都支持直接使用 JavaScript Module。
HTTP/2可以合并請求來優(yōu)化模塊并發(fā)請求性能。
vite針對復雜的第三方庫,會自動識別并提前打包緩存起來,避免過多http請求(類似于dll)
從webpack遷移到vite
項目里使用了create-react-app,內置了很多配置項(遷移成本高)
-
團隊內部構建的SDK庫由于打包后的語法不標準,webpack不報錯,vite會報錯(更改SDK)
css import語句只能在頂層 項目里使用到了monaco-editor,官方提供的插件只有webpack版本,遷移到vite不好處理(團隊內部寫了rollup插件)
vite里less沒有autoimport的配置(寫死在less inject里)
vite變量形式和webpack不一樣,webpack可以識別process.env.xxx,vite是import.meta.xxx(想的是開發(fā)用vite,build用webpack,需要兼容)
項目里用到的scoped-css-loader等loader沒有vite版插件(類似于@vitejs/plugin-vue-jsx,寫一個插件在vite里使用babel)
webpack可以很好的處理web和node共用變量(webpack可以轉換cjs,vite需要通過optimize項配置)
由于webpack到vite的遷移成本比較高,vite build時的速度和webpack也差不多,使用hardsource等插件后webpack dev的速度也是可以接受的,決定還是在webpack體系下做優(yōu)化
升級webpack
截止目前,cra正式版還停留在webpack4版本,好在alpha版升級到了webpack5,可以使用react-app-rewired start --scripts-version react-scripts
來指定react-scripts版本。實測升級到alpha版后速度更慢了,在優(yōu)化了配置后速度也沒有明顯提升。
猜測原因是webpack5的緩存策略主要是dev的時候有用,本質上和hardsource沒有太大區(qū)別,但首次生成緩存的速度比hardsource稍快,生產環(huán)境中一般會禁用或重新構建緩存。
替換babel
babel負責將js、ts、jsx等格式代碼轉換為js。相似功能的還有:tsc、esbuild、swc。
tsc在轉換時默認會檢查ts類型,插件環(huán)境沒有babel好,一般都會使用babel。
babel不會檢測類型,只負責把ts轉換到js,速度會比tsc要快,插件生態(tài)支持的也好一些(react熱更新、vue3jsx語法轉換官方都只提供了babel的插件)注:swc與nextjs合作,內建了react熱更新支持。esbuild調研后未發(fā)現(xiàn)支持,vite的react熱更新插件還是使用了babel
esbuild、swc也不會檢測類型,他們會用go和rust生成的二進制文件處理js或ts,速度比babel更快。如果沒有使用babel插件,可以在webpack中直接用esbuild-loader或swc-loader替換babel,但插件生態(tài)幾乎為零(減少了暴露給插件的API以提升速度,esbuild transformer無法使用插件)ps:Vercel團隊最近吸納了swc的作者,并在新版nextjs里提供了替換babel的選項,未來可期
用babel、esbuild和swc時,需要使用fork-ts-checker-webpack-plugin校驗ts類型(cra默認啟用)
一般在使用tsc打包的時候,也會關閉類型檢查并使用fork-ts-checker-webpack-plugin檢測類型以優(yōu)化速度。
fork-ts-checker-webpack-plugin是一個webpack插件,它會在打包時fork出一個進程并行進行檢查,可以更好的利用多核cpu的能力,過程中幾乎不影響webpack主進程,故可以優(yōu)化速度。
替換terser
terser負責壓縮babel和webpack生成的產物,去掉無效代碼,去掉日志輸出代碼,縮短變量名,生成source-map等,可以有效壓縮體積
terser是js寫的,壓縮時的內存占用、cpu占用都很高,雖然有cache、多進程等選項,但提升并不理想
esbuild帶有壓縮的功能,使用esbuild替換terser做壓縮,可以帶來比較大的速度提升,但生成產物比terser壓縮的大10%,在中后臺項目且會拆分文件的場景下,文件尺寸不是痛點。
目前團隊有測試和預上線環(huán)境,目前測試和沙箱環(huán)境也會壓縮代碼,可以在測試和沙箱構建時禁用壓縮,經測試可以帶來40%左右的速度提升
source-map
webpack提供了如下的source-map選項,
不同選項的構建速度和性能以及適用場景都有很大差別,在這里不詳細敘述
團隊測試和沙箱環(huán)境構建時可以不分離sourcemap(或者用cheap-eval-source-map映射到行)以提升速度。
-
用替代品生成sourcemap(用wasm-webpack-sources替換webpack-sources)
wasm-webpack-sources -
更新webpack-sources的版本,ps: nextjs團隊在博客中提到,升級webpack-sources版本后,構建source-map的速度比不開啟僅多花了11%左右的時間
nextjs博客 延遲構建source-map,由于線上環(huán)境不會暴露source-map,可以先關閉source-map構建出一份產物到線上,然后打開source-map再在后臺打包一次并將source-map上傳到Sentry等監(jiān)控平臺和線上文件映射上。
使用多進程打包
happypack和thread-loader
- happypack已經很多年沒有維護了,核心原理是啟用多進程,多個loader并行處理文件,happypack開發(fā)人員建議,如果使用webpack4及以上更推薦使用thread-loader,thread-loader做的事情和happypack一樣
-
thread-loader是webpack官方推薦的,原理是將loader放在單獨的一個worker進程內處理,但實測下來babel-loader前放置thread-loader后的速度更慢了
無thread-loader
有thread-loader
開啟thread-loader時,監(jiān)測到有一瞬間fork出了很多node進程,但接下來就消失了。可能是因為以下限制(用了babel-plugin),實際thread-loader并沒有啟用成功
thread-loader的限制 -
在看到thread-loader官方的用例后,推測也可能是由于項目是ts的,webpack需要遞歸的調用babel-loader來解析語法和生成依賴關系,進程間通信會消耗大量時間
thread-loader官方測試用例
使用緩存
cache-loader、開啟loader自帶的cache選項、dll、hardsource、webpack5
- cache-loader也是webpack官方推薦使用的,加在耗時較長的loader前面,在heavy loader執(zhí)行前,對比要處理的文件和緩存文件的mtime,mtime沒變的話直接取緩存文件,實測構建時間會變長。
loader: ['cache-loader', 'other-loader']
簡單看了一下,cache-loader分兩個階段:pitch和loader
pitch階段的處理流程是:cache-loader -> other-loader
loader階段的處理流程是:other-loader -> cache-loader
pitch階段根據(jù)當前正在處理的文件,讀取.cache目錄中對應的cache文件,對比mtime判斷是否可以復用
loader階段依賴pitch階段的判斷,如果pitch階段判斷當前文件的緩存失效了,loader階段就要重新生成緩存。
- babel-loader自帶了cache選項,但babel-loader的cache必須經過一次編譯,才會將索引的文件與文件編譯結果緩存在內存中。在后續(xù)的編譯過程中,如果發(fā)現(xiàn)索引的文件已經緩存過了,才會直接引用已經編譯緩存的結果。(還是會有編譯的過程)
-
dll動態(tài)鏈接方案
DLL和緩存的區(qū)別
可以將共用不經常改變的依賴(如react、react-dom、vue、antd、moment)打包成dll
webpack打包引入庫時入口會被動態(tài)指向dll文件里,實測是有用的,但dll方案在18年左右被社區(qū)的腳手架拋棄,大概意思是使用dll會大量增加維護的成本,(我在使用時也遇到有些插件打成dll后報錯),webpack4相比webpack3帶來的打包速度提升使得dll有些得不償失
cra
vue-cli
hardsource和webpack5
hardsource和webpack5持久化緩存的方案類似,webpack5持久化緩存結果至硬盤上,第一次編譯文件的時候,計算文件的hash。將編譯結果與hash關聯(lián)起來。第二次編譯文件時,首先加載本地緩存結果,進入正常編譯環(huán)節(jié)時,對編譯的文件再次求hash,如果此hash在緩存庫中已經存在了,那么將直接跳過編譯環(huán)節(jié),直接輸出編譯結果。
這兩種方案都是dev的時候才會有用(記得官方有個issue說實驗基于緩存build可能會有5%的概率出錯),升級到cra5之后發(fā)現(xiàn)復用緩存的條件極其嚴格,每次重新build時都會重新構建緩存,hardsource首次構建的消耗時間比較大,webpack5由于的緩存是基于webpack4構建時的內存改造得來,首次構建帶來的額外時間消耗并不大,二次構建hardsource和webpack5的速度相當。
external
- external之后會把webpack會把import語法轉成訪問全局變量,直接忽略語法解析,也不會調用loader。
external的主要問題是,有些庫之間相互依賴,比如antd依賴moment和react,mobx-react-lite依賴react,也依賴了mobx,引入順序和cdn質量需要額外花精力維護,很多庫官方也沒有提供umd的包,使用第三方的umd包可能會有問題。 - react中比較穩(wěn)定的umd庫有react react-dom antd moment,可以放心external掉
external可以配合Systemjs使用,systemjs支持直接引入esm而不必去找umd,webpack4也內建支持把libraryTarget改成SystemJs的形式,直接使用esm格式包時不需要type=module,也可以進行動態(tài)加載改造,微前端框架single-spa也使用這種方式進行依賴動態(tài)加載
<script type="systemjs-importmap">
{
imports": {
"react": "http://gw.alipayobjects.com/os/lib/react/16.13.1/umd/react.production.min.js",
"react-dom": "http://gw.alipayobjects.com/os/lib/react-dom/16.13.1/umd/react-dom.production.min.js",
"moment": "http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.0/moment.min.js",
"antd": "http://unpkg.com/antd@4.6.6/dist/antd.min.js",
"handsontable": "http://cdn.jsdelivr.net/npm/handsontable@8.4.0/dist/handsontable.full.min.js",
"braft-editor": "http://cdn.jsdelivr.net/npm/braft-editor@2.3.9/dist/index.min.js",
"lodash": "http://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js",
"mobx": "http://cdnjs.cloudflare.com/ajax/libs/mobx/5.15.7/mobx.min.js",
"webpackBundle":"./output.bundle.js"
}
}
</script>
<script>
System.import('xxx').then((res) => {
System.import('webpackBundle');
})
</scirpt>
webpack5 Module Federation預編譯node_modules
module federation 是 webpack5 提出的新特性,含義為模塊聯(lián)邦。主要是使用于微前端場景,可以在運行時動態(tài)引入子應用。
優(yōu)化思路:利用 webpack5 的 module federation 特性,構建一個虛擬的 federation 應用,項目直接僅使用編譯好的依賴,這樣就可以直接減去熱更新和啟動時對依賴的重新編譯。實際和dll的原理類似?但速度比dll要快很多
業(yè)內腳手架umi已在這方面做出了實踐,但沒有提供通用的webpack插件,可以期待未來社區(qū)的產出
硬件更換
由于JS的多線程能力不佳,webpack在打包時更吃CPU單核性能,多核性能幾乎(在多核服務器上測試還沒有筆記本塊)沒有用。
截止目前,蘋果還沒有發(fā)布第二代自研桌面處理器,(APPLE M1x在制程不變的情況下無法大幅提高主頻,大概率會通過堆核心、增加GPU、增加總線帶寬、提高內存頻率等方式做優(yōu)化,可以預見單核性能不會有類似Intel -> M1的巨大提升)基于的硬件選購建議是,M1 16g配置的Mac電腦在未來幾年內都會是非常適合前端開發(fā)者使用的(傳統(tǒng)x86芯片的電腦在不改變封裝邏輯的前提下預計相同價位提升至M1的單核性能水平需要很長時間,基于JS的打包生態(tài)遷移到rust和go也需要很久)。
使用nice命令提高本機webpack進程優(yōu)先級后速度也略有提升(需要root權限)
總結
基于上述調研和項目的業(yè)務場景,最終決定應用以下優(yōu)化
- 用esbuild替換terser作壓縮
- 測試和預上線環(huán)境禁用壓縮并啟用cheap-source-map
-
應用dll
以下是在本機的一些實驗結果
external和dll
esbuild壓縮測試
優(yōu)化后vs優(yōu)化前生產環(huán)境構建提升了大約43%的時間,測試環(huán)境構建提升了大約58%的時間
項目測試
一些困惑
- Module Federation為什么比dll快
- 能不能使用JS實現(xiàn)多線程打包