file

最近這幾個月在 BP Studio 上又有一大堆進展,弄到該來整理成心得文章的東西多到滿了出來,於是又要再來強迫自己把它們寫下來了。這篇就先從之前也曾分享過的 Rsbuild 談起,因為 BP Studio v0.7 的一大改版重點就是把主要的建置工具從 Gulp + esbuild 遷移至 Rsbuild 之上。早先我本來會覺得 Rsbuild 不太適合像 BP Studio 這種自訂程度這麼高的專案,但到最後,其實整個遷移工程並沒有我想得那麼遙不可及;我只需要跟「打包」的思維更加同步,很多原本自訂的東西就都是存在對等的替代方案的,或者很多時候甚至還更方便。

模組串接

首先第一件要做的事情就是把原本都位於 HTML 最上層的各個模組串接起來,選擇其中一個當作是入口,而其它所有的模組都是透過它 import 進來的。這包括所有同步與非同步載入的 JS 程式碼與 CSS 樣式表,和任何其它類型的資源(如 WASM 等等)。

原本,BPS 裡面有三個主要的 JS 模組:main.js、client.js 以及透過 web worker 執行的 core.js 1。其中 core.js 因為是 web worker、自然是獨立的模組,而 main.js 和 client.js 拆成兩個模組的主要原因是為了增進載入效能、不要一口氣把所有程式碼在啟動的時候都載入的緣故。然而,esbuild 並不支援自訂 chunk 的功能,所以為了把那兩部份拆開,我只好設置兩個入口點並且分別用 esbuild 編譯,然後它們之間再透過模組匯出的全域物件來存取對方的功能。其它一些比較大的第三方套件,例如 Pixi.js,我也會比照同樣的方式將它們拆成獨自的 chunk。

但說穿了,這其實是因為 esbuild 不支援自訂 chunk、我才只好這麼做。這種作法會有若干的麻煩:

  1. 我必須自己管理模組的延遲載入。
    當時我自己有一段類似這樣的函數:

    function loadScript(src: string): Promise<void> {
        return new Promise(resolve => {
            const script = document.createElement("script");
            script.src = src;
            script.async = false;
            script.onload = () => resolve();
            script.onerror = () => {
                // 一些我自訂的錯誤處理機制
            };
            document.head.appendChild(script);
        });
    }

    然後我自己要去確保在我操作 client.js 匯出的全域物件之前、必須先適時執行 loadScript("client.js") 才行,否則就跑出全域物件尚未定義的錯誤。當然要確保這一點並不是很難,大概只有在開發初期的幾次遞迴中會出現這種失誤,但是這個載入機制總之很黑箱,並不好維護。

  2. TypeScript 必須額外宣告全域物件
    為了讓 TypeScript 能正確知道那些全域物件的型別,我就必須另外寫 .d.ts 檔案宣告它們的存在,所以在 v6 以下的 BPS 當中我都有一個檔案宣告了一堆那樣的全域物件。
  3. 第三方套件的 tree shaking 非常麻煩
    剛才有提到,對於 Pixi.js 我也會類似地設置入口模組並且編譯成新的 chunk,而不是直接使用他們建置好的 JS 檔案,因為那些現成的 JS 檔案都是包含了所有功能的建置、較為肥大;根據我實際使用的需求來建置的話,檔案大小往往可以減少一半以上。
    可是,因為我是把這些套件各自當成入口模組去編譯,在沒有上下文的情況下、我唯有手動指定究竟這些模組要匯出些什麼、才能夠正確地做到 tree shaking。以 Pixi.js 來說,BPS v6 會有這樣的一個檔案:

    export {
        Renderer, Ticker, getTestContext, ContextSystem, Rectangle,
        Matrix, utils, MASK_TYPES, Transform, RAD_TO_DEG, DEG_TO_RAD,
        settings, Point, ExtensionType, extensions, UPDATE_PRIORITY, Filter,
        defaultVertex, State, Color, BLEND_MODES, Texture, Polygon, PI_2,
        RoundedRectangle, Circle, Ellipse, SHAPES, MSAA_QUALITY, DRAW_MODES,
        Geometry, Buffer, TYPES, WRAP_MODES, Shader, Program,
        BatchTextureArray, BaseTexture, UniformGroup, BatchGeometry,
        BatchDrawCall, ObservablePoint, StartupSystem
    } from "@pixi/core";

    然後我再把這個當成入口模組去編譯就能得到我要的東西。這個清單只能手動盤點,所以非常難以維護。

而改用了 Rsbuild 之後,其實這些都有很好的解決,因為 Rsbuild 底層的 Rspack 跟 Webpack 一樣支援自訂 chunk,所以一旦我們把整個系統都用明確的 import 串接起來之後:

  1. 模組的延遲載入有自動的管理。
    新的寫法會變成這樣(其中 webpackChunkName 可以用來指定產生的 chunk 的名稱):

    const bp = await import(/* webpackChunkName: "client" */ "client/main");

    如此一來就可以取得 client/main.ts 匯出的(原本是全域的)bp 物件。Rspack 注入的底層會自動管理 chunk 檔案的載入、所以絕對不用擔心使用的時候模組還沒載入進來,而且還可以搭配 @rsbuild/plugin-assets-retry 套件來做到失敗時自動重新嘗試的效果。

  2. 不需要額外定義 TypeScript。
    因為有明確的 import 而不再使用全域物件了,就不用再另外宣告了,TypeScript 自然會沿著引入的路徑抓到正確的型別。
  3. 可以自動 tree shaking。
    由於現在整個專案全部的模組都是一起編譯的,有明確的上下文之後就可以自動判斷出沒有用到而可以去除的部份了。

正確地把全部的模組與資源都用這種方式引入了之後,原則上就可以用 Rsbuild 建置出能夠跑得起來的站台了。再來的工作就是把之前用 Gulp 做到的其它自訂建置動作設法在 Rsbuild 的架構下實現。這主要有兩個方法。

預先建置資源

如同我在上一篇提到過的,一個簡單的自訂方法,就是先把特定的資源以自訂的方式建置好、然後再打包進來。這特別適合符合下列條件的資源:

  1. 建置會產生出多個檔案而非單一的檔案。
  2. 每次建置過程相對來說會花比較多時間。
  3. 更新比較不頻繁、久久才會需要建置一次。
  4. 使用的工具鏈要跟 Rsbuild 整合比較困難的情況。

對於像這一類的資源,有若干個我還是繼續維持著先用 Gulp(或其它工具)把它們建置好、再用 Rsbuild 打包進來的模式,所以並不是說就真的完全捨棄 Gulp 不使用,但除此之外絕大部分的工作確實都已經移交到了 Rsbuild 之上。這些在 Gulp 之外額外建置的東西包括:

  • 客製化的 FontAwesome 與其它自訂圖示包。
  • 把 C++ 程式碼編譯成 WebAssembly(使用 Emscripten + Make)。
  • Vue SSG 的預先編譯(因為目前 Rsbuild 尚未提供 SSG 的功能)。

使用 loader

另一個主要的自訂方法就是使用 loader 來處理並轉換資源。這個 loader 是從 webpack 那邊承襲過來的概念,絕大部分的 webpack loader 都可以直接在 Rspack 裡面使用,而如果沒有現成的 loader 可以做到自己想做的工作,要自己寫一個也不算太難。

例如,其中一個我會用到的功能是用

/// #if DEBUG
...
/// #endif

這樣的預處理語法來標註一些只有在偵錯模式下才要執行的程式碼(用執行階段的 if 當然也可以做到這一點,但有些東西我基於各種理由連這多一層的判別都不打算用,而是希望那段程式碼在發行階段根本不存在)。這在 esbuild 當中可以透過 esbuild-ifdef 之類的外掛來做到,而在 webpack/Rspack 當中則有類似功能的 ifdef-loader。只要把這個 loader 加入檔案處理的 chain 當中,就會根據當前的設定來選擇保留或排除那些程式碼。在 Rsbuild 當中,加入這個 loader 的寫法如下:

import { defineConfig } from "@rsbuild/core";

export default defineConfig({
    ... // 其它設定
    tools: {
        bundlerChain: (chain, { CHAIN_ID }) => {
            // 抓出處理 JS 檔案的 chain(TS 檔案也是這一條 chain 在處理的)
            chain.module.rule(CHAIN_ID.RULE.JS)
                // 這只是把這套設定取個名字,不重要
                .use("ifdef")
                // 把順序排在 SWC 編譯器的後面(loader 執行的順序與載入的順序是相反的,
                // 換句話說,這樣的結果是 ifdef-loader 會先執行、然後才是 SWC 編譯)
                .after(CHAIN_ID.USE.SWC)
                // 使用 loader(直接傳入套件名稱的字串、或 JS 檔案的路徑)
                .loader("ifdef-loader")
                .options({
                    // 在這邊定義要注入的變數
                    DEBUG: process.env.NODE_ENV !== "production",
                });
        },
    },
});

另外一個例子是我會把更新 log 寫成 Markdown 格式,而這些 log 檔案在建置的時候我希望它們先用 marked 套件轉換成 HTML,然後再做 minify 處理。這些都有現成的 loader 可以做到:

export default defineConfig({
    ... // 其它設定
    tools: {
        ... // 其它設定
        rspack: (_, { addRules }) => {
            addRules({ // 新增一個規則,對象為 .md 的檔案
                test: /\.md$/,
                use: [
                    // 記得這邊的順序跟執行的順序是相反的
                    "html-minifier-loader", // 不用指定選項的話,寫 loader 名稱就好了
                    { // 要指定選項的話就要用物件的寫法
                        // 這個 loader 背後使用的就是 marked 套件
                        loader: "markdown-loader",
                        options: {
                            headerIds: false,
                            mangle: false,
                        },
                    },
                ],
                // 指定這一個類型的檔案為資源模組,亦即其結果不會被打包起來、
                // 而是個別檔案獨立發布,且在 JS 中以最終路徑的字串形式匯入進來。
                type: "asset/resource",
                generator: {
                    // 指定最後產生的檔案路徑與檔名格式
                    filename: "log/[name][ext]",
                },
            });
        },
    },
});

自訂 loader

如果現成的 loader 套件沒有辦法達到某些需求,要自己寫 loader 也不會太困難。例如在 BPS 當中,我有一個需求是把 Bootstrap 編譯好的 CSS 檔案透過 PurgeCSS 來樹搖掉那些我實際上沒用到的部份。當然這個我也可以在 Rsbuild 的建置鏈最後才用 PurgeCSS 去處理,但是這樣一來會把除了 Bootstrap 以外的所有 CSS(包括我自己寫的部份)都處理進去,這不僅多餘、也讓設置白名單變得更困難。因此,比較理想的解法是在套用 loader 的階段就把 Bootstrap 給處理一遍。

要寫出這樣的 loader,首先就是建立一個 .mjs 檔案 2、然後讓它預設輸出一個函數如下:

/**
 * Apply PurgeCSS to the bundled Bootstrap
 * @type {import("@rspack/core").LoaderDefinitionFunction}
 */
export default function(content, map, meta) {
    // 告訴 Rspack 這是一個 async loader,我們會自己呼叫 callback
    const callback = this.async();

    // 如果接到 Bootstrap 以外的東西就跳過不處理
    if(!this.resourcePath.match(/lib[\\/]bootstrap[\\/]bootstrap.scss$/)) {
        callback(null, content, map, meta);
        return;
    }

    // 設定這個 loader 要依賴的目錄(其中 srcDir 是字串),
    // 在開發模式中如果依賴目錄有異動、就會重新執行這個 loader。
    this.addContextDependency(srcDir);

    // 做這個 loader 真正要做的事情
    new PurgeCSS()
        .purge({
            content: compare, // 一個 glob 字串陣列,表示要比對的檔案
            css: [{ raw: content }],
            safelist: {
                // 一些已知的白名單
                standard: [
                    /backdrop/,
                    /modal-static/,
                ],
                variables: [
                    "--bs-primary",
                    /^--bs-btn-disabled/,
                    /^--bs-nav-tabs/,
                ],
            },
            variables: true,
        })
        .then(result => {
            // 呼叫 callback;第二個參數傳轉換過的字串內容或 Buffer。
            // 目前 sourcemap 還沒有辦法整合成功,
            // 所以這邊不產出 sourcemap 給下個步驟,反正沒差。
            // 後面的 meta 參數作用不明,直接照樣傳給下一個 loader。
            callback(null, result[0].css, undefined, meta);
        })
        .catch(err => callback(err)); // 有錯誤的話用第一個參數傳回去
};

寫好了之後,用類似的方法把這個 loader 掛上去即可:

export default defineConfig({
    ... // 其它設定
    tools: {
        bundlerChain: (chain, { CHAIN_ID }) => {
            ... // 其它 chain 的修改
            chain.module.rule(CHAIN_ID.RULE.SASS)
                .use("bootstrap-loader")
                // 放在這個位置,執行時就會是在 Rsbuild 做完所有預處理之後、
                // 準備要進行 CSS 打包之前。
                .after(CHAIN_ID.USE.CSS)
                .loader("./lib/bootstrap/loader.mjs"); // 自訂 loader 之路徑
        },
    },
});

啟動優化

到這邊就把各種先前用 Gulp 進行的建置工作該沿用的沿用、該遷移的遷移完成了,所以整個建置出來的站台在功能面上已經與之前無異,而再來就是我在這個系列第十七篇曾經提過的效能問題了,尤其是當時也是最難搞定的 TBT。

首先,像 Rsbuild/Rspack 這種打包工具往往都會在產物當中注入一些用來管理模組相依性的底層,雖然它們提供了如 HMR 之類的良好功能、但也同時會在執行階段的時候增加一些模組載入的 overhead。從 Chrome 開發工具產生的效能圖表中,我們往往會發現、光是「載入一個非同步模組」這樣的動作(完全還沒執行)就可以用上不可忽視的時間,大概是因為那些底層需要花力氣去解析模組所在位置的緣故吧。而且即使 Rspack 預設會做 concatenateModules(即在可能的情況下把模組扁平化,但不會像 esbuild 那樣徹底),但就結果來說模組的開銷仍舊是很顯著。不管如何,既然我們知道這邊會有一些效能代價,為了要繼續把 TBT 壓低,辦法其實很簡單就是繼續插入更多更多的斷點scheduler.yield())。在 BPS 的改版當中,我大概比起前一個版本要插入了整整三倍左右的斷點,而且經常是模組的載入跟執行之間也是插一個斷點這樣。

另外一個花了我很多時間優化的環節就是打包之後的自訂 chunk。如同我在第十七篇解釋過的,最好設法把每一個 chunk 的大小都控制在 200KiB 以內。在設定自訂 chunk 的時候,最為人熟知的工具就是 webpack bundle analyzer,而在 Rsbuild 家族之中則有更強大的 Rsdoctor 可用,它除了把 webpack bundle analyzer 整合進來之外還提供了更多的診斷、可以檢視建置出來的模組之間的關係。

要在 Rsbuild 當中設置 Rsdoctor,可以用如下的寫法:

import { RsdoctorRspackPlugin } from "@rsdoctor/rspack-plugin";

export default defineConfig({
    ... // 其它設定
    tools: {
        rspack: (_, { appendPlugins }) => {
            appendPlugins(new RsdoctorRspackPlugin({
                linter: {
                    // 這個我覺得不好用,可以關閉
                    rules: { "ecma-version-check": "off" },
                },
                supports: {
                    generateTileGraph: true, // 啟用 webpack bundle analyzer 功能
                },
            }));
        },
    },
});

如此一來在建置的時候就會開啟 Rsdoctor 的伺服器,打開來點選右上角的「Bundle Size」然後按左邊的「Bundle Analyzer Graph」就可以看到打包出來的 chunk 各自包含了些什麼模組:

file

如圖所示,最後的 BPS 即使是最大的 chunk 也只有不到 150KiB,這樣就是很理想的切割。

不過要弄到這麼理想、真的也是花了不少力氣;主要是因為 Rsbuild 雖然內建了若干個 chunk 分割的策略、但那些策略老實說都不能達到高度優化的結果,唯有完全自訂分割才能達到我的要求,然而 Rsbuild 自訂 chunk 的 API 又相當低階,我近乎等於是需要手動窮舉一個套件背後所有的遞移相依套件、才能正確地讓它們被打包在同一個 chunk 當中,而這要長遠維護自然是不可能的。

於是,我自己寫了一些工具函式,來自動根據 PNPM 的 pnpm-lock.yaml 檔案盤點套件的相依性、並產生出對應的 RegExp 物件給 Rsbuild。這些工具函式我已經發佈成為 @mutsuntsai/rsbuild-utils 套件,它使用起來會類似像這樣:

import { createDescendantRegExp, makeTest } from "@mutsuntsai/rsbuild-utils";

export default defineConfig({
    ... // 其它設定
    performance: {
        chunkSplit: {
            strategy: "custom", // 指定採用自訂 chunk 策略
            splitChunks: {
                cacheGroups: {
                    // 這裡面每一個成員都是一個 chunk,成員名稱隨便取即可,
                    // 真正的 chunk 名稱是由裡面的 name 欄位決定的。
                    vue: {
                        // test 欄位指定這個 chunk 的打包條件;
                        // 我們用 createDescendantRegExp 工具函式
                        // 來收集 vue 背後的所有相依套件
                        test: createDescendantRegExp("vue"),
                        name: "vue",
                        // 這個的值可以是 initial, async 或 all,
                        // 代表打包符合條件的同步載入模組、非同步載入模組或全部
                        chunks: "all",
                    },
                    index: {
                        // makeTest 函式會把多個傳入的 RegExp 彙整成單一的判別函式
                        test: makeTest(
                            /src[\\/](app|log)\b/,
                            /idb-keyval/, /probably-china/
                        ),
                        name: "index",
                        chunks: "all",
                    },
                    i18n: {
                        // 這邊收集 vue-i18n 的相依性,
                        // 但是會跳過稍早收集 vue 相依性的時候已經被收集過的那些
                        // (亦即 createDescendantRegExp 是有副作用的)
                        test: makeTest(
                            createDescendantRegExp("vue-i18n"),
                            /locale\.ts$/, /\.json$/
                        ),
                        name: "vue-i18n",
                        chunks: "all",
                        // 如果定義出來的 chunk 對象有重疊,可以用這個控制優先度
                        priority: 1,
                    },
                    ... // 其它 chunk
                },
            },
        },
    },
});

透過類似這樣的設置,就可以充分地控制到底哪些東西要打包在同一個 chunk 當中、以充分壓低每個 chunk 的大小了。但是另外一方面,拆出太多的 chunk 也不是好事,因為那樣會徒增 HTTP 請求的次數,所以我們要列出完整的自訂清單、來確保每一個模組都有被規劃好要放在哪一個 chunk 當中。然而,即便我們這麼做,有的時候還是會發現 Rsbuild 不管怎樣就是沒有照我們的意思把模組打包在一起、而堅持要分開成不同的 chunk;有幾個原因可能導致 Rsbuild 這麼做:

  1. 有些模組被不相容的東西引用到。例如模組 A 同時被主執行緒的模組 B 與 web worker 中的模組 C 引用到,此時就不可能把 A 跟 B 或 C 兩者之一打包在一起,A 無論如何必須獨立開來。
  2. 有些模組沒有採用標準的 CJS 或 ESM 語法來寫(例如寫法比較奇怪的 UMD),Rspack 不確定要怎麼處理它。此時如果可以取得該模組的原始碼來直接建置,會比較容易成功。
  3. 同時使用了 /* webpackChunkName: "..." */ 語法跟自訂 chunk 定義:前者會干擾後者的運作,所以如果要使用後者,就不要再加上前者了。

另外,Rsbuild/Rspack 背後基於的 SWC 編譯器就目前看起來、在 tree shaking 方面的能耐比起 esbuild 還略顯遜色,例如在 Pixi.js v7 裡面有相依到一個我實際上不會使用的 url 套件、且這玩意兒背後還有一大串相依,這個之前 esbuild 當中它會正確地把這整串相依都直接去掉,可是 SWC 就不會這麼做。這有一個解決辦法就是用自己寫的 stub 模組去把它替換掉:

// url.mjs
export const url = {};

首先像這樣寫一個匯出空物件的 stub 模組,然後在 Rsbuild 設定當中做替換:

export default defineConfig({
    ... // 其它設定
    resolve: {
        alias: {
            "./url.mjs$": "(假的 url.mjs 之路徑)",
            "vue-slicksort$":
                "./node_modules/vue-slicksort/dist/vue-slicksort.esm.js",
        },
    },
});

另外注意到上面的替換中我還加上了一個給 vue-slicksort 用的,這是因為預設的情況下 vue-slicksort 在打包的時候會打包其 UMD 建置版本,而這個版本會進一步導致 Vue compiler 整個被打包了進來,沒有必要。指定打包 ESM 建置版本就不會有此問題了。

編譯獨立的 JavaScript 檔案

這個需求在 BPS 當中雖然沒有,但是我在把我的另外一個專案 FEN Tools 遷移到 Rsbuild 上的時候就有遇到。在該專案當中,它除了網站本身之外,還有提供一個 sdk.js 檔案、可以讓別人放到它們的網站上來發揮一些功能,而這個檔案也是從整個專案的原始碼中編譯出來的。為了要在建置過程中順便產生這樣的檔案,可以透過如下的設置做到:

export default defineConfig({
    ... // 其它設定
    source: {
        entry: {
            ... // 其它的入口點(最終各自會被編譯成網頁)
            "sdk": {
                import: "./src/app/api/sdk.ts",
                html: false, // 設定這個入口點不產生網頁
            },
        },
    },
    output: {
        filename: {
            js(pathData) {
                // 如果對應的入口點是 sdk,就把建置結果放在網站的根目錄底下、
                // 而非一般的 static/js 底下,且讓產出的檔名固定
                if(pathData.runtime == "sdk") return "../../sdk.js";
                // 否則就根據預設行為決定檔名
                return isProduction ? "[name].[contenthash:8].js" : "[name].js";
            }
        },
    },
});

結語

Rsbuild 在正確的設置之下,幾乎可以把過去所有的自訂都取代掉,除了少數幾個未能解決的問題之外:

  1. Vue SSG 暫時還是只能用原本的 Gulp + esbuild 來處理,從而沒辦法去掉 esbuild 的相依性。
  2. Vue 沒有辦法設定在開發模式的時候也忽略掉 template 當中的 HTML 註解,這會導致我事先編譯的 SSG 出現 hydration mismatch 的問題。暫時我的解法也只能避免在 template 當中使用 HTML 註解。
  3. SWC 在進行 property mangle(參見第八篇)的時候會有一些 bug,這個暫時無法解決,只能先不使用此機制。

但若先接受這些不完美之處,Rsbuild 整體帶來的開發優勢還是遠大於過去的作法。真的對這個工具越來越感到滿意了。

12.20 更新

其實昨天就在我寫這篇文章的同時,Rspack 團隊出了一個很大的包:他們團隊中有人的 NPM token 被惡意人士盜用,發布了 @rspack/core 的 v1.1.7 版,該版本會在安裝或更新之後自動執行一個 postinstall 腳本。雖然他們在大約一個小時中就緊急發布修正版,但是如果在那端時間剛好有執行套件更新的人就可能會受影響:如果是來自中國、香港、俄羅斯、白俄羅斯或伊朗的 Linux 使用者(其它對象則無事),就會被該腳本在系統中埋入一個挖礦程式、並且可能會被盜取一些雲端服務的憑證(詳細可參見這邊)。雖然我不在這次攻擊的對象之中,但我確實也剛好在那段時間之中安裝過 1.1.7 版,想到這樣的漏洞理論上有可能導致更嚴重的攻擊,不禁還是覺得毛骨悚然。希望他們能充分記取這次的教訓、更加嚴格把關發布機制,也希望整個 NPM 生態能對這類攻擊的可能性更加強整體的安全措施。


  1. 跟同名套件純屬雷同。 

  2. 似乎只有這樣才能在裡面使用 ES 模組的語法,其它副檔名例如 .ts 之類的都不行(如果整個專案有設定 "type": "module" 的話也許可以,但 BPS 專案本質上與該設定不相容,所以我沒有嘗試)。 


分享此頁至:
最後修改日期: 2025/01/08

留言

撰寫回覆或留言

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