Start your app the better way with Styled System
25 Feb 2020本文主要由此投影片「Start your app the better way with Styled System」之講稿改寫。
點此下載投影片。
本文會從 CSS 歷史開始談起,接著會來看過去我們在使用元件庫上遇到的問題,以及怎麼使用 Styled System 來解決這些問題。
CSS 的原罪
CSS 的原罪就是 CSS 只有全域而沒有區域的概念,也就是說,只要當前頁面的 DOM 結構符合載入樣式的規則,就會被套用這個樣式。這會產生兩個問題 - 命名衝突(name collision)和重用性(reusability)的問題。
命名衝突(Name Collisions)
第一個是命名衝突的問題,命名衝突是指因 CSS 樣式規則同名所造成樣式覆蓋的問題。
如這張圖所示,這裡分別有兩個元件 slideshow(左)和 tabs(右),它們各自擁有 .list
和 .list .item
的樣式,若再載入了一個同樣由 .list
和 .list .item
所撰寫或權重更高的樣式規則,就會把這個樣式套用上去,原先屬於這兩個元件的樣式就被覆蓋掉了,這就是命名衝突所造成的影響。
重用性(Reusability)
第二個是重用性的問題,由於 CSS 不是程式語言,它只是描述樣式的方法,因此在撰寫上很鬆散,沒有模組的概念,難以重用。若無法重用,就會造成程式碼毫無限制的成長,終至難以擴充和維護。
因此,一直以來,CSS 不管是撰寫模式還是開發工具,主要都是解決「命名衝突」和「重用性」這兩個問題。
CSS Methodologies
在撰寫模式方面,OOCSS、BEM、SMACSS 這些都是撰寫 CSS 的方法,讓前端工程師能試圖將 CSS 樣式規則切分成獨立模組來開發,而不是撰寫一大堆不可分割的程式碼。
這裡以 BEM 為例,BEM 將頁面元件分為三種類型
- B(Block)是指區塊,像是 header、footer、sidebar、container。
- E(Element)是指區塊的一小部份,是可重用的元件。
- M(Modifier)是指區塊或元件的狀態。
以下圖為例
- 這裡有一群 card,class 的名稱就取名為
.card-list
,單字之間用一個 dash 分隔。 - 裡面元件稱為 item,用 雙底線 分隔,表示是
.card-list
的元件。 - 右下角是這個元件被 highlight 的狀態,要用 雙 dash 分隔,表示是 item 的 highlight 狀態。
OOCSS、BEM、SMACSS 主要都是利用命名規則的方式,讓樣式規則更模組化、更好重用;雖然命名規則能稍微避免一些命名衝突所造成的樣式覆蓋的問題,但仍無法完全解決,所以每次在寫樣式,前端工程師都不得不先將整個專案搜尋一下看看有沒有可以重用的,或是在當前引用的 css 檔案找找有沒有同名的樣式規則,避免覆蓋。
Preprocessor / Postprocessor
在工具方面,預處理器(Preprocessor)和後處理器(Postprocessor),例如:LESS、SASS、SCSS、PostCSS,讓開發者可以把 CSS 當成程式語言來撰寫,像是有變數、巢狀、函式等,雖然能適度地解決命名衝突和重用性的問題,但都不算是從根本解決。
下面的例子是 LESS 用 mixing 的方式傳入變數 scope 來產生屬於這個模組的專屬樣式。
.search-box-mixin(@scope) {
.@{scope}-search-box {
.search-input {
border: 1px solid @gray;
}
.tooltip {
margin: 8px 0 0 1px;
}
}
}
傳入模組名稱「page」。
.search-box-mixin('page');
屬於這個模組「page」的專屬的 CSS。
.page-search-box .search-input {
border: 1px solid #ddd;
}
.page-search-box .tooltip {
margin: 8px 0 0 1px;
}
因此雖然適當命名可以解決命名衝突和重用性的問題,但只要是人來處理都可能產生先前提到的問題,像是有人取了重複名稱的模組。
以上就是簡單介紹 CSS 的主要困境「命名衝突」和「重用性」的問題與歷史上所用過的各種解法。
元件化的時代
回顧完過去,那我們來看現在的狀況。
現代的網頁都是以元件為單位來建立的。在以元件為基礎的時代,我們撰寫 CSS 的方式,也跟過去將樣式放在獨立的檔案有所不同,像是現在流行使用 CSS in JS,就是將該元件的樣式寫在元件的 js 裡面。
CSS Modules
CSS Modules 想做的是企圖把樣式鎖在特定的元件裡面。CSS Modules 仍是將樣式寫在樣式檔案裡面,並且透過 Webpack 設定 css-loader,針對引用進來的 class name 轉成 hash,成為唯一的名稱,這樣就能將 CSS 樣式規則限制在特定元件底下,完全避免因命名衝突所造成的樣式覆蓋的問題。
CSS in JS
既然想把樣式限制在元件裡面,那直接把樣式寫在元件裡面就變得很合理,將樣式寫在元件裡面,就是 CSS in JS。
用 CSS in JS 這樣的概念開發的函式庫很多,這裡就以 Styled Components 為例。
下面的例子是利用 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,由於元件的樣式會被轉為 hash 的 class name,是唯一的名稱,因此就可以避免命名衝突所造成的樣式覆蓋的問題。
- JS 與 CSS 都在同一支 JS 檔案,能共享變數來做邏輯判斷,不用間接新增或移除某個 class 來控制樣式。
- 每個元件只管自己的樣式,要重構樣式只要重構這個元件即可,方便重構。
- CSS in JS 將樣式寫在元件裡面,讓重用變得簡單,因為要重用這個元件同時也重用了它的樣式。
- 由於樣式和元件合併了,因此只會載入要用到的 CSS 程式碼,不相關的都不會載入,增進瀏覽器的載入效能。
但是,我們還有些問題尚未解決,像是…
- 由於元件是由不同人或不同時間所開發的,元件屬性的命名風格可能會不一致,這在後續維護上很令人困惑。
- 將樣式寫在元件裡面,要怎麼制定主題樣式?例如:切換 light 或 dark 模式?SMACC 用 theme 作為 prefix 的命名規則來做,那現在要怎麼辦呢?
- 將樣式寫在元件裡面,要怎麼做 RWD?
- 有沒有辦法精簡程式碼?是否可以不重複撰寫同樣的程式碼呢?例如:media query
@media (...) { ... }
;元件的樣式會依據傳入的屬性值來做調整,關於這部份對應的程式碼實在很繁瑣,有更簡潔的寫法嗎?
在使用元件庫上曾經遇到哪些問題
我們來看看先前在使用一些元件庫時,在客製化樣式方面遇到的問題…在此用 Ant Design 為例子。
假設我們想要客製化按鈕的字體顏色,第一個解法是去擴充 LESS 原始檔。我們必須找到這個按鈕的 class name,然後循著程式碼找到設定的變數是@btn-default-color
,接著開一個新的 LESS 檔並引入 button 樣式的主檔案 index.less
,然後在裡面重新定義 @btn-default-color
色碼為 blue
,這樣就能改變這個按鈕的字體顏色為藍色。
第二個解法是利用 CSS in JS 的 library 將要覆寫的樣式放進去,如右邊程式碼所示,將 Ant Design 的 <Button>
元件用 <CustomButton>
重新包裝即可。
import styled from 'styled-components';
import { Button as AntdButton } from 'antd';
const CustomButton = styled(AntdButton)`
span {
color: blue;
}
}`;
export default CustomButton;
以上客製化元件樣式的兩種方法,其實都很麻煩。有沒有更簡單的解法呢?
Styled System
這時候我們就可以用 Styled System 來做些改進了。Styled System 是一個搜集了許多 utility function 的 library,主要用來幫我們處理樣式,以下依照它的優點來看一些範例,了解到底要怎麼用它來改進我們的元件庫。
我們先來看 Styled System 到底有什麼優點?它可以讓我們有更精簡的程式碼、元件屬性命名能一致、容易客製化樣式、對於行動裝置的樣式有很好的支援度。
更精簡的程式碼
Styled System 第一個優點是能讓我們能更精簡程式碼,這是因為 Styled System 提供許多便利的 utility function 來幫助我們用更簡易的方法來撰寫樣式。
這是關於 Styled System 的 utility function 的例子,這裡有一個 Box 元件,它設定字體顏色為黑色、背景為蕃茄紅色。
幾乎所有 CSS-in-JS 函式庫在建立 styled component 時,都可接受函式(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 function 統一來做這件事情,看起來稍微乾淨一點。
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 可用,可到它的官網查詢。
由於 Styled System 提供許多便利的 utility function 來幫助我們用更簡易的語法的撰寫樣式,而不用自己刻很多東西,讓我們的程式碼更簡潔,開發更便利。
元件屬性命名能一致
第二個優點是元件屬性命名能一致,由於元件的開發是不同人不同時間撰寫的,屬性命名風格難以統一,這在後續維護上很令人困惑。雖然這可以透過文件或工具來規範,還有其他方法嗎?
舉例來說,這裡有兩個元件 Button 和 Label,它們都需要設定字體的顏色,可能由於開發人員或時間的不同,而有 color 和 fontColor 兩種,props 的名稱不同卻是要做一樣的事情,這很讓人困惑。由於 Styled System 提供了 API 來做樣式的對應設定,就可以強迫開發人員一定會用一樣的屬性名稱,這裡就是統一用 color 來定義字體顏色。
易於客製化樣式
第三個優點是 Styled System 能讓我們很容易地來客製化樣式,像是主題樣式和個別元件樣式的設定。
主題樣式
第一個要來看主題樣式。我們可以定義一個物件檔,裡面放全站主題樣式的設定。
const theme = {
color: {
white: '#fefefe',
},
bg: {
tomato: 'tomato',
},
};
將這個物件傳給 ThemeProvider,ThemeProvider 會利用 React Context 來傳遞樣式的設定到後續所有的元件,就可以讓所有的元件取用這個主題物件所定義的設定,不用一個一個元件設定。
上圖右是我們先準備好的 theme object,定義了背景色 bg 和字體的顏色 color,上圖左是元件,當元件設定的 color 或 bg 可在 theme 物件找到時,就會自動 mapping 來使用,這樣就能達到定義主題樣式的目的。
個別元件樣式
主題物件 theme 也可以定義元件單獨的樣式,只要在 variant 指定查找的 key 即可。
Variants
單一元件也可以利用 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 去指定要哪一種就可以了,其中 color 和 bg 的值也是去查找 theme object 的設定。
Mobile First
第四個優點,是 Styled System 對於行動裝置有很好的支援度。
Styled System 提供簡易的 array syntax 語法來針對不同 breakpoint 設定各自的樣式。 以下是一般傳統的寫法,針對每個 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 是 40em、52em、64em,在設定好 breakpoint 後,只要傳入相對應的數值即可,好處是少寫一些程式碼,也清楚明瞭。
<Thing fontSize={[16, 20, 24]} width={[1, 1 / 2]} />
以上就是 Styled System 的優點:它可以讓我們有更精簡的程式碼、屬性命名能一致、容易客製化樣式、對於行動裝置的樣式有很好的支援度。
從此以後,寫 css 都變簡單了呢!
Chakra UI
Chakra UI 是利用 Styled System 建立的 UI 元件庫,我們就來看它好在哪裡吧。
客製化元件的樣式
由於 Chakra UI 使用 Styled System 來撰寫樣式,因此客製化樣式的方法很簡單,只要設定樣式相關的屬性即可,API 與 Styled System 相同。如下,左邊按鈕為 Chakra UI 預設樣式,中間按鈕為使用 Chakra UI 的 variantColor 為 green 的選項,並複寫字體顏色 color 為灰色(色碼是 #ddd
),右邊為覆寫 Chakra UI 預設樣式的背景顏色 bg 為藍色(色碼是 #41d2f2
)、字體顏色 color 為白色(色碼是 #fff
)。這樣的好處是,就不用較為麻煩的 css prop 或 styled(...)
的方式來覆寫。
<Button>Button</Button>
<Button variantColor='green' color='#ddd'>Button</Button>
<Button bg='#41d2f2' color='fff'>Button</Button>
variantColor
是指按鈕的顏色,可參考原始碼的設定,就可看到背景色是取 green 的 500(色碼:#38a169
)、hover 後是 600(色碼:##2f855a
);又由於我在 color 這裏覆寫了原版的字體顏色設定 white,因此就變成了灰色(色碼:#ddd
)了。樣式設定可參考這支檔案。
擴充主題樣式
主題樣式可讓元件庫快速切換特定特定款式的樣式,例如:light 或 dark 色系,也就是利用 theme object 來定義需要客製化的主題樣式,然後利用 ThemeProvider
傳遞下去。
承上,色碼也可以從 theme object 定義,如下,先擴充 Chakra UI 的 colors。
import { theme } from '@chakra-ui/core';
export default {
...theme,
colors: {
...theme.colors,
brand: {
white: '#fefefe',
blue: '#41d2f2',
yellow: '#f2dc6d',
purple: '#7700bb',
},
},
};
在 <Button>
元件中使用定義好的 brand.white
。
<Button bg='#41d2f2' color='brand.white'>
Button
</Button>
行動裝置的支援度
Chakra UI 如同 Styled System 提供簡易的 array syntax 語法來針對不同 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]} />
QnA
最後是分享幾個我們在開始使用 Styled System 遇到的小問題和解法。
CSS 順序
若把由 Styled System 傳入的 css 放到最上方,容易被原先設定的樣式覆蓋掉。
const Box = styled.div`
${color}
margin: 15px 0;
padding: 15px;
border-radius: 10px;
`;
因此建議要把由 Styled System 傳入的 css 放到最下方,避免被覆改。
const Box = styled.div`
margin: 15px 0;
padding: 15px;
border-radius: 10px;
${color}
`;
Traditional CSS rules feat. Styled Components
其實這不是使用 Styled System 的問題!我們在將 Styled System 導入專案時,遇到了新舊規則並存的問題。我們無法一次就改完全站的樣式,勢必會有過渡時期,新舊並存,也就是傳統 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 要怎麼做測試?
其實也不是使用 Styled System 的問題!也是我們在將 Styled System 導入專案時遇到的問題。
要怎麼產生一個不是 hash 的 class name 來做 end-to-end 測試?解法是用 Styled Components 的 attrs 加上 props「className」即可。
References
以下是一些我閱讀和參考的資料,大家有興趣可以來看看。
- Styled System
- We need a better UI component library - Styled System
- 從 Styled System 看下一代 CSS 技術
- The Three Tenets of Styled System
- Styled System: Pseudo selectors in Variant
- How to create responsive UI with styled-components
- CSS 實戰心法
後記
本投影片與講稿是參加去年 Modern Web 之 Anna Su 所分享的 We need a better UI component library - Styled System 與 React 小聚 從 Styled System 看下一代 CSS 技術後,重製後在自家公司的 FED Virtual Group 分享的 σ`∀´)σ