跳至主要内容
💬 回報這一頁

漂移防護

問題

設計系統的規則寫在文件裡,三個月後就沒有人記得。

不是因為大家不專業,是因為趕時間的時候,違反規則永遠是比較快的選項: 硬編一個顏色比查 token 快、複製一段 <table> 比讀 DataTable 的 API 快。

三道防線,依「防漏成本」排序

同一條規則可以擋在三個不同的地方,越前面越便宜:

防線手段擋掉什麼
① 編譯層清空 Tailwind 預設色盤bg-red-500 根本產不出樣式,不必靠人抓
② lintESLint 規則繞過編譯層的逃逸路徑:bg-[#fff]、inline style 硬編色、深入內部路徑
③ 測試守衛測試結構性規則:相依邊界、token 成對、產物同步
前兩道保護「取用端」,第三道保護「規範本身」

只做測試守衛的話,規範 repo 很乾淨,但取用端照樣可以硬編顏色—— 而跨系統一致正是設計系統存在的理由。三道要一起上。

① 編譯層:讓違規寫不出來

@dooping/tokens 的 Tailwind preset 覆蓋(而非 extend)theme.colors, 所以套用 preset 之後,Tailwind 的預設色盤整個消失:

tailwind.config.js
module.exports = {
presets: [require("@dooping/tokens/tailwind-preset")],
content: ["./src/**/*.{ts,tsx}"],
};
<div className="bg-red-500" /> {/* ❌ 產不出樣式,畫面上直接看得出來 */}
<div className="bg-danger/10" /> {/* ✅ */}

保留 transparentcurrentinheritwhiteblack——它們不承載品牌語意。 真的需要額外色階,在自己的 config 用 theme.extend.colors 加回去:那是明示的例外,會出現在 diff 裡, 比默默用預設色盤好。

② lint:堵住繞路

編譯層擋不住的是繞路——bg-[#ff0000]style={{ color: '#f00' }}、 直接 import 上游內部路徑。這些用 ESLint 擋:

# 複製到你的專案(跟元件一樣走複製,不是 npm 套件)
curl -O https://raw.githubusercontent.com/kielchang/dooping-design-book/main/templates/eslint.dooping.cjs
.eslintrc.cjs
module.exports = { extends: ["./eslint.dooping.cjs"] };

規則本身很短,值得讀過再用——尤其 overrides 那段要依你的專案結構調整。

③ 把結構性規則寫成測試

以下八支都可以直接抄,但不是每一支都在這個 repo 裡跑——分清楚比較有用:

守衛在本 repo
1–4邊界/barrel/token 一致性/詞彙✅ 在跑
5禁止原始調色盤字面色建議,本 repo 目前只做了防線 ①(清空預設色盤)
6Token 副本同步不適用——本 repo 直接 alias 到產物,沒有副本
7文件掛鉤✅ 在跑(tests/doc-hooks.test.ts
8雙站部署的路徑過濾部分——本 repo 兩支 workflow 共用 concurrency group,但沒有分站

第 6 與第 8 支是寫給有獨立文件站的宿主專案的,本 repo 的架構剛好不需要。 「不適用」不等於「不重要」——它們擋的漂移在那種架構下一樣真實。

下面這些守衛不是各自獨立的規則,是同一個承諾的幾個面向: 「這個元件庫可以被原樣搬到別的專案。」 barrel 說「公開範圍到哪裡」、邊界守衛說「邊界有沒有被穿透」、版號說「你抄的是哪一版」。 少任何一件,另外兩件都會慢慢失效——分散的規則各自都很容易被「這次先例外一下」繞過, 講成一組之後,繞過任何一條都會明顯地違反同一個承諾。

1. 邊界守衛:元件不得依賴應用層

const ALLOWED_EXTERNAL = ["react", "react-dom", "@radix-ui/", "lucide-react",
"clsx", "tailwind-merge", "class-variance-authority", "@dooping/tokens"];

const FORBIDDEN = [
{ re: /from\s+["']zustand/, why: "狀態管理屬於應用層" },
{ re: /from\s+["']react-router/, why: "路由屬於應用層" },
{ re: /from\s+["']@\//, why: "@/ 別名指向宿主專案" },
];

for (const file of shippedFiles) {
for (const spec of imports(file)) {
if (spec.startsWith(".")) continue;
expect(ALLOWED_EXTERNAL.some((p) => spec.startsWith(p))).toBe(true);
}
}

這支測試就是「元件庫能被任何專案拿去用」的自動化保證。 任何應用層概念滲進來,它會在變成技術債之前先紅。

2. Barrel 覆蓋率:新元件不會被漏掉

const missing = shippedFiles
.map(toModuleName)
.filter((m) => !barrelSource.includes(`"./${m}"`));
expect(missing).toEqual([]);

沒有這條,新元件會安靜地不出現在公開 API 裡,然後有人繞過 barrel 直接深層 import。

Barrel 有兩個方向的意義,而守衛只擋住了其中一個:

  • 漏掉(守衛已擋):新元件沒進 barrel,會被人繞過 barrel 深層 import
  • 多出來(守衛擋不住):不該公開的東西進了 barrel,就再也拿不掉了

第二種比第一種難救。一旦某個內部輔助元件被匯出,就會有人開始用它, 之後任何調整都是 breaking change——而它從來不是你打算支援的東西。

判準:這個東西有沒有做出視覺或互動的決策?

進 barrel不進 barrel
有視覺/互動決策、任何專案都能用綁定特定業務語彙(欄位名、狀態機、流程)
只依賴 token 與彼此需要應用層的狀態、路由、資料來源
有 story、有文件頁只是把幾個元件拼在一起的私有便利函式
✅ 這樣做

拿不定主意就先不匯出。加進 barrel 是 minor,拿掉是 major。

🚫 不要這樣

不要為了「反正遲早會用到」預先匯出。預先匯出的東西沒有文件、 沒有 story,卻已經是契約了。

3. Token 一致性:淺深成對、CSS 與來源同步

// 最容易發生的漂移:加了淺色 token 忘了配深色
const light = Object.keys(semanticColors("light"));
const dark = new Set(Object.keys(semanticColors("dark")));
expect(light.filter((k) => !dark.has(k))).toEqual([]);

// CSS 產物與來源同步(防止有人手改 dist)
expect(missingInCss).toEqual([]);

深色缺一個 token,通常要等到有人切到深色才會發現——可能是三個月後的使用者回報。

4. 詞彙守衛:防止領域語意回流

const FORBIDDEN_TERMS = ["…產業術語…", "…角色流程詞…"];
// 掃描 packages/、book/docs、docs/adr、registry
expect(hits).toEqual([]);

這支是本 repo 特有的:工具箱萃取自一套特定產業的系統, 守衛確保「示範資料與文件裡一個領域詞都不留」。

你的專案未必需要這一支,但值得想一想:你的設計系統有沒有偷偷綁定某個業務假設?

5. 禁止原始調色盤字面色

// 元件庫不得出現原始調色盤字面色,狀態色一律走語意 token
const FORBIDDEN_PALETTE =
/\b(?:border|bg|text|ring|from|to|via|fill|stroke)-(?:amber|emerald|rose|sky|orange|yellow)-\d{2,3}\b/g;

for (const file of shippedFiles) {
const hits = [...readFileSync(file, "utf8").matchAll(FORBIDDEN_PALETTE)];
expect(hits, `${file} 請改用語意 token(如 text-success / border-edit)`).toEqual([]);
}

這支和防線 ① 不重複,它補的正是 ① 的盲點。 清空預設色盤之後, bg-amber-500 只是靜默產不出樣式——不報錯、不紅燈,元件看起來「有點不對」而已。 更要緊的是取用端:他們不一定套我們的 preset(token 是唯一硬相依), 那時 bg-amber-500真的渲染成琥珀色,而且完全不跟主題走。

token 守衛也擋不到:它檢查的是「token 本身完不完整、淺深有沒有成對」, 檢查不到「有人根本沒用 token」——直接寫 bg-amber-100 的那一行,在它眼裡不存在。

而這正是最容易發生的漂移:趕時間的時候,bg-amber-100 比「先去查該用哪個語意 token」快, 而且當下看起來一模一樣。等到有人換主題或切深色,才發現有十幾處顏色不會跟著變。

特別要盯琥珀--edit 是保留色(ADR-0002)。一旦有人用原始色階寫了一個 「順便也是琥珀色」的高亮,「已改動未送出」這個訊號就被稀釋了,而稀釋是不可逆的—— 使用者一旦學到「琥珀有時候沒有意義」,就再也不會相信它。

6. Token 副本同步

理想情況是文件站直接吃 token 產物(@import 或 alias),這樣根本沒有副本—— 本 repo 自己就是這樣做的(見下一節)。但很多宿主專案做不到: 文件站是另一個框架,有自己的 CSS 管線與樣式重置,直接匯入會與站台主題打架, 或是產物裡的深色選擇器與文件站的深色開關對不上。

這時務實的做法是留一份副本,並且立刻替它配一支守衛:

it("token 副本涵蓋樣式設定引用的全部變數", () => {
const config = read("tailwind.config.js");
const copy = read("docs-site/src/css/tokens.css");

const wanted = new Set(
[...config.matchAll(/var\((--[a-z0-9-]+)\)/g)].map((m) => m[1])
// 執行期由第三方元件注入的變數不是設計 token,不需入副本
.filter((v) => !v.startsWith("--radix"))
);
expect(wanted.size).toBeGreaterThan(10); // 守衛本身沒有空轉
for (const v of wanted) expect(copy).toContain(`${v}:`);
});
重點為什麼
樣式設定實際引用的變數為準,不是以來源檔的變數清單為準來源檔會有還沒被用到的 token,強迫副本全帶只會製造噪音
排除執行期注入的變數第三方元件會在 runtime 注入 --* 變數,它們不是設計 token
斷言「想要的集合不是空的」正則寫錯時 wanted 會是空集合,迴圈一次都不跑,守衛變成永遠綠燈
✅ 這樣做

副本檔開頭寫清楚它是副本、來源在哪、由哪支守衛盯著

🚫 不要這樣

不要以為「反正加 token 的時候會記得同步」。加 token 的人在改的是元件, 他的腦子裡沒有文件站。症狀會是文件站的活範例顏色慢慢跑掉—— 那是最不容易被回報的一種壞掉,因為它看起來只是「有點怪」。

7. 文件掛鉤:引用的東西必須存在

文件一旦開始嵌入真元件(story、示意流程、範例畫面), 它就對元件庫產生了以字串為鍵的相依,而字串沒有型別檢查,改名不會有人告訴你。

// story id 由 stories 檔的 meta.title + export 名推導(沿用 Storybook 的 id 規則)
const validIds = collectStoryIds("packages/react/src/**/*.stories.tsx");
for (const f of docFiles)
for (const [, id] of read(f).matchAll(/<StoryFrame[^>]*id="([^"]+)"/g))
expect(validIds.has(id), `${f} 引用不存在的 story:${id}`).toBe(true);

失敗訊息要指出是哪個檔案引用了哪個不存在的鍵。 這支守衛紅的時候, 動手的人通常正在改元件、對文件毫無概念——訊息要能讓他不必理解文件站就知道該改哪一行。

順帶做兩件事:禁止回歸(把已經廢除的做法寫成斷言——「不要再用截圖」寫在規範裡會被遺忘, 寫成測試不會)、正向下限(斷言文件至少嵌了 N 個視覺輔助,確保守衛不是在對空集合微笑)。

本 repo 這一支是 tests/doc-hooks.test.ts(隨 零截圖文件示意 的第一個嵌入一起加的)。實作時有兩件事是量出來才知道的:

一、id 的推導必須對著真值驗一次。 這支守衛的核心是「用 meta.title + export 名 重算 Storybook 的 id」,而重算就是第二份真相——推導規則與 Storybook 稍有出入, 守衛就會開始說謊,而且兩個方向都很糟:推少了會誤判正確的引用壞掉, 推多了會放過真的壞引用。所以要拿 build-storybook 產物的 index.json 逐一對照過(本 repo 是 33 個 id 雙向全中)。只跑「測試綠了」不算驗過。

二、行內程式碼要排除,圍籬程式碼要照驗。 散文需要能討論這個標籤本身 (「掃出所有 <StoryFrame id="…">」),把它當引用會誤判——這一條是實際踩到的, 本頁那一節的說明文字自己先觸發了一次假警報。 但圍籬裡的範例要驗:那是讀者會複製走的東西,範例裡的 id 死掉一樣是壞的, 只是晚一步才被發現。

8. 雙站部署的路徑過濾與它的例外

應用程式與文件站各自獨立部署,用路徑過濾避免互相牽動:

# 應用程式:文件變更不重佈
paths-ignore:
- "docs-site/**"

# 文件站:文件變更才重佈……
paths:
- "docs-site/**"
# ……但元件變更也必須重佈(例外,見下)
- "packages/react/**"
- ".storybook/**"

前半是效率問題(改一個錯字不必重建整個應用程式),後半才是重點:

容易漏掉的例外

文件站內嵌元件與 story,所以元件變更必須觸發文件站重佈。 只設 paths: ["docs-site/**"] 的話,元件改版後文件站不會重建, 裡面嵌的仍是舊版元件——文件與現行介面當場脫鉤, 而這件事沒有任何測試看得見,因為兩邊各自都是綠的。

這就是為什麼「零截圖」的承諾(元件改版,示意圖自動跟上)必須由 CI 設定來兌現。 承諾寫在文件裡,兌現寫在 workflow 裡;只有前者沒有後者,承諾是假的。

還有一個小陷阱:兩個部署寫入的是同一個發佈分支的不同子目錄。 它們要共用同一個 concurrency group,否則同時觸發時會互相覆蓋—— 症狀是「明明部署成功了,但站上還是舊的」。

加分:讓文件站直接吃元件原始碼

resolve: { alias: { "@dooping/react": path.resolve("../packages/react/src") } }

文件裡的範例是真元件,不是複製品、不是截圖。 元件改了、範例當場跟著改——文件不可能過期,因為它根本沒有自己的副本。

判準:什麼時候需要一支新守衛

上面這些看起來各不相干,但判準只有一條:

判準

同一份事實只要存在於兩個地方,就需要一支守衛。

兩個地方守衛
淺色 token ↔ 深色 token成對檢查(第 3 支)
元件檔 ↔ barrel覆蓋率(第 2 支)
token 來源 ↔ 文件站副本副本涵蓋率(第 6 支)
元件庫 ↔ 文件引用的 story id掛鉤存在性(第 7 支)
元件庫 ↔ 已部署的文件站CI 路徑過濾(第 8 支)

而最好的守衛,是讓那份事實只存在一個地方—— 能 alias 就不要複製,能自動推導就不要手寫清單。 守衛是次佳解,用在「消不掉的第二份」上。

不要只靠 code review

✅ 這樣做

能寫成測試的規則就寫成測試。人只 review 寫不成測試的部分(命名、文案、取捨)。

🚫 不要這樣

不要把「請記得用 token」寫在 wiki 然後期待大家自律。 三個月後那頁 wiki 沒有人打開過。