我暫時中斷了這個系列文章,開啟了一段支線任務——起因只是我對 Angular 打包體積(Bundle Size)以前有多大的一段模糊記憶。
當我在本系列的 ng-firebase-tts 與 ng-firebase-image-analyzer 專案中修改程式碼時,我注意到 main.js 與初始打包總體積看起來比我印象中還要大。我在 Antigravity CLI 中詢問 Gemini 4 為什麼打包體積變大了,Gemini 4 找出了根本原因,我們也順利修復了它。然而改了幾處程式碼之後,同樣的情況又發生了。接著又發生了一次。
在重複進行了太多次完全相同的打包除錯對話後,我終於忍無可忍:「我受夠了——我們來打造一個技能(Skill)拯救我的理智吧。」
於是我暫停撰寫系列文章,請 /learn 與 Antigravity CLI 將我們重複的除錯過程轉化為可重複使用的技能:angular-bundle-guard。它的 Node.js 靜態分析程式碼我一行都沒有親手寫過。每當我們在 ng-firebase-tts 或 ng-firebase-image-analyzer 中發現導致 main.js 或延遲載入區塊(Lazy Chunk)膨脹的新原因——或者找到削減元件 CSS 位元組的新方法——我們就將它回饋給這個技能,直到它演進為完整的 JavaScript 與 Tailwind CSS v4 打包稽核工具。
就連那段「對先前打包體積的模糊記憶」也被寫進了 SKILL.md 中,其中判定基準建置(Baseline Build)體積的 Source A 字面上就是 User Memory / Target(之後才會退而求其次,使用同次工作階段稍早的建置結果,或是針對 HEAD 建立暫時的 git worktree)。
我們並沒有在一開始就設計好這套完整架構。每項檢查都是當我們在 ng-firebase-tts 與 ng-firebase-image-analyzer 遇到實際的打包問題時,透過 /learn 自然地逐步加入,最終成長為完全零外部相依性(僅使用 node:fs、node:path、node:crypto、node:os、node:child_process)的單次掃描分析器(analyze-bundle.mjs)。
flowchart TD
Trigger["User Memory or Git Baseline"] --> Build["analyze-bundle.mjs (Smart stats.json Check)"]
Build --> Stage1["Stage 1: Initial Bundle and Lazy JS Audit"]
Build --> Stage2["Stage 2: Component CSS and Tailwind v4 Audit"]
Stage1 --> V8["8 Initial Bundle Leak Vectors (main.js)"]
Stage1 --> L3["3 Lazy Chunk Leak Vectors (chunk-*.js)"]
Stage2 --> C1["1. Dead CSS and Unused @theme Tokens"]
Stage2 --> C2["2. Short Static @apply (<= 3 Utilities to HTML)"]
Stage2 --> C3["3. Partial Static Splits (Free HTML Slots + Variant Pairs)"]
Stage2 --> C4["4. Duplicate @apply and @keyframes Consolidation"]
V8 & L3 & C1 & C2 & C3 & C4 --> Report["HTML Report: Component CSS Files > 1 KB Table"]
Report --> Refactor["Incremental Byte Shaving: [class] Bindings, focus:outline-*, Trim Typography, Spacing, and Duration"]
main.js 與延遲載入區塊洩漏早期在 Antigravity CLI 中與 Gemini 4 對話時,目標非常單純:找出為什麼某次程式碼變更會導致 main.js 或延遲載入區塊變大或變慢。其中三個模式特別讓我確信,我們同時需要自動化防護機制與專屬技能。
core/<domain> 中混合桶子檔案(Barrel File index.ts)的陷阱在 core/<domain> 下建置服務(Service)與工具函式(Utility Function)時,常見的做法是從單一 core/<domain>/index.ts 桶子檔案重新匯出所有內容——包含服務、工具函式、常數、型別與介面——以保持匯入語句簡潔。
然而,我們發現混合桶子檔案會透過兩種令人頭痛的方式將程式碼洩漏進 main.js:
injectAsync(): 當元件使用 injectAsync()(我們的非同步服務注入輔助函式)從 core/<domain> 延遲載入大型服務,但 main.js 中的某個即時載入(Eager)檔案卻從同一個 core/<domain> 桶子檔案靜態匯入了某個常數或輔助函式時,打包工具會評估整個桶子檔案,並將原本應該延遲載入的服務直接拉進 main.js——使 injectAsync() 產生的區塊退化成一個微小且毫無作用的包裝函式!injectAsync(),當即時載入檔案從桶子檔案匯入一個小型工具函式時,打包工具也會把該桶子檔案中重新匯出的所有服務一併拉進初始打包中。這促成了我們的嚴格經驗法則,現在由 angular-bundle-guard 作為 Vector 1 強制執行:
[!IMPORTANT]
桶子檔案經驗法則: 僅從桶子檔案index.ts匯出輕量項目(型別、介面與常數)。務必直接從個別檔案路徑匯入工具函式與服務。
// AVOID in src/app/core/ai/index.ts: Re-exporting services and utilities in a barrel
export * from './ai.type';
export * from './ai.constant';
export * from './ai.util';
export * from './ai.service'; // Leaks into main.js when an eager file imports from '@/core/ai'!
// GOOD in src/app/core/ai/index.ts: Only export types, interfaces, and constants
export type * from './ai.type';
export type * from './ai.interface';
export * from './ai.constant';
// Import utilities and services directly from their file paths:
import { formatPrompt } from '@/core/ai/ai.util';
// And at the class level in a component:
private readonly aiService = injectAsync(() =>
import('@/core/ai/ai.service').then((m) => m.AiService),
);
import type 宣告與 ESLint 自動化修復另一個隱蔽的元凶是使用標準的值匯入(import { SomeModel } from '...')而非 import type 來匯入介面或型別。當 TypeScript 編譯器未能完全消除它們時,值匯入會留下執行階段的模組相依邊界(Module Edges),進而把非預期的檔案拉進打包結果中。
追查那次型別匯入錯誤讓我發現了 @typescript-eslint/consistent-type-imports。我將這條規則加入 eslint.config.mjs:
// eslint.config.mjs
rules: {
'@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }],
}
接著我在 package.json 中設定了 lint:fix 指令碼:
{
"scripts": {
"lint:fix": "eslint \"src/**/*.{ts,js,mjs}\" --fix"
}
}
現在,執行 npm run lint:fix 會立即將型別的值匯入轉換為 import type,而 angular-bundle-guard 則會在打包稽核期間再次檢查是否仍有遺留自 .interface.ts 或 .type.ts 檔案的值匯入。
@defer + injectAsync() 與循序 await import())一旦我們開始將程式碼從 main.js 拆分出去,我們就遇到了新的問題:循序網路呼叫(Sequential Network Calls),也就是某個延遲載入下載必須等待另一個下載完成後才能開始。透過 /learn,我們教導 angular-bundle-guard 在 Lazy Vector 2(循序延遲載入區塊下載) 中捕捉這兩種模式。
@defer 元件內部使用 injectAsync()我們使用 @defer 將非關鍵元件從 main.js 與初始打包中分離出來,讓瀏覽器依需求下載它們的 JavaScript 區塊。
使用 injectAsync() 時,您會在類別層級(Class Level)宣告該輔助函式(以捕捉 Angular 的注入脈絡),接著在方法內部透過 await this.featureService() 呼叫它。然而,在 @defer 元件內部這麼做會觸發兩次循序網路呼叫:首先下載 @defer 元件區塊,接著當 await this.featureService() 執行時又發起一次額外的網路呼叫。
由於延遲載入元件已經與 main.js 及初始打包分離,使用標準的 inject() 函式預先載入其服務會將該服務一併打包進 @defer 區塊中,省去那次額外的網路往返(Network Round Trip):
// AVOID inside a @defer component: Leads to an extra sequential network call!
export class DeferredFeatureComponent {
private readonly featureService = injectAsync(() =>
import('@/core/feature/feature.service').then((m) => m.FeatureService),
);
async runAction() {
// Extra network round trip after the @defer component chunk is already downloaded
const service = await this.featureService();
return service.execute();
}
}
// GOOD inside a @defer component: Already separated from main.js—load the service upfront!
export class DeferredFeatureComponent {
// Bundled with the @defer chunk; saves the extra network round trip
private readonly featureService = inject(FeatureService);
runAction() {
return this.featureService.execute();
}
}
import() 呼叫另一次循序下載發生在 loadFirebase() 中,我們先後等待了兩個彼此獨立的 Firebase 匯入——firebase/app 與 firebase/remote-config。由於這兩個模組下載彼此並不相依,循序等待它們會迫使第二個區塊必須等待第一個區塊下載完成後才能開始下載。
使用 await Promise.all() 可以同時並行下載這兩個 Firebase 區塊:
// AVOID: Awaiting two independent Firebase imports one after another
private async loadFirebase(): Promise<void> {
try {
const { initializeApp } = await import('firebase/app');
const remoteConfigSdk = await import('firebase/remote-config');
// ... other initialization code ...
} catch (error) {
// ... error cleanup ...
}
}
// GOOD: Download independent Firebase imports concurrently with await Promise.all()
private async loadFirebase(): Promise<void> {
try {
const [{ initializeApp }, remoteConfigSdk] = await Promise.all([
import('firebase/app'),
import('firebase/remote-config'),
]);
// ... other initialization code ...
} catch (error) {
// ... error cleanup ...
}
}
隨著我們持續與 Gemini 4 一起除錯各種打包體積退化並執行 /learn,這個技能擴充到能稽核 8 項初始打包洩漏向量(例如混合桶子檔案、遺漏 import type、頂層匯入大型 SDK、破壞 @defer 的 viewChild(ComponentClass)、app.routes.ts 中的即時載入路由,以及靜態 JSON 匯入)外加 3 項延遲載入區塊向量(臃腫的延遲載入區塊、循序延遲下載,以及跨延遲載入區塊的共用桶子檔案洩漏)。
當 JavaScript 匯入洩漏受到控制後,這個技能演進出一個我原本沒預料到的方向:獵捕隱藏在 JavaScript 區塊內部的 CSS 膨脹。
在 Angular 預設的 ViewEncapsulation.Emulated 模式下:
src/styles.css 會保留在 .css 檔案中(在 main.js 中佔 0 B)。
styleUrl 的元件都會將其 CSS 規則編譯為 JavaScript 字串常值(styles: ["..."]),直接嵌入 main.js 或其延遲載入的 .js 區塊內部。
更糟的是,我們的專案使用了 Tailwind CSS v4。每當元件的 .css 檔案在 @apply 中使用了依賴 CSS 自訂屬性(Custom Properties)的工具類別——例如 border、ring、shadow、font-sans、leading-*、tracking-*、bg-linear-*、translate、scale、animate-* 或 duration-*——Tailwind CSS v4 就會將約 2.5 到 3.2 kB 的 @property --tw-* 與 @supports 定義注入該元件編譯後的 JS 區塊中:
/* Inside a component .css file */
.card-wrapper {
@apply flex flex-col gap-4 rounded-xl border p-6 shadow-sm;
}
// What actually gets embedded inside your Angular lazy chunk-*.js (simplified):
styles: [".card-wrapper[_ngcontent-%COMP%]{display:flex;flex-direction:column;...}@property --tw-border-style{syntax:'*';inherits:false;initial-value:solid}@property --tw-shadow{syntax:'*';inherits:false;initial-value:0 0 #0000}... /* ~3 kB of @property rules per component! */"]
看到僅有幾行 @apply CSS 的元件,卻在各個延遲載入區塊中重複夾帶約 3 kB 的 @property 樣板程式碼,實在令人大開眼界——這也成為我們技能的下一個目標。
由於常見的工具類別(flex、flex-col、gap-4、w-full)已經存在於全域 styles.css 檔案中,將靜態工具類別從元件的 .css 檔案移至其 .html 範本的 class="..." 中,對全域 CSS 打包僅增加 0 B,同時還能縮減 JS 區塊體積——而且如果您移除某個元件中所有觸發 @apply 的工具類別,該元件整塊約 3 kB 的 @property 定義就會完全消失!
為了在最大化打包體積縮減的同時保持 HTML 範本的可讀性,/learn 教導 angular-bundle-guard 以每個 HTML 元素 <= 3 個類別作為上限,稽核 4 大 CSS 最佳化類別。選擇 3 是個人偏好:< 3 太過嚴格而不切實際,> 3 則會開始覺得太多類別擠在 HTML 中。(話雖如此,當工具類別非常直觀,且將第四個類別內嵌進 HTML 能讓我刪除該條 .css 規則以縮減打包體積時,我有時也會手動放寬到 4 個類別!)
經過多輪元件修改後,無用的 .class 選擇器、未使用的 @keyframes 以及未使用的 --* 自訂屬性往往會殘留在 .css 檔案中。該技能會掃描同層級的 .html 與 .ts 檔案,標記出仍會被編譯進 JS 區塊的無用類別。
@apply 類別(<= 3 個工具類別 -> 完整移至 HTML)如果某個元件 .css 類別僅封裝了 1–3 個靜態 Tailwind 工具類別,且在 .html 中替換它之後元素的總類別數仍維持在 <= 3((currentHtmlClassCount - 1) + utilities.length <= 3),技能就會建議將這些工具類別移至 .html 並刪除該條 .css 規則(而當某條規則有 4 個直觀的工具類別時,我偶爾也會將 4 個全部內嵌,以消除該 .css 類別):
<!-- Before: .results-wrapper in component.css had `@apply flex flex-col gap-6;` -->
<section class="results-wrapper">...</section>
<!-- After: 3 classes in HTML, 0 bytes in the component JS chunk! -->
<section class="flex flex-col gap-6">...</section>
當某條規則包含超過 3 個工具類別時,技能會計算 HTML 元素上還剩餘多少個可用類別位置(freeHtmlSlots = 3 - currentHtmlClassCount),並建議將獨立的工具類別(flex、w-full、relative)移至 .html,同時保留原本的類別名稱。
[!TIP]
透過/learn學到的權重(Specificity)防護機制: 技能始終會將成對的x:*變體保留在同一個樣式層中(例如grid-cols-1+md:grid-cols-2,或border+focus-visible:border-2),確保.css中 Angular 的[_ngcontent]選擇器永遠不會覆寫掉.html中的響應式或焦點狀態工具類別。
@apply 與 @keyframes在稽核樣式表的過程中,我們也發現元件 .css 檔案中藏有重複的 @apply 規則與多餘的 @keyframes 動畫,並教導技能自動捕捉它們:
@apply 選擇器(.a 與 .b)在某個頁首元件中,我們有兩個獨立的 CSS 類別——假設為 .a 與 .b——各自套用了相同的約 10 個工具類別:
/* BEFORE: Two selectors in the same .css file with identical @apply utilities */
.a {
@apply inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors;
}
.b {
@apply inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors;
}
實在沒有理由將這兩個選擇器都編譯進 JS 區塊中。現在技能會標記出同一檔案中完全相同的規則,讓您可以從 .css 中刪除 .b,並在 .html 中統一使用 .a。
styles.css 與元件 CSS 重複定義的 @keyframes fadeIn當我在 ng-firebase-image-analyzer 中搜尋 @keyframes 時,我發現 @keyframes fadeIn 定義在 src/styles.css 中,同時又在某個元件的 .css 檔案裡重複定義了一次——兩者僅在 translateY() 的數值上有微小差異。當我詢問 Gemini 4 時,它建議刪除元件中的 @keyframes fadeIn 副本,改用 src/styles.css 中的全域 animate-fade-in 工具類別。我們將這個經驗回饋給 /learn,因此現在 angular-bundle-guard 會掃描 src/styles.css 中的全域 @utility 動畫,並提示您將元件層級重複的 @keyframes 替換為全域工具類別。
animate-spin 衝突的元件 @keyframes spin更棒的是,有一個元件 .css 檔案竟然從頭定義了自己的 @keyframes spin——但 Tailwind CSS 本來就已經內建了 animate-spin!我們從該元件的 .css 檔案中刪除了 @keyframes spin,將範本改用 animate-spin,並教導技能標記出任何與 Tailwind 內建動畫(spin、pulse、ping、bounce)衝突的元件 @keyframes。
在產生的 HTML 報告中,位於 4 大最佳化類別正上方有一個可摺疊的表格,標題為 2. Component CSS Files > 1 KB (2, +11 <= 1 KB)。它會依編譯後的位元組大小由大到小列出「肥大」的元件 .css 檔案,顯示:
| 來源 CSS 檔案 | 編譯後大小 | Tailwind CSS v4 @property 額外開銷 |
觸發的 @apply 類別 |
|---|---|---|---|
src/app/.../some.component.css:14 |
4.82 kB (4,936 B) |
3.05 kB (63%) — duplicate @property --tw-* boilerplate |
border (L14,28), focus-visible:ring-2 (L19), tracking-wide (L24), leading-relaxed (L35) |
當某個元件 .css 檔案出現在這個表格頂端時,我們並不總是能將它縮減到 0 kB——複雜的元件仍然需要自訂樣式。相反地,我會根據最新的 HTML 報告詢問 Gemini 4 如何最佳化個別元件。這些檢視揭露了幾項微幅最佳化技巧,雖然不會讓樣式表直接降為 0 kB,卻能在各個元件與 styles.css 之間東省西省幾個位元組:
focus:ring-* 改為 focus:outline-*當我詢問 Gemini 4 如何最佳化 HTML 報告中某個肥大的 .css 檔案時,它建議將 focus:ring-* 改寫為 focus:outline-*(並將 focus-visible:ring-* 改寫為 focus-visible:outline-*)。
為什麼呢?在 Tailwind CSS 中,ring-* 工具類別會編譯為多變數的 box-shadow 組合(--tw-ring-inset、--tw-ring-offset-width、--tw-ring-offset-color、--tw-ring-color、--tw-ring-shadow)。將 focus:ring-* / focus-visible:ring-* 改寫為 focus:outline-* / focus-visible:outline-* 會改用原生 CSS 的 outline 屬性,讓 Tailwind 注入編譯後 CSS 的宣告與變數更少:
/* BEFORE: Injects multi-variable --tw-ring-* / --tw-shadow-* declarations */
.action-input {
@apply rounded-md border px-3 py-2 focus-visible:ring-2 focus-visible:ring-primary;
}
/* AFTER: Uses native CSS outline—shaving injected bytes from the compiled rule */
.action-input {
@apply rounded-md border px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary;
}
[class] 繫結在檢視大型 .css 檔案(例如 ng-firebase-image-analyzer 中的圖片拖放區)時,我也會尋找那些僅為了切換 UI 狀態而存在的類別——例如是否正在將檔案拖曳到拖放區上方。透過將這些條件式工具類別移至 .html 中的 [class]="..." 繫結,我就能從 .css 中刪除額外的狀態選擇器,縮減樣式表體積:
<!-- Conditional utilities moved to [class] in component.html (e.g., dragging a file or not) so state classes can be deleted from .css -->
<div
class="dropzone"
[class]="isDragging() ? 'border-primary bg-primary/10' : 'border-outline-variant bg-surface-container'"
>
...
</div>
tracking-*、leading-*、uppercase、預設值(font-sans / text-base)、響應式間距與 duration-*檢視這些肥大的 .css 檔案也讓我們發現幾個可以簡化或移除的字體排印、間距與轉場(Transition)工具類別:
tracking-*、leading-* 與 uppercase: 額外的字元間距(tracking-*)在視覺上對我來說並不重要,而且 tracking-* 與 leading-* 在 @apply 內部都會觸發 Tailwind CSS v4 的 @property 額外開銷。配合直接在 .html 中將靜態徽章或標題文字改為 ALL CAPS 大寫以刪除 uppercase,移除 tracking-*、leading-* 與 uppercase 縮減了我們的元件 .css 檔案,同時也從全域 styles.css 中移除了那些未使用的工具類別定義。font-sans 與 text-base: 由於應用程式的預設字型系列與字型大小本來就已經是 font-sans 與 text-base,我們刪除了多餘的 font-sans(另一個會在 @apply 中觸發 @property 的類別)與 text-base 宣告。sm:、md: 與 lg: 的外距(Margin)與內距(Padding): 在響應式內距或外距(sm:p-*、md:p-*、lg:m-*)可以在不同螢幕尺寸間使用相同間距數值的地方,將它們統一為單一間距類別即可消除額外的 @media 規則。styles.css 中設定預設 200ms 轉場時間並刪除 duration-*: 看到各個元件中散落著不同的 duration-* 工具類別後,我在 src/styles.css 中設定了 --default-transition-duration: 200ms,並從 .html 與 .css 檔案中移除了 duration-*。/* In src/styles.css: Set default transition duration to 200ms globally */
@theme {
--default-transition-duration: 200ms;
}
為了確保這些重構不會破壞既有行為,/learn 在 angular-bundle-guard 中編入了三道嚴格的防護機制:
[class] 繫結保護(isDynamicallyBoundInHtml): 一旦某個類別透過 [class.foo]、[class] 或 [ngClass] 進行動態繫結,技能就會自動將它排除在靜態內嵌建議之外。*.spec.ts)選擇器警告: 如果單元測試透過 By.css('.some-class') 或 querySelector('.some-class') 查詢某個類別,HTML 報告會顯示 ⚠️ Test Queries Selector 徽章以及確切的 .spec.ts 行號,避免您在刪除 .css 類別時弄壞測試。.css 規則是刻意覆寫定義在 src/styles.css 中的全域類別(例如 sign-in-modal.component.css 中的 .error-block),技能會將它原封不動地保留在 .css 中。個別來看,這些修改每次只省下了幾個位元組或幾 kB。但當它們在 ng-firebase-tts 與 ng-firebase-image-analyzer 中累積起來——從刪除無用類別與重複的 @apply/@keyframes 規則,到內嵌靜態工具類別、將狀態類別移至 [class],以及精簡 focus:ring-*、字體排印、響應式間距與 duration-* 工具類別——它們顯著縮減了 main.js、我們的延遲載入區塊以及 styles.css 的體積。
angular-bundle-guard最初只是一時挫折下的念頭——「我受夠了——我們來打造一個技能拯救我的理智吧」——最終卻成為我 Angular 工作流程中最實用的工具之一,而且完全是透過在 Antigravity CLI 中與 Gemini 4 及 /learn 結對開發而成的。(事實上,打造這個技能的過程非常順利,我現在正打算再打造另一個技能,把這些部落格文章集結成書!)
您可以將 angular-bundle-guard 安裝為 Antigravity CLI 外掛程式,或透過 skills CLI 安裝:
# Option 1: Install as an Antigravity CLI (agy) plugin
agy plugin install https://github.com/railsstudent/angular-bundle-guard
# Option 2: Install via npx skills
npx skills add https://github.com/railsstudent/angular-bundle-guard
或者直接從任何 Angular 專案的根目錄執行這個零相依性的分析器:
node <skill-dir>/scripts/analyze-bundle.mjs
ng-firebase-tts 與 ng-firebase-image-analyzer