构建组件库

开始之前

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

  • 代码结构
  • 样式解决方案
  • 组件需求分析和编码
  • 自建测试用例分析和编码
  • 代码的打包和发布
  • CI/CD,文档生成

创建组件库的色彩体系

色彩两大体系:

系统色板:

  • 符合Vision的各种颜色
  • 中性色板:黑白灰三色

产品色板:

  • 一到两个主要色彩(品牌色/primary color)
  • 一到两个次要颜色(secondary color)
  • 一系列功能色

组件库样式变量分类

  • 基础色彩系统
    • 基本色彩
    • 功能色彩
  • 字体系统
    • Font Family
    • Font Size
    • Font Weight
    • Line Height
    • Header Size
    • Link
    • Body
  • 表单
  • 按钮
  • 边框和阴影
  • 可配置开关

如何编写组件测试

jest——JavaScript通用测试库

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

  • __tests__ 文件夹中的.js后缀文件
  • .test.js后缀的文件
  • .spec.js后缀的文件

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类型:

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

Button大小:

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

Button状态

  • disable
// 不同的按钮类型,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组件的测试

测试用例设计:

  • should render the default button
  • should render the correct HTML tag with correct className when render Button given btnType is Primary, size is Large, additional class is klass
  • should render disabled button when render Button given disabled attribute true
  • should render a link when render Button given btnType is link and href is provided
  • should render a disabled link when render Button given btnType is link and href is provided and disabled attribute true

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组件的测试

测试用例设计:

  • should render correct html tags when render Menu given default props
  • should change active menu item and call the right callback when click Menu component item
  • should render vertical mode when render Menu component given mode is vertical

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
  • MenuItem的index有多层结构,需要修改为数据类型为string,下拉菜单中的MenuItem index表现为类似"1-0"

封装subMenu:

  • 根据menu的mode判断下拉列表的toggle形式
    • 横向menu通过鼠标的hover开关
    • 纵向的menu通过点击事件来开关下拉菜单
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

组件库打包

模块的历史

  • 全局变量与命名空间、自执行函数:jQuery
    • 依赖全局变量
    • 污染全局变量、不安全
    • 手动管理依赖、控制执行顺序
    • 上线之前手动合并
  • common.js规范:为服务器端诞生的,不符合前端规范
    • require & module.exports
  • AMD:为前端模块化诞生的,解决了common.js规范的问题
    • define(function (){ const bar = require('../bar') })
    • 没办法使用直接使用,也需要模块打包工具require.js
  • Es6 module
    • import & export
    • 也没办法直接在浏览器中使用,需要bundle为es5的代码

模块打包的流程

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343