第一次参赛是 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
>>: EP 6: Use self-define Code Snippet to improve Coding Experience for design Command in ViewModel
大纲 "##" 该如何使用 实作一个泛型函数 参考资料 1. "##&...
前言 今天来部份自我介绍,聊聊身为 Scrum Master 的一些经历。一如系列文章的初衷,希望能...
前言 模型已经训练好了,剩下来的就是如何将模型布署到手机上, 在这之前,我要先带大家安装所需要的软件...
问你这棵树有没有哪条从root到leaf的路径,是满足路径上的节点加总起来等於targetsum的?...
在上一篇中介绍了什麽是 Angular CLI 与他可以提供许多方便功能,不过只是大概介绍他的用法与...