React + TypeScript 仿antd组件库

399 阅读4分钟

0. 组件库色彩体系

  • 景泰蓝 #259ce8 RGB - 39 117 182
  • 黄昏灰 #474b4c RGB - 71 75 76
  • 竹绿 #1ba784 RGB - 27 167 132
  • 茉莉黄 #f8df72 RGB - 248 223 114
  • 夕阳红 #de2a18 RGB - 222 42 24
  • 碧青色 #5cb3cc RGB - 92 174 204

添加normalize.scss 作为reboot.scss
设置variables.scss 作为变量定义文件

1. Button组件

  • Button 不同的Type - primary, default, danger, Link Button
  • Button 不同的Size - small, normal, large
  • Button 不同的状态 - disabled

使用 classNames 小工具

npm install classnames
npm i @types/classnames --save

1.1 Button需求分析和设计思路

image.png

//定义Button组件的props接口 即可以传什么值给Button组件
interface BaseButtonProps {
    className?: string;
    disabled?: boolean;
    size?: ButtonSize;
    btnType?: ButtonType;
    children: React.ReactNode;
    href?: string;
}

// 解构赋值取出props
const {
    btnType,
    //用户自定义的className
    className,
    disabled,
    size,
    children,
    href,
    // 剩下的Props
    ...restProps
} = props

// btn, btn-lg, btn-primary
// ES6的计算属性
const classess = classNames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    'disabled': (btnType === ButtonType.Link) && disabled
})


if (btnType === ButtonType.Link && href) {
    return (
        <a className={classess}
           href={href}
          
           {/* 这里给了restProps才能使用onClick等事件和其他的props 
           Menu组件需要给style的prop而这里不用给原因是后面定义了原生Button的props和自定义Button的props的交叉类型 */}

           {...restProps}
        >
            {children}
        </a>
    )
} else {
    return <button
        className={classess}
        disabled={disabled}
        {...restProps}
    >
        {children}
    </button>
}

1.2 Button组件样式

image.png

// 选择有disabled这个class和disabled这个属性
&.disabled,
&[disabled] {
  cursor: not-allowed;
  opacity: $btn-disabled-opacity;
  box-shadow: none;
  > * {
    pointer-events: none;
  }
}
// 编写_mixin.scss 文件
@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) {
  padding: $padding-y, $padding-x;
  font-size: $font-size;
  border-radius: $border-radius;
}

@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;
  }
}
// _style.scss 文件 编写大小按钮和不同类型的样式
.btn-lg {
  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $border-radius-lg);
}

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

.btn-primary {
/*第一个参数是background-color,第二个是border-color,
第三个是文字color,后面三个对应的是hover后的前三个参数*/
  @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)
}

.btn-link {
  font-weight: $font-weight-normal;
  color: $btn-link-color;
  text-decoration: $link-decoration;
  box-shadow: none;

  &:hover {
    color: $btn-link-color;
    text-decoration: $link-decoration;
  }

  &:focus,
  &.focus {
    text-decoration: $link-hover-decoration;
    box-shadow: none;
  }

  &:disabled,
  &.disabled{
    color: $btn-link-disabled-color;
    pointer-events: none;
  }

1.3 Button添加原生组件 事件等

// React自带的HTML原生标签的一些属性
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>
// Partial是typescript的方法 用于使交叉类型变得可选
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>

image.png 把React.FC的泛型换成上面新定义的ButtonProps
从props中取出className作为用户定义的className, restProps用于获取onClick,autoFocus等原生HTML自带的属性。

1.4 Button组件测试

Jest测试框架, react自带
React testing library
新版本的create-react-app已经自带安装了三个库
npm install --save-dev @testing-library/react

src/components/Button/button.test.tsx

// @ts-nocheck
/* eslint-disable */
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import Button, { ButtonProps } from './button'
const defaultProps = {
    onClick: jest.fn()
}

const testProps: ButtonProps = {
    btnType: 'primary',
    size: 'lg',
    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}>Nice</Button>)
        const element = wrapper.getByText('Nice') 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}>Nice</Button>)
        const element = wrapper.getByText('Nice')
        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://dummyurl">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}>Nice</Button>)
        const element = wrapper.getByText('Nice') as HTMLButtonElement
        expect(element).toBeInTheDocument()
        expect(element.disabled).toBeTruthy()
        fireEvent.click(element)
        expect(disabledProps.onClick).not.toHaveBeenCalled()
    })
})

2. Menu组件

image.png

Menu组件应由Menu和MenuItem两部分组成。Menu标签下所有的节点都应该放置在MenuItem中。

2.1 Menu组件的props定义和初步编写

export interface MenuProps {
    //默认是哪个Item高亮
    defaultIndex?: number;
    //设置CSS类
    className?: string;
    //vertical和horizontal两种展示模式
    mode?: MenuMode;
    children: React.ReactNode;
    style?: React.CSSProperties;
    //设置Item被选中时的回调
    onSelect?: SelectCallback;
}

初步上手编写组件

import classNames from 'classnames'
const {index, disabled, className, style, children} = props
/* 通过classnames库 为Menu设置类名,mode==='vertical’的时候是
'menu-vertical'类,默认是horizontal类 */
const classes = classNames('showmaker-menu', className, {
    'menu-vertical': mode === 'vertical'
})

2.2 MenuItem组件的props定义

export interface MenuItemProps {
    index: number;
    disabled?: boolean;
    className?: string;
    style?: React.CSSProperties;
    children?: ReactNode;
}
const {index, disabled, className, style, children} = props
const context = useContext(MenuContext)
//当disabled为true的时候设置'is-disabled, context.index === index的时候设置为is-active'
const classess = classNames('menu-item', className, {
    'is-disabled': disabled,
    'is-active': context.index === index
})

2.3 React的组件间通信--利用createContext从Menu传递到MenuItem

// 如果没有在Menu上传onSelect事件回调,那就只是改变active,有传就改变active并执行这个回调。
const handleClick = (index: number) => {
        setActive(index)
        if (onSelect) {
            onSelect(index)
        }
    }
const passedContext: IMenuContext = {
        index: currentActive ? currentActive : 0,
        onSelect: handleClick
}
// 接着在Menu.Provider包含children,value={passedContext}

// MenuItem.tsx
/* 定义同名的handleClick函数 执行context传过来的onSelect函数 
实际上就是Menu组件中的handleClick函数 */

const handleClick = () => {
    if(context.onSelect && !disabled){
        context.onSelect(index)
    }
}
return (
    <li className={classess} style={style} onClick={handleClick}
    >
        {children}
    </li>
);

2.4 完善Menu和MenuItem组件

Menu组件下的所有子元素都应该是MenuItem,否则报错。


// MenuItem.tsx 设置MenuItem组件的displayName为'MenuItem'
MenuItem.displayName = 'MenuItem'

//编写函数renderChildren
// React.Children.map 是安全版本的Array.map 会跳过null,undefined等无法被渲染的值防止报错
const renderChildren = () => {
    return React.Children.map(children, (child, index) => {
    // 把child断言为React的函数组件元素,泛型确定为MenuItemProps,
    // 取出displayName进行判断 确定是否是MenuItem,是的话才渲染,否则报错。
        const childElement = child as React.FunctionComponentElement<MenuItemProps>
        const {displayName} = childElement.type
        if (displayName === 'MenuItem') {
            return child
        }else {
            console.error("warning: Menu has a child which is not a MenuItem component")
        }
    })
}


<MenuContext.Provider value={passedContext}>
    {renderChildren()}
</MenuContext.Provider>

2.5 SubMenu组件

第一步先确认好SubMenu组件应该有什么props

export interface SubMenuProps {
    //两个必须具备的属性 index和title
    index: number;
    title: string;
    //用户自定义的className
    className?: string;
    children: React.ReactNode;

}

// 属于menu-item也属于submenuitem
const classess = classNames('menu-item submenu-item', className, {
    'is-active': context.index === index
})

第二步 渲染SubMenu
思路:SubMenu本身属于MenuItem,因此本身是一个li标签。这个li标签中包含了一个div和一个ul,div用于渲染SubMenu的title,ul用于包含真正的子菜单,ul里面渲染的子菜单也是MenuItem。

const renderChildren = () => {
    const subMenuClasses = classNames('showmaker-submenu', {
        'menu-opened': menuOpen
    })
    const childrenComponent = React.Children.map(children, (child, index) => {
        const childElement = child as React.FunctionComponentElement<MenuItemProps>
        if (childElement.type.displayName === 'MenuItem') {
            return childElement
        } else {
            console.error("Warning: SubMenu has a child which is not a MenuItem!")
        }
    })
    return (
        <ul className={subMenuClasses}>
            {childrenComponent}
        </ul>
    )
}

第三步:SubMenu组件交互

  • horizontal水平状态下的Menu,通过onClick点击事件来打开和关闭SubMenu
  • vertical垂直窗台下的Menu,通过鼠标hover来打开和关闭SubMenu
//当menu的mode是vertical竖直的时候 通过点击来打开和关闭子菜单
const handleClick = (e: React.MouseEvent) => {
    e.preventDefault()
    setOpen(!menuOpen)
    console.log(menuOpen)
}

let timer: any
const handleMouse = (e: React.MouseEvent, toggle: boolean) => {
    clearTimeout(timer)
    e.preventDefault()
    timer = setTimeout(() => {
        setOpen(toggle)
    }, 50)
}

const clickEvents = context.mode === 'vertical' ? {
    onClick: handleClick
} : {}
const hoverEvents = context.mode === 'horizontal' ? {
    onMouseEnter: (e: React.MouseEvent)=>{handleMouse(e, true)},
    onMouseLeave: (e: React.MouseEvent)=>{handleMouse(e, false)}
} : {}

渲染的函数

const renderChildren = () => {
    const subMenuClasses = classNames('showmaker-submenu', {
        'menu-opened': menuOpen
    })
    const childrenComponent = React.Children.map(children, (child, index) => {
        const childElement = child as React.FunctionComponentElement<MenuItemProps>
        if (childElement.type.displayName === 'MenuItem') {
            return childElement
        } else {
            console.error("Warning: SubMenu has a child which is not a MenuItem!")
        }
    })
    return (
        <ul className={subMenuClasses}>
            {childrenComponent}
        </ul>
    )
}
return (
    <li key={index} className={classess} {...hoverEvents}>
        <div className={'submenu-title'} {...clickEvents}>
            {title}
        </div>
        {renderChildren()}
    </li>
);

第四步:完善SubMenu

  • 改变props中的index的类型,从number改为string,方便SubMenu及其子节点的index设置。
  • 新增defaultOpenedSubmenu属性,控制是否让submenu默认打开和关闭,其类型应为string数组。

第一点
第一点直接修改props的数据类型就可以了,修改一处过后,由于typescript的特性,很多地方都会报错,方便直接找到需要修改的地方。这也是typescript比JavaScript强大之处,能够在编译阶段就帮助程序员找到bug。

第二点

// 当index存在(其实index是必传属性,因为这里并没有给组件设计自增长index,需要用户手动输入所有index)且context.mode为vertical垂直的时候,才会有默认展开。
// 判断的条件就是传到Menu组件上的props中的defaultOpenSubMenus这个数组里包含了需要默认展开的submenu的index。
const isOpened = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false
const [menuOpen, setOpen] = useState(isOpened)
//默认展开为index为2的SubMenu
<Menu defaultIndex={'0'} defaultOpenSubMenus={['2']} mode={"vertical"}>
    <MenuItem index={'1'}>cool link</MenuItem>
    <SubMenu index={'2'} title={'dropdown'}>
        <MenuItem index={'2-0'}>cool dropdown 0</MenuItem>
        <MenuItem index={'2-1'}>cool dropdown 1</MenuItem>
        <MenuItem index={'2-2'}>cool dropdown 2</MenuItem>
    </SubMenu>
    <MenuItem index={'3'}>cool link3</MenuItem>
</Menu>

默认展开了SubMenu。 image.png

2.6 Menu组件的CSS样式

.showmaker-menu {
  display: flex;
  // 弹性盒子 默认是nowarp不换行 这里选择换行
  flex-wrap: wrap;
  padding-left: 0;
  // 这里是为horizontal水平模式设置的 vertical模式下会被替代成上下10px 左右20px
  margin-bottom: 30px;
  list-style: none;
  border-bottom: $menu-border-width solid $menu-border-color;
  box-shadow: $menu-box-shadow;
  >.menu-item {
    padding: $menu-item-padding-y $menu-item-padding-x;
    cursor: pointer;
    transition: $menu-transition;
    &:hover, &:focus {
      text-decoration: none;
    }
    &.is-disabled {
      color: $menu-item-disabled-color;
      pointer-events: none;
      cursor: default;
    }
    &.is-active, &:hover {
      color: $menu-item-active-color;
      border-bottom: $menu-item-active-border-width solid $menu-item-active-color;
    }
  }
  .submenu-item {
    position: relative;
    // 这整个submenu-title去掉之后也暂未发现有什么影响
    .submenu-title {
      display: flex;
      align-items: center;
    }
    .arrow-icon {
      transition: transform .25s ease-in-out;
      margin-left: 3px;
    }
    &:hover {
      .arrow-icon {
        transform: rotate(180deg);
      }
    }
  }
  .is-vertical {
    .arrow-icon {
      transform: rotate(0deg) !important;
    }
  }
  .is-vertical.is-opened {
    .arrow-icon {
      transform: rotate(180deg) !important;
    }
  }
  .showmaker-submenu {
    display: none;
    list-style:none;
    padding-left: 0;
    //处理文本内的空白字符和换行,nowarp连续的空白字符会被忽略,且不会换行。
    white-space: nowrap;
    //transition: $menu-transition;
    .menu-item {
      padding: $menu-item-padding-y $menu-item-padding-x;
      cursor: pointer;
      transition: $menu-transition;
      color: $body-color;
      &.is-active, &:hover {
        color: $menu-item-active-color !important;
      }
    }
  }
  .showmaker-submenu.menu-opened {
    display: block;
  }
}
// 水平模式的menu
.menu-horizontal {
  >.menu-item {
  //水平模式下先把下方的bottom设置为透明的 hover的时候才显现
    border-bottom: $menu-item-active-border-width solid transparent;
  }
  //水平模式下的submenu
  .showmaker-submenu {
  //子绝父相 水平模式下hover
    position: absolute;
    background: $white;
    z-index: 100;
    top: calc(100% + 8px);
    left: 0;
    border: $menu-border-width solid $menu-border-color;
    box-shadow: $submenu-box-shadow;
  }
}
.menu-vertical {
  flex-direction: column;
  border-bottom: 0px;
  margin: 10px 20px;
  border-right: $menu-border-width solid $menu-border-color;
  >.menu-item {
    border-left: $menu-item-active-border-width solid transparent;
    &.is-active, &:hover {
      border-bottom: 0px;
      border-left: $menu-item-active-border-width solid $menu-item-active-color;
    }
  }
}

处理思路:
首先先思考Menu应该具有两种模式,vertical和horizontal。以horizontal作为默认值来考虑。

  • horizontal模式
    horizontal具有下边框,直接在showmaker-menu这个大类选择器下设置。接着设置menu-item这个类,使用子选择器+类选择器 >.menu-item.在这里设置transition动画过渡和padding,cursor。并且在这里设置hover,focus,disabled和active属性。
    $menu-transition: color .15s ease-in-out, border-color .15s ease-in-out !default;
.showmaker-menu {
  display: flex;
  // 弹性盒子 默认是nowarp不换行 这里选择换行
  flex-wrap: wrap;
  padding-left: 0;
  // 这里是为horizontal水平模式设置的 vertical模式下会被替代成上下10px 左右20px
  margin-bottom: 30px;
  list-style: none;
  border-bottom: $menu-border-width solid $menu-border-color;
  box-shadow: $menu-box-shadow;
    >.menu-item {
    padding: $menu-item-padding-y $menu-item-padding-x;
    cursor: pointer;
    transition: $menu-transition;
    &:hover, &:focus {
      text-decoration: none;
    }
    &.is-disabled {
      color: $menu-item-disabled-color;
      pointer-events: none;
      cursor: default;
    }
    &.is-active, &:hover {
      color: $menu-item-active-color;
      border-bottom: $menu-item-active-border-width solid $menu-item-active-color;
    }
  }
 }

接着设置SubMenu。display一开始是none,通过JavaScript来添加类名, 控制展开和关闭。打开的时候display设置为block。horizontal模式的时候是通过子绝父相,在menu下方8px处top: calc(100%+8px) 设置出submenu

// 默认的showmaker-menu下的submenu代码
.showmaker-submenu {
    display: none;
    list-style:none;
    padding-left: 0;
    white-space: nowrap;
    //transition: $menu-transition;
    .menu-item {
      padding: $menu-item-padding-y $menu-item-padding-x;
      cursor: pointer;
      transition: $menu-transition;
      color: $body-color;
      &.is-active, &:hover {
        color: $menu-item-active-color !important;
      }
    }
  }
.showmaker-submenu.menu-opened {
    display: block;
}

z-index设大,可以遮挡住其他。left为0,和父盒子保持对齐。boxshadow用来让submenu出现的更清楚,有立体感。

CSS-boxshadow box-shadow 属性用于在元素的框架上添加阴影效果。你可以在同一个元素上设置多个阴影效果,并用逗号将他们分隔开。该属性可设置的值包括阴影的 X 轴偏移量、Y 轴偏移量、模糊半径、扩散半径和颜色。

$submenu-box-shadow: 0 2px 4px 0 rgba(0,0,0,.12), 0 0 6px 0 rgba(0,0,0,.04);

// horizontal 模式下的submenu代码
.menu-horizontal {
  >.menu-item {
    border-bottom: $menu-item-active-border-width solid transparent;
  }
  .showmaker-submenu {
    position: absolute;
    background: $white;
    z-index: 100;
    top: calc(100% + 8px);
    left: 0;
    border: $menu-border-width solid $menu-border-color;
    box-shadow: $submenu-box-shadow;
  }
}

3. Input组件

3.1 Input组件的props定义和初步编写

InputProps实现InputHTMLAttributes接口并且泛型直接设置为HTMLElement, InputPrps之所以不用写className和style在props里面就是这个原因。Omit可以消除接口中的某个属性进行重新定义。这里就是消除了size属性进行重新定义。

image.png 回顾Menu组件,我们可以看到Menu组件的props接口没有进行任何包装,因此必须在props里面定义出className和style属性。

image.png

4. Upload组件

4.1 Upload组件的props定义和初步编写

image.png