阅前提醒:moon-ui 为学习向组件库,所以要求不会特别高,会实现一些基本功能。
一、Button 组件需求分析
1.1 列出 Button 可能需要的一些参数
- 类型 Type
- Primary
- Dashed
- Link
- Default
- 大小 Size
- Large
- Medium
- Small
- 状态
- 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 初步展现效果
在浏览器中打开的效果与期望一致。
接下来的工作就是根据 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区别已经出来了
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;
}
}
已经在视觉上基本上达到了要求了
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>
)
// ......
效果如下
四、完善优化
到这里,其实组件的完善程度已经有个七七八八了,还剩一些按钮和链接的属性需要添加上来了。下面是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得到的测试覆盖率)
经过测试,我们的 Button 组件的测试覆盖度达到了100%。
总结
经过需求分析,基本的代码书写,样式及其他功能的完善这一系列的步骤下来逐步的完善Button组件,得到了一个还算ok的组件了。
接下来我将会去写其他的组件(具体是什么组件待定),但是这种步骤需要调整一下,经过我在测试时发现,的确先写测试用例,再去写实际代码会更优一些,所以步骤上会将测试部分提前并融入到书写和优化的每一个步骤中,这也就是大名鼎鼎的TDD开发模式。 加油!冲鸭!