【Day01】数据输入元件 - Button

参赛前言

第一次参赛是 2019铁人赛(连结),也是刚接触 React 不久,透过那次真的觉得收获良多。虽然参加完有种这辈子除非疯了,不然绝不可能参加第二次的心情,但看到後来铁人赛得奖作品居然可以出书,所以今年终於下定决心要再来疯一次,毕竟人可以老,但青春不能老/images/emoticon/emoticon65.gif

这次选的题目,在头洗下去之後才觉得怎麽这个题目有点大/images/emoticon/emoticon04.gif,而且真的还蛮需要经验才有办法驾驭这个题目,因此在准备的过程当中也越来越觉得自己很渺小,但也因为有遇到困难,才算得上是一个挑战,越不容易完成,在真的完成之後才越觉得珍惜和可贵。

虽然青春的热血无限,但毕竟时间有限,希望在有限的时间当中能够尽力而为,跟队友们、参赛者们、读者们一起完成这个挑战!


内容提要

这次如同参赛题目,以 React 框架来进行实作,每篇的架构主要为:

  • 元件介绍
  • 参考设计 & 属性分析
  • 介面设计
  • 元件实作

但会依照不同的需要做一些微调,但希望能尽量保持一致。
这次会用到处理样式的工具是选用 styled-component,styled-component 是一个 CSS-In-JS 的函式库,他将样式元件化的概念能让我们在编写 React 元件时看起来更加简洁,也有许多很好用的特性来帮助我们处理复杂的样式,例如我们可以像在处理元件一样的将 props 传入,并且撰写一些判断逻辑来处理 CSS 样式等等。

另外在参考设计的部分,由於时间有限,因此先选用知名的 Material-UI 以及 Antd 来参考为主,其他的函式库或是参考文章,会视文章需要及时间许可的条件选择性的加入。

为了避免太过於流水帐的撰写,元件实作的部分只会提到一些关键步骤,其他部分会在文章最後附上完整 source code 以及能帮助我们 preview 的 storybook 提供给大家参考。

希望以上描述能够帮助到读者,那我废话到此为止,开赛啦!!!/images/emoticon/emoticon08.gif


元件介绍

Button 元件代表一个可点击的按钮,在使用者点击之後会触发相对应的业务逻辑。
在过往的经验当中,我们常在下面情境下会需要按钮:

  • 填完表单之後要点击「确认」来送出资料,或是点击「取消」来放弃编辑
  • 复杂的表单动作,有「上一步」、「下一步」的按钮
  • 操作网站上一些无法复原的危险动作(ex: 删除资料),需要第二次的确认「按钮」
  • 在需要编辑资料的系统,我们要做一些 CRUD 相关的操作
    • 点开文章以浏览详情
    • 新增文章
    • 编辑文章
    • 删除文章
    • 上传档案
  • 为了引导使用者前往购物、前往注册、前往你希望他去的地方,藉此来达成一些商业目的等等,也需要明显的按钮来引导他下一步该做什麽

按钮是几乎在每一个网站上都会出现的元件,虽然如此,但是他被放置在不同地方所希望达到的目的也不尽相同,上面信手捻来就可以举出很多例子。但是也因为同一个元件要用来做很多不同的事情,因此他的变化也很多。

虽然很常出现而被觉得是一个很简单的元件,但没有仔细考虑的话,很多细节会容易被忽略,很容易发生一开始设计了一个想要全站共用样式的按钮,但变化到後来因为一开始没有考虑周详,导致後来无法共用而不得不再重复造轮子的惨案发生。

参考设计 & 属性分析

在设计元件的时候,一方面我们会担心自己考虑得不够周详,另一方面也会担心设计出来的东西过於特立独行,自己以为这样很好,但是其实没有人这样做,虽然说得上是创新,但反过来,也很容易让使用者甚至其他开发者无法理解你的好意。

因此我通常会找一些知名的 React UI library 来参考他的样式及介面的设计,比较常参考的有

  • Material UI (MUI)
  • Ant Design (Antd)
  • React Bootstrap

变化模式

变化模式上面的变化,Material UI 叫做 variant,Ant Design 叫做 type,Bootstrap 里面看起来比较类似的属性是 variant,但是 Bootstrap 的 variant 里面同时可以控制颜色以及描边样式。

在变化模式下面有几种变化

  • contained button 实心按钮
  • outlined button 描边按钮
  • dashed button 虚线按钮
  • text button 文本按钮
  • link button 连结按钮

MUI 上面没有特别区分 text button 和 link button,甚至连 dashed button 也没有。以变化模式上来看的话,Antd 似乎是比较丰富。

但其实我个人是觉得并不会因为 MUI 他在 variant 里面没有那麽多种变化,就表示他输人家一筹,因为或许有些样式并不是很常用到需要纳入 variant ,毕竟要多维护一种样式也是需要增加维护成本,而且透过 MUI 的客制化样式的机制也能够做到同样的效果。以我个人开发的经验,老实说我真的也不是很常遇到需要 dashed button 的时候,所以我觉得不能用这样「我有你没有」的标准来断定一个 library 的优劣,毕竟不同元件库有他不同的使用情境。反倒是我觉得这个元件库方不方便我们客制化,才是我比较在意的。

所以到底要不要把每一种变化都纳入自己的设计考量,我觉得也倒不一定,需要考虑自己网站的需求,或是跟团队、设计师一起来讨论。不过因为我们知道有这些变化的可能性之後,也能避免把 code 写死,导致以後如果哪天哪根筋不对,突然又需要的时候,可以不需要大改动就能够扩充。

颜色属性

MUI 的颜色属性叫做 color ,可以传入的参数有

  • primary
  • secondary
  • default
  • inherit

Antd 看起来似乎没有特别的 color 属性,他预设是 primary 蓝色,若 danger 属性为 true 就会变红色。

Bootstrap 是透过 variant 属性来决定,有

  • primary
  • secondary
  • success
  • warning
  • danger
  • info
  • light
  • dark
  • link

当我们在做後台系统或是不需要那麽缤纷的 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 是到处都会用到的元件,所以整个系统被这个东西污染得到处都是,已经到很难回头的地步,我也很後悔当初没有硬起来阻止这件事的发生。

我自己开发的建议是希望能够直接沿用原本元件的事件属性,因为

  • 假设你有些地方要 onClick,有些地方要 handleClick,这样其实很容易会写错,若今天来了一位新同事,我相信他第一直觉一定会写 onClick ,而不是 handleClick。
  • 由於除了 onClick 之外,也有很多其他的事件属性,例如 onFocus, onBlur, onChange...等等,那是不是每个地方都要统一改名字叫做 handleXXX ? 若有一个地方没改到,那事件属性命名也就会产生不一致的问题,对於其他工程师要来维护这个元件会造成困扰。
  • 假设今天公司有多个专案,以 A、B 专案为例,A 专案设计了一个 button,里面事件叫做 onClick,B 专案也设计了一个 button,里面的事件叫做 handleClick。第一个问题是,我们在维护上会如同前面说的容易混淆写错。第二个是,假设今天公司要统一样式,那要把 B 专案的 button 从底层抽换成 A 专案的 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
  • 合法的颜色代码,ex: #1976d2
  • 不小心把上述两者打错字,或是根本就传一个不合法的字串

所以我们的逻辑是这样的:

  1. 检查是否为合法的颜色代码?我们可以用 regular expression 来帮助我们检查。
  2. 若合法,则直接回传;若不合法,则检查是否为保留字(primary, secondary)?
  3. 若是保留字,则将保留字转换成对应的颜色代码。
  4. 若都不是,则给他一个预设值,我们预设是 primary 的颜色代码。
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


<<:  无法上网?请询问你的 ISP:何谓网路服务供应商?

>>:  EP 6: Use self-define Code Snippet to improve Coding Experience for design Command in ViewModel

[C 语言笔记--Day10] 如何用 C 语言实作一个泛型函数

大纲 "##" 该如何使用 实作一个泛型函数 参考资料 1. "##&...

成为 Scrum Master

前言 今天来部份自我介绍,聊聊身为 Scrum Master 的一些经历。一如系列文章的初衷,希望能...

[Day 23] Android Studio 七日陨石开发:安装与创建第一个专案 (上)

前言 模型已经训练好了,剩下来的就是如何将模型布署到手机上, 在这之前,我要先带大家安装所需要的软件...

Leetcode: 112. Path Sum

问你这棵树有没有哪条从root到leaf的路径,是满足路径上的节点加总起来等於targetsum的?...

[Angular] Day3. angular.json

在上一篇中介绍了什麽是 Angular CLI 与他可以提供许多方便功能,不过只是大概介绍他的用法与...