天天看點

面對 ESM 的開發模式,webpack 還有還手之力嗎?

作者 | 鲲塵
面對 ESM 的開發模式,webpack 還有還手之力嗎?

snowpack / vite 等基于 ESM 的建構工具出現,讓項目的工程建構不再需要建構一個完整的 bundle。很多人都覺得我們不再需要打包工具的時代即将到來。借助浏覽器 ESM 的能力,一些代碼基本可以做到無需建構直接運作。對于 webpack 而言,社群掀起的這一波 ESM 熱潮,将 webpack 編譯的速度推到了風口浪尖。webpack 在 v5 版本中也是針對編譯的性能做出了不少努力,除了提供了實體緩存的優化之外,還提供 Module Federation 的方案,這給我們上層的應用實踐帶來了很多想象的空間。

以前 webpack 大有一統建構工具的趨勢,而現在我們可以結合業務的特點有更多的選擇。

為什麼需要打包

JavaScript 程式設計過程中很多時候,我們都在修改變量,在一個複雜的項目開發過程中,如何管理函數和變量作用域,顯得尤為重要。而 JavaScript 的子產品化提供了我們更好的方式來組織和維護函數以及變量。大家熟知的 JavaScript 子產品除了上述的 ESM 之外,還有 CJS、AMD、CMD、UMD 等等規範。而在 npm 生态開發的背景下,CJS 子產品是開發過程中接觸最多也是無法避免的。但由于浏覽器并不能直接執行基于 CJS 打包的子產品,是以類似 webpack 等打包工具便應運而生。

對于早期的 web 應用而言,打包子產品既能夠處理 JS 子產品化,又能将多個子產品打包合并網絡請求。使用這類建構工具打包項目的确是個不錯的選擇。時至今日基本上主流的浏覽器版本都支援 ESM,并且并發網絡請求帶來的性能問題,在 HTTP/2 普及下不像以前那麼凸顯的情況下,大家又将目光轉向了 ESM。就目前的體驗而言,基于原生 ESM 在開發過程中的建構速度似乎遠遠優于 webpack 之類的打包工具的。

初探 ESM 建構工具

使用 ESM

<script src="index.js" type="module"></script>           

通過

type="module"

告訴浏覽器,目前腳本使用 ESM 模式,浏覽器會建構一個依賴關系圖,借助浏覽器原生的 ESM 能力完成子產品的查找、解析、執行個體化到執行的過程。

為什麼快

為什麼基于 ESM 的建構工具 snowpack / vite 會比 webpack 在建構的時候要快很多,借用 snowpack 官網的一張圖檔來說明:

面對 ESM 的開發模式,webpack 還有還手之力嗎?

最核心的兩個特點:

  • 首先它們的建構複雜度非常低,修改任何元件都隻需做單檔案編譯,時間複雜度永遠是 O(1)
  • 借助 ESM 的能力,子產品化交給浏覽器端,不存在資源重複加載問題,如果不是涉及到 jsx 或者 typescript 文法,甚至可以不用編譯直接運作

建構流程

如果僅僅是把源碼交給浏覽器執行,是滿足不了大部分項目的訴求,源碼中通常我們是直接 import 第三方子產品,除此之外還會導入樣式、資源,包括源碼開發過程中使用最新 es 文法、jsx、ts 文法,這些在浏覽器中都無法直接運作。

建構工具針對上述的問題,根據不同類型和需求針對性進行了處理,以下便是簡化版的流程概覽:

面對 ESM 的開發模式,webpack 還有還手之力嗎?

import 語句處理

對于 esm 子產品首先需要處理的便是 import 語句,而項目開發過程中常見以下幾種情況:

import 第三依賴,比如

import React from 'react';

import 資源,比如圖檔的導入

import img from './img.png';

import css import './index.css'

import 依賴

snowpack 中将三方依賴的語句均轉化為 /web_modules/*.js

import React from 'react';

// 轉化為 

import React from '/web_modules/react.js';           

如果 npm 下所有的依賴都能夠打出标準 ESM 子產品,那這一步的處理其實可以簡單得将所有 web_modules 的子產品請求攔截并傳回其 node_modules 下的 ESM 子產品就行。但現實是很多 npm 生态下的依賴還不支援,是以目前大部分做法會在初次啟動項目時進行預打包,完成 CJS / UMD 轉化為 ESM 的操作。

面對 ESM 的開發模式,webpack 還有還手之力嗎?
配置邏輯上一般也會支援主動篩選已生成 ESM 的子產品,降低預先打包成本

import 圖檔

開發過程中 import 圖檔資源的時候,實際上是希望最終傳回其靜态資源的 URI,snowpack 内部首先将此類資源的 import 語句進行改寫:

import img from './img.png';

//  轉化為

import img from './img.png.proxy.js';           

而在 img.png.proxy.js 檔案中則可以預設導出對應的檔案位址:

// img.png.proxy.js

export default '/dist/assets/img.png';           

工具在生成

*.proxy.js

的時候可以對應将資源拷貝到指定輸出路徑即可。

import css

樣式導入的改寫規則同圖檔類似,差別上僅僅是生成

*.css.proxy.js

中的内容。

思路上也是将導入的 css 變成 JS 子產品,對于 css 而言,處理通過 link 的方式引入之外,還可以通過 style 标簽注入,那 css 子產品的代理規則也變得相當清晰了:

// code 便是 css 檔案中讀取的内容
const code = ${JSON.stringify(code)};
const styleEl = document.createElement("style");
const codeEl = document.createTextNode(code);
styleEl.type = 'text/css';
styleEl.appendChild(codeEl);
document.head.appendChild(styleEl);           

如果想啟動 css module,在 snowpack 中約定了 *.module.css 檔案将啟用 CSS Module 功能。相比上述插入 标簽的方式,增加了樣式 class 名稱對應類名的導出,即:

...
// let json = ${JSON.stringify(moduleJson)};
let json = { "test": "App--Test--3kX9Z4E"}
export default json;           
除了上述提到的改寫 import 類型之外,還有 json、less、sass 等檔案的處理,本質上處理邏輯大同小異,均是通過将 import 代理到新生成的檔案,并在中加入特定腳本,完成最終一個 ESM 子產品的轉化,以支援浏覽器處理加載。

Module Federation 的應用

ESM 給我們帶來的體驗就是快,因為它不需要像 webpack 除了要處理編譯之外,還需要分析源碼中的 node_module 依賴關系,并将它們合并、拆分,打包。

不管是官方還是社群也都不遺餘力地去設計各種各樣的方案來優化性能,比如 cache-loader、thread-loader、dllPlugin、babel cacheDirectory、hard-source-webpack-plugin 等等優化方式,但實際的效果并沒有那麼令人驚豔,并且都有一定的使用成本。

直到 webpack 5 的出現,它帶來的長效緩存能力可以在檢測到檔案變更的時候,根據依賴關系僅對依賴樹上相關檔案進行編譯,并且通過優化後的建構緩存及 resolver 緩存大大提升建構速度。而另一個特性 Module Federation(以下簡稱 MF)的出現,帶來了基于 webpack 開發的全新協作方式,它讓不同建構任務間的子產品複用變的更加簡單。

MF 的設計動機是為了能夠讓不同的團隊協作開發一個或者多個應用。這種協助的模式跟去年如火如荼的微前端開發模式如出一轍。

面對 ESM 的開發模式,webpack 還有還手之力嗎?

MF 方案能夠将一個應用拆分并導出不同的子產品,并且子產品依賴的底層三方庫,同樣能夠被共享。借助這個能力我們可以為應用提前建構一個公共依賴,來減少編譯内容和打包體積。

核心用法

首先來認識下 MF 中的兩個核心概念:

  • Host:提供了

    remotes

    選項,可以應用其他 Remote 應用中 expose 的子產品
  • Remote:提供了

    exposes

    選項,為其他應用提供子產品
面對 ESM 的開發模式,webpack 還有還手之力嗎?

使用方式:

// webpack

const { ModuleFederationPlugin } = require("webpack").container;
...
plugins: [
  new ModuleFederationPlugin({
    name: 'remoteRuntime',  // 必須,唯一 ID,作為輸出的子產品名,使用的時通過 ${name}/${expose} 的方式使用 
    remotes: ['remote'], // 可選,表示作為 Host 時,去消費哪些 Remote
    exposes: { // 可選,表示作為 Remote 時,export 哪些屬性被消費
    './ComponentA': './src/components/A',
  },
    shared: ['react', 'react-dom'], // 可選,優先用 Host 的依賴,如果 Host 沒有,再用自己的
  })
]           

消費子產品的應用使用 exposes 時,除了引入 Host 生成的 remoteEntry (子產品依賴關系)腳本之外,代碼層面需要對應修改:

// 消費形式 import ${name}/${expose}
import ComponentA from 'remoteRuntime/ComponentA';           

執行邏輯

如何希望使用 webpack 5 module federation 的能力,除了工程上的配置之外,源碼層面也需要做對應的修改:

// index.js
import('./bootstrap');

// bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import ComponentA from 'ComponentA';
...           

在渲染邏輯執行前,需要增加一層 bootstrap 的 import 邏輯,本質是為了能夠讓異步加載的 runtime 依賴,完成加載後再執行主邏輯,核心流程如下:

面對 ESM 的開發模式,webpack 還有還手之力嗎?

代碼核心執行流程變更如下:

  1. 首先先加載 index.js,這一般是應用的主bundle
  2. 主 bundle 中會去加載,

    __webpack_require__(“./boostrap.js”)

  3. 而 boostrap 子產品中将會包含各種依賴 chunk 的資訊
    • 通過 overridables 邏輯,内部會判斷這個 chunk 依賴的 shared 資訊,并根據資訊進行加載
    • 通過 remotes 邏輯,加載外部依賴子產品,資訊已經在 remoteEntry 中提供
  4. 完成是以應用依賴加載完後,執行最終應用邏輯

依賴抽離模式

了解上述的原理之後,我們回過頭來看有沒有可能讓 webpack 項目也像 ESM 模式一樣不打包依賴,将所有的三方依賴都預先準備好,在主應用啟動的時候,直接依賴這些提前編譯的子產品。

借助 MF 的能力,可以将所有的三方依賴都作為 remote 依賴引入。在 ICE 的實踐過程中,利用 MF 方案 和 webpack 5 的實體緩存的确給項目開發帶來了很大建構速度的提升,方案的核心實作邏輯如下:

  1. 首先通過 exposes 能力,将項目運作時依賴去全部導出,并完成 remoteEntry 和相關 remote 子產品的建構
  2. 項目開啟 MF 能力,并依賴已建構好的 remote
  3. 将項目中運作時依賴通過 babel 能力轉化為 remote 子產品加載模式,即 import xx from {expose} 的形式

完成上述的處理後,項目啟動後的 bundle 将不會再将遠端已打包的 runtime 相關依賴打包在一起。

優化前:

面對 ESM 的開發模式,webpack 還有還手之力嗎?

啟用方案優化後:

面對 ESM 的開發模式,webpack 還有還手之力嗎?
上述方案已在不同類型業務中實踐,歡迎👏大家聯系探讨使用場景

同類方案對比

dll

應用的三方依賴編譯成 dll,每次應用依賴改變的時候,應用都需要重新建構,并且無法進行按需加載,多個應用間基本無法共用 dll,缺乏動态性的特點。

externals

與之對應的 externals 方案,依賴被建構成一個個檔案,應用在打包的時候需要聲明有哪些外部子產品被引用,externals 無法按需加載,并且需要自己維護相應的依賴關系和scripts 加載順序。

小結

去年的時候 snowpack / vite 的工程生态和建構的定制能力同 webpack 還是有着不小的差距,而随着國内對 ESM 生态的關注,越來越多的建構工具開始去嘗試 ESM 的方式進行開發。

ESM 的開發模式很大程度上解決了在 dev 開發的啟動速度的問題,對于目前很多子產品未導出 ESM 的情況,也提供了預編譯的方式。同時像 snowpack 等工具在生産構模組化式下提供了基于 webpack 打包的插件,讓開發者沒有太大負擔的去應用最終的産物。這的确是一種漸進式的方式,但肯定不是長久方案。

webpack 的慢除了需要分析依賴并打包成一個 bundle 的影響之外,使用 babel 編譯和 sass-loader 等能力的耗時也是非常的長。這也是為什麼現在社群主流 ESM 子產品方案在做源碼編譯的時候,會選擇 esbuild 作為預設的編譯工具,它編譯的速度足夠快。webpack 同樣利用優化實體緩存的方式來提升建構的性能,特别是二次建構和熱更新都能夠得到很大的編譯速度提升。

webpack 提供的能力更像是一套企業級的解決方案,對源碼 / 建構的任意節點都提供了充分的鈎子和能力,友善開發者進行定制。而 ESM 則是利用浏覽器的子產品加載能力,不去解析子產品依賴,内部實作邏輯以快為首要考慮條件,能交給浏覽器直接運作的就不編譯。針對 ICE 或者社群的一些應用研發架構而言,大部分方案為了降低開發者的認知和開發成本,實作上會結合工程和運作時能力來簡化開發成本,邏輯處理上對 webpack 的生态都有一定程度的依賴。那這部分能力怎麼去結合 ESM 的開發模式,來幫助開發者在工程建構體驗和源碼開發體驗上都能夠得到極緻的提升,将是接下來又一個着重發力的風口。

面對 ESM 的開發模式,webpack 還有還手之力嗎?

繼續閱讀