开始之前
构建一个组件库需要考虑哪些问题
- 代码结构
- 样式解决方案
- 组件需求分析和编码
- 自建测试用例分析和编码
- 代码的打包和发布
- 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
...
组件单元测试,测什么?
- 测试能不能保持正常行为,比如Button组件能work as a button,可以添加onClick事件监听等
- 测试渲染的结果是不是期望的HTML元素:tagName === BUTTON ?
- 测试样式属性——根据属性值的不同,能不能得到相应的className(样式是否被正确添加)
- 测试特殊属性的的作用: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