想一下情境,設計師傳來新的品牌規範:主色從藍色改成綠色。
Day 22 才剛把 Tailwind 接好,打開設定改一個顏色就行了:
@theme {
--color-primary: #16a34a;
}
重新整理,我自己寫的元件全部變綠了。工具列的按鈕、側邊欄的選中狀態、頁首的連結,都是綠的。
但 PrimeReact 的元件還是藍的。表格表頭的排序圖示是藍的、對話框的確認按鈕是藍的、下拉選單選中的那一項也是藍的。
同一個畫面上,一半綠一半藍。
更尷尬的是,我回頭仔細對了一下改之前的畫面,才發現其實一直都有三種藍:Tailwind 預設的 blue-500、PrimeReact 主題內建的藍,還有設計師在 Figma 裡指定的那個藍。三個很接近,所以從來沒有人注意到。
把問題攤開來看,這個專案裡其實有三套設計規範,各自住在不同的地方:
| 來源 | 住在哪 | 誰會讀它 |
|---|---|---|
| 設計師的規範 | Figma 和一份 PDF | 人,靠肉眼對照 |
| Tailwind | @theme 裡的變數 |
我寫的 utility class |
| PrimeReact 主題 | 主題 CSS 檔 | PrimeReact 的元件 |
三套之間沒有任何連結。設計師改了規範,要有人手動去改 Tailwind,再有人想辦法去改 PrimeReact。漏掉任何一邊,畫面就會出現兩種顏色。
我試著用最直覺的方式補救:PrimeReact 的主題檔裡有一些 CSS 變數,像是 --primary-color,我把它也改成綠色。
結果有一部分元件變綠了,另一部分還是藍的。翻了主題檔才知道,以我們用的那個版本來說,很多元件的顏色是編譯時就寫死的色碼,並沒有引用那個變數。改變數,只改得到有引用它的那些。
這時我才意識到,問題不在「顏色要改哪裡」,而在於這個專案根本沒有一個「主色」的唯一來源。
這個唯一來源,業界叫它 design token。
概念很簡單:所有的設計決定——主色是什麼、錯誤訊息用什麼紅、卡片的圓角多大、區塊之間隔多遠——都抽成一個有名字的變數。元件裡不寫 #16a34a,而是寫「主色」。
而且 token 通常分成兩層:
第一層:原始值(primitive)。 單純描述「有哪些顏色」,跟用途無關:
--green-500: #16a34a;
--green-600: #15803d;
--red-600: #dc2626;
--gray-100: #f3f4f6;
第二層:語意(semantic)。 描述「這個顏色拿來做什麼」,引用第一層:
--color-primary: var(--green-500);
--color-primary-hover: var(--green-600);
--color-danger: var(--red-600);
--color-surface: var(--gray-100);
元件只用第二層。刪除按鈕寫的是 danger,不是 red-600。
這樣分的好處是,品牌色從藍改成綠,只要改一行:
--color-primary: var(--green-500); /* 原本是 var(--blue-500) */
所有寫了 primary 的地方一起變。沒有人需要知道「主色以前是藍的」。
Day 22 那顆刪除按鈕,我當時寫的是 bg-red-600。今天回頭看,它應該是 bg-danger。
tokens.css我把設計師的規範整理成一份檔案:
/* app/styles/tokens.css */
:root {
/* 原始值 */
--green-500: #16a34a;
--green-600: #15803d;
--red-600: #dc2626;
--gray-100: #f3f4f6;
--gray-900: #111827;
/* 語意 */
--color-primary: var(--green-500);
--color-primary-hover: var(--green-600);
--color-danger: var(--red-600);
--color-surface: var(--gray-100);
--color-text: var(--gray-900);
--radius-card: 8px;
}
然後讓 Tailwind 去讀它,而不是自己定義一套:
/* app/app.css */
@import "./styles/tokens.css";
@import "tailwindcss";
@theme inline {
--color-primary: var(--color-primary);
--color-primary-hover: var(--color-primary-hover);
--color-danger: var(--color-danger);
--color-surface: var(--color-surface);
--radius-card: var(--radius-card);
}
@theme 裡宣告的變數,Tailwind 會幫它產生對應的 class,所以 bg-primary、text-danger、rounded-card 都能用了。加上 inline,是因為這些值引用了別的變數,inline 會讓 Tailwind 直接把引用寫進產生的 class 裡,避免變數解析時出問題。
現在的關係變成這樣:
tokens.css(唯一來源)
↓
Tailwind @theme → bg-primary、text-danger……
↓
我寫的元件
設計師改規範,我只改 tokens.css。Tailwind 這一側,自動跟上了。
剩下 PrimeReact。
這裡有兩條路,各有代價。
保留 PrimeReact 原本的主題(官方叫 styled mode),但讓它的顏色跟 tokens.css 一致。主題檔裡有引用變數的部分,直接指向我們的 token:
:root {
--primary-color: var(--color-primary);
}
至於那些寫死的色碼,就得用 PrimeReact 提供的主題工具,以同一組顏色重新產生一份主題檔。
好處是元件的外觀一行都不用自己寫,表格、日期選擇器這些複雜元件,照樣漂漂亮亮的。
代價是 token 改了,主題檔要記得重新產生一次。兩邊還是有可能對不上,只是機率小了很多。
PrimeReact 有一個 unstyled mode:元件只提供行為——鍵盤操作、開關狀態、無障礙屬性——外觀一律不管。外觀透過一個叫 pass-through 的機制,用 Tailwind 的 class 傳進去:
<PrimeReactProvider value={{ unstyled: true, pt: customPt }}>
<App />
</PrimeReactProvider>
// customPt.ts(節錄)
export const customPt = {
button: {
root: 'bg-primary hover:bg-primary-hover text-white rounded-card px-4 py-2',
},
};
這樣 PrimeReact 完全不帶自己的顏色,所有外觀都來自 Tailwind,而 Tailwind 又來自 tokens.css。整個專案只剩一套 token。
順帶一提,Day 22 那個「元件庫樣式沒放進 layer、蓋過 utility」的問題,在這條路上直接消失了——因為根本沒有元件庫的樣式。
好處是只有一個來源,絕對不會再出現兩種顏色。
代價是每個元件的外觀都要自己寫。按鈕還好,但 DataTable 這種有幾十個部位的元件,要把每一個部位的樣式都補齊,是一筆不小的工。PrimeReact 有提供一份 Tailwind 版的預設 pass-through 可以當起點,但裡面用的是 Tailwind 預設色票,還是得改成我們的語意 token。
| 對齊主題 | 無樣式模式 | |
|---|---|---|
| token 來源 | 兩份,要保持同步 | 一份 |
| 前期成本 | 低 | 高 |
| 複雜元件 | 現成可用 | 每個部位自己寫 |
| 與 Tailwind 的樣式衝突 | 要靠 layer 處理(Day 22) | 不存在 |
企業系統很少有機會一次重來。我們最後兩條路都用了:已經在用的複雜元件(表格、日期選擇器),先走第一條路對齊顏色;之後新加的元件,一律走無樣式模式。 慢慢地,第一條路的範圍會越來越小。
這是 Week 4 我學到的第一個「和解」:不是選一條最乾淨的路,而是選一條今天走得動、明天能慢慢收斂的路。
ViewEncapsulation 的缺席Angular 的元件樣式有封裝。今天要把這件事講完整。
在 Angular,我在元件裡寫 .card { padding: 16px; },編譯後會變成 .card[_ngcontent-abc-123],只作用在這個元件上。別的元件也有 .card?沒關係,互不干擾。
React 沒有這層封裝,所有 CSS 預設都是全域的。兩個人在不同的檔案寫了 .card,後載入的那個就會贏,誰也不知道。
React 社群補上封裝的方法有好幾種,我們實際用到兩種:
Tailwind 的 utility class。 這其實是一種繞過問題的方法:p-4、rounded-card 這些 class 的意思是固定的,不會有人「重新定義」它,所以根本不會衝突。也就是說,大部分元件用 Tailwind 寫,就不需要封裝。
CSS Modules。 少數真的需要寫自訂 CSS 的元件,檔名用 .module.css,Vite 會自動把 class 名稱改成唯一的:
import styles from './FilePreview.module.css';
<div className={styles.card}> // 編譯後變成類似 FilePreview_card_x7k2
這就是 React 版的 ViewEncapsulation,只是要自己選擇使用。
但寫到這裡,我發現一件很有意思的事:token 正是那個不該被封裝的東西。
CSS 變數會順著 DOM 樹往下繼承,不管中間有沒有封裝。Angular 的元件樣式就算被封裝了,var(--color-primary) 一樣讀得到外面定義的值——Angular Material 的主題系統,靠的正是這一點。
所以封裝跟 token 其實分工很清楚:
.card 不要影響到你的 .card。前者應該盡量局部,後者應該刻意全域。React 沒有預設的封裝,反而逼我把這兩件事分清楚了。
token 整理好之後,有一個附加價值是我原本沒想到的。
設計師其實很早就問過能不能做深色模式,當時我覺得工程太大,一直往後推。現在只要在 tokens.css 再加一段:
[data-theme="dark"] {
--color-surface: var(--gray-900);
--color-text: var(--gray-100);
}
在 <html> 上切換 data-theme,所有用語意 token 的元件就一起換了。原本以為要改幾十個檔案的事情,變成十幾行 CSS。
前提當然是,沒有人在元件裡寫死色碼。
那個一半綠一半藍的畫面,問題不在 React,也不在 Tailwind 或 PrimeReact。問題在於這個專案沒有「主色」的唯一來源——三套規範各自為政,靠人眼對齊。
Design token 把設計決定變成有名字的變數,再分成兩層:原始值描述「有哪些顏色」,語意描述「拿來做什麼」。元件只認語意,品牌色怎麼改,都只改一行。
至於 Angular 的封裝,React 沒有預設給我,但我也看清楚了它在管什麼:它管的是選擇器的範圍,不是數值。選擇器要局部,token 要全域,這兩件事本來就該分開。