Webpack 打包太慢?來試試 Bundleless
????一 引言
Webpack 最初是為了解決前端模塊化以及使用 Node.Js 生態的問題而出現,在過去的 8 年時間里,Webpack 的能力越來越強大。
?? 
但因為多了打包構建這一層,隨著項目的增長,打包構建速度越來越慢,每次啟動都要等待幾十秒甚至幾分鐘,然后啟動一輪構建優化,隨著項目的進一步增大,構建速度又會降低,陷入不斷優化的循環。
?? 
在項目達到一定的規模時,基于 Bundle 的構建優化的收益變得越來越有限,無法實現質的提升。我們從另一個角度思考,webpack 之所以慢,主要的原因還是在于他將各個資源打包整合在一起形成 bundle,如果我們不需要 bundle 打包的過程,直接讓瀏覽器去加載對應的資源,我們將有可能可以跳出這個循環,實現質的提升。
?? 
在 Bundleless 的架構下,我們不再需要構建一個完整的 bundle,同時在修改文件時,瀏覽器也只需要重新加載單個文件即可。由于沒有了構建這一層我們將能夠實現以下的目標:
- 極快的本地啟動速度,只需要啟動本地服務。
- 極快的代碼編譯速度,每次只需要處理單個文件。
- 項目開發構建的時間復雜度始終為 O(1),使得項目能夠持續保持高效的構建。
- 更加簡單的調試體驗,不再強依賴 sourcemaps 即可實現穩定的單文件的 debug。
基于以上的可能性 Bundleless 將重新定義前端的本地開發,讓我們重新找回前端在 10 年前修改單個文件之后,只需要刷新即可即時生效的體驗,同時疊加上前端的 HotModuleReplace 相關技術,我們可以把刷新也省去,最終實現保存即生效。
實現 Bundleless 一個很重要的基礎能力是模塊的動態加載能力,這一主要的思路會有兩個:
- System.js 之類的 ES 模塊加載器,好處是具有較高的兼容性。
- 直接利用 Web 標準的 ESModule,面向未來,同時整體架構也更加簡單。
在本地開發過程中兼容性的影響不是特別大,同時 ESModule 已經覆蓋了超過 90% 的瀏覽器,我們完全可以利用 ESModule 的能力讓瀏覽器自主加載需要的模塊,從而更加低成本同時面向未來實現 Bundleless。
社區中在近一兩年也出現了很多基于 ESModule 的開發工具,如 Vite、Snowpack、es-dev-server 等。本文將主要分享基于瀏覽器的 ESModule 能力實現 Bundless 本地開發的相關思路、核心技術點以及 Vite 的相關實現和在供應鏈 POS 場景下的落地實踐。
二 從資源加載看 Bundle 和 Bundleless 的不同
下面以大家最熟悉的 create-react-app 默認項目為例,從實際的頁面渲染資源的加載過程對比 Bundle 和 Bundleless 的區別。
?? 
基于 Webpack 的 bundle 開發模式
?? 
上面的圖具體的模塊加載機制可以簡化為下圖:
?? 
在項目啟動和有文件變化時重新進行打包,這使得項目的啟動和二次構建都需要做較多的事情,相應的耗時也會增長。
基于 ESModule Bundleless 模式
?? 
從上圖可以看到,已經不再有一個構建好的 bundle、chunk 之類的文件,而是直接加載本地對應的文件。
?? 
從上圖可以看到,在 Bundleless 的機制下,項目的啟動只需要啟動一個服務器承接瀏覽器的請求即可,同時在文件變更時,也只需要額外處理變更的文件即可,其他文件可直接在緩存中讀取。
對比總結
?? 
Bundleless 模式可以充分利用瀏覽器自主加載的特性,跳過打包的過程,使得我們能在項目啟動時獲取到極快的啟動速度,在本地更新時只需要重新編譯單個文件。下面將分享如何基于瀏覽器 ESModule 的能力實現 Bundleless 的開發。
三 如何實現 Bundleless
如何使用 ESModule 模塊加載
實現 Bundleless 的第一步是要讓瀏覽器自主加載對應的模塊。
使用 type="module" 開啟 ESModule
利用 import-maps 支持 bare import
分享一個在 chrome 中已經實現了的 import-maps 的標準 ,可以讓我們直接用 import React from 'react' 這樣的寫法,未來我們可以利用此能力實現線上的 Bundleless 部署。
以上我們介紹了瀏覽器中原生的 ESModule 是如何使用的。面向本地開發的場景,我們只需要啟動一個本地的 devServer 承載瀏覽器的請求映射到對應的本地文件,同時動態地將項目中 import 的資源路徑指向我們的本地地址,即可讓瀏覽器直接加載本地的文件,比如可以使用下面的寫法,將入口 JS 文件直接指向本地的路徑,然后 devServer 再攔截相應的請求返回對應的文件。
如何加載非 JS 的文件資源
通過 ESModule 我們借助瀏覽器的能力實現了 JS 的自主加載,但實際的項目代碼中我們不僅僅會 import JS 文件,也會有下面的寫法:
而瀏覽器在處理文件時是依據 Content-Type 的,不關心具體的文件類型,所以我們需要在瀏覽器發起請求時,將對應的資源轉化為 ESModule 格式,同時設置對應的 Content-Type 為 JS,返回給瀏覽器執行,瀏覽器就會按照 JS 的語法進行解析處理,整體的流程可見下圖:
?? 
以下是 Vite 的相關實現,在請求返回的過程中,對不同的文件進行動態處理:
?? 
如何實現 HotModuleReplace
HotModuleReplace 能夠在我們修改代碼后,不需要刷新頁面,直接在當前場景下生效,結合 Bundleless 極快的生效速度,我們能夠實現幾乎沒有延遲的保存即生效的體驗。對于 React,在 Webpack 場景下目前只能通過使用 react-hot-loader 來實現,但這一塊受限于具體的實現,有一些場景會存在 bug,作者也建議遷移到 React 團隊實現的 react-refresh,而這一塊在 Webpack 中還沒有相應的實現。在 Bundleless 場景下,因為我們的每個組件都是獨立加載的,所以要集成 react-refresh,我們只需要在瀏覽器請求返回時在文件的頂部和底部加上相應的腳本即可完成集成。
?? 
要完整的實現 HotModuleReplace 會比上面畫得更加復雜,還需要有一套依賴分析機制來判斷當一個文件發生變更之后要替換哪些文件以及是否需要 reload。在 Bundleless 的場景下,因為不再需要打包為一個完整的 bundle,同時我們也能更加靈活地對單個文件進行修改,這一塊相關的實現會更加容易。
以下是在 Vite 中的相關實現:
?? 
如何優化大量請求導致頁面加載慢
Bundleless 的模式不再打包,提升了啟動的速度,但對于一些有較多外部依賴或者自身文件數量較多的模塊,需要發起大量請求才能獲取到全部的資源,這個會降低開發過程中頁面加載的時間。比如下面是直接在瀏覽器中 import lodash-es 會并發出大量的請求:
?? 
在這一塊上我們可以做相應的優化,將外部的依賴提前打包成單個文件來減少在開發過程中由于外部依賴過多而發起過多的網絡請求。
在 Vite 的啟動流程中有一個 vite optimize 的過程會自動將 package.json 中的 depenencies 借助 Rollup 打包成 ES6 Module。
?? 
提前打包帶來的好處除了能夠提升頁面的加載速度,借助 @rollup/plugin-commonjs 我們能夠將 commonjs 的外部依賴打包為 ESModule 的形式引入,進一步擴大 Bundleless 的適用范圍。
四 在供應鏈 POS 場景下落地實踐
我們團隊負責的供應鏈 POS 業務主要可分為面向建材家居的家裝行業和線下小店的零售行業,在技術架構上采用了各個域 bundle 獨立開發,然后最終借助底層的 sdk 合并為一個大的 SPA 的形式。由于項目的復雜性,在日常開發過程中,有以下的一些痛點:
- 項目的啟動和耗時相對較長。
- 改動后二次編譯時間長。
- 缺少穩定的 HMR 能力,開發過程中需要重復造場景。
- debug 依賴 sourcemaps 能力,有時會出現不穩定的情況。
基于以上的問題,借助 Vite 的相關實現,我們對本地開發環境進行了 Bundleless 的嘗試和落地,在實驗的一些項目中對于本地的開發體驗有了很大的提升。
在啟動以及修改生效的速度上帶來極大的提升
目前已實現單 bundle 維度的開發,打包構建速度:
?? 
Webpack
?? 
Vite Bundleless
從上面的可以看出,在啟動單個 bundle 時,Webpack 需要 10s 左右的時間,而基于 Bundleless 的 Vite 只需要 1s 左右,提升 10 倍。
?? 
整體的頁面加載時間在 4s 左右,仍然比 Webpack 的打包構建時間要短,同時從上面的視頻中也可以看到 HMR 的速度達到了毫秒級的響應,實現了基本無感的保存即生效。
不依賴 sourcemap 調試單個文件
?? 
落地過程中遇到的問題和解決
在實際落地過程中,遇到的問題主要是相關模塊不符合 ESModule 規范以及一些寫法上的標準化:
- 部分模塊沒有 ESModule 的打包。
- less 依賴 node_modules 的寫法的規范。
- jsx 文件后綴規范。
- babel-runtime 的處理。
部分模塊沒有 ESModule 的打包
對于沒有 ESModule 打包輸出或者輸出的錯誤的包,根據不同的類型使用不同的策略:
- 內部的包:通過升級腳手架,發布帶有 ESModule 的包的新版本。
- 外部依賴:通過 issue、pull request 等形式,推動了 number-precision 等模塊的升級。
- 同時有一些由于歷史原因無法打出 ESModule 的包可以借助 @rollup/plugin-commonjs 打包為 ESModule。
less 依賴 node_modules 的寫法的規范
JSX 文件后綴規范
Vite 在運行的過程中會依據文件不同的后綴名進行對應的編譯處理,而在 Webpack 模式下我們通常會將 JSX、JS 等文件都丟給 babel-loader 進行處理,這使得有一些原本是 JSX 的文件沒有寫JSX 后綴。Vite 只會對 /\.(tsx?|jsx)$/ 的文件進行 esbuild 編譯,對于純 JS 會直接跳過 esbuild 的過程。對于這種情況我們是逐步將錯誤的原先沒有寫 JSX 的文件遷移為 JSX 文件。
babel-runtime 的處理
在使用了 babel-plugin-transform-runtime 之后,打包的輸出結果會是下面這樣:
?? 
上面所引用的 @babel/runtime/helpers/extends 是 commonjs 的格式無法直接使用,針對這個情況,有兩種解法:
1)針對內部自己打包的模塊,可以在進行 es6 打包時添加 useModules 配置,這樣打包出來的代碼就會是直接引用@babel/runtime/helpers/esm/extends<br />:
?? 
2)針對重新打包成本較高的模塊,可以通過 Vite 的插件機制進行轉換,將 @babel/runtime/helpers 在運行時替換為 @babel/runtime/helpers/esm 可以通過 alias 配置實現:
?? 
以上是在 Vite 開發環境的遷移過程中遇到的一些問題和處理的分享,這一塊的更大范圍的落地還在進行中。Bundleless 的落地不僅僅是為了適配 Vite 的開發模式,同時也是面向未來規范各個模塊代碼的過程,將我們的模塊進行標準的 ESModule 化,在有新的工具和思想出現時可以用更低成本進行落地。
五 直接使用 Bundleless 進行部署的可行性
受限于網絡請求和瀏覽器的解析速度,對于較大型的應用,bundle 在加載速度上還是能夠帶來較大的收益。V8 在 2018 年也給出了相關性能上的建議:在本地開發和小型的 Web 應用中使用。在今天的場景下,隨著瀏覽器和網絡性能的不斷提升,結合 ServiceWorker 之類的緩存能力,網絡加載的影響和越來越小,對于一些不需要考慮兼容性問題的場景可以進行內部的嘗試,直接部署通過 ESModule 加載的代碼。
六 總結
本文主要分享了 Bundleless 架構下,如何提升前端的研發效率、實現思路以及在具體業務場景下落地實踐。Bundleless 本質上是將原先 Webpack 中模塊依賴解析的工作交給瀏覽器去執行,使得在開發過程中代碼的轉換變少,極大地提升了開發過程中的構建速度,同時也可以更好地利用瀏覽器的相關開發工具。
站在當前的背景下,Web 各個領域 JavaScript/CSS/HTML 相關的標準都已成熟,同時瀏覽器內核也趨于統一,前端工程化的核心重點已逐步遷移到研發提效上,而 Bundleless 的模式能夠帶來長效的啟動和 HMR 的速度,是未來的一大發展趨勢。隨著瀏覽器內核和 Web 標準的不斷統一,前端的代碼可以不再打包直接運行將成為可能,這將進一步提高整體的研發效率。
最后非常感謝 ESModule、Vite、Snowpack 等標準和工具的出現,讓前端的開發體驗往前跨了一大步。































