利用 Styled System 建立一個更好的 UI 元件庫!

本文主要由此投影片「Build a better UI component library with Styled System」之講稿改寫。

Build a better UI component library with Styled System from Hsin-Hao Tang

投影片連結下載


本文分為兩部份,第一部份從 CSS 歷史上所用過的元件化的方法談起,舉凡這些方法如何做元件化、解決什麼問題和沒有解決什麼問題;第二部份來看目前我所推薦的解法,也就是利用 Styled Components 和 Styled System 來建立元件庫,並且來看這樣的解法好在哪裡。

Everything in CSS is global

CSS 只有 global 而沒有 local 的概念,也就是說,只要當前頁面的 DOM 結構符合載入樣式的規則,就會被套用這個樣式。這會產生兩個問題 - 命名衝突(name collision)、重用性(reusability)。

命名衝突(Name Collisions)

命名衝突是指因 CSS 樣式規則同名所造成樣式覆蓋的問題。如下圖所示,這裡分別有兩個元件 slideshow(左)和 tabs(右),它們各自擁有 .list.list .item 的樣式,若再載入了一個同樣由 .list.list .item 所撰寫或權重更高的樣式規則,就會把這個樣式套用上去,原先屬於這兩個元件的樣式就被覆蓋掉了,這就是命名衝突所造成的狀況。

命名衝突 Name Collisions

重用性(Reusability)

由於 CSS 不是程式語言,它只是樣式描述的方法,因此在撰寫上很鬆散,沒有模組的概念,難以重用。若無法重用,就會造成程式碼毫無限制的成長,終至難以擴充和維護。


稍後介紹的解法,主要都是解決「命名衝突」和「重用性」這兩個問題。


CSS Methodologies

以下來一一介紹歷史上我們用過的一些解法。

這些都是撰寫 CSS 的方法,讓前端工程師能試圖將 CSS 樣式規則切分成獨立模組來開發,而不是撰寫一大堆不可分割的程式碼。

OOCSS (Object-Oriented CSS)

第一個要介紹的是 OOCSS,OOCSS 只能用 class 來撰寫樣式,並且有兩個主要規則

結構與樣式分離(Separation of Structure from Skin)

我們在切版的時候,幾乎都是先將網頁上的元素依照 Box Model 排列好,然後再填入色彩。

我們可以想像是在打造一棟房子,首先先建立房子的骨架,接著再為房子上油漆-排列這件事就是打造房子的骨架或結構,填入色彩就是加入呈現的樣式(建築物範例參考自這裏)。

結構與樣式分離 Separation of Structure from Skin

因此,我們可以將 CSS 指令分為兩種

將結構與樣式結合,就是一個完整的元件。這樣做的好處是同樣的結構能替換不同的樣式,同樣的,同一樣式也能套用在不同結構上。

下面來看一個例子。

Example 1

這裡有三個按鈕,分別是小的黃色按鈕、大的紅色按鈕、小的藍色按鈕,下方各自對應未優化的 CSS 樣式。

結構與樣式分離 Separation of Structure from Skin

若需求改變

這樣撰寫的方式就很難修改、無法被重用,要重新刻一個很類似的按鈕。

經過「結構與樣式分離」的拆解後,我們將結構相關的 CSS 指令放在 button、small、large 這三個 class 裡面,與樣式相關的就分別放在 yellow、pink、blue、green 裡面,這樣之後就可以任意組合 button 的大小、間隔和顏色。

結構與樣式分離 Separation of Structure from Skin

如下圖,剛剛提到的需求變更,就可以輕鬆組合出來,不用從頭到尾再刻一次相似的程式碼,提高了重用性。

結構與樣式分離 Separation of Structure from Skin

內容與容器分離(Separation of Container and Content)

依舊以建築物為例,假設建築物本身是容器,門、窗等細節是內容,那麼這些細節應該要可以裝在各個建築物上(建築物範例參考自這裏)。

內容與容器分離 Separation of Container and Content

這樣內容就不會被限制於只能在哪個容器之內,元件就能更容易被組裝,提高重用性。

下面再看一個例子。

Example 2

內容與容器分離 Separation of Container and Content

一樣以按鈕(button)為例,不同的是外面包了一層表單(form)…

.form { ... }
.form .button { ... }
.form .button.yellow { ... }
.form .button.pink { ... }
.form .button.blue { ... }

上面這樣的寫法限制 button 只能在 form 裡面,改進如下。

.form { ... }
.list { ... }
.button { ... }
.button.yellow { ... }
.button.pink { ... }
.button.blue { ... }

改進後的寫法 button 沒有被包裹的容器限制,就可以有彈性的放在 form 或 list 裡面,這樣的組合方式更有彈性,更好重用。

以 OOCSS 為核心概念而開發的著名的框架即是 Bootstrap

Bootstrap


總結,OOCSS 用這兩個原則「結構與樣式分離」、「內容與容器分離」拆分 CSS 指令,讓樣式能利用 class 來分裝,使其便於組裝元件,增加重用性,但可惜這並沒有解決到命名衝突的問題。


BEM (Block, Element, Modifier)

第二個要來看 BEM,BEM 將頁面元件分為三種類型

BEM (Block, Element, Modifier)

Example 3

我們來看一個例子,如下圖所示

BEM (Block, Element, Modifier)


由於 BEM 命名規則很特殊,因此主要有三個優點

因為 BEM 的命名規則而導致名稱過長,可能會造成 HTML 變得很大包,記得做 GZIP、Brotli


SMACSS (Scalable and Modular Architecture for CSS )

隨著網頁發展愈來愈精美,要處理的細節也愈來愈多,那麼就可以使用 SMACSS。

它也是靠命名規則來解決命名衝突和重用性的問題,其規則是將網頁的元素分為五種類型

SMACSS, Scalable and Modular Architecture for CSS

Example 4

範例如下圖所示

SMACSS, Scalable and Modular Architecture for CSS


OOCSS、BEM 與 SMACSS 皆是利用命名規則的方式,讓樣式能更模組化、更好重用。雖然命名規則能稍微避免一些命名衝突的問題,但仍無法徹底解決,所以每次在寫樣式時,前端工程師都不得不先將整個專案搜尋一下,或是在當前引用的 CSS 檔案找找有沒有同名的樣式規則,避免覆蓋,真的很辛苦 (〒︿〒)


CSS Modules

拜工具所賜,我們可以利用 Webpack 設定 css-loader(點此查看),針對引用進來的 class name 轉成 hash,成為唯一的名稱,這樣就能將 CSS 樣式規則限制在特定元件底下,完全避免同名覆蓋的問題。

範例如下圖,左邊是之前我們的寫法,很容易重名;右邊是將 class name 轉為 hash,成為唯一的名稱,就不會同名了。

CSS Modules

CSS Modules 所產生的 hash 的 class 層級和原本一樣多,所以 rendering performance 不如下面 CSS in JS 只有一層來得好。


以概念上來說,CSS Modules 想做的就是企圖把樣式鎖在特定的元件裡面。


CSS in JS

CSS in JS

既然想把樣式限制在元件裡面,那直接寫在元件裡面如何?也由於 SPA 架構的盛行,前端程式碼以元件來構成頁面,因此以元件為單位來撰寫樣式就很直覺,將樣式寫在元件裡面,就是 CSS in JS。

Styled Components

用 CSS in JS 這樣的概念開發的函式庫很多,這裡就以 Styled Components 為例。

Styled System

利用 Styled Component 建立一個元件,樣式就寫在裡面。

import styled from 'styled-components';

const Button = styled.button`
  margin: 0 10px;
  padding: 10px;
  background: #fefefe;
  border-radius: 3px;
  border: 1px solid #ccc;
  color: #525252;
`;

使用 CSS in JS 的好處是


總結 CSS Modules 和 CSS in JS 到底解決了什麼問題?

But!最重要的就是這個 But!

我們還有些問題尚未解決,像是…


利用 Styled System 建立一個更好的 UI 元件庫

這時候我們就可以用 Styled System 來幫我們做些改進了。Styled System 是一個搜集了許多 utility function 的 library,主要用來幫我們處理樣式,以下依照它的優點來看一些範例,了解到底要怎麼用它來改進我們的元件庫。

加快實作速度

由於 Styled System 提供許多便利的 utility function 來幫助我們更簡易的語法的撰寫樣式,因此我們能很快實作和修改程式碼。

Utility Functions

這裡有一個 Box 元件,它設定字體顏色為白色、背景為蕃茄紅色。

Styled System

幾乎所有 CSS-in-JS 函式庫在建立元件時,都可接受函式(function)作為參數、並代入 props 來動態決定樣式,Styled Components 也不例外。

如下,color 與 background 的值是 props 傳入的,我們會在 styled component 取出單獨的值來一個個做設定。

const Box = styled.div`
  margin: 15px 0;
  padding: 15px;
  color: ${(props) => props.color};
  background: ${(props) => props.bg};
  border-radius: 10px;
`;

這樣很麻煩,所以自行實作一個函式 getStyles 來做這件事。

const getStyles = ({ color, bg }) => ({
  color,
  background: bg,
});

const Box = styled.div`
  ${getColor};
  margin: 15px 0;
  padding: 15px;
  border-radius: 10px;
`;

Styled System 提供 color 這個 utility function,可達到相同的功能,可以想像 color 這個 utility function 挖了一個更大的洞一起填入 color 和 background 並幫我們做了一些繁瑣的 mapping 工作,對開發來說就便利許多。

import { color } from 'styled-system';

const Box = styled.div`
  ${color}
  margin: 15px 0;
  padding: 15px;
  border-radius: 10px;
`;

Styled System 針不同特性的樣式而也有許多的 utility function 可用,可到它的官網查詢,點此查看

不一致

這部份我們會來看「主題樣式」和「不一致的屬性名稱」兩個議題。

主題樣式

如何制定全域主題樣式呢?每個元件單獨設定是很麻煩的事情。

我們可以定義一個檔案,裡面放全站主題樣式的物件,在 root 放置 ThemeProvider,並將將這個物件傳給 ThemeProvider,ThemeProvider 會利用 React Context 來傳遞樣式的設定到後續所有的元件,這樣所有的元件就都可以取用這個主題物件所定義的設定了,不用一個個元件來做引用和設定。

以下是先準備好的 theme 物件,定義了背景色 bg 和字體的顏色 color。

const theme = {
  color: {
    white: '#fefefe',
  },
  bg: {
    tomato: 'tomato',
  },
};

當元件設定的值 color 或 bg 可在 theme 物件找到時,就會自動 mapping 並使用,達到定義全域主題樣式的目的,不用每個元件單獨設定,易於維持全站樣式的一致性。

<ThemeProvider theme={theme}>
  <Box color='white' bg='tomato' />
</ThemeProvider>

Styled System

定義元件的個別樣式

可在主題物件內定義元件個別的樣式,只要在 variant 指定查找的 key 即可。

<button variant="danger" size="large" />
const buttonStyle = variant({ key: 'buttons' });
const buttonSizeStyle = variant({ prop: 'size', key: 'buttons.size' });

const Button = styled.div`
  ${buttonStyle}
  ${buttonSizeStyle}
  padding: 15px;
`;
const theme = {
  buttons: {
    danger: {
      color: 'white',
      background: '#f25e7a',
    },
    size: {
      default: { height: 50 },
      large: { height: 100 },
    },
  },
};

Variants

除了全域設定 theme object 外,單一元件也可以利用 variants 來做個別設定。

如下,我們為元件 Box 定義了兩種類別 primary 和 secondary。

import { variant } from 'styled-system';

const Box = styled('div')(
  variant({
    variants: {
      primary: { color: 'black', bg: 'tomato' },
      secondary: { color: 'black', bg: 'yellow' },
    },
  }),
);

元件在使用時,只要利用 variant 去指定要哪一種就可以了,就會自動對照是要用 primary 或 secondary 的字體顏色 color 和背景顏色 bg,其中 color 和 bg 的值也是去查找全域定義的 theme 物件。

Styled System

不一致的屬性名稱

由於元件的開發是不同人不同時間撰寫的,風格難以統一,屬性命名都不相同,後續可能會造成接手人員感到困惑、難以維護。雖然這可以透過文件(例如:Storybook)或 linter 來規範,還有其他方法嗎?

不一致的屬性名稱

上圖有兩個元件 <Button><Label>,它們都會設定字體的顏色,可能由於開發人員或時間的不同,而有 color 和 fontColor 兩種,props 的名稱不同卻是要做一樣的事情,這很讓人困惑。由於 Styled System 提供了 API 來做樣式的對應設定,就可以強迫開發人員一定要用一樣的名稱來定義屬性-統一為 color。

Mobile-First

Styled System 提供簡易的 array syntax 語法來針對不同 breakpoint 設定各自的樣式,點此查看

Responsive styles

Styled System 可用陣列傳入針對個別 breakpoint 所需要設定的值,預設的 breakpoint 是 40em、52em、64em。

一般傳統的寫法,針對每個 breakpoint 寫各自的樣式。

.thing {
  font-size: 16px;
  width: 100%;
}

@media screen and (min-width: 40em) {
  font-size: 20px;
  width: 50%;
}

@media screen and (min-width: 52em) {
  font-size: 24px;
}

以下改為 Styled System 的寫法,在設定好 breakpoint 後,只要用陣列傳入相對應的數值即可,好處是少寫一些程式碼,也清楚明瞭。

<Thing fontSize={[ 16, 20, 24 ]} width={[1, 1/2]} />

以上…我推薦 React + Styled Components + Styled System 是目前建立元件庫的最好解法。

React + Styled Components + Styled System


QnA

既然這東西這麼好,那我們可以直接放到專案裡面了嗎?

Traditional CSS rules feat. Styled Components

我們首先遇到的第一個問題,就會是新舊規則並存的問題。我們無法一次就改完全站的樣式,勢必會有過渡時期,新舊並存,也就是傳統 CSS 樣式的寫法與 Styled Components 並存。如果遇到以下這種壓不過的狀況,就只能用 !important 來處理。

如下,第一條的分數是 101 分,第二條是 styled component 產出的樣式,會用單一層 hash class name 來包裝,得到 10 分,第一條壓過第二條。

/* in site.css, score: 100 + 1 = 101 */
#root div {
  color: red;
}

/* in styled component, score: 10 */
.jqouBD {
  color: black;
}

解法只能用 !important 來處理,得到一萬分!

/* in site.css, score: 100 + 1 = 101 */
#root div {
  color: red;
}

/* in styled component, score: 10000 */
.jqouBD {
  color: black !important;
}

亂碼的 class name 要怎麼做測試?

要怎麼產生一個不是 hash 的 class name 來做 end-to-end 測試?解法是用 Styled Components 的 attrs 加上 props「className」即可。

亂碼的 class name 要怎麼做測試?

Demo

Simple Example

References

以下是一些我閱讀和參考的資料,大家有興趣可以來看看。


後記

本投影片與講稿是參加今年 Modern Web 之 Anna Su 所分享的 We need a better UI component library - Styled System 後,重新製作後分享給我們家團隊的,由於是面對非前端工程師,所以內容稍微簡單、以說明觀念居多 σ`∀´)σ


styled-system styled-components CSS Modules CSS in JS OOCSS BEM SMACSS css Bootstrap Critical Rendering Path End-to-End Testing 端對端測試 Loading Performance Media Query Modern Web Rendering Performance Responsive Web Design react.js webpack 加載效能 效能調校 自動化測試 轉譯效能 關鍵轉譯路徑 響應式網頁 sharing 趨勢科技 Trend Micro