file

當事情做得越來越多的時候,做事的方法也會逐漸出現演變。

之前我在開發自己的開源 web app 的時候,我是習慣採用 Gulp(主要配合 esbuild)來進行建置動作。採用 Gulp 作為主要建置框架的優點、在於可以百分之百精確自訂整個建置流程與產物的每一個細節,所以可以產生出架構最複雜、且最佳化程度最高的建置結果。但是 Gulp 最大的缺點,就在於設置起來相當花時間——這如果是從無到有地一邊開發、一邊隨著需要逐漸增加設置的話,可能還不會有什麼感覺,但是今天如果我又要打造一個全新的應用程式的時候,光是想到我需要把那些複雜的 Gulp 工作量身打造地設置一次、老實說我就已經會有點卻步了。

換句話說,今天假如我工作之餘全部的心力都用在維護一兩個開源專案上,那用 Gulp 絕對沒問題,但是當我同時要維護的專案陸續增加到五六個應用程式的時候,我就開始發現這樣下去有點行不通了。為了能夠讓我更專注在各個應用程式的開發本身,我必須要捨棄掉一點完美主義,也就是說建置出來的東西稍微不那麼最佳化也沒關係、能跑就好,但是取而代之地,我想要換來的是:

  1. 建置的設置要盡可能地簡單:不用安裝太多套件、設定檔不要太複雜。
  2. 自訂程度要足夠、不要那種很 opinionated 的東西(例如 Vite)。
  3. 效能要夠好:不考慮 webpack 和 rollup 那類老字號的東西,速度太慢了。
  4. 好的開發輔助功能:原生 TypeScript 支援、熱模組替換(HMR)與自動生成 sourcemap 這些都不能少。
    後兩者值得多說明:Gulp 本身只是自動化工具,而 esbuild 也只是純建置工具,它們都沒有提供 HMR 這種炫炮功能的能力,而且即使是 sourcemap 這麼基本的東西,要靠那兩者做到正確也是相當不容易的事情 1。這兩點都使得這套工具鏈在開發體驗方面並不是很好。

結果就在我開發 ReferenceFinder 的過程中,我認識了 Rsbuild 這個完美符合上述要求的建置工具。因為它的開發體驗真的很好,除了 ReferenceFinder 之外,後來我又陸續地把幾個規模比較小的舊專案改成用這個來建置,使用至今都覺得相當滿意。本篇中就來簡單介紹一下 Rsbuild 和我的使用心得。

初期工具鏈

如同我在上一篇當中解釋的,ReferenceFinder 是採用 React 寫成的。React 自己有一個官方的工具叫 create-react-app(CRA)可以建立新的應用程式、並且讓它搭配官方的建置工具 react-scripts 使用,但是這個東西非常之 opinionated、近乎毫無自訂空間,連專案資料夾的結構都規定得死死的,我並不喜歡,所以很久以前我自己在自修 React 的時候我就知道有一個工具叫 react-app-rewired 可以在不進行「eject」的情況下去修改 CRA 的運作方式、達到一定的自訂效果。而在我展開了 ReferenceFinder 的開發之後,我又得知了有一個套件叫 customize-cra 提供了一些工具方法、可以讓 react-app-rewired 使用起來變得比較方便一點。於是在 ReferenceFinder 的開發初期,我就是用後兩者的組合來進行建置和輔助開發。

起先這樣的工具鏈並沒有什麼大問題,然而 react-app-rewired + customize-cra 只是修改了 react-scripts 的行為,本身還是一樣是 react-scripts、背後是基於 webpack 的,而 webpack 最為人詬病之處就是它的速度實在有點慢(新興工具都最喜歡強調自己比 webpack 快了多少倍……)。原本我以為「ReferenceFinder 的規模又不大,沒差吧?」,但沒想到我才開發了沒多久、我就明顯感受到自己對於「每次都要建置好幾秒鐘才能開始」的不耐煩了。

只不過,當時我已經對於整個專案做了不少針對 webpack 的自訂設置了,所以我也沒有打算完全砍掉重練,而是希望可以找一個跟 webpack 充分相容、但是速度可以快一點的開發工具。這是我就想起了之前我就有稍微留意的 Rspack

Rspack 與 Rsbuild 簡介

Rspack 算是一個非常年輕的新興工具,它是 2022 年才首度釋出的,所以它的使用者至今仍然不多。它的主打概念就是「與 webpack 相容,但是用 Rust 寫成的打包工具」,這本身聽起來就頗有吸引力的了。問題是,就跟 webpack 一樣,Rspack 也是一個比較多功能而低階的工具,而 CRA 是對 webpack 做了一大堆的黑箱設置才能帶來完整的 React 開發與建置功能,我就不禁懷疑:我有辦法對 Rspack 做到同樣程度的設置嗎?

幸好,我完全不必這麼做。因為 Rspack 家族去年 10 月推出了更年輕的新成員,就是這篇的主角 Rsbuild(因為實在太年輕,Google 非常容易把它跟 esbuild 搞混!)。Rsbuild 扮演的角色就跟 CRA 很像,是幫忙把 web app 開發過程當中常見的打包設置都幫我們弄好,然後再去呼叫底層的 Rspack 去進行高速的打包。Rsbuild 官方提供了 React、Vue 2/3、Svelte、Solid 這幾個框架的外掛,甚至還貼心地提供文件說明怎麼從 CRA 等等的舊工具鏈直接搬遷過來。讓我非常驚豔的是,我照著那個文件做,還真的馬上就能成功建置了、一點狀況都沒有發生!我近年來真的很少遇到那麼「開箱即用」的工具了,所以印象真的很深刻!

不僅如此,它設置起來比 customize-cra 還要更加簡單;例如像是 WebAssembly 的打包在 webpack 當中還要額外設置,但是 Rsbuild 則是一開始就幫我們設置好了。此外,Rsbuild 還整合了 .browserslistrc,會根據裡面的目標瀏覽器版本自動加入 polyfill(亦可關閉此功能、自己手動加入),並且可以透過一個外掛在建置的時候提供 ECMAScript 語法相容性檢查(這很好用!)。諸如此類的貼心設計再加上 Rust 核心帶來的建置速度,開發起來真的是有夠舒服的。

在 ReferenceFinder 之後,我也陸續把幾個專案也遷移到 Rsbuild 上來進行建置,其中包括了用 vanilla JS 和 Vue 3 寫成的專案,也都是高度開箱即用、而且該串的東西都串得很好(特別是我在 esbuild 當中始終無法完全搞定的 Vue 之 sourcemap 問題)。用過就回不去了。

Rspack 和 Rsbuild 背後開發的團隊是 Web Infra,即字節跳動公司(ByteDance,抖音的母公司)底下養的一個專做開源的團隊。雖然我對抖音的印象並不好,但 Rsbuild 真的是我近年來遇到開發體驗最好的工具,沒有之一。

自動化建置 vs 打包

整體來說,如果是把之前使用 webpack(或其它基於 webpack 的工具)的專案遷移到 Rsbuild 上來,基本上會是幾乎沒有狀況(或者只需要很小的調整)就可以運作的。但是對於原本使用 Gulp 或者類似的自動化建置工具來開發的專案來說,因為自動化建置(automated build)本身跟打包(bundling)就是非常不同的概念,整個專案可能就會需要多一點重構才能正確運作。對此,我就來稍微說一下兩者的差異。

簡單來說,自動化建置的核心概念是「讓程式自動執行我們手動執行的建置步驟」,也就是說,我們自己是很清楚建置步驟是什麼的,甚至如果我們想要的話、我們也可以手動去一步一步地完成這個過程,我們只是讓程式自動去做完這些步驟而已。因此,自動化建置的設置基本上就是我們去撰寫腳本、讓自動化工具知道建置的步驟有哪些、什麼地方可能要做判斷、工作之間的相依關係如何……如此地把整個建置過程用自動化工具能理解的語法去呈現出來。這會導致幾個特性:

  1. 整個建置過程到底能不能產生出合理的東西、是要看我們如何去撰寫腳本的,自動化工具無法保證任何事情。
  2. 自動化工具對於每個步驟所能執行的事情幾乎沒有限制,因此我們可以選擇搭配幾乎任意的工具去處理我們的原始碼和資源,能處理的對象也非常自由。此外因為自動化工具的 API 相對單純,外掛要撰寫通常很容易。
  3. 最初的原始碼與最終產生的檔案之間可以歷經很多次的 pass(例如可以加上 preprocessing 和 postprocessing 等等) 、來做到高度的自訂與最佳化。
  4. 網頁上可以有多個入口(entry)模組,而各個模組之間的關聯未必單看原始碼就可以明顯看得出來。

但打包則是很不一樣的思考方式。打包最狹義來說僅只是指「把相依的 JavaScript 模組集結成單一 JavaScript 檔案」的意思,而雖然現在的打包工具除此之外還會順便做很多事情,但核心想法仍然是環繞在「集結相依資源」的大方向上。具體來說,這跟自動化建置會有如下的差異:

  1. 打包工具執行的工作通常都是它本身規劃好的,只要執行沒出錯,原則上就會產生出合理的網頁出來(有沒有 bug 是另一回事)。
  2. 打包工具執行的工作很受限於它本身的設計以及外掛提供的功能,而且要擴充功能只能透過針對它所設計的外掛程式,能處理的對象也較為有限。而打包工具的 API 通常很複雜,自己要撰寫外掛會有一定的門檻。
  3. 通常原始碼只會經過簡單的轉譯(像是 TypeScript 和 SCSS 的轉譯)和打包就成為最終輸出,不會有太多自訂空間,且最佳化的程度相當有限。甚至打包出來的程式碼通常還會多出了一些額外的底層架構、是用來在執行階段管理模組相依性用的 2
  4. 基本上只能使用單一入口模組,而其它的模組最後都會打包在一起(頂多就是拆成 chunk 而已)然後透過入口模組去呼叫。而為了要讓打包程式可以正確認得模組之間的關聯,所有的模組和資源在原始碼中都會明確地進行引入(import)。

雖然乍看之下這樣好像顯得打包的作法是弱點居多,但是事實上正是因為打包的作法有這些限制、不至於讓整個建置過程太過天馬行空,才有辦法做到強大的建置效能以及 HMR 等等的高級功能。

雖然我自己也未必總是可以把任意的舊專案轉換成 100% 可打包的架構,但是對於新建立的專案來說,在沒有既有包袱的情況下要貫徹打包的架構就容易多了。而對舊的專案來說,其實也是可以讓兩種作法並存的:我們可以先特別編譯那些需要客製化的部份,然後再讓打包工具把編譯好的東西打包進來即可;雖然這樣有點拖泥帶水,但是倒不失為是過渡時期的一個可行作法。

與 webpack 之相容性

雖然 Rspack 在相當大的程度上是與 webpack 相容的,但是必須特別指出,其相容性仍舊不是 100%,於是有一些 webpack 的外掛如果直接拿到 Rspack/Rsbuild 上頭來用的話是會有問題的。不過,我到目前為止的經驗是,那些有相容性問題的外掛通常只要稍微修改一兩個小地方就可以正確在 Rspack/Rsbuild 上執行了。

例如,我發現 purgecss-webpack-plugin 有相容性問題,而調查下去發現其原因在於 rspack 的內部資料結構有一個小地方跟 webpack 不同導致的,其解決方式也很簡單,如我在這邊所述(同時這個問題後來也已經在 v1.0.0-alpha 當中解決了)。

另一個我發現不相容的例子是 favicons-webpack-plugin,它的原因在於它裡面用到了 webpack 特有的快取機制,而 Rspack 在這部份並不打算沿用 webpack 的作法(如這邊所述)。不過解決的方法也不難,只要把那個外掛裡面跟快取有關的部份註解掉就行了。當然,這意味著其實應該要把那個外掛 fork 出來另外做一個 favicons-rspack-plugin,不過因為我並不是真的非得要用這個外掛不可,所以我就暫且沒這麼做了。

整合 Workbox precaching

Workbox 官方有提供 workbox-webpack-plugin,但是這個外掛同樣地在 Rspack/Rsbuild 上頭也有一點小小不相容,幸好這部份倒是已經有人做了一個 fork 叫 @aaroon/workbox-rspack-plugin。其使用也很簡單,以 GenerateSW() 來說:

import { defineConfig } from "@rsbuild/core";
import { GenerateSW } from "@aaroon/workbox-rspack-plugin";

export default defineConfig({
    ...
    tools: {
        rspack: (_, { appendPlugins, isProd }) => {
            if(isProd) {
                appendPlugins(new GenerateSW({
                    clientsClaim: true,
                    skipWaiting: true,
                }));
            }
        },
    },
});

如此一來會自動在建置當中產生兩個檔案:service-worker.js(包含有 precaching manifest,是根據建置當中的其它檔案自動產生的)和 workbox-xxxx.js(Workbox 的核心 chunk),然後我們只要自己記得把 service-worker.js 在應用程式當中註冊上去就可以了(這件事情這個外掛不會幫我們做,要自己做)。注意到上面我們做了一個 isProd 的判斷,因為通常我們並不需要在開發模式當中也使用 service worker 3

而如果是要用 InjectManifest() 的話,則是這樣用的:

import { defineConfig } from "@rsbuild/core";
import { InjectManifest } from "@aaroon/workbox-rspack-plugin";

export default defineConfig({
    ...
    rspack: (_, { appendPlugins, isProd }) => {
        if(isProd) {
            appendPlugins(new InjectManifest({
                swSrc: "./src/service/sw.ts", // service worker 原始檔路徑
            }));
        }
    },
});

這樣寫的話,就會自動把我們的 service worker 原始碼加入成為新的建置入口點來進行編譯,編譯好了以後再順便注入 precaching manifest。

結語

目前 Rsbuild 還是非常新的一個工具,但是以不到一年的年紀來說,我對它的成熟度實在感到非常印象深刻,也感受得到它強大的成長潛力。目前 Rsbuild 幾乎每個月都會有一個 minor 更新、且每週都會有一兩次 patch 更新,開發非常之活躍,回應 issue 也算積極,是滿可以繼續期待的。很推薦大家用看看!

我對 Rsbuild 的初體驗評分如下:

文件清楚:⭐⭐⭐⭐⭐
設置容易:⭐⭐⭐⭐⭐
概念易懂:⭐⭐⭐⭐⭐


  1. 特別是因為我有用 Vue,至今我無法 100% 解決 SFC 的一些 sourcemap 對應的問題。我想 esbuild 最嚴重的問題就是它對於 Vue 的支援非常不怎麼樣,因為並沒有所謂官方的 esbuild Vue 外掛這種東西,而只有一大堆(有十來個之多)由個人獨自開發的第三方外掛,那些不但維護得不好、常常功能也不完整。 

  2. 作為打包工具,esbuild 算是一個例外,因為它的設計會直接把所有的模組扁平化合併,所以產生出來的結果並不會加上類似的模組管理底層。但是或許因為這樣的設計,使得 esbuild 至今沒有辦法在自訂 chunk 這方面有所突破。 

  3. 但也有例外,例如當 service worker 除了負責快取之外還負責其它重要工作的時候。要注意的是,如果要在開發模式當中也產生 service worker 的話,HMR 會導致 Workbox 一直在主控台當中輸出一些警告訊息,如果覺得那些警告很煩,可以參考這則討論來消除警告。 

我的文章對您有幫助嗎?請我喝杯咖啡吧:

分享此頁至:
最後修改日期: 2024/07/02

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。