跟外部組件合作的層次:離介面越近、合作越穩
核心原則
客製外部組件的穩定性與「離組件作者保證的對外介面多遠」成反比。 客製貼著介面做、跟組件作者站在同一邊、組件升級時客製不會打到;客製挖到組件內部實作、跟作者每個版本對抗、依賴前提隨時可能崩潰。理解四層的代價差異、是「跟外部組件合作」這件事的工程基礎。
為什麼有「層次」這個概念
組件 = 對外契約 + 內部實作
任何外部組件可以分成兩部分:
| 部分 | 內容 | 作者保證 |
|---|---|---|
| 對外契約 | CLI 參數、props、CSS class hook、CSS variable hook、event 介面 | 跨版本相容(major 升級可能 break) |
| 內部實作 | 內部 DOM 結構、private function、framework hash class、自家 CSS specificity | 不保證、隨時可能變動 |
對外契約是「跟使用者的合約」、內部實作是「達成契約的手段」。客製貼著契約做 = 在合約範圍內、作者會維持;挖內部 = 跨越合約、作者沒義務維持。
「層次」反映依賴強度
從合約走向內部、依賴強度遞增:
1最穩 ─────────────────────────────────────────── 最不穩
2介面層 → 鄰接層 → 邊界內 DOM → 內部邏輯不是「能不能做」、是「做了之後依賴什麼會不會變」。介面層依賴公開契約(穩)、內部邏輯依賴 source code(隨時變)。
四個層次
第 1 層:介面(最穩)
內容:組件作者公開設計的客製機制。
- CLI flag(如
--root-selector) - Props / config(如
pageSize: 10) - CSS variable hook(如
--component-color) - 官方提供的 event / callback
穩定性:作者保證跨版本(minor / patch 升級不 break)。
例:Pagefind 的 --root-selector main — 用作者設計的索引邊界客製。
升級成本:通常零 — 作者改實作不影響介面。
第 2 層:鄰接(半穩)
內容:組件邊界元素的可辨識特徵 — class name、id、CSS reset 邊界。
穩定性:作者通常維持、但不像介面那麼正式。Major 版本可能改名。
例:Pagefind 的 .pagefind-ui--reset class 邊界 — 不是官方介面、但 .pagefind-ui 這個 class 名相對穩定。
升級成本:低-中 — class 改名才會打到、通常會在 release note 提及。
第 3 層:邊界內 DOM(不穩)
內容:組件內部的 DOM 結構、framework 生成的 hash class、子節點的相對位置。
穩定性:隨 framework 渲染週期 / 版本變動。
例:Pagefind 的 .svelte-yyy hash class、.pagefind-ui__drawer 在 form 內部的層級結構。
升級成本:高 — framework 升級可能立即打破。
第 4 層:內部邏輯(最不穩)
內容:組件 source code 的行為、private function、內部 state 流。
穩定性:完全不保證。
例:fork 組件改一個 function、monkey-patch 一個 private method。
升級成本:每次升級都要重新 merge / patch。
每往內一層的代價
| 層 | 依賴前提 | 升級風險 | 可逆性 | 客製成本 |
|---|---|---|---|---|
| 介面 | 公開契約 | 低 | 高 — 改參數即還原 | 低 |
| 鄰接 | 邊界元素辨識特徵 | 中 | 中 — 改 selector | 中 |
| 邊界內 DOM | 內部結構穩定 | 高 — 渲染週期可能即時打破 | 低 — 客製跟內部結構深耦合 | 高 |
| 內部邏輯 | source code | 最高 | 最低 — 重新 merge | 最高 |
四個維度全部隨層級遞增。沒有「往內推一層、某個維度反而變好」的情境 — 完全單向。
為什麼工程師仍會挖內部
知道有代價、為什麼仍會挖?通常因為:
| 動機 | 為什麼錯誤 |
|---|---|
| 「介面不夠用」 | 通常是沒找全 — 介面比想像中多 |
| 「直接挖比較快」 | 第一次快、之後每次升級慢 |
| 「現在能動就好」 | 沒考慮升級成本 |
| 「組件不會更新」 | 通常會更新、只是時機不可控 |
挖內部的真實成本在升級時顯現、寫的當下看不出來。
三類常見的「想做但成本高」情境
情境 A:覆寫組件 specificity
組件用 hash class(.x.svelte-y.svelte-y)把 specificity 拉到 30、自家 CSS 蓋不過。
第 3 層做法(不穩):寫 .x.x 雙寫、加 !important、跟組件 specificity 對抗。
跳出層級的做法:用 CSS Layers 把組件 CSS 包進 layer、自家 CSS 留 unlayered — 跳出 specificity 線性比較戰場。
對應實作:#24 CSS Layers 取代 specificity 戰。
通則:當「往內挖」會無限升級成本、找「跳出比較維度」的機制(layers / shadow DOM / portal)。
情境 B:在 framework 管的 DOM 內注入元素
想在組件內部塞一個自家 UI element — framework 重繪時清掉、需要 observer 補打、跟渲染週期競爭。
第 3 層做法(不穩):注入 + observer 補打 + 跟 framework re-render 賽跑。
留在邊界外的做法:把客製 UI 留在 framework 邊界外、用 CSS(absolute、margin spacer)控制視覺位置 — 不進入 framework 的 children list。
對應實作:#5 與 framework-managed DOM 共處。
通則:客製跟 framework 各自有 DOM 邊界、要共存就用 CSS 控制位置、不要互相侵入。
情境 C:覆寫深度成本累積
要做的覆寫需要對抗 UA + 跨瀏覽器 + framework 三層、寫 5+ 條 CSS 才完成、改善的 UX 價值小。
第 3+4 層做法(不穩):硬寫到底、最後產出脆弱客製。
事先告知 + 接受原設計的做法:開工前報告成本(需要寫多少、跨多少瀏覽器、有什麼殘留風險)、讓決策者用「成本 vs 改善價值」判斷。常常結論是「接受原設計」。
對應實作:#19 覆寫深度的成本告知。
通則:客製深度要事先評估、不要默默承擔。「接受原設計」是合理選項。
設計工具
工具 1:邊界辨識先於改動
寫客製之前、先列出組件提供的邊界:
1組件邊界清單:
2- 索引邊界:--root-selector (介面層)
3- 重置邊界:.pagefind-ui--reset class (鄰接層)
4- specificity 邊界:svelte hash class (邊界內 DOM 層)
5- 樣式 hook:CSS variables (介面層、若有)對應每個客製需求、找最外層能滿足的邊界 — 介面夠用就用介面、不夠才推到鄰接、再不夠才考慮邊界內。
工具 2:跳維度的機制
當「同層對抗」會無限升級、找「跳到不同維度」的機制:
| 同層對抗 | 跳維度的機制 |
|---|---|
| Specificity 數字戰 | CSS Layers(分組權重) |
| Framework children 競爭 | CSS 控制位置(不進 children) |
| DOM 結構深耦合 | Shadow DOM / portal(隔離) |
| 樣式覆寫戰 | CSS-in-JS scope / namespace |
跳維度通常是一次性設計成本、之後免疫於同層的累積成本。
工具 3:覆寫成本告知 protocol
開工前評估三個累積層:
| 層 | 評估問題 |
|---|---|
| UA 預設 | 跨瀏覽器有差異嗎?需要幾種 pseudo? |
| Framework specificity | 需要 layers / important / 雙寫嗎? |
| Framework 渲染週期 | 改了會被 reset 嗎?需要 observer 補打嗎? |
任一層需要對抗、把成本攤開讓使用者決定值不值得做。
何時接受原設計、不打覆寫戰
「接受原設計」常被當作放棄、實際上是評估後的合理選擇。判讀條件:
| 條件 | 接受原設計的訊號 |
|---|---|
| 改善的使用者價值低(純視覺微調) | 是 |
| 實作累積三層成本(UA + 跨瀏覽器 + framework) | 是 |
| 覆寫深度在第 3 層以上(邊界內 DOM 或內部邏輯) | 是 |
| 沒有跳維度的機制可用 | 是 |
| 寫了 5+ 條 CSS 還沒蓋過 | 是 |
四個條件中三個符合 — 強烈建議接受原設計。
不該套用「貼著邊界」的情境
這條原則有適用邊界、不是所有客製都能停在介面層:
| 情境 | 為什麼可以挖內部 |
|---|---|
| Fork 組件作為內部維護版本(不再升級上游) | 已經跟原組件分離、沒有升級成本 |
| 組件已停止維護、必須自行接手 | 上游不更新、internal 跟 external 等價 |
| 為組件作者貢獻 PR | 是改 source code、不是覆寫 |
| 學習用途 | 不在乎升級、想理解內部 |
核心判準:這個客製要跟組件升級共存嗎? 是 → 貼邊界;否 → 怎麼做都行。
跟其他抽象原則的關係
| 抽象原則 | 跟本原則的關係 |
|---|---|
| #43 最小必要範圍 | 跟外部組件合作時、客製範圍也該最小必要 — 兩者疊加 |
| #44 SSoT | 組件提供的 hook(CSS variable)是 fact 的一個來源、自家 token 應該對齊到組件 hook |
| #42 2 次門檻 | 同一個覆寫戰打第 2 次失敗 = 該換維度(從同層對抗跳到 layers) |
跟外部組件合作的設計、通常需要同時應用多條原則。
對應的實作篇
每篇示範這個原則在不同情境的應用:
| 篇 | 對應層次議題 | 焦點 |
|---|---|---|
| #1 在外部組件上加客製功能:以邊界為中心 | 邊界辨識 | 索引 / 重置 / specificity 三邊界的辨識與選擇 |
| #5 與 framework-managed DOM 共處 | 邊界內 DOM 的隔離 | 客製 UI 留邊界外、CSS 控制位置 |
| #19 覆寫深度的成本告知 | 多層覆寫的成本管理 | 開工前報成本、讓使用者決定 |
| #24 CSS Layers 取代 specificity 戰 | 跳出層級對抗 | 用 layers 跳出 specificity 線性比較 |
讀的時候從本篇出發、依情境挑實作篇。
判讀徵兆
| 訊號 | 自問 | 回應 |
|---|---|---|
| 「這個組件介面不夠用」 | 真的找全了嗎?官方 hook / config / event 都看過? | 通常沒找全 → 補找 |
「再加一條 !important 就好」 | 是不是已經在第 3 層對抗了? | 是 → 跳維度(layers) |
| 「在組件內塞個 div」 | 會不會被 framework 重繪清掉? | 是 → 留邊界外、用 CSS 定位 |
| 「為了這個小視覺改善寫 5 條 CSS」 | 改善價值 vs 成本對得起來嗎? | 否 → 接受原設計 |
| 「組件升級後客製失效」 | 客製深度是不是太深? | 是 → 重寫到淺層 |
| 「fork 組件改 function」 | 升級成本能承擔嗎? | 否 → 找介面層做法、或正式 fork 為內部版本 |
核心原則:跟外部組件合作的工程基礎是「層次認知」 — 先辨識能做的範圍、選最外層能滿足需求的位置、跳維度而非同層對抗、必要時接受原設計。「組件不能客製成這樣」常常是「該層做不到、要往外或跳維度找」、不是真的不能。
跟 #59 Filter × Source 合成策略 同構:本卡的「四層合作」跟 #59 的「五策略」都是「離 source 公共介面越近、合作越穩」— 介面層 ≈ 推進 query (A)、邊界層 ≈ 多 index (C)、邊界 DOM ≈ 自動續抓 (B)、內部結構 ≈ 接受 D / E。同個原則套用在「客製 UI vs 客製 filter」兩個情境。