从零构建自己的React组件库(壹)—— Button

476 阅读6分钟

阅前提醒:moon-ui 为学习向组件库,所以要求不会特别高,会实现一些基本功能。

一、Button 组件需求分析

1.1 列出 Button 可能需要的一些参数

  1. 类型 Type
  • Primary
  • Dashed
  • Link
  • Default
  1. 大小 Size
  • Large
  • Medium
  • Small
  1. 状态
    • Disabled
    • Loading
    • Danger

1.2 一般 Button 组件的使用

<Button
  size="large"
  btnType="primary"
  disabled
  href=""?
  className=""?
  autoFocus=""?
  loading=""?
  ...
  >
  Button Text
</Button>

二、代码编写

2.1 基本类型及参数确定

首先根据前面的需求分析确定我们的 Button 组件需要有哪些状态 或类型 等,将其以代码的形式表现出来

// src/components/Button/button.tsx
export enum ButtonSize {
    Large = 'large',
    Medium = 'medium',
    Small = 'small'
}

export enum ButtonType {
    Primary = 'primary',
    Default = 'default',
    Link = 'link',
    Dashed = 'dashed',
}

interface BaseButtonProps {
    className?: string;
    disabled?: boolean;
    loading?: boolean;
    danger?: boolean,
    size?: ButtonSize;
    type?: ButtonType;
    shape?: ButtonShape;
    href?: string;
    children?: React.ReactNode;
}

2.2 针对 Button 组件进行编码

根据上面的props,先将其展开,然后进行使用。其实大多数都是针对于不同的状态给不同的样式(加 class),那么为了更好的处理这种情况,安装一下 classnames 这个库。

yarn add classnames @types/classnames

另外再对 LinkButton 进行一下特殊处理成 a 标签,基本上一个的大体Button按钮就出来了

// src/components/Button/button.tsx
// ......

const Button: React.FC<BaseButtonProps> = (props) => {
  const {
    className,
    disabled,
    loading,
    danger,
    size,
    btnType,
    href,
    children
  } = props

  // btn 是固定的 class
  const classes = classNames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    'loading': loading,
    // 类型为链接时需要特殊添加disabled样式,按钮是元素本身就存在这个属性的
    'disabled': btnType === ButtonType.Link && disabled, 
    'danger': danger
  })


  // 针对 link 类型的按钮进行特殊处理
  if (btnType === ButtonType.Link && href) {
    return (
      <a href={href} className={classes}>
        {children}
      </a>
    )
  }

  return (
    <button
      className={classes}
      disabled={disabled}
      >
      {children}
    </button>
  )
}

// 添加上默认属性
Button.defaultProps = {
  disabled: false,
  loading: false,
  danger: false,
  btnType: ButtonType.Default,
}

export default Button

接着我们来测试一下是否根据我们所想的那样,给不同的参数添加不通的 class 或 设置成 link 是 展现的是 a 标签

// src/App.tsx

import Button from "./components/Button/button";

function App() {
        return (
            <div className="App">
                <Button>Defalut</Button>
                <Button disabled>Disabled</Button>
                <Button btnType={ButtonType.Primary}>Primary</Button>
                <Button btnType={ButtonType.Link} href="https://www.google.com">Link</Button>
                <Button btnType={ButtonType.Link} href="https://www.google.com" disabled>Link Disabled</Button> 
                <Button btnType={ButtonType.Dashed}>Dashed</Button>
                <Button danger>Danger</Button>
                <Button btnType={ButtonType.Text}>Text</Button>
                <Button size={ButtonSize.Large}>Large</Button>
                <Button size={ButtonSize.Medium}>Medium</Button>
                <Button size={ButtonSize.Small}>Small</Button>
            </div>
        );
}

export default App;

2.3 初步展现效果

在浏览器中打开的效果与期望一致。 image.png 接下来的工作就是根据 class 去添加其对应的的样式了

三、样式添加

1. 基本样式添加

按钮的样式基本上就是确定 padding、font-size、line-height,以及不同 size 大小的按钮和disabled 时的样式,根据这些,在_variables.scss文件中添加这些基本样式

// src/styles/_variables.scss
// ......

// 边框 和 border radius

$border-width:                1px !default;
$border-color:                $gray-300 !default;

$border-radius:               .25rem !default;
$border-radius-lg:            .3rem !default;
$border-radius-sm:            .2rem !default;

// 按钮
// 按钮基本属性
$btn-font-weight:             400;
$btn-padding-y:               .375rem !default;
$btn-padding-x:               .75rem !default;
$btn-font-family:             $font-family-base !default;
$btn-font-size:               $font-size-base !default;
$btn-line-height:             $line-height-base !default;

//不同大小按钮的 padding 和 font size
$btn-padding-y-sm:            .25rem !default;
$btn-padding-x-sm:            .5rem !default;
$btn-font-size-sm:            $font-size-sm !default;

$btn-padding-y-lg:            .5rem !default;
$btn-padding-x-lg:            1rem !default;
$btn-font-size-lg:            $font-size-lg !default;

// 按钮边框
$btn-border-width:            $border-width !default;

// 按钮其他
$btn-box-shadow:              inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;
$btn-disabled-opacity:        .65 !default;

// 链接按钮
$btn-link-color:              $link-color !default;
$btn-link-hover-color:        $link-hover-color !default;
$btn-link-disabled-color:     $gray-600 !default;


// 按钮 radius
$btn-border-radius:           $border-radius !default;
$btn-border-radius-lg:        $border-radius-lg !default;
$btn-border-radius-sm:        $border-radius-sm !default;

$btn-transition:              color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;

然后在src/components/Button目录下添加_style.scss文件,在内加上Button组件的基本样式

.btn {
	position: relative;
	display: inline-block;
	font-weight: $btn-font-weight;
	line-height: $btn-line-height;
	color: $body-color;
	white-space: nowrap;
	text-align: center;
	vertical-align: middle;
	background-image: none;
	border: $btn-border-width solid transparent;
	padding: $btn-padding-y $btn-padding-x;
	font-size: $btn-font-size;
	border-radius: $btn-border-radius;
	box-shadow: $btn-box-shadow;
	cursor: pointer;
	transition: $btn-transition;
	&.disabled,
	&[disabled] {
		cursor: not-allowed;
		opacity: $btn-disabled-opacity;
		box-shadow: none;
    // 让disabled掉的事件也失效
		> * {
			pointer-events: none;
		}
	};
}

此时Button组件看上有了基本轮廓了,接着再根据不同的类型添加样式,使Button组件更加符合预期的效果

2. size 样式添加

为了能让css代码重用,在 styles 目录下创建一个_mixin.scss文件,然后导入到 index.scss

// src/styles/index.scss

// config
@import 'variables';

// layout
@import 'reboot';

// mixin
@import './mixin';

// button
@import '../components/Button/style'

先尝试在在_mixin.scss文件中先写上共用函数button-size

// src/styles/_mixin.scss

@mixin button-size($padding-y, $padding-x, $font-size, $border-raduis) {
	padding: $padding-y $padding-x;
	font-size: $font-size;
	border-radius: $border-raduis;
}

将刚刚写的 .btn中的这部分替换成 mixin 函数,接着再针对 size 添加不同的样式

// src/components/Button/style.scss

.btn {
  ...
  border: $btn-border-width solid transparent;
	@include button-size($btn-padding-y, $btn-padding-x, $btn-font-size, $btn-border-radius);
	// 被替换 padding: $btn-padding-y $btn-padding-x;
	// 被替换 font-size: $btn-font-size;
	// 被替换 border-radius: $btn-border-radius;
	box-shadow: $btn-box-shadow;
  ...
}

.btn-large {
	@include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);	
}

.btn-small {
	@include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm);	
}

页面上成功展示的size区别已经出来了 image.png

3. type 样式添加

_mixin.scss中写上 Button 的公共样式函数button-style

// src/styles/_mixin.scss

@mixin button-size($padding-y, $padding-x, $font-size, $border-raduis) {
	padding: $padding-y $padding-x;
	font-size: $font-size;
	border-radius: $border-raduis;
}

// 添加 Button 的类型共用函数
@mixin button-style(
	$background,
	$border,
	$color,
	$hover-background: lighten($background, 7.5%), // sass 内置函数 
	$hover-border: lighten($border, 10%),
	$hover-color: $color
) {
	color: $color;
	background: $background;
	border-color: $border;
	&:hover {
		color: $hover-color;
		background: $hover-background;
		border-color: $hover-border;
	}
	&:focus,
	&:focus-visible {
		color: $hover-color;
		background: $hover-background;
		border-color: $hover-border;
	}
	// 针对按钮的 disabled 的处理
	&:disabled,
	&.disabled {
		color: $color;
		background: $background;
		border-color: $border;
	}
}

接着在Button/style.scss中添加几种类型的按钮样式

// src/components/Button/style.scss

//......

.btn-primary {
	@include button-style($primary, $primary, $white)
}


.btn-default {
	@include button-style($white, $gray-400, $body-color, $white, $primary, $primary)
}

.btn-dashed {
	@include button-style($white, $gray-400, $dark, $white, $primary, $primary);
	border-style: dashed;
}

.btn-link {
	font-weight: $font-weight-normal;
	color: $btn-link-color;
	text-decoration: $link-decoration;
	box-shadow: none;
	&:hover {
		color: $btn-link-hover-color;
		text-decoration: $link-hover-decoration;
	}
	&:focus,
	&.focus {
		text-decoration: $link-hover-decoration;
		box-shadow: none;
	}
	&:disabled,
	&.disabled {
		color: $btn-link-disabled-color;
		pointer-events: none;
	}
}

已经在视觉上基本上达到了要求了 image.png

4. 状态样式添加

状态中已经添加上了 disabled 了,还需要添加 danger 与 loading 了。其中 danger 很简单

// src/components/Button/style.scss
// ......

.btn-danger {
	@include button-style($white, $danger, $danger)
}

而 loading 需要一个图标,在 iconfont 上找一个心仪的图标下载成svg格式,接着将其svg标签的内容拷贝出来,在src目录下新建icons/loadingOutlined/index.tsx_style.scss 文件。

// icons/loadingOutlined/index.tsx

export default function LoadingOutlined() {
    return (
        <span className="anticon anticon-loading anticon-spin">
            <svg
                viewBox="0 0 1024 1024"
                focusable="false"
                data-icon="loading"
                width="1em"
                height="1em"
                fill="currentColor"
                aria-hidden="true"
                className="icon"
                version="1.1"
                xmlns="http://www.w3.org/2000/svg"
                p-id="1944"
                xmlnsXlink="http://www.w3.org/1999/xlink"
            >
                <path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" />
            </svg>
        </span>
    )
}
// icons/loadingOutlined/_style.tsx

.anticon-loading{
	transition: margin-left .3s cubic-bezier(.645,.045,.355,1);
	display: inline-block;
	color: inherit;
	font-style: normal;
	line-height: 0;
	text-align: center;
	text-transform: none;
	vertical-align: 0.2em;
	text-rendering: optimizelegibility;
	animation: none;
	padding-right: 8px;
	>* {
		line-height: 1;
	};
	svg {
		animation: loadingCircle 1s infinite linear;
		display: inline-block;
	}
}

@keyframes loadingCircle {
	100% {
		transform: rotate(360deg);
	}
}

添加好样式以后在Button组件中使用即可

// src/components/Button/button.tsx

// ......

  return (
    <button
        className={classes}
        style={style}
        disabled={disabled}
    >
        {
            loading && (
                <span className="btn-loading-icon">
                    <LoadingOutlined />
                </span>
            )
        }
        <span>{children}</span>
    </button>
  )

// ......

效果如下 May-10-2022 19-03-13.gif

四、完善优化

到这里,其实组件的完善程度已经有个七七八八了,还剩一些按钮和链接的属性需要添加上来了。下面是Button组件的完整代码,其中添加原生属性的代码在 line39 - line54,以及对应的a标签和button标签上的 resetProps

// src/components/Button/button.tsx
import classNames from "classnames";
import LoadingOutlined from "../../icons/loadingOutlined/index";

export enum ButtonSize {
	Large = 'large',
	Medium = 'medium',
	Small = 'small'
}

export enum ButtonType {
	Primary = 'primary',
	Default = 'default',
	Link = 'link',
	Dashed = 'dashed',
	Text = 'text'
}

export enum ButtonShape {
	Round = 'round',
	Circle = 'circle',
	Default = 'default'
}

interface BaseButtonProps {
	className?: string;
	disabled?: boolean;
	loading?: boolean;
	danger?: boolean;
	size?: ButtonSize;
	btnType?: ButtonType;
	shape?: ButtonShape;
	href?: string;
	style?: React.CSSProperties;
	children?: React.ReactNode;
}

// 添加原生组件所需的 props
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>
// Partial 将传入参数可选化, ButtonProps 内的属性都变成了可选项了
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>
// 设置成 ButtonProps
const Button: React.FC<ButtonProps> = (props) => {
    const {
        className,
        disabled,
        loading,
        danger,
        size,
        btnType,
        href,
        children,
        ...restProps
    } = props

    // 默认先添加一个btn类
    // const classes = classNames('btn')

    // 接着根据不同的条件添加不同的类
    const classes = classNames('btn', className, {
        [`btn-${btnType}`]: btnType,
        [`btn-${size}`]: size,
        // [`btn-${shape}`]: shape,
        'btn-danger': danger,
        'loading': loading,
        // 类型为链接时需要特殊添加disabled样式,按钮是元素本身就存在这个属性的
        'disabled': btnType === ButtonType.Link && disabled,
    })

    // 针对 link 类型的按钮进行特殊处理
    if (btnType === ButtonType.Link) {
        return (
            <a
                href={href}
                className={classes}
                style={style}
                {...restProps}
            >
		{children}
            </a>
	)
    }

    return (
        <button
            className={classes}
            style={style}
            disabled={disabled}
            {...restProps}
        >
            {
                loading && (
                    <span className="btn-loading-icon">
                        <LoadingOutlined />
                    </span>
                )
            }
            <span>{children}</span>
        </button>
    )
}

Button.defaultProps = {
    disabled: false,
    loading: false,
    danger: false,
    btnType: ButtonType.Default,
}

export default Button

五、测试

也许一般的写个组件用一下,发现功能复合预期就完了,但这里既然要写组件库就要写严谨一点,怎么也不可能少了测试用例~

因为篇幅及文章类型问题,这里我直接把我写的测试用例给出来。

// src/components/Button/button.test.tsx
import { fireEvent, render } from '@testing-library/react'
import { ReactNode } from 'react'

import Button, { ButtonProps, ButtonSize, ButtonType } from './button'

// 小试牛刀一下
// test('our first react test case', () => {
// 	const wrapper = render(<Button>i am a button</Button>)
// 	const element = wrapper.queryByText('i am a button')
// 	expect(element).toBeTruthy()
// 	expect(element).toBeInTheDocument()
// })

const defaultProps = {
  onClick: jest.fn() // jest 提供的监控事件
}

const testProps: ButtonProps = {
  btnType: ButtonType.Primary,
  size: ButtonSize.Small,
  className: 'test-class-name'
}

function getRenderButtonElement(child: ReactNode, props: ButtonProps = {},) {
  const { container } = render(<Button {...props}>{child}</Button>)
  return container.firstElementChild
}

describe('test Button component', () => {
  it('should render the correct default button', () => {
    const element = getRenderButtonElement('default', defaultProps)
    expect(element).toBeInTheDocument()
    
    if (element) {
      expect(element.tagName).toEqual('BUTTON')
      expect(element).toHaveClass('btn btn-default')
      
      fireEvent.click(element) // jest 提供的触发事件 fireEvent
      expect(defaultProps.onClick).toHaveBeenCalled()
    }
  })
  it('should render the correct component based on different props', () => {
    const element = getRenderButtonElement('different props', testProps)
    expect(element).toBeInTheDocument()
    
    expect(element).toHaveClass('btn btn-primary btn-small test-class-name')
  })
  
  it('should render the correct component when danger set to true', () => {
    const element = getRenderButtonElement('danger', { danger: true })
    expect(element).toBeInTheDocument()
    
    expect(element).toHaveClass('btn btn-danger')
  })
  
  it('should render the correct component when danger set to true and btnType equals primary', () => {
    const element = getRenderButtonElement('primary danger', { danger: true, btnType: ButtonType.Primary })
    expect(element).toBeInTheDocument()
    
    expect(element).toHaveClass('btn btn-danger btn-primary')
  })
  
  it('should render a link when btnType equals link and href provided', () => {
    const element = getRenderButtonElement('link', { btnType: ButtonType.Link, href: 'www.google.com' })
    expect(element).toBeInTheDocument()
    
    expect(element?.tagName).toEqual('A')
    expect(element).toHaveClass('btn btn-link')
    expect(element?.getAttribute('href')).toBe('www.google.com')
    
  })
  
  it('should render disabled button when disabled set to true', () => {
    const element = getRenderButtonElement('disabled', { disabled: true, ...defaultProps }) as HTMLButtonElement
    expect(element).toBeInTheDocument()
    expect(element?.getAttribute('disabled'))
    expect(element.disabled).toBeTruthy()
    
    fireEvent.click(element)
    expect(defaultProps.onClick).not.toHaveBeenCalled()
    
    
    const element1 = getRenderButtonElement('disabled link', { btnType: ButtonType.Link, disabled: true }) as HTMLAnchorElement
    expect(element1).toBeInTheDocument()
    expect(element1).toHaveClass('btn btn-link btn-disabled')
    
    fireEvent.click(element1)
    expect(defaultProps.onClick).not.toHaveBeenCalled()
  })
  
  it('should render loading button when loading set to true', () => {
    const element = getRenderButtonElement('disabled', { loading: true })
    expect(element).toBeInTheDocument()
    expect(element).toBeInTheDocument()
    expect(element?.firstElementChild).toHaveClass('btn-loading-icon')
  })
  
})

最终运行 yarn test获得测试结果(下图使用的是yarn test --coverage得到的测试覆盖率)

image.png

经过测试,我们的 Button 组件的测试覆盖度达到了100%。

总结

经过需求分析,基本的代码书写,样式及其他功能的完善这一系列的步骤下来逐步的完善Button组件,得到了一个还算ok的组件了。

接下来我将会去写其他的组件(具体是什么组件待定),但是这种步骤需要调整一下,经过我在测试时发现,的确先写测试用例,再去写实际代码会更优一些,所以步骤上会将测试部分提前并融入到书写和优化的每一个步骤中,这也就是大名鼎鼎的TDD开发模式。 加油!冲鸭!