构建组件库

2020-06-14  本文已影响0人  梁某人的剑

开始之前

构建一个组件库需要考虑哪些问题

创建组件库的色彩体系

色彩两大体系:

系统色板:

产品色板:

组件库样式变量分类

如何编写组件测试

jest——JavaScript通用测试库

jest会自动将以下三类文件视为测试文件:

jest断言案例

test('test common matcher', ()=>{
  expect(2 + 2).toBe(4)
  expect(2 + 2).not.toBe(5)
})

test('test to be true or false', function () {
  expect(1).toBeTruthy()
  expect(0).toBeFalsy()
})

test('test object', function () {
  expect({name: 'llr'}).toEqual({name: 'llr'})
})

React目前推荐的测试框架——@testing-library/react

作为React组件测试框架的后起之秀,@testing-library/react已经被create-react-app内置在生成的项目中了

@testing-library/jest-dom同样被内置在CRA生成的项目中,不同于jest普通的断言,它提供了一系列方便的DOM断言,比如:toBeEmpty, toHaveClass, toContainHTML, toContainElement...

组件单元测试,测什么?

  1. 测试能不能保持正常行为,比如Button组件能work as a button,可以添加onClick事件监听等
  2. 测试渲染的结果是不是期望的HTML元素:tagName === BUTTON ?
  3. 测试样式属性——根据属性值的不同,能不能得到相应的className(样式是否被正确添加)
  4. 测试特殊属性的的作用:disable or 改变HTML类型的属性能否达成期望

Button组件的编写

Button类型:

// 使用enum管理按钮的类型
export enum ButtonType {
    Primary = "primary",
    Default = "default",
    Danger = "danger",
    Link = "link",
}

Button大小:

// 使用enum管理按钮的大小
export enum ButtonSiz {
    Large = "lg",
    Small = "sm",
}

Button状态

// 不同的按钮类型,disable的表现是不一样的,button标签自带diabled属性,a标签没有disabled属性
    const classes = classNames('btn', className, {
        [`btn-${btnType}`]: btnType,
        [`btn-${size}`]: size,
        'disabled': (btnType === ButtonType.Link) && disable
    })
    if (btnType === ButtonType.Link && href) {
        return <a 
            className={classes}
            href={href}
            {...restProps}
        >{children}</a>
    } else {
        return <button 
            className={classes}
            disabled={disable}
            {...restProps}
        >{children}</button>
    }

Button组件的属性

自定义属性
interface BaseButtonProps {
    className?: string;
    disable?: boolean;
    href?: string;
    size?: ButtonSiz;
    btnType?: ButtonType;
    children: React.ReactNode;
}
内置的属性,如常见的onClick方法

button标签:React.ButtonHTMLAttributes<HTMLElement>
a标签:React.AnchorHTMLAttributes<HTMLElement>

使用ts类型别名定义交叉类型
// 最终Button标签的类型为ButtonProps,使用Partial interface包裹是将类型属性都设置为可选参数
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>
Partial的作用

假设我们有一个定义 user 的接口:

interface IUser {
  name: string
  age: number
}

经过 Partial 类型转化后得到:

type optional = Partial<IUser>

// optional的结果如下
type optional = {
    name?: string | undefined;
    age?: number | undefined;
}

Button组件的测试

测试用例设计:

Menu组件

Menu组件语义分析(伪代码):

<Menu defaultIndex={0} onSelect={} mode="vertical">
  <Menu.Item>
    title one
  </Menu.Item>
  <Menu.Item disabled>
    disabled menu item
  </Menu.Item>
  <Menu.Item>
    <a href="#">Link in menu</a>
  </Menu.Item>
</Menu>

Menu组件的属性分析

使用 string-literal-types来限制组件属性值的范围,比enum更好用

interface MenuProps {
  defaultIndex: number;
  mode: string;
  onSelect: (selectedIndex: number) => void;
  className: String
}
interface MenuItemProps {
  index: number;
  disabled: boolean;
  className: String
}
通过属性来生成Menu组件的className

Menu组件

const Menu: FC<MenuProps> = (props)=>{
    const {defaultIndex, className, mode, style, children, onSelect} = props
    
    const classes = classNames('tui-menu', className, {
        'menu-vertical': mode === 'vertical'
    })

    return <ul className={classes} style={style}>
        <MenuContext.Provider value={passedContext}>
            {children}
        </MenuContext.Provider>
    </ul>
}

MenuItem组件

const MenuItem: FC<MenuItemProps> = (props) => {
    const {index, disabled, className, style, children} = props

    const classes = classNames('tui-menu-item', className, {
        'is-disabled': disabled
    })

    return (
        <li className={classes} style={style}>
            {children}
        </li>
    )
}
useState记录Menu组件activeItem状态,通过useContext hook与子组件共享该状态

声明、构造MenuContext

// 定义MenuContext类型接口
interface IMenuContext {
    index: number;
    onSelect?: SelectCallback;
}
// 声明Context
export const MenuContext = createContext<IMenuContext>({index: 0})

const Menu: FC<MenuProps> = (props)=>{
    ...
    
    const [currentActive, setActive] = useState(defaultIndex)

    const handleClick = (index: number) => {
        setActive(index)
        if(onSelect){
            onSelect(index)
        }
    }

    // 将currentActive与onSelect方法绑定到Context中
    const passedContext: IMenuContext = {
        index: currentActive ? currentActive : 0,
        onSelect: handleClick
    }

    // 使用MenuContext.Provider包裹children
    return <ul className={classes} style={style} data-testid="test-menu">
        <MenuContext.Provider value={passedContext}>
            {children}
        </MenuContext.Provider>
    </ul>
}

MenuItem组件中获取MenuContext

import {MenuContext} from "./menu";

const MenuItem: FC<MenuItemProps> = (props) => {
    ...
    // 获取useContext
    const context = useContext(MenuContext)
    
    const classes = classNames('tui-menu-item', className, {
        'is-disabled': disabled,
        // 根据context中的index值判断当前MenuItem是否为active状态
        'is-active': context.index === index
    })
    
    // 调用context中的onSelect方法
    const handleClick = ()=>{
        if (context.onSelect && !disabled && (typeof index === "number")) {
            context.onSelect(index)
        }
    }

    return (
        <li className={classes} style={style} onClick={handleClick}>
            {children}
        </li>
    )
}
限制Menu组件的children只能为MenuItem,自动为MenuItem添加index值

使用React.Children.map遍历Menu组件下的子组件

使用React.cloneElement将数组index值注入到Menu子组件MenuItem中

const Menu: FC<MenuProps> = (props)=>{
    const {...} = props
    
    ...
    
    const renderChildren = ()=>{
        return React.Children.map(children, ((child, index) => {
            const childElement = child as React.FunctionComponentElement<MenuItemProps>
            const { displayName } = childElement.type
            if (displayName === 'MenuItem') {
                // ⚠️:将index作为自组件的props注入
                return React.cloneElement(childElement, {index})
            }else {
            // ⚠️:如果子组件的displayName不对,报警告
                console.error("Warning: Menu has a child which is not a MenuItem component")
            }
        }))
    }

    return <ul className={classes} style={style}>
        <MenuContext.Provider value={passedContext}>
            {renderChildren()}
        </MenuContext.Provider>
    </ul>
}

Menu组件的测试

测试用例设计:

Menu组件需求升级——Menu中支持下拉列表

期望得到的组件功能语以化表达:

<Menu mode="vertical" defaultIndex="0">
   <MenuItem>
        menu 1
    </MenuItem>
    <MenuItem disabled>
        menu 2
    </MenuItem>
    <SubMenu title="dropdown">
        <MenuItem>
            dropdown 1
        </MenuItem>
        <MenuItem>
            dropdown 2
        </MenuItem>
    </SubMenu>
    <MenuItem>
        menu 3
    </MenuItem>
</Menu>

Menu组件需要修改的部分:

封装subMenu:

export interface SubMenuProps {
    index?: string;
    title?: string;
    className?: string;
}

const SubMenu: FC<SubMenuProps> = (props) => {
    const {index, title, className, children} = props
    const [open, setOpen] = useState(false)
    const context = useContext(MenuContext)
    const classes = classNames('tui-menu-item tui-submenu-item', className, {
        'is-active': context.index === index
    })

    const handleClick = (e: React.MouseEvent)=>{
        e.preventDefault()
        setOpen(!open)
    }

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

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

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

    return (
        <li key={index} className={classes} {...hoverEvents}>
            <div className="submenu-title" {...clickEvents}>{title}</div>
            {renderChildren()}
        </li>
    )
};

组件的调试与文档——StoryBook

安装storyBook

npx -p @storybook/cli sb init

配置读取ts

.storybook/main.js

module.exports = {
  stories: ['../src/**/*.stories.tsx', '../src/**/*.stories.js'],
  addons: [
    '@storybook/preset-create-react-app',
    '@storybook/addon-actions',
    '@storybook/addon-links',
  ],
};

配置全局样式

config.ts

import { configure } from "@storybook/react";
import '../src/styles/index.scss'

configure(require.context('../src', true, /\.stories\.tsx$/), module);

编写Button组件的story

当前storybook版本是5.3,5.2以上的版本已经推荐使用CSF语法编写story:
button.stories.tsx

import {action} from "@storybook/addon-actions";
import React from "react";
import Button from "./button";

const styles: React.CSSProperties = {
    textAlign: "center"
}
const CenterDecorator = (storyFn: any) => <div style={styles}>{storyFn()}</div>

export default {
    title: 'Button',
    component: Button,
    decorators: [CenterDecorator],
};

export const DefaultButton = () =>
    <Button onClick={action('clicked')}>Default Button</Button>;

DefaultButton.story = {
    name: '默认按钮',
};

export const buttonWithDifferentSize = () =>
    <>
        <Button size="lg">Large Button</Button>
        <Button>Default Button</Button>
        <Button size="sm">Small Button</Button>
    </>

export const buttonWithDifferentType = () =>
    <>
        <Button btnType="primary">Primary Button</Button>
        <Button btnType="default">Default Button</Button>
        <Button btnType="danger">Danger Button</Button>
        <Button btnType="link" href="https://www.baidu.com" target="_blank">Link Button</Button>
    </>

StoryBook的插件

配置addon-info插件,丰富组件的文档信息: .storybook/config.tsx

import {configure, addDecorator, addParameters} from "@storybook/react";
import '../src/styles/index.scss'
import React from "react";
import {withInfo} from "@storybook/addon-info";

const wrapperStyles: React.CSSProperties = {
    padding: '20px 40px'
}

const storyWrapper = (storyFn: any) => (
    <div style={wrapperStyles}>
        <h3>Component Demo</h3>
        {storyFn()}
    </div>
)

addDecorator(storyWrapper)
addDecorator(withInfo)
addParameters({info: {inline: true, header: false}})

configure(require.context('../src', true, /\.stories\.tsx$/), module);

配置react-docgen-typescript-loader webpack loader使react-docgen支持ts,同时配置过滤器,过滤掉html自带的props,只在文档中展示自定义的props.storybook/main.js

module.exports = {
  stories: ['../src/**/*.stories.tsx'],
  addons: [
    '@storybook/addon-actions',
    '@storybook/addon-links',
  ],
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.tsx$/,
      use: [{
          loader: require.resolve("react-docgen-typescript-loader"),
          options: {
            shouldExtractLiteralValuesFromEnum: true,
            propFilter: (prop) => {
              if (prop.parent) {
                return !prop.parent.fileName.includes('node_modules')
              }
              return true
            }
          }
        }],
    });
    config.resolve.extensions.push('.ts', '.tsx');
    return config;
  },
};

在组件实现代码加上注释,可以完善react-gendoc的描述:

import React, {AnchorHTMLAttributes, ButtonHTMLAttributes, FC} from "react";
import classNames from 'classnames'

type ButtonSiz = 'lg' | 'sm'
type ButtonType = 'primary' | 'default' | 'danger' | 'link'

interface BaseButtonProps {
    className?: string;
    /** Setting Button's disable*/
    disable?: boolean;
    href?: string;
    /** Setting Button's size*/
    size?: ButtonSiz;
    /** Setting Button's type*/
    btnType?: ButtonType;
    children: React.ReactNode;
}

type NativeButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLElement>
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>

/**
 *
 The most commonly used button elements on the page, suitable for completing specific interactions
 * ### Reference method
 *
 * ~~~js
 * import { Button } from 'thought-ui'
 * ~~~
 */
export const Button: FC<ButtonProps> = (props) => {
    const {
        btnType,
        className,
        disable,
        size,
        children,
        href,
        ...restProps
    } = props

    const classes = classNames('btn', className, {
        [`btn-${btnType}`]: btnType,
        [`btn-${size}`]: size,
        'disabled': (btnType === 'link') && disable
    })
    if (btnType === 'link' && href) {
        return <a className={classes} href={href} {...restProps}>{children}</a>
    } else {
        return <button className={classes} disabled={disable} {...restProps}>{children}</button>
    }
}

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

export default Button

组件库打包

模块的历史

模块打包的流程

Typescript Files------tsc---->ES6 Modules Files----入口文件index.tsx----Bundler: webpack、rollup...----->浏览器可直接执行的文件

选择Javascript的模块格式

UMD(Universal Module Definition)是一种可以直接在浏览器中使用的模块格式,这种方式可以支持用户直接使用script标签引用模块。

ES模块:
ES模块是官方标准,可以进行代码静态分析,从而实现tree-shaking的优化,并提供诸如循环引用和动态绑定等高级功能。

所以:ES模块作为打包的输出结果

创建组件库模块的入口文件

components/Button/index.tsx:

import Button from "./button";

export default Button;

components/Menu/index.tsx:

import {FC} from 'react'

import Menu, {MenuProps} from "./menu";
import MenuItem, {MenuItemProps} from "./menuItem";
import SubMenu, {SubMenuProps} from "./subMenu";

export type IMenuComponent = FC<MenuProps> & {
    Item: FC<MenuItemProps>,
    SubMenu: FC<SubMenuProps>,
}

const FinalMenu = Menu as IMenuComponent;

FinalMenu.Item = MenuItem;
FinalMenu.SubMenu = SubMenu;

export default FinalMenu;

组件库模块的入口文件src/index.tsx:

export {default as Button} from './components/Button'
export {default as Menu} from './components/Menu'

使用tsc打包ts文件为ES文件

tsconfig.build.json

{
  "compilerOptions": {
    "outDir": "build",
    "module": "ESNext",
    "target": "ES5",
    "declaration": true,
    "jsx": "react",
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true
  },
  "include": [
    "src"
  ],
  "exclude": [
    "src/**/*.test.tsx",
    "src/**/*.stories.tsx"
  ]
}

package.json

"scripts": {
    ...
    "build-ts": "tsc -p tsconfig.build.json"
  },

使用node-sass打包样式文件

package.json

"scripts": {
  "build": "npm run build-ts && npm run build-css",
  "build-ts": "tsc -p tsconfig.build.json",
  "build-css": "node-sass ./src/styles/index.scss ./build/index.css"
}

打包上传到npm

语义化版本号

自动化publish、commit之前的自动化测试与lint

// package.json scripts
{
    "lint": "eslint --ext js,ts,tsx src --max-warning 1",
    "test:nowatch": "cross-env CI=true react-scripts test",
    "build": "npm run clean && npm run build-ts && npm run build-css",
    "prepublishOnly": "npm run test:nowatch && npm run lint && npm run build"

}

配置husky的config:

{
  "husky": {
    "hooks": {
      "pre-commit": "npm run test:nowatch && npm run lint"
    }
  }
}

登录npm并执行npm run push

git addusr

npm run publish

配置circle ci自动化部署storybook到gh-pages

.circlecl/config.yml:

version: 2.1
orbs:
  node: circleci/node@1.1.6
  gh-pages: sugarshin/gh-pages@0.0.6
jobs:
  build-and-test:
    executor:
      name: node/default
    steps:
      - checkout
      - node/with-cache:
          steps:
            - run: yarn install
            - run: yarn run test:nowatch
  deploy-sb-ghpages:
    executor:
      name: node/default
    steps:
      - checkout
      - run: yarn install
      - run: yarn run build-storybook
      - gh-pages/deploy:
          build-dir: storybook-static
          ssh-fingerprints: xxx

workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build-and-test
      - deploy-sb-ghpages:
          requires:
            - build-and-test
上一篇下一篇

猜你喜欢

热点阅读