Single Source of Truth:值的住址只能有一處
核心原則
同一個值的權威來源只能有一個位置。 「位置」可以是 CSS selector、可以是定義機制、可以是函式 — 重點是讀者能明確指認「這個值的真相在哪」。多個來源會在時間維度上分歧、漏改、debug 時不知道哪個生效。SSoT 不是潔癖、是維護性的物理基礎。
為什麼多源會 drift
時間維度的失敗模式
寫程式的當下、多源沒問題 — 兩個值剛寫進去都是 64px、看起來一致。問題出在後續:
| 時間點 | 一源情境 | 多源情境 |
|---|---|---|
| 第 1 次寫 | 寫一處 | 寫多處(手動同步) |
| 第 2 個月、需求變動要改值 | 改一處、所有引用點自動跟上 | 改多處、可能漏掉 |
| 第 6 個月、新人接手 | 看一處就知道 | 不知道哪個是「真的」 |
| 第 1 年、不同人改不同源 | — | 多源開始分歧、產生隱性 bug |
多源的隱形成本在時間維度累積。寫的當下看不出問題、是因為當下還沒分歧。
寫程式時的觀察盲點
寫多源的人通常不是不知道 SSoT 原則、是沒意識到自己在寫多源。常見盲點:
| 盲點 | 看起來像 | 實際上是 |
|---|---|---|
| 「複製過來改一下、改完就同步了」 | 兩處數值同步 | 多源的開始 — 之後改哪一處不一定 |
| 「這個常數兩個檔案都會用、各自宣告比較清楚」 | 各檔自包含 | 多源 — 改的時候要 grep 找全 |
| 「寫死 + 量測雙保險、哪個對用哪個」 | 防禦設計 | 多源 — 不知道哪個生效 |
| 「先寫一個、之後重構抽出 token」 | 漸進式 | 多源固化 — 通常沒有後續重構 |
辨識自己寫多源、需要主動的 sanity check:「這個值的真相在哪?只有一個答案嗎?」
Fact vs Derivation 的區分
SSoT 適用對象是值、但不是所有「值」都該 SSoT — 區分兩種:
| 類型 | 定義 | SSoT 規則 |
|---|---|---|
| Fact(事實值) | 設計決定、不能從別處算出 | 只能在一處宣告 |
| Derivation(導出值) | 從 fact 計算得出 | 完全用 fact 計算、不重複宣告 fact |
例子(CSS):
1/* Fact — 設計決定 */
2:root {
3 --search-title-h: 64px; /* H1 高度是設計選擇 */
4 --search-form-h: 68px; /* form 高度是設計選擇 */
5 --search-gap: 20px; /* 間距是設計選擇 */
6}
7
8/* Derivation — 從 fact 計算 */
9.scope-position {
10 top: calc(var(--search-title-h) + var(--search-form-h) + var(--search-gap));
11 /* 不該寫 152px、那是 derivation 重複了 facts */
12}把 derivation 寫成具體值(152px)= 把 fact 在 derivation 裡再宣告一次 = 多源。
區分 fact / derivation 的判讀問題
寫一個值時自問:「這個值是設計選擇、還是從別的值算出來的?」
- 設計選擇 → fact、找住址定義
- 從別的值算出來 → derivation、用 calc / function 表達、不寫具體數字
混淆兩者會固化多源 — 把 derivation 寫成數字、未來 fact 改了 derivation 不會跟著改。
三類 SSoT 違反
跨 #3 / #26 / #27 的三類具體表現:
違反 1:定義位置散落(住址多源)
同一個值在多個 selector 重複定義:
1/* 散在三處 */
2:root { --search-title-h: 64px; }
3body.page-search { --search-form-h: 68px; }
4.search-shell { --search-gap: 20px; }不是錯、但維護者要 grep 才知道哪些值在哪、改的時候容易漏掉某處。
解法:定義集中在「跟使用範圍最匹配的最高層 selector」、其他地方只 var() 引用。
對應實作:#26 CSS 變數定義位置統一。
違反 2:來源機制混搭(機制多源)
同一個值有多種「取得方式」並存:
1// 對齊基準上四個值、其中三個寫死、一個量測
2--search-title-h: 64px; // 寫死
3--search-form-h: 68px; // 寫死
4--search-gap: 20px; // 寫死
5--search-scope-h: ResizeObserver; // runtime 量測寫回
寫死值依賴的渲染條件變了(字型、theme、scale)— 量測值會跟著變、寫死值不會、兩者錯位。
解法:選一邊走到底。內容靜態 → 全寫死;內容動態 → 全量測。混搭就是多源。
對應實作:#27 runtime 量測模式統一。
違反 3:對齊基準的真相分散(語意多源)
對齊問題本質是「方程組」 — 每個參與對齊的值都是基準的一個分量。任何一個分量值不確定、整個基準都靠不住。
1問題:filter padding-top 對不準 H1 + form 下緣
2分量:H1 (64px) + form (68px) + gap (20px) = 152px
3若任一分量是「我估的」、整個 152px 就不可信解法:每個分量都要有明確的「真相位置」 — 寫死的 token 或 ResizeObserver 量測寫回變數、二選一。沒有「估算」這個選項。
對應實作:#3 視覺對齊用單一真實來源。
設計工具
1. 定義集中、引用分散
1body.page-search {
2 /* 集中定義 */
3 --search-title-h: 64px;
4 --search-form-h: 68px;
5}
6.search-shell .pagefind-ui__drawer {
7 /* 分散引用 */
8 margin-top: calc(var(--search-title-h) + var(--search-form-h));
9}「集中定義」= fact 一個住址;「分散引用」= derivation 不重新宣告 fact。
2. 命名前綴標明範圍
1--token-* /* 全站 design token */
2--page-search-* /* 搜尋頁專用 */
3--pagefind-ui-* /* 組件 hook */前綴讓維護者一眼看出值的「歸屬」 — 改的時候知道影響範圍、不會誤改別處。
3. JS 寫入跟 CSS 定義同 selector
1body.page-search {
2 --search-scope-h: 60px; /* fallback */
3}1document.body.style.setProperty('--search-scope-h', h + 'px');
2// 寫到 body.style、跟 CSS 定義同 selector、cascade 一致
JS 寫入位置跟 CSS fallback 在同一 selector — 兩套機制保持一致來源。
4. 用 calc 表達 derivation、不寫具體數字
1/* 好 — derivation 用 calc */
2.scope-position { top: calc(var(--a) + var(--b)); }
3
4/* 較差 — derivation 寫具體數字 */
5.scope-position { top: 152px; } /* 152 是 a + b 算出來的、現在固化在這裡 */calc 把 derivation 顯式表達 — 未來 fact 改了、derivation 自動跟上。
不該套用 SSoT 的情境
跟其他原則一樣、SSoT 也有適用邊界:
| 情境 | 為什麼可以多源 |
|---|---|
| 跨系統的紀錄(DB + cache) | 多源是效能 / 可用性的設計、有顯式同步機制 |
| 跨服務的 reference data | 微服務各自存一份是常態、有 eventual consistency |
| 國際化字串(en + zh-TW) | 各語言版本是「不同 fact」、不是同一 fact 的多源 |
| 開發 / production 環境的設定 | 各環境是「不同 fact」、不是同源 drift |
核心判準:多源是不是「同一 fact 的多份拷貝」?是 → 違反 SSoT;不是 → 各自是獨立 fact、不違反。
跟其他抽象原則的關係
#43 最小必要範圍 處理「範圍越窄越穩定」、本原則處理「值的住址越唯一越穩定」。兩者方向不同、目的相同 — 都是讓行為可預測:
- 最小必要範圍:縮影響範圍、避免誤命中
- SSoT:縮值的來源數、避免分歧
兩者經常同時出現:縮 selector 範圍時、selector 本身的定義也該 SSoT(避免散在多處)。
對應的實作篇
每篇示範這個原則在不同議題的應用:
| 篇 | SSoT 違反類型 | 修正方向 |
|---|---|---|
| #3 視覺對齊用單一真實來源 | 對齊基準分量值來源不明 | 每分量都要有明確住址 |
| #26 CSS 變數定義位置統一 | 變數定義散多 selector | 集中在使用範圍的最高層 |
| #27 runtime 量測模式統一 | 寫死跟量測混搭 | 選一邊走到底 |
讀的時候從本篇出發、依議題挑實作篇。
判讀徵兆
| 訊號 | 自問 | 回應 |
|---|---|---|
| 「改一個 token 要 grep 找定義位置」 | 定義是否散落? | 是 → 集中到一處 |
| 「不知道哪個值生效」 | 來源是否多源? | 是 → 找出多源、保留一個權威來源 |
| 「我估的值跟實際差 2px」 | 該值是否該量測或從 fact 算? | 是 → 補真相位置、不用估算 |
| 「兩處數值看起來該一致、實際分歧了」 | 是否多源 drift? | 是 → 抽出共同 fact、各處引用 |
| 「複製這個值過來改一下」 | 寫多源前自問? | 警訊 → 抽 token、不要複製 |
核心原則:SSoT 守的是「未來改值時、知道改哪裡」 — 寫的時候多想一秒、未來改的時候少痛半天。多源是時間維度的隱形成本、寫程式當下看不出來、是因為時間還沒到。
延伸到 stream 操作:#64 Feature 操作要跟 Source 同層合成 是本原則在 stream 領域的應用 — 在下游做 filter / sort = 等於建了個第二定義(subset 上的「filter 結果」)跟 stream 全集競爭、是另一種形式的 SSoT 違反。多源便利(就地寫個值)、單源對齊(找 fact 位置)— 這個反相關的更高層原則見 #67 寫作便利度跟意圖對齊反相關。
延伸到 UI state:#70 URL 是 stateful UI 的儲存層 是本原則在「可分享 state」的應用 — URL 是該類 state 的 SSOT、不寫 URL = state 多源(in-memory + 使用者期望的 URL 但實際不存在)。