天天看點

Webpack 打包太慢?來試試 Bundleless

Webpack 打包太慢?來試試 Bundleless

一 引言

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

Webpack 打包太慢?來試試 Bundleless

但因為多了打包建構這一層,随着項目的增長,打包建構速度越來越慢,每次啟動都要等待幾十秒甚至幾分鐘,然後啟動一輪建構優化,随着項目的進一步增大,建構速度又會降低,陷入不斷優化的循環。

Webpack 打包太慢?來試試 Bundleless

在項目達到一定的規模時,基于 Bundle 的建構優化的收益變得越來越有限,無法實作質的提升。我們從另一個角度思考,webpack 之是以慢,主要的原因還是在于他将各個資源打包整合在一起形成 bundle,如果我們不需要 bundle 打包的過程,直接讓浏覽器去加載對應的資源,我們将有可能可以跳出這個循環,實作質的提升。

Webpack 打包太慢?來試試 Bundleless

在 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 打包太慢?來試試 Bundleless

基于 Webpack 的 bundle 開發模式

Webpack 打包太慢?來試試 Bundleless

上面的圖具體的子產品加載機制可以簡化為下圖:

Webpack 打包太慢?來試試 Bundleless

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

基于 ESModule Bundleless 模式

Webpack 打包太慢?來試試 Bundleless

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

Webpack 打包太慢?來試試 Bundleless

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

對比總結

Webpack 打包太慢?來試試 Bundleless

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.jsx
import 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 的文法進行解析處理,整體的流程可見下圖:

Webpack 打包太慢?來試試 Bundleless

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

Webpack 打包太慢?來試試 Bundleless

如何實作 HotModuleReplace

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

Webpack 打包太慢?來試試 Bundleless

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

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

Webpack 打包太慢?來試試 Bundleless

如何優化大量請求導緻頁面加載慢

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

Webpack 打包太慢?來試試 Bundleless

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

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

Webpack 打包太慢?來試試 Bundleless
Webpack 打包太慢?來試試 Bundleless

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

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

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

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

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

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

目前已實作單 bundle 次元的開發,打包建構速度:

Webpack 打包太慢?來試試 Bundleless

Webpack

Webpack 打包太慢?來試試 Bundleless

Vite Bundleless

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

Webpack 打包太慢?來試試 Bundleless

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

不依賴 sourcemap 調試單個檔案

Webpack 打包太慢?來試試 Bundleless

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

在實際落地過程中,遇到的問題主要是相關子產品不符合 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 之後,打包的輸出結果會是下面這樣:

Webpack 打包太慢?來試試 Bundleless

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

1)針對内部自己打包的子產品,可以在進行 es6 打包時添加 useModules 配置,這樣打包出來的代碼就會是直接引用@babel/runtime/helpers/esm/extends

Webpack 打包太慢?來試試 Bundleless

2)針對重新打包成本較高的子產品,可以通過 Vite 的插件機制進行轉換,将 @babel/runtime/helpers 在運作時替換為 @babel/runtime/helpers/esm 可以通過 alias 配置實作:

Webpack 打包太慢?來試試 Bundleless

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

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

受限于網絡請求和浏覽器的解析速度,對于較大型的應用,bundle 在加載速度上還是能夠帶來較大的收益。V8 在 2018 年也給出了相關性能上的建議:在本地開發和小型的 Web 應用中使用。在今天的場景下,随着浏覽器和網絡性能的不斷提升,結合 ServiceWorker 之類的緩存能力,網絡加載的影響和越來越小,對于一些不需要考慮相容性問題的場景可以進行内部的嘗試,直接部署通過 ESModule 加載的代碼。

六 總結

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

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

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

繼續閱讀