---
# System prepended metadata

title: React 元件外部擴充樣式寫法
tags: [note]

---

# React 元件外部擴充樣式寫法

Elantris

[TOC]

---

## 前言

我經手的專案大都是需要高度客製化樣式的網站，我的策略都是先看過所有網站設計圖，整理出不斷重複出現的元件，構築這個網站專用的元件庫。後續在串接後端資料、實作操作流程時都能直接引入元件維持網站整體一致的外觀設計。

在研究了幾個 UI Library 之後我試圖找出各家寫法上的差異，簡化成自己比較容易理解、擴充、維護的寫法，日後我在自建網站專用元件庫時可以參考。

## 方案

UI Library 的原則是盡可能將每個元件寫得乾淨一點，不跟任何系統功能有耦合，所有資料都是透過 props 帶入。以下提供兩個想法，實務上我都是同時混搭兩種寫法。

### Variant/Theme

根據設計圖事先準備好固定的變體與主題，在取用元件時透過改變 `variant` 或 `theme` props 決定要用哪一個組合。

這個方法的優缺點也很明顯，主要目的在於限縮組合數量，設計團隊可以比較容易掌握實際的排版結果、工程團隊不用處理過於自由奔放的樣式。但相對地代表限縮了延展性，每當遇到需求有不同的樣式都要改動元件本身的結構或判斷。

```tsx
const Panel: React.FC<{
  variant?: 'default' | 'glory'
  theme?: 'default' | 'light' | 'dark'
  title?: React.ReactNode
  children?: React.ReactNode
  footer?: React.ReactNode
}> = ({ variant = 'default', theme = 'default', title, children, footer }) => {
  /* ... */
}
```

渲染的部分就是 conditional rendering 自由發揮，例如：

1. 不同的結構

```tsx
if (variant === 'glory') {
  return (
    <div className="panel panel-glory">
      {/* ... */}
    </div>
  )
}
return (
  <div className="panel">
    <div className="panel-title">
      {/* ... */}
    </div>
  </div>
)
```

2. 對應的 className/style 組合

```tsx
return (
  <div
    className={`panel ${variant === 'glory' ? 'panel-glory' : ''}`}
    style={theme === 'light' ? { background: 'white' } : { background: 'black' }}
  />
)
```

```tsx
const classNames = {
  variant: {
    default: '',
    glory: 'panel-glory',
  },
  theme: {
    default: '',
    light: 'panel-light',
    dark: 'panel-dark',
  },
}

return (
  <div className={`panel ${classNames.variant[variant]} ${classNames.theme[theme]}`} />
)
```

### ClassName/Style

直接把 `className` 與 `style` 當作 props 傳遞，本身有預設的樣式，在取用元件時可以任意存取元件結構中的 `className` 與 `style` 屬性，客製出畫面需求當下需要的元件外觀。

這個寫法雖然對樣式的自由度高，相對地會犧牲元件的重複使用性，而且通常不太方便改變元件的渲染結構。

常見的寫法會把結構中每一個 Element 的 className 變成對應 props：

```tsx
<Dialog
  wrapperClassName=""
  headerClassName=""
  closeIconClassName=""
  titleClassName=""
  bodyClassName=""
  footerClassName=""
/>
```

或是整合起來：

```tsx
<Dialog
  classNames={{
    wrapper: '',
    header: '',
    closeIcon: '',
    title: '',
    body: '',
    footer: '',
  }}
/>
```

渲染部分通常會選擇擴充 (extend)，也可以用取代 (replace) 的形式，端看元件渲染要如何接納這個外部帶進來的參數：

```tsx
return (
  <div className={`dialog-header ${classNames?.header ?? ''}`}>
    {/* ... */}
  </div>
)
```

### Utility-first

其實跟上述兩種的寫法差不多，就是 Tailwind 特化板的寫法，可以搭配 [tailwind-merge](https://github.com/dcastil/tailwind-merge) 來組出實際渲染的 className。

```tsx
import { twMerge } from 'tailwind-merge'

// ...

return (
  <div
    className={twMerge(
      'flex items-center p-4',
      variant === 'glory' && 'ring-2 ring-blue-500/50',
      wrapperClassName,
    )}
  />
)
```

## 結語

時時刻刻提醒自己幫特定網站寫專用 UI Library 就是在管理全站的顯示元件，目標是提供單純的元件給共同開發者方便取用，在實作頁面功能時能維持一致的操作邏輯或樣式外觀。

使用 variant/theme 可以提供預先設計好的樣式組合；使用 className/style 提供完全客製化的介面。同時混用兩種寫法也是完全沒有問題的，只是當不同來源的 props 對樣式都有影響的話，需要好好規劃每一個參數實際影響渲染的哪一個部分，終極目標是寫出直覺、方便、好用的元件。
