核心原則

客製外部組件的穩定性與「離組件作者保證的對外介面多遠」成反比。 客製貼著介面做、跟組件作者站在同一邊、組件升級時客製不會打到;客製挖到組件內部實作、跟作者每個版本對抗、依賴前提隨時可能崩潰。理解四層的代價差異、是「跟外部組件合作」這件事的工程基礎。


為什麼有「層次」這個概念

組件 = 對外契約 + 內部實作

任何外部組件可以分成兩部分:

部分內容作者保證
對外契約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」兩個情境。