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 开发
- 创建Button组件目录如下:
--components
--Button
--_style.scss // 样式文件
--button.stories.tsx // demo
--button.test.tsx // 单元测试
--button.tsx // 核心代码逻辑
--index.tsx // 导出组件
- 定义按钮尺寸大小枚举值
export type ButtonSize = 'lg' | 'sm'
- 定义按钮类型枚举值
export type ButtonType = 'primary' | 'default' | 'danger' | 'link'
- 定义按钮的props
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>
- 根据传入的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
})
- 判断是显示a标签还是button按钮
if (btnType === 'link' && href) {
return (
<a
className={classes}
href={href}
{...restProps}
>
{children}
</a>
)
} else {
return (
<button
className={classes}
disabled={disabled}
{...restProps}
>
{children}
</button>
)
}
- 定义按钮样式变量
代码语言:txt复制src/styles/_variable.scss
// 按钮
// 按钮基本属性
$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;
- 编写按钮基本样式
.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;
}
- 编写按钮尺寸大小的代码,这里同样适用mixin,使用button-size函数进行复用。
.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);
}
- 编写按钮类型的样式代码,这里同样适用mixin,使用了button-style,这就需要在_mixin.scss中进行定义
.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)
}
- 在_mixin.scss中定义 button-style函数
@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;
}
}
- 编写 按钮是 link 标签时候的样式代码
.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;
}
}
- 完整代码
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 单元测试
- 测试工具
代码语言:txt复制https://testing-library.com/docs/react-testing-library/intro/
pnpm install --save-dev @testing-library/react
- jest-dom
代码语言:txt复制https://testing-library.com/docs/ecosystem-jest-dom/
增加dom操作的类型断言
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-组件样式
持续更新
目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!