iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 23 篇

Day 23|品牌色改了,畫面一半綠一半藍

  • 分享至 

  • xImage
  •  

想一下情境,設計師傳來新的品牌規範:主色從藍色改成綠色。

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:把設計決定變成有名字的變數

這個唯一來源,業界叫它 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 怎麼接上同一個來源

這裡有兩條路,各有代價。

第一條路:讓主題對齊 token

保留 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。
  • token 管的是數值:全專案的「主色」只有一個,大家都讀同一個。

前者應該盡量局部,後者應該刻意全域。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 要全域,這兩件事本來就該分開。


上一篇
Day 22|撞牆與和解:在 React 遇見 Angular 幫我擋下的全域 CSS 災難
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言