開始之前
構(gòu)建一個組件庫需要考慮哪些問題
- 代碼結(jié)構(gòu)
- 樣式解決方案
- 組件需求分析和編碼
- 自建測試用例分析和編碼
- 代碼的打包和發(fā)布
- CI/CD籽御,文檔生成
創(chuàng)建組件庫的色彩體系
色彩兩大體系:
系統(tǒng)色板:
- 符合Vision的各種顏色
- 中性色板:黑白灰三色
產(chǎn)品色板:
- 一到兩個主要色彩(品牌色/primary color)
- 一到兩個次要顏色(secondary color)
- 一系列功能色
組件庫樣式變量分類
- 基礎(chǔ)色彩系統(tǒng)
- 基本色彩
- 功能色彩
- 字體系統(tǒng)
- Font Family
- Font Size
- Font Weight
- Line Height
- Header Size
- Link
- Body
- 表單
- 按鈕
- 邊框和陰影
- 可配置開關(guān)
如何編寫組件測試
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已經(jīng)被create-react-app內(nèi)置在生成的項目中了
@testing-library/jest-dom
同樣被內(nèi)置在CRA生成的項目中,不同于jest普通的斷言拇泛,它提供了一系列方便的DOM斷言兴使,比如:toBeEmpty
, toHaveClass
, toContainHTML
, toContainElement
...
組件單元測試疤估,測什么灾常?
- 測試能不能保持正常行為,比如Button組件能work as a button铃拇,可以添加onClick事件監(jiān)聽等
- 測試渲染的結(jié)果是不是期望的HTML元素:tagName === BUTTON 钞瀑?
- 測試樣式屬性——根據(jù)屬性值的不同,能不能得到相應(yīng)的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狀態(tài)
- disable
// 不同的按鈕類型仔戈,disable的表現(xiàn)是不一樣的,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;
}
內(nèi)置的屬性监徘,如常見的onClick方法
button標簽:React.ButtonHTMLAttributes<HTMLElement>
a標簽:React.AnchorHTMLAttributes<HTMLElement>
使用ts類型別名定義交叉類型
// 最終Button標簽的類型為ButtonProps,使用Partial interface包裹是將類型屬性都設(shè)置為可選參數(shù)
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>
Partial的作用
假設(shè)我們有一個定義 user 的接口:
interface IUser {
name: string
age: number
}
經(jīng)過 Partial 類型轉(zhuǎn)化后得到:
type optional = Partial<IUser>
// optional的結(jié)果如下
type optional = {
name?: string | undefined;
age?: number | undefined;
}
Button組件的測試
測試用例設(shè)計:
- 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狀態(tài)凰盔,通過useContext hook與子組件共享該狀態(tài)
聲明、構(gòu)造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,
// 根據(jù)context中的index值判斷當前MenuItem是否為active狀態(tài)
'is-active': context.index === index
})
// 調(diào)用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將數(shù)組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組件的測試
測試用例設(shè)計:
- 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有多層結(jié)構(gòu)落剪,需要修改為數(shù)據(jù)類型為string,下拉菜單中的MenuItem index表現(xiàn)為類似"1-0"
封裝subMenu:
- 根據(jù)menu的mode判斷下拉列表的toggle形式
- 橫向menu通過鼠標的hover開關(guān)
- 縱向的menu通過點擊事件來開關(guān)下拉菜單
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>
)
};
組件的調(diào)試與文檔——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);
編寫B(tài)utton組件的story
當前storybook版本是5.3尿庐,5.2以上的版本已經(jīng)推薦使用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" 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;
},
};
在組件實現(xiàn)代碼加上注釋,可以完善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
組件庫打包
模塊的歷史
- 全局變量與命名空間皮假、自執(zhí)行函數(shù):jQuery
- 依賴全局變量
- 污染全局變量鞋拟、不安全
- 手動管理依賴、控制執(zhí)行順序
- 上線之前手動合并
- common.js規(guī)范:為服務(wù)器端誕生的惹资,不符合前端規(guī)范
- require & module.exports
- AMD:為前端模塊化誕生的贺纲,解決了common.js規(guī)范的問題
- 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...----->瀏覽器可直接執(zhí)行的文件
選擇Javascript的模塊格式
UMD(Universal Module Definition)是一種可以直接在瀏覽器中使用的模塊格式,這種方式可以支持用戶直接使用script標簽引用模塊侮措。
ES模塊:
ES模塊是官方標準稠肘,可以進行代碼靜態(tài)分析,從而實現(xiàn)tree-shaking的優(yōu)化萝毛,并提供諸如循環(huán)引用和動態(tài)綁定等高級功能。
所以:ES模塊作為打包的輸出結(jié)果
創(chuàng)建組件庫模塊的入口文件
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并執(zhí)行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