第一次參賽是 2019鐵人賽
(連結),也是剛接觸 React 不久,透過那次真的覺得收穫良多。雖然參加完有種這輩子除非瘋了,不然絕不可能參加第二次
的心情,但看到後來鐵人賽得獎作品居然可以出書,所以今年終於下定決心要再來瘋一次,畢竟人可以老,但青春不能老
。
這次選的題目,在頭洗下去之後才覺得怎麼這個題目有點大,而且真的還蠻需要經驗才有辦法駕馭這個題目,因此在準備的過程當中也越來越覺得自己很渺小,但也因為有遇到困難,才算得上是一個挑戰,越不容易完成,在真的完成之後才越覺得珍惜和可貴。
雖然青春的熱血無限,但畢竟時間有限,希望在有限的時間當中能夠盡力而為,跟隊友們、參賽者們、讀者們一起完成這個挑戰!
這次如同參賽題目,以 React 框架來進行實作,每篇的架構主要為:
但會依照不同的需要做一些微調,但希望能盡量保持一致。
這次會用到處理樣式的工具是選用 styled-component
,styled-component 是一個 CSS-In-JS 的函式庫,他將樣式元件化的概念能讓我們在編寫 React 元件時看起來更加簡潔,也有許多很好用的特性來幫助我們處理複雜的樣式,例如我們可以像在處理元件一樣的將 props 傳入,並且撰寫一些判斷邏輯來處理 CSS 樣式等等。
另外在參考設計的部分,由於時間有限,因此先選用知名的 Material-UI 以及 Antd 來參考為主,其他的函式庫或是參考文章,會視文章需要及時間許可的條件選擇性的加入。
為了避免太過於流水帳的撰寫,元件實作的部分只會提到一些關鍵步驟,其他部分會在文章最後附上完整 source code 以及能幫助我們 preview 的 storybook 提供給大家參考。
希望以上描述能夠幫助到讀者,那我廢話到此為止,開賽啦!!!
Button
元件代表一個可點擊的按鈕,在使用者點擊之後會觸發相對應的業務邏輯。
在過往的經驗當中,我們常在下面情境下會需要按鈕:
按鈕是幾乎在每一個網站上都會出現的元件,雖然如此,但是他被放置在不同地方所希望達到的目的也不盡相同,上面信手捻來就可以舉出很多例子。但是也因為同一個元件要用來做很多不同的事情,因此他的變化也很多。
雖然很常出現而被覺得是一個很簡單的元件,但沒有仔細考慮的話,很多細節會容易被忽略,很容易發生一開始設計了一個想要全站共用樣式的按鈕,但變化到後來因為一開始沒有考慮周詳,導致後來無法共用而不得不再重複造輪子的慘案發生。
在設計元件的時候,一方面我們會擔心自己考慮得不夠周詳,另一方面也會擔心設計出來的東西過於特立獨行,自己以為這樣很好,但是其實沒有人這樣做,雖然說得上是創新,但反過來,也很容易讓使用者甚至其他開發者無法理解你的好意。
因此我通常會找一些知名的 React UI library 來參考他的樣式及介面的設計,比較常參考的有
變化模式
變化模式上面的變化,Material UI 叫做 variant
,Ant Design 叫做 type
,Bootstrap 裡面看起來比較類似的屬性是 variant
,但是 Bootstrap 的 variant 裡面同時可以控制顏色以及描邊樣式。
在變化模式下面有幾種變化
MUI 上面沒有特別區分 text button 和 link button,甚至連 dashed button 也沒有。以變化模式上來看的話,Antd 似乎是比較豐富。
但其實我個人是覺得並不會因為 MUI 他在 variant 裡面沒有那麼多種變化,就表示他輸人家一籌,因為或許有些樣式並不是很常用到需要納入 variant ,畢竟要多維護一種樣式也是需要增加維護成本,而且透過 MUI 的客製化樣式的機制也能夠做到同樣的效果。以我個人開發的經驗,老實說我真的也不是很常遇到需要 dashed button 的時候,所以我覺得不能用這樣「我有你沒有」的標準來斷定一個 library 的優劣,畢竟不同元件庫有他不同的使用情境。反倒是我覺得這個元件庫方不方便我們客製化,才是我比較在意的。
所以到底要不要把每一種變化都納入自己的設計考量,我覺得也倒不一定,需要考慮自己網站的需求,或是跟團隊、設計師一起來討論。不過因為我們知道有這些變化的可能性之後,也能避免把 code 寫死,導致以後如果哪天哪根筋不對,突然又需要的時候,可以不需要大改動就能夠擴充。
顏色屬性
MUI 的顏色屬性叫做 color ,可以傳入的參數有
Antd 看起來似乎沒有特別的 color 屬性,他預設是 primary 藍色,若 danger 屬性為 true 就會變紅色。
Bootstrap 是透過 variant 屬性來決定,有
當我們在做後台系統或是不需要那麼繽紛的 B2B 系統的時候,在顏色上面有 primary, secondary 規範是很好的,全站的主題會統一,顏色要告訴使用者的訊息也很清楚,例如 danger 的顏色或 warning 的顏色。
在做 B2C 網站的時候,雖然一般也是都會訂出 primary, secondaery 顏色的規範,但是難免還是有的時候會因應設計師的要求,或是其他的考量,需要用一些特別的顏色,或是顏色上的微調,以工程師的實作上來說,或是設計規範,我們是不希望有這樣的特例,但當這些規範與商業考量有一些衝突的時候,還是需要做一些妥協來因應這些需求。
以我個人來說,我也是希望能夠有 primary, secondary 來決定主題,但希望不限於這兩種顏色的傳入,所以在設計的時候我也會想要保留顏色調整的彈性,例如說我可能讓 color 這個 props 可以支援 primary, secondary 這樣的關鍵字之外,也能夠支援色票的傳入(ex: #1A73E8),但這樣的做法我覺得也見人見智,可能有人會覺得這樣的 props 會容易混淆,或是無法避開打錯字,但若真的有因為這樣而覺得很困擾的狀況,可以再看怎麼樣來避免,但目前我先簡單來做。
在 css 屬性當中,color 代表文字的顏色,background-color 代表背景色,一個按鈕當中,有許多地方需要配色,例如文字、背景、邊界(border)等等,為了避免混淆,我暫且把 Button 的主題色 props 叫做 themeColor。
另外還有一個顏色配色上的考量,就是可能深色背景色需要搭配淺色文字,淺色背景色需要搭配深色文字,否則文字會看不清楚,若給單一顏色是無法做到上面的調整,因此若有預設好的主題配色 primary, secondary 在使用起來其實也會比較方便。
<Button
themeColor="primary" // primary | secondary | #1A73E8 | ...
>
按鈕
</Button>
帶有圖示(icon)的按鈕
MUI 這邊的介面上設計了讓我們可以傳入 startIcon 以及 endIcon,也就是這個 Icon 不限於一定要放在文字之前還是之後。
Antd 這邊就只有設計一個 icon 的 props 讓我們可以傳入,所以我們只能在文字的左邊加入我們想要的 icon。
帶有 icon 的 button 在我個人的開發經驗上面是蠻常遇到的,而且 icon 真的不會只限於文字的左邊或右邊,所以我覺得 icon 可以支援出現在左邊和右邊是蠻重要的。
<Button
startIcon={<Icon />}
>
按鈕
</Button>
<Button
endIcon={<Icon />}
>
按鈕
</Button>
另一方面,因應 RWD 的設計,帶有 icon 的 button 在窄螢幕的狀態下,常常會需要變成只有 icon 的 button,所以 button 能夠支援只有 icon 沒有文字的樣式,也是蠻需要被考慮進去的。
比較一下 MUI 與 Antd 的 icon button,MUI 是需要另外再使用 IconButton 元件來做
import IconButton from '@material-ui/core/IconButton';
import DeleteIcon from '@material-ui/icons/Delete';
<IconButton aria-label="delete">
<DeleteIcon />
</IconButton>
而 Antd 是可以直接延續使用原本的 button ,把 children element 省略就可以做到
<Button
icon={<PoweroffOutlined />}
/>
狀態屬性
我們在點擊發送表單的按鈕的時候,會需要處理一些不同的狀態,例如說可能有一些欄位是必填但是還沒被填寫,因此不希望使用者按下發送按鈕,這時我們需要 button 是 disabled 的狀態。
當按下發送按鈕的時候,因為前後端需要透過 API 溝通,因此要有一個非同步的狀態,例如讓使用者知道現在是 loading 中,因此我們也需要讓 button 有一個 loading 狀態。
因此我們看 MUI、Antd、Bootstrap 的按鈕上面,都有一個 disabled 的 props 讓我們可以傳入。
另外,在處理 loading 狀態的部分,Antd 他提供一個 loading 的 props 讓我們設置載入的狀態。
<Button
loading
>
按鈕
</Button>
而 MUI 及 Boostrap 看起來是希望你在 button children element 的地方自己處理 loading 的樣式,類似像下面這樣:
<Button>
{isLoading && <Spinner />}
按鈕
</Button>
我覺得這兩種介面都各有優缺,看自己的系統需要可以做選擇,如果很確定每一種 button 的 loading 樣式都一樣的話,我覺得用 loading props 傳入是比較方便,而且程式碼也簡潔、易讀。但如果需要比較客製化的彈性的話,可能在 button children element 的地方再自己依照需求去刻 loading 樣式會比較不會被限制住。
其他外觀屬性
有一些其他屬性,例如說像 Antd 裡面有 shape 屬性,來決定按鈕的形狀(ex: circle, round...),然後我看 MUI、Antd、Bootstrap 都有 size 這個屬性,來決定按鈕的大小。這些外型上的屬性我覺得也是看系統需要,如果前端工程師跟設計師之前彼此之間有講好一個默契,在 Guideline 上面有大、中、小這幾種 button 的 size,那我覺得這樣設計元件是會蠻方便的,系統也會比較統一,但我覺得這些屬性好像也不是這麼適合每一個系統,因為雖然有些系統會有大、中、小這些固定 size 的按鈕,但是有時候就是難免會出現一些惱人的特例,這部分我覺得是還蠻難的一個課題,像我在新創公司的經驗,為了因應快速調整、快速變化的需求,很難好好的把一些規範定下來之後再來設計這個系統,所以要遵守這些規範其實還蠻難的。但在老公司的經驗,整個團隊、開發流程也比較成熟,所以對於這種設計規範就比較講究。
如果是比較能遵守規範的系統,元件 props 設計就讓他可以傳入大、中、小這些固定的 size 就好,但若需要比較常因應一些變化,我覺得我會寫一個 BaseButton,把不會變的共用部份寫好,另外會變的部分再透過傳入 claaName 來調整。
<BaseButton
className={props.className}
{...otherProps}
>
按鈕
</Button>
像我自己在開發的時候喜歡用 styled-components,這樣我就能夠繼承原本的 BaseButton,在這之上再客製化我在另一個地方特別需要的 button,類似像這樣:
import styled from 'styled-components';
import BaseButton from 'components/BaseButton';
const SpecialButton = styled(BaseButton)`
// some styling here
`;
<SpecialButton
{...props}
>
按鈕
</SpecialButton>
事件屬性
事件屬性對一個 button 是蠻重要也蠻常用的,像是 onClick 事件。
我有看過有人刻意把自己寫的 button 事件參數改名為 handleClick,跟他討論他也很堅持他這樣的作法,因為他覺得比較喜歡這個名字,然後他也希望能跟一般的 button 元件做一個區分。想當然,在他的堅持之下,我們後面維護系統的路變得更加的坎坷,而且因為 button 是到處都會用到的元件,所以整個系統被這個東西污染得到處都是,已經到很難回頭的地步,我也很後悔當初沒有硬起來阻止這件事的發生。
我自己開發的建議是希望能夠直接沿用原本元件的事件屬性,因為
保留對 button 的使用習慣
介面設計上我自己也會希望盡量保留原本我們對 button 元件的使用習慣。
例如說,我們原本使用 button 的方式如下:
<button
{...props}
>
確認按鈕
</button>
目前我們所看到的原生 button 也是長這樣,MUI、Antd、Bootstrap 也都是這樣,所以我覺得我會希望把自己設計的 button 也能這樣被使用:
<CustomButtonA
{...props}
>
確認按鈕
</CustomButton>
而不是這樣:
<CustomButtonB
{...props}
text="確認按鈕"
/>
我之前也是看到有人把按鈕設計成上述 CustomButtonB 這樣的形式,他的理由是說希望按鈕的文字傳進去可以是固定的資料型別(這邊範例的狀況是使用 typescript 做型別的確保),但我個人的看法是覺得這樣的設計還蠻特立獨行的,除了違反我們一般的習慣之外,這樣的設計讓未來 button 的變化和擴充就只能透過 props 傳入來改變,而沒辦法好好善用 children element,如果 button 的內容不限於文字,例如我們 button 內的 icon 或是 loading 狀態希望透過 children element 來處理,就會比較難做到。對於他希望做型別確保這件事,我覺得雖然有他的好意,但後續衍伸的缺點確實還蠻困擾我的,所以我覺得這個優點真的有點難吸引我。
預計設計的介面
根據上述我自己的考量與評估(還有自己的喜好 XD),目前預計設面的介面如下表格:
屬性 | 說明 | 類型 | 默認值 |
---|---|---|---|
variant | 設置按鈕類型 | contained, outlined, text | |
themeColor | 設置按鈕的顏色 | primary, secondary, 色票 | pirmary |
startIcon | 設置按鈕左方圖示 | node | |
endIcon | 設置按鈕右方圖示 | node | |
isLoading | 載入中狀態 | boolean | false |
isDisabled | 禁用狀態 | boolean | false |
children | 按鈕的內容 | node |
當然元件的設計也沒有那麼一翻兩瞪眼,終歸一句話,我覺得還是要依據自己專案的狀況以及團隊的共識來設計會比較好,在設計的過程當中,「討論」是很重要的,若設計師設計自己的,工程師設計工程師的,PM 也按照他想像的開 spec,彼此各做各的,在未經討論取得共識的狀況下,最後哪一天突然把 spec 和設計圖拿出來,要求工程師一個 sprint 要做出符合他們期待的,那這樣對於整個專案來說我覺得就是個災難。
基本樣式
我們採用的 CSS-in-JS 工具是 styled-components,首先我會在基礎的 button 上面給一些預設的樣式,這些預設的樣式主要是一些不會因為 props 傳入而有所改變的樣式,也就是不論你的 props 是什麼,大部分狀況下都需要共同擁有的樣式。例如按鈕預設的長寬、滑鼠 hover 上去的滑鼠圖示、圓角樣式...等等。
const StyledButton = styled.button`
border: none;
outline: none;
min-width: 100px;
height: 36px;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
border-radius: 4px;
cursor: pointer;
transition: color 0.2s, background-color 0.2s, border 0.2s, opacity 0.2s ease-in-out;
&:hover {
opacity: 0.9;
}
&:active {
opacity: 0.7;
}
`;
const Button = (props) => (
<StyledButton {...props}>
<span>{children}</span>
</StyledButton>
);
變化模式 variant
variant 我們希望傳入的參數包含有 contained
, outlined
, text
這三種。所以我們需要依據這些參數來取得對應的樣式。
其中一種方式我們可以用 if...else... 的方式來處理,例如:
if (variant === 'contained') {
return containedStyle;
} else if (variant === 'outlined') {
return outlinedStyle;
} else if (variant === 'textStyle') {
return textStyle;
} else {
return containedStyle;
}
但是這樣寫我覺得有點冗長,而且假設除了上述三種 variant 之外,我們要做擴增,那就是需要再多寫一個 else...if... 判斷式。
另一個方式我們可以用物件的方式將 variant 以及對應的樣式用 key-value 的結構來儲存:
const variantMap = {
contained: containedStyle,
outlined: outlinedStyle,
text: textStyle,
};
未來要擴增的時候,我們只需要增加這個 key-value 的對應即可。但要記得處理使用者不小心傳入不存在的 key 的情況:
const StyledButton = styled.button`
//...other style
${(props) => variantMap[props.$variant] || variantMap.primary}
`;
themeColor
themeColor 我們希望傳入的參數包含有 primary
, secondary
, 以及色票。所以我們先準備一下我們的主題色:
export const COLOR = {
primary: '#1976d2',
secondary: '#dc004e',
};
當然我們的主題色如果能夠用 ThemeProvider 來處理,那會是更漂亮,甚至我們能夠做到網站主題色的切換,但這邊我們為了方便講解,我們先用上述方式陽春的來處理。
因此,props 傳進來的時候,我們想要先統一轉換成合法的顏色代碼,傳進來的 themeColor 有幾種可能:
primary
or secondary
#1976d2
所以我們的邏輯是這樣的:
const makeBtnColor = (themeColor) => {
/**
* Color codes regular expression
* https://regexr.com/39cgj
*/
const colorRegex = new RegExp(/(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^)]*\)/);
const isValidColorCode = colorRegex.test(themeColor.toLocaleLowerCase());
return isValidColorCode ? themeColor : (COLOR[themeColor] || COLOR.primary);
};
const btnColor = makeBtnColor(themeColor);
透過上述方式,我們可以把 themeColor 轉換成一個合法的顏色代碼,我們用 btnColor 這個參數來儲存。有了 btnColor ,我們就可以把它提供給不同的 variant 來做顏色的調整。
isDisabled
按鈕的禁用狀態,按鈕的禁用狀態有兩個部分需要處理,一個是外觀,一個是行為。
顏色的處理上,只要是 disabled ,我們一律給他灰色,因此,上一段提到的 btnColor 我們要做一些小調整
const DISABLED_COLOR = '#dadada';
const btnColor = isDisabled ? DISABLED_COLOR : makeBtnColor(themeColor);
接著就是他滑鼠的 cursor 我們可以給他禁用圖標,而且由於禁用的按鈕不會有點擊事件,所以我們也取消讓人家覺得他可以點的樣式,例如 hover 及 active 的樣式。
const disabledStyle = css`
cursor: not-allowed;
&:hover, &:active {
opacity: 1;
}
`;
const StyledButton = styled.button`
//...other style
${(props) => (props.$isDisabled ? disabledStyle : null)}
`;
另外, onClick 事件我們希望在元件內就讓他 disabled,因為如果我們只有處理 disabled 的樣式,但是忘記處理 onClick 事件,就會讓禁用按鈕也觸發事件,這樣會讓人覺得很奇怪,所以比起每次從外面 onClick 的 props 來處理,不如在元件內就把他處理好,避免哪天自己不小心忘記而產生 bug。
<StyledButton
{...props}
onClick={isDisabled ? null : props.onClick}
>
{children}
</StyledButton>
isLoading
按鈕的載入狀態我們讓他在文字的左邊有一個 circular progress
實作上的想法如下,當 isLoading 為 true 的時候,我們就顯示 circular progress,就是這麼單純,然後稍微調整一下他的大小、圖示與文字的對齊、間距就可以了:
const Button = (props) => (
<StyledButton {...props}>
{isLoading && (
<StyledCircularProgress
$variant={variant}
$color={btnColor}
size={16}
/>
)}
<span>{children}</span>
</StyledButton>
);
考慮到樣式上變化的細節,當 variant 不同時,circular progress 的顏色也需要不同,這部分如果忘記處理就會看起來怪怪的,但基本上就是跟著文字的顏色走應該就沒錯了。
const StyledCircularProgress = styled(CircularProgress)`
margin-right: 8px;
color: ${(props) => (props.$variant === 'contained' ? '#FFF' : props.$color)} !important;
`;
另外值得一提的部分是, Button 會有 loading 狀態的時候,大部分的狀況是表單送出在等待 API response 的時候,所以在 loading 狀態,是否還允許使用者點擊按鈕繼續觸發 API request 呢?很多情境下其實我們不希望這樣的狀況發生。
如果這個 loading 瞬間完成,快到使用者無法點兩下,那倒還好,但如果真的都這麼快,好像我們也不用特別做一個 loading 狀態放在那邊告知使用者。
因此,若考慮到上述狀況,我們直接讓 loading 狀態的時候就 disable Button,其實也是蠻不錯的,這樣的機制要直接做在共用 Button 內?還是要由元件外面的條件來控制?我覺得也是一個值得討論的議題,但團隊有共識還是最重要的。
startIcon & endIcon
在按鈕文字的左邊、右邊放上 icon ,邏輯上跟 isLoading 差不多,就是 props 有傳入,就讓他出現,沒傳入,就不要 render 出來。
因為 startIcon & endIcon 都是由外部傳入的,因此樣式也可以由外部來控制,不會被綁死在元件當中。
甚至如果我們不喜歡這個 startIcon & endIcon ,我們直接把 icon 跟按鈕內容透過 children 傳進來,以這樣的架構來看也是可以做到的,不過既然我們都已經提供了 startIcon & endIcon ,非特殊狀況下,我們還是希望整個專案中能夠統一寫法:
const Button = (props) => (
<StyledButton {...props}>
/* 省略程式碼... */
{startIcon && <StartIcon>{startIcon}</StartIcon>}
<span>{children}</span>
{endIcon && <EndIcon>{endIcon}</EndIcon>}
</StyledButton>
);
客製化樣式
對於這個按鈕特殊情況下的樣式,我們也保留了客製化樣式的空間,例如我們允許從外面傳入 className 以及 style 這兩個 props,所以我們可以做到如下的操作,藉此來改變透過其他 props 無法調整的樣式:
Button 元件原始碼:
Source code
Storybook:
Button
不好意思請教一下,為什麼你的props的'.'之後要加一個'$'字號來取得變數值呢?是習慣嗎?因為蠻少看到這種把prop前面加'$'號的寫法,還是
這是用來取default值的方式?因為我把$字號拿掉後是可以正常運作的除了variant這個prop
和CircularProgress 中的variant 衝突需要改名外都是可以正常運作。
您好,感謝您的回覆!
在 styled-components v5.1
之後支援這個功能,加上「$」符號為命名的變數,可以讓此 props 變數成為一個臨時的變數,只會停留在 styled-components 這一層,可以防止他傳遞到下一層的 React 節點。
非常謝謝講解!