前端反卷计划-组件库-04-Button组件开发

2023-11-27 09:35:54 浏览数 (2)

Hi, 大家好!我是程序员库里。

今天开始分享如何从0搭建UI组件库。这也是前端反卷计划中的一项。

在接下来的日子,我会持续分享前端反卷计划中的每个知识点。

以下是前端反卷计划的内容:

目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!

Button

4.1 需求分析

以antd design的Button组件为例

按钮类型

按钮尺寸

不可用状态

4.2 Demo

代码语言:txt复制
<Button size='large' type='primary' disabled>
按钮
</Button>

4.3 API

属性

说明

类型

默认值

type

按钮类型

primary

default

danger

link

default

size

按钮尺寸

lg

sm

disabled

设置按钮失效状态

boolean

FALSE

href

点击跳转的地址,指定此属性 button 的行为和 a 链接一致

string

4.4 开发

  1. 创建Button组件目录如下:
代码语言:txt复制
--components
    --Button
        --_style.scss            // 样式文件
        --button.stories.tsx     // demo
        --button.test.tsx        // 单元测试
        --button.tsx            // 核心代码逻辑
        --index.tsx            //  导出组件
  1. 定义按钮尺寸大小枚举值
代码语言:txt复制
export type ButtonSize = 'lg' | 'sm'
  1. 定义按钮类型枚举值
代码语言:txt复制
export type ButtonType = 'primary' | 'default' | 'danger' | 'link'
  1. 定义按钮的props
代码语言:txt复制
import React, { FC, ButtonHTMLAttributes, AnchorHTMLAttributes } from 'react'

interface BaseButtonProps {
  className?: string;
  /**设置 Button 的禁用 */
  disabled?: boolean;
  /**设置 Button 的尺寸 */
  size?: ButtonSize;
  /**设置 Button 的类型 */
  btnType?: ButtonType;
  children: React.ReactNode;
  href?: string;
}
// ButtonHTMLAttributes 是 React 中的一个内置泛型类型,它用于表示 HTML 按钮元素 (<button>) 上可以接受的属性。这些属性包括按钮的标准 HTML 属性,如 onClick、disabled、type 等
type NativeButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLElement>
// AnchorHTMLAttributes 是 React 中的一个内置泛型类型,它用于表示 HTML 锚点元素 (<a>) 上可以接受的属性。这些属性包括锚点元素的标准 HTML 属性,例如 href、target、onClick 等
type AnchorButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLElement>
// Partial可选
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps> 
  1. 根据传入的props决定按钮尺寸、按钮类型等逻辑
代码语言:txt复制
const {
    btnType,
    className,
    disabled,
    size,
    children,
    href,
    ...restProps
  } = props
  // btn, btn-lg, btn-primary
  const classes = classNames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    'disabled': (btnType === 'link') && disabled
  })
  1. 判断是显示a标签还是button按钮
代码语言:txt复制
if (btnType === 'link' && href) {
    return (
      <a
        className={classes}
        href={href}
        {...restProps}
      >
        {children}
      </a>
    )
  } else {
    return (
      <button
        className={classes}
        disabled={disabled}
        {...restProps}
      >
        {children}
      </button>
    )
  }
  1. 定义按钮样式变量

src/styles/_variable.scss

代码语言:txt复制
// 按钮
// 按钮基本属性
$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;
  1. 编写按钮基本样式
代码语言:txt复制
.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;
  @include button-size( $btn-padding-y,  $btn-padding-x,  $btn-font-size,  $border-radius);
  box-shadow: $btn-box-shadow;
  cursor: pointer;
  transition: $btn-transition;
  &.disabled,
  &[disabled] {
    cursor: not-allowed;
    opacity: $btn-disabled-opacity;
    box-shadow: none;
    > * {
      pointer-events: none;
    }
  }
}

在这里为了不重复复制样式代码,我们采用mixin来处理。

比如上面代码中的@include button-size 函数,这个是scss的一个特性,可以从官网上看下介绍。

代码语言:txt复制
@include button-size( $btn-padding-y,  $btn-padding-x,  $btn-font-size,  $border-radius);

要使用上面的方法,需要在mixin编写上面的函数

新建 src/styles/_mixin.scss,编写如下代码:

这里解释一下:相当于在button-size中传了4个参数,使用这4个参数来定义样式属性,使用的时候即可传入对应的样式变量即可。

代码语言:txt复制
@mixin button-size($padding-y, $padding-x, $font-size, $border-raduis) {
  padding: $padding-y $padding-x;
  font-size: $font-size;
  border-radius: $border-raduis;
}
  1. 编写按钮尺寸大小的代码,这里同样适用mixin,使用button-size函数进行复用。
代码语言:txt复制
.btn-lg {
  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);
}
.btn-sm {
  @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm);
}
  1. 编写按钮类型的样式代码,这里同样适用mixin,使用了button-style,这就需要在_mixin.scss中进行定义
代码语言:txt复制
.btn-primary {
  @include button-style($primary, $primary, $white)
}
.btn-danger {
  @include button-style($danger, $danger, $white)
}

.btn-default {
  @include button-style($white, $gray-400, $body-color, $white, $primary, $primary)
}
  1. 在_mixin.scss中定义 button-style函数
代码语言:txt复制
@mixin button-style(
  $background,
  $border,
  $color,
  $hover-background: lighten($background, 7.5%),
  $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 {
    color: $hover-color;
    background: $hover-background;
    border-color: $hover-border;    
  }
  &:disabled,
  &.disabled {
    color: $color;
    background: $background;
    border-color: $border;    
  }
}
  1. 编写 按钮是 link 标签时候的样式代码
代码语言:txt复制
.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;
  }
}
  1. 完整代码
代码语言:txt复制
import React, { FC, ButtonHTMLAttributes, AnchorHTMLAttributes } from 'react'
import classNames from 'classnames'

export type ButtonSize = 'lg' | 'sm'
export type ButtonType = 'primary' | 'default' | 'danger' | 'link'

interface BaseButtonProps {
  className?: string;
  /**设置 Button 的禁用 */
  disabled?: boolean;
  /**设置 Button 的尺寸 */
  size?: ButtonSize;
  /**设置 Button 的类型 */
  btnType?: ButtonType;
  children: React.ReactNode;
  href?: string;
}
type NativeButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLElement>
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>
/**
 * 页面中最常用的的按钮元素,适合于完成特定的交互
 * ### 引用方法
 * 
 * ~~~js
 * import { Button } from 'curry-design'
 * ~~~
 */
export const Button: FC<ButtonProps> = (props) => {
  const {
    btnType,
    className,
    disabled,
    size,
    children,
    href,
    ...restProps
  } = props
  // btn, btn-lg, btn-primary
  const classes = classNames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    'disabled': (btnType === 'link') && disabled
  })
  if (btnType === 'link' && href) {
    return (
      <a
        className={classes}
        href={href}
        {...restProps}
      >
        {children}
      </a>
    )
  } else {
    return (
      <button
        className={classes}
        disabled={disabled}
        {...restProps}
      >
        {children}
      </button>
    )
  }
}

Button.defaultProps = {
  disabled: false,
  btnType: 'default'
}

export default Button;

4.1.14 效果展示

http://localhost:6006/?path=/docs/example-button--docs

4.5 单元测试

  1. 测试工具

https://testing-library.com/docs/react-testing-library/intro/

代码语言:txt复制
pnpm install --save-dev @testing-library/react
  1. jest-dom

https://testing-library.com/docs/ecosystem-jest-dom/

增加dom操作的类型断言

代码语言:txt复制
npm install @testing-library/jest-dom --save-dev

create-react-app已经帮我们导入了src/setupTests.ts

代码语言:txt复制
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

安装jest的ts类型,create-react-app默认自带了,就不用安装了。之前版本没有,就需要安装。

代码语言:txt复制
npm install --save-dev @types/jest

4.5.1 测试1:展示正确的默认按钮

代码语言:txt复制
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import Button from './button'

const defaultProps = {
    onClick: jest.fn()
}

describe('test Button component', () => {
    // 渲染正确的默认的按钮
    it('should render the correct default button', () => {
      // 通过 render 来渲染一个按钮,赋值给wrapper
      const wrapper = render(<Button {...defaultProps}>按钮</Button>)
      // 使用wrapper的getByText获取指定文字的按钮,赋值给element
      const element = wrapper.getByText('按钮') as HTMLButtonElement
      // 使用expect函数,表示断言。将element传入expect,调用toBeInTheDocument表示按钮插入到了页面中
      expect(element).toBeInTheDocument()
      // 获取按钮的tagName,使用toEqual函数看按钮的tagName是否叫BUTTON
      expect(element.tagName).toEqual('BUTTON')
      // 使用toHaveClass函数来判断按钮是否有btn btn-default这两个class
      expect(element).toHaveClass('btn btn-default')
      // 传入按钮的disabled,使用toBeFalsy来判断按钮是否带有disabled属性,toBeFalsy表示false
      expect(element.disabled).toBeFalsy()
      // 使用fireEvent执行点击事件
      fireEvent.click(element)
      // 执行上述点击事件后,使用toHaveBeenCalled来判断按钮是否被点击了,toHaveBeenCalled表示按钮被点击了。
      expect(defaultProps.onClick).toHaveBeenCalled()
    })
})

在终端输入:npm run test 执行下测试用例,看是否通过。可以看到测试用例通过了。

4.5.2 测试2:根据传入的props渲染对应的按钮

代码语言:txt复制
const testProps: ButtonProps = {
    btnType: ButtonType.Primary,
    size: ButtonSize.Large,
    className: 'klass'
}

it('should render the correct component based on different props', () => {
        const wrapper = render(<Button {...testProps}>按钮</Button>)
        const element = wrapper.getByText('按钮')
        expect(element).toBeInTheDocument()
        expect(element).toHaveClass('btn-primary btn-lg klass')
    })

测试结果:

4.5.3 测试3:测试按钮类型是a标签

代码语言:txt复制
 it('should render a link when btnType equals link and href is provided', () => {
        const wrapper = render(<Button btnType='link' href="http://www.baidu.com">Link</Button>)
        const element = wrapper.getByText('Link')
        expect(element).toBeInTheDocument()
        expect(element.tagName).toEqual('A')
        expect(element).toHaveClass('btn btn-link')
    })

测试结果:

4.5.4 测试4:测试按钮的disabled属性

代码语言:txt复制
const disabledProps: ButtonProps = {
    disabled: true,
    onClick: jest.fn(),
}
it('should render disabled button when disabled set to true', () => {
        const wrapper = render(<Button {...disabledProps}>按钮</Button>)
        const element = wrapper.getByText('按钮') as HTMLButtonElement
        expect(element).toBeInTheDocument()
        expect(element.disabled).toBeTruthy()
        fireEvent.click(element)
        expect(disabledProps.onClick).not.toHaveBeenCalled()
    })

测试结果:

以上4个测试用例全部通过。

4.5.6 完整测试用例代码

代码语言:txt复制
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import Button, { ButtonProps, ButtonSize, ButtonType } from './button'

const defaultProps = {
    onClick: jest.fn()
}

const testProps: ButtonProps = {
    btnType: ButtonType.Primary,
    size: ButtonSize.Large,
    className: 'klass'
}
const disabledProps: ButtonProps = {
    disabled: true,
    onClick: jest.fn(),
}

describe('test Button component', () => {
    it('should render the correct default button', () => {
        const wrapper = render(<Button {...defaultProps}>按钮</Button>)
        const element = wrapper.getByText('按钮') as HTMLButtonElement
        expect(element).toBeInTheDocument()
        expect(element.tagName).toEqual('BUTTON')
        expect(element).toHaveClass('btn btn-default')
        expect(element.disabled).toBeFalsy()
        fireEvent.click(element)
        expect(defaultProps.onClick).toHaveBeenCalled()
    })

    it('should render the correct component based on different props', () => {
        const wrapper = render(<Button {...testProps}>按钮</Button>)
        const element = wrapper.getByText('按钮')
        expect(element).toBeInTheDocument()
        expect(element).toHaveClass('btn-primary btn-lg klass')
    })
    it('should render a link when btnType equals link and href is provided', () => {
        const wrapper = render(<Button btnType='link' href="http://www.baidu.com">Link</Button>)
        const element = wrapper.getByText('Link')
        expect(element).toBeInTheDocument()
        expect(element.tagName).toEqual('A')
        expect(element).toHaveClass('btn btn-link')
    })
    it('should render disabled button when disabled set to true', () => {
        const wrapper = render(<Button {...disabledProps}>按钮</Button>)
        const element = wrapper.getByText('按钮') as HTMLButtonElement
        expect(element).toBeInTheDocument()
        expect(element.disabled).toBeTruthy()
        fireEvent.click(element)
        expect(disabledProps.onClick).not.toHaveBeenCalled()
    })
})

系列篇

前端反卷计划-组件库-01-环境搭建

前端反卷计划-组件库-02-storybook

前端反卷计划-组件库-03-组件样式

持续更新

目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!

0 人点赞