Webpack 打包太慢?來試試 Bundleless

程式碼派就是我發表於2020-07-13

一 引言

Webpack 最初是為了解決前端模組化以及使用 Node.Js 生態的問題而出現,在過去的 8 年時間裡,Webpack 的能力越來越強大。

image.png

但因為多了打包構建這一層,隨著專案的增長,打包構建速度越來越慢,每次啟動都要等待幾十秒甚至幾分鐘,然後啟動一輪構建最佳化,隨著專案的進一步增大,構建速度又會降低,陷入不斷最佳化的迴圈。

image.png

在專案達到一定的規模時,基於 Bundle 的構建最佳化的收益變得越來越有限,無法實現質的提升。我們從另一個角度思考,webpack 之所以慢,主要的原因還是在於他將各個資源打包整合在一起形成 bundle,如果我們不需要 bundle 打包的過程,直接讓瀏覽器去載入對應的資源,我們將有可能可以跳出這個迴圈,實現質的提升。

image.png

在 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 的區別。

image.png

基於 Webpack 的 bundle 開發模式

image.png

上面的圖具體的模組載入機制可以簡化為下圖:

image.png

在專案啟動和有檔案變化時重新進行打包,這使得專案的啟動和二次構建都需要做較多的事情,相應的耗時也會增長。

基於 ESModule Bundleless 模式

image.png

從上圖可以看到,已經不再有一個構建好的 bundle、chunk 之類的檔案,而是直接載入本地對應的檔案。

image.png

從上圖可以看到,在 Bundleless 的機制下,專案的啟動只需要啟動一個伺服器承接瀏覽器的請求即可,同時在檔案變更時,也只需要額外處理變更的檔案即可,其他檔案可直接在快取中讀取。

對比總結

image.png

Bundleless 模式可以充分利用瀏覽器自主載入的特性,跳過打包的過程,使得我們能在專案啟動時獲取到極快的啟動速度,在本地更新時只需要重新編譯單個檔案。下面將分享如何基於瀏覽器 ESModule 的能力實現 Bundleless 的開發。

三 如何實現 Bundleless

如何使用 ESModule 模組載入

實現 Bundleless 的第一步是要讓瀏覽器自主載入對應的模組。

使用 type="module" 開啟 ESModule

<div id="root"></div><script type="module">
  // 直接在 script 標籤中使用 type="module" 即可使用 ESModule 的方式
  import React from 'https://cdn.pika.dev/react'
  import ReactDOM from 'https://cdn.pika.dev/react-dom'
  ReactDOM.render('Hello World', document.getElementById('root'))</script>

利用 import-maps 支援 bare import

分享一個在 chrome 中已經實現了的 import-maps 的標準 ,可以讓我們直接用 import React from 'react' 這樣的寫法,未來我們可以利用此能力實現線上的 Bundleless 部署。

<div id="root"></div><!-- 開啟 chrome://flags/#enable-experimental-productivity-features --> <script type="importmap">
  {    "imports": {      "react": "https://cdn.pika.dev/react",      "react-dom": "https://cdn.pika.dev/react-dom"
    }
  }</script><script type="module">
  // 支援 bare import
  import React from 'react'
  import ReactDOM from 'react-dom'
  ReactDOM.render('Hello World!', document.getElementById('root'))</script>

以上我們介紹了瀏覽器中原生的 ESModule 是如何使用的。面向本地開發的場景,我們只需要啟動一個本地的 devServer 承載瀏覽器的請求對映到對應的本地檔案,同時動態地將專案中 import 的資源路徑指向我們的本地地址,即可讓瀏覽器直接載入本地的檔案,比如可以使用下面的寫法,將入口 JS 檔案直接指向本地的路徑,然後 devServer 再攔截相應的請求返回對應的檔案。

<div id="root"></div><!-- 直接指向本地路徑 --><script type="module" src="/src/main.jsx"></script>

如何載入非 JS 的檔案資源

透過 ESModule 我們藉助瀏覽器的能力實現了 JS 的自主載入,但實際的專案程式碼中我們不僅僅會 import JS 檔案,也會有下面的寫法:

// main.jsximport React from 'react'import ReactDOM from 'react-dom'import './index.css' // import css 檔案import App from './App'  // import jsx 檔案// 使用 JSX 語法ReactDOM.render(<App />, document.getElementById('root'))

而瀏覽器在處理檔案時是依據 Content-Type 的,不關心具體的檔案型別,所以我們需要在瀏覽器發起請求時,將對應的資源轉化為 ESModule 格式,同時設定對應的 Content-Type 為 JS,返回給瀏覽器執行,瀏覽器就會按照 JS 的語法進行解析處理,整體的流程可見下圖:

image.png

以下是 Vite 的相關實現,在請求返回的過程中,對不同的檔案進行動態處理:

image.png

如何實現 HotModuleReplace

HotModuleReplace 能夠在我們修改程式碼後,不需要重新整理頁面,直接在當前場景下生效,結合 Bundleless 極快的生效速度,我們能夠實現幾乎沒有延遲的儲存即生效的體驗。對於 React,在 Webpack 場景下目前只能透過使用 react-hot-loader 來實現,但這一塊受限於具體的實現,有一些場景會存在 bug,作者也建議遷移到 React 團隊實現的 react-refresh,而這一塊在 Webpack 中還沒有相應的實現。在 Bundleless 場景下,因為我們的每個元件都是獨立載入的,所以要整合 react-refresh,我們只需要在瀏覽器請求返回時在檔案的頂部和底部加上相應的指令碼即可完成整合。

image.png

要完整的實現 HotModuleReplace 會比上面畫得更加複雜,還需要有一套依賴分析機制來判斷當一個檔案發生變更之後要替換哪些檔案以及是否需要 reload。在 Bundleless 的場景下,因為不再需要打包為一個完整的 bundle,同時我們也能更加靈活地對單個檔案進行修改,這一塊相關的實現會更加容易。

以下是在 Vite 中的相關實現:

image.png

如何最佳化大量請求導致頁面載入慢

Bundleless 的模式不再打包,提升了啟動的速度,但對於一些有較多外部依賴或者自身檔案數量較多的模組,需要發起大量請求才能獲取到全部的資源,這個會降低開發過程中頁面載入的時間。比如下面是直接在瀏覽器中 import lodash-es 會併發出大量的請求:

image.png

在這一塊上我們可以做相應的最佳化,將外部的依賴提前打包成單個檔案來減少在開發過程中由於外部依賴過多而發起過多的網路請求。

在 Vite 的啟動流程中有一個 vite optimize 的過程會自動將 package.json 中的 depenencies 藉助 Rollup 打包成 ES6 Module。

image.png

image.png

提前打包帶來的好處除了能夠提升頁面的載入速度,藉助 @rollup/plugin-commonjs 我們能夠將 commonjs 的外部依賴打包為 ESModule 的形式引入,進一步擴大 Bundleless 的適用範圍。

四 在供應鏈 POS 場景下落地實踐

我們團隊負責的供應鏈 POS 業務主要可分為面向建材家居的裝潢行業和線下小店的零售行業,在技術架構上採用了各個域 bundle 獨立開發,然後最終藉助底層的 sdk 合併為一個大的 SPA 的形式。由於專案的複雜性,在日常開發過程中,有以下的一些痛點:

  • 專案的啟動和耗時相對較長。
  • 改動後二次編譯時間長。
  • 缺少穩定的 HMR 能力,開發過程中需要重複造場景。
  • debug 依賴 sourcemaps 能力,有時會出現不穩定的情況。

基於以上的問題,藉助 Vite 的相關實現,我們對本地開發環境進行了 Bundleless 的嘗試和落地,在實驗的一些專案中對於本地的開發體驗有了很大的提升。

在啟動以及修改生效的速度上帶來極大的提升

目前已實現單 bundle 維度的開發,打包構建速度:

640.gif

Webpack

640 (1).gif

Vite Bundleless

從上面的可以看出,在啟動單個 bundle 時,Webpack 需要 10s 左右的時間,而基於 Bundleless 的 Vite 只需要 1s 左右,提升 10 倍。

image.png

整體的頁面載入時間在 4s 左右,仍然比 Webpack 的打包構建時間要短,同時從上面的影片中也可以看到 HMR 的速度達到了毫秒級的響應,實現了基本無感的儲存即生效。

不依賴 sourcemap 除錯單個檔案

image.png

落地過程中遇到的問題和解決

在實際落地過程中,遇到的問題主要是相關模組不符合 ESModule 規範以及一些寫法上的標準化:

  • 部分模組沒有 ESModule 的打包。
  • less 依賴 node_modules 的寫法的規範。
  • jsx 檔案字尾規範。
  • babel-runtime 的處理。

部分模組沒有 ESModule 的打包

對於沒有 ESModule 打包輸出或者輸出的錯誤的包,根據不同的型別使用不同的策略:

  • 內部的包:透過升級腳手架,釋出帶有 ESModule 的包的新版本。
  • 外部依賴:透過 issue、pull request 等形式,推動了 number-precision 等模組的升級。
  • 同時有一些由於歷史原因無法打出 ESModule 的包可以藉助 @rollup/plugin-commonjs 打包為 ESModule。

less 依賴 node_modules 的寫法的規範

@import '~@ali/pos-style-mixin/style/lst.less'; 
// ~ 只在 webpack 中 less-loader 的支援,在原生的 less 中不支援// 統一遷移為下面的模式@import '@ali/pos-style-mixin/style/lst.less';// 同時在原先的 webpack 構建中的 less-laoder 中配置 lessOptions,用於最後的打包/*
{
    loader: 'less-loader',
        options: {
            lessOptions: {
                javascriptEnabled: true,
                paths: [path.resolve(cwd, '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 之後,打包的輸出結果會是下面這樣:
image.png

上面所引用的 @babel/runtime/helpers/extends 是 commonjs 的格式無法直接使用,針對這個情況,有兩種解法:

1)針對內部自己打包的模組,可以在進行 es6 打包時新增 useModules 配置,這樣打包出來的程式碼就會是直接引用@babel/runtime/helpers/esm/extends

image.png

2)針對重新打包成本較高的模組,可以透過 Vite 的外掛機制進行轉換,將 @babel/runtime/helpers 在執行時替換為 @babel/runtime/helpers/esm 可以透過 alias 配置實現:

image.png

以上是在 Vite 開發環境的遷移過程中遇到的一些問題和處理的分享,這一塊的更大範圍的落地還在進行中。Bundleless 的落地不僅僅是為了適配 Vite 的開發模式,同時也是面向未來規範各個模組程式碼的過程,將我們的模組進行標準的 ESModule 化,在有新的工具和思想出現時可以用更低成本進行落地。

五 直接使用 Bundleless 進行部署的可行性

受限於網路請求和瀏覽器的解析速度,對於較大型的應用,bundle 在載入速度上還是能夠帶來較大的收益。V8 在 2018 年也給出了相關效能上的建議:在本地開發和小型的 Web 應用中使用。在今天的場景下,隨著瀏覽器和網路效能的不斷提升,結合 ServiceWorker 之類的快取能力,網路載入的影響和越來越小,對於一些不需要考慮相容性問題的場景可以進行內部的嘗試,直接部署透過 ESModule 載入的程式碼。

六 總結

本文主要分享了 Bundleless 架構下,如何提升前端的研發效率、實現思路以及在具體業務場景下落地實踐。Bundleless 本質上是將原先 Webpack 中模組依賴解析的工作交給瀏覽器去執行,使得在開發過程中程式碼的轉換變少,極大地提升了開發過程中的構建速度,同時也可以更好地利用瀏覽器的相關開發工具。

站在當前的背景下,Web 各個領域 JavaScript/CSS/HTML 相關的標準都已成熟,同時瀏覽器核心也趨於統一,前端工程化的核心重點已逐步遷移到研發提效上,而 Bundleless 的模式能夠帶來長效的啟動和 HMR 的速度,是未來的一個發展趨勢。隨著瀏覽器核心和 Web 標準的不斷統一,前端的程式碼可以不再打包直接執行將成為可能,這將進一步提高整體的研發效率。

最後非常感謝 ESModule、Vite、Snowpack 等標準和工具的出現,讓前端的開發體驗往前跨了一大步。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31550522/viewspace-2704096/,如需轉載,請註明出處,否則將追究法律責任。

相關文章