React.js 组件库设计

课程目标

  • 掌握可复⽤的 react 组件设计思路和原则;
  • 能够基于业界成熟⽅案搭建⼀个 react 组件库;
  • 能够编写⾼质量的组件⽂档;
  • 能够不使⽤脚⼿架从 0 - 1 搭建⼀个 react 组件库;
  • 能够理解不同组件库架构的差异和各⾃使⽤场景;
  • 能够制定标准的代码规范和提交规范;
  • 能够掌握组件单元测试的编写;
  • 了解组件库发布流程。

课程大纲

  1. 编写可复⽤⾼质量的 react 组件;
  2. 编写⾼质量的组件⽂档;
  3. 业界成熟的组件库脚⼿架介绍;
  4. 常⻅的组件库架构差异解析;
  5. 引⼊代码规范和提交规范;
  6. 构建⼯具选择;
  7. 单元测试的编写;
  8. 版本号规范解析。

知识要点

react 组件的设计原则

有意义

  • 命名准确,充分表意
  • 参数准确,必要的类型检查
  • 适当的注释

通⽤性

  • 不要耦合特殊的业务代码
  • 不要包含特定的代码处理逻辑

⽆状态,⽆副作⽤

  • 状态向上层提取,尽量少用内部状态
  • 解耦 IO 操作

避免过度封装

  • 合理冗余
  • 避免过度抽象

单⼀职责

  • 一个组件只完成一个功能
  • 尽量避免不同组件相互依赖、循环依赖

易于测试

  • 更容易的单元测试覆盖

组件⽂档编写

⽂档结构

  • 组件描述
  • 组件示例及代码演示
  • 组件入参描述

业界成熟的组件库脚⼿架

组件库架构差异及各⾃使⽤场景

Multirepo

一个仓库只有一个项目,以一个 npm 包发布,适用于基础组件库

优点
  • 项目简单,调试安装方便
缺点
  • 项目庞大时构建和发布耗时长
  • 组件库使用时需整体引入,造成一定的资源浪费。(可通过 es module 方式解决)
典型案例

Monorepo

⼀个仓库内管理多个项⽬,以多个 npm 包⽅式发布,依赖集中管理,npm 包版本可以集中管理,也可以单独管理。通常适⽤于有⼀定关联的组件,但各组件需要⽀持单独的 npm 包发布和安装。

优点
  • 共同依赖统⼀管理,版本控制更加容易,依赖管理会变的⽅便。
  • ⽀持组件的单独发布和单独构建。
  • 使⽤时可以单独引⼊。
缺点
  • 项目搭建复杂度高
典型案例

Monorepo 管理⼯具

  • lerna
  • yarn workspace
  • pnpm

引⼊代码规范和提交规范

Eslint & Prettier

⼀个⾼质量的组件库,eslint 和 prettier 是必须的,能够帮助我们统⼀整个仓库的代码规范。

也可以使用业界成熟的 eslint 配置

  • @umijs/fabric
    .eslintrc.js
    1
    2
    3
    4
    5
    6
    module.exports = {
    extends: [require.resolve('@umijs/fabric/dist/eslint')],
    rules: {
    // your rules
    },
    }
    .stylelintrc.js
    1
    2
    3
    4
    5
    6
    module.exports = {
    extends: [require.resolve('@umijs/fabric/dist/stylelint')],
    rules: {
    // your rules
    },
    }
    .prettierrc.js
    1
    2
    3
    4
    5
    const fabric = require('@umijs/fabric');

    module.exports = {
    ...fabric.prettier,
    };

以上是 eslint 的配置,需要靠我们⼿动执⾏,万⼀我们忘记⼿动执⾏怎么办?

我们可以使⽤ lint-stased:

staged 是 Git ⾥的概念,表示暂存区,lint-staged 表示只检查并矫正暂存区中的⽂件。⼀来提⾼校验效率,⼆来可以为⽼的项⽬带去巨⼤的⽅便。

package.json
1
2
3
4
5
6
7
8
{
"lint-staged": {
"*.tsx": [
"eslint --fix",
"git add"
]
}
}

Typescript

推荐在项⽬中使⽤ typescript,良好的类型定义也是⼀个必须标准。
常⽤的tsconfig.json配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"compilerOptions": {
"outDir": "dist",
"module": "esnext",
"target": "es5",
"lib": ["esnext", "dom"],
"baseUrl": "./",
"jsx": "react",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true,
"experimentalDecorators": true,
"strict": true,
"skipLibCheck": true,
"declaration": true
},
"exclude": [
"node_modules",
"build",
"dist"
],
"include": ["src/*.ts"]
}

commitizen & commitlint & husky

commitizen 帮助我们⾃动⽣成统⼀格式的提交前缀,能够在多⼈协作开发时,保持统⼀格式的提交记录。
commitizen 有很多提交规则,由于我们使⽤ lerna 搭建项⽬。所以使⽤ cz-lerna-changelog 规则:

1
2
3
4
5
6
7
8
9
10
{
"scripts": {
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
}
}

commitlint 能够检查错误格式的commit提交。

commitlint.config.js
1
module.exports = { extends: ['@commitlint/config-conventional'] }

husky 能拦截格式错误的 commit 提交

1
2
3
4
5
6
7
{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}

组件库⽂档

Docz

docz 的使⽤⽅法很简单,在安装完后,在 package.json 中加⼊如下命令,就可以使⽤了

1
2
3
4
5
6
7
{
"scripts": {
"doc:dev": "docz dev",
"doc:build": "docz build",
"doc:serve": "docz build && docz serve"
}
}

构建⼯具的选择

Rollup vs Webpack

Webpack
  • 代码分割:Webpack 可以将你的 app 分割成许多个容易管理的分块,这些分块能够在⽤户使⽤你的 app 时按需加载。这意味着你的⽤户可以有更快的交互体验
  • 静态资源导⼊:图⽚、CSS 等静态资源可以直接导⼊到你的 app 中,就和其它的模块、节点⼀样能够进⾏依赖管理。
Rollup
  • Tree Shaking:是 rollup 提出的⼀个特性,利⽤的 es6 模块的静态特性对导⼊的模块进⾏分析,只抽取使⽤到的⽅法,从⽽减⼩打包体积。
  • 配置使⽤简便,⽣成的代码相对于 Webpack 更简洁。
  • 可以指定⽣成⽣产中使⽤的各种不同的模块(amd,commonjs,es,umd)。

@umi/father

基于rollup,配置简单,⽀持多种架构的组件库打包。
最简单的配置:

.fatherrc.js
1
2
3
export default {
entry: 'src/index.js'
}

Monorepo 配置

1
2
3
4
5
6
7
8
9
10
import { readdirSync } from 'fs';
import { join } from 'path';
const pkgs = readdirSync(join(__dirname, 'packages')).filter(
pkg => pkg.charAt(0) !== '.' && ![].includes(pkg),
);
export default {
target: 'node',
cjs: { type: 'babel', lazy: true },
pkgs: [...pkgs],
};

编写单元测试

jest 是常⽤的单元测试框架。
testing-library 专注于测试 react 组件,与之配套的还有 react-hooks-testing-library,专⻔⽤来测试 react-hooks。

使⽤jest:

结构

编写单元测试所涉及的⽂件应存放于以下两个⽬录:

  • mocks/:模拟⽂件⽬录
  • [name].mock.json:【例】单个模拟⽂件
  • tests/:单元测试⽬录
  • [target].test.js:【例】单个单元测试⽂件,[target]与⽬标⽂件名保持⼀致,当⽬标⽂件名为index 时,采⽤其上层⽬录或模块名。
[target].test.js ⽂件常⻅格式
1
2
3
4
5
6
7
8
9
10
11
12
const thirdPartyModule = require('thrid-party-module')
describe('@fe/module-name', () => {
const mocks = {}
beforeAll(() => {})
beforeEach(() => {})
test(' ', () => {
mocks.fake.mockReturnValue(' ')
const target = require('../target.js')
const result = target.foo(' ')
expcet(result).toBe(' ')
})
})

保证每个 describe 内部只有 mock 对象、⽣命周期钩⼦函数和 test 函数,将模拟对象都添加到 mocks 对象的适当位置,将初始化操作都添加到适当的⽣命周期函数中。

使⽤testing-library

React 测试库是⼀组能让你不依赖 React 组件具体实现对他们进⾏测试的辅助⼯具。它让重构⼯作变得轻⽽易举,还会推动你拥抱有关⽆障碍的最佳实现。React 测试库并不是 Jest 的替代⽅案,因为他们需要彼此,并且有不同的分⼯。

  1. 利⽤ react 测试库渲染APP组件
  2. 利⽤ react 测试库获取元素
  3. 利⽤ Jest 来进⾏写测试⽤例和断⾔
1
2
3
4
5
6
7
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
获取元素
  • getByRole <div role="alert"></div>
  • getByLabelText: <label for="search" />
  • getByPlaceholderText: <input placeholder="Search" />
  • getByAltText: <img alt="profile" />
  • getByDisplayValue: <input value="JavaScript">
  • getByTestId: <any data-testid="xxx">

除此之外,还有 queryByxxx 和 findByxxx 的查询函数,什么时候⽤ get/query/find ?,需要了解它们的不同。getBy 返回元素或者错误。getBy 在查找不到元素时返回错误,这是⾮常⽅便的,有助于我们在开发的过程中尽早的发现⾃⼰的⽤例发⽣了错误。findBy ⽤于查询⼀个在异步之后会被最终渲染的元素。

  • queryByText/findByText
  • queryByRole/findByRole
  • queryByLabelText/findByLabelText
  • queryByPlaceholderText/findByPlaceholderText
  • queryByAltText/findByAltText
  • queryByDisplayValue/findByDisplayValue
断⾔函数

正常情况下,这些断⾔函数来⾃ Jest,但是 React 测试库拓展了,加⼊了⼀些⾃⼰的断⾔函数。

  • toBeDisabled
  • toBeEnabled
  • toBeEmpty
  • toBeEmptyDOMElement
  • toBeInTheDocument
  • toBeInvalid
  • toBeRequired
  • toBeValid
  • toBeVisible
  • toContainElement
  • toContainHTML ● toHaveAttribute
  • toHaveClass
  • toHaveFocus
  • toHaveFormValues
  • toHaveStyle
  • toHaveTextContent
  • toHaveValue
  • toHaveDisplayValue
  • toBeChecked
  • toBePartiallyChecked
  • toHaveDescription
事件触发/回调处理

我们可以通过 React 测试库的 fireEvent 去模拟⽤户交互⾏为:(输⼊⽂字到Input框内)
Search组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Search({ value, onChange, children }) {
return (
<div>
<label htmlFor="search">{children}</label>
<input
id="search"
type="text"
role="textbox"
value={value}
onChange={onChange}>
</div>
);
}

我们想要测试当我们在 Search 的 Input 框内输⼊值时,onChange 是否有按预期的被调⽤,则需要通过 jest 给我们提供的 fn 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
describe('Search', () => {
test('calls the onChange callback handler', () =>{
const onChange = jest.fn();
render(
<Search value="" onChange={onChange}>
</Search>
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Javascript' },
});
expecet(onChange).toHaveBeenCalledTimes(1);
})
})

可以看到 onChange 通过 fireEvent 触发的情况下,只调⽤了⼀次,这个时候,我们可以使⽤ userEvent 去替代 fireEvent,⽐起 fireEvent,userEvent 更加的贴近⼈类的交互⾏为,在输⼊⽂字的时候,可以看到 onChange 会被调⽤多次(这是因为 userEvent 更加模拟了⼈类的键盘输⼊,keyDown 等)

1
2
3
4
5
6
7
8
9
10
11
describe('Search', async () => {
test('calls the onChange callback handler', async () => {
const onChange = jest.fn();
render(
<Search value="" onChange={onChange}>
Search:
</Search> );
await userEvent.type(screen.getByRole('textbox'), 'JavaScript');
expect(onChange).toHaveBeenCalledTimes(10);
})
})
react-hooks-testing-library

react-hooks-testing-library,是⼀个专⻔⽤来测试 React hook 的库。我们知道虽然 hook 是⼀个函数,可是我们却不能⽤测试普通函数的⽅法来测试它们,因为它们的实际运⾏会涉及到很多 React 运⾏时(runtime)的东⻄,react-hooks-testing-library 的库来允许我们像测试普通函数⼀样测试我们定义的 hook,这个库其实背后也是将我们定义的 hook 运⾏在⼀个 TestComponent ⾥⾯,只不过它封装了⼀些简易的 API 来简化我们的测试。

renderHook
  • renderHook ⽤来渲染 hook 的,它会在调⽤的时候渲染⼀个专⻔⽤来测试的 TestComponent 来使⽤我们的 hook。renderHook 的函数签名是 renderHook(callback, options?),它的第⼀个参数是⼀个 callback 函数,这个函数会在 TestComponent 每次被重新渲染的时候调⽤,因此我们可以在这个函数⾥⾯调⽤我们想要测试的hook。
  • renderHook 的返回值是 RenderHookResult 对象,这个对象会有下⾯这些属性:
    • result:result 是⼀个对象,它包含两个属性,⼀个是 current,它保存的是 renderHookcallback 的返回值,另外⼀个属性是 error,它⽤来存储 hook 在 render 过程中出现的任何错误。
    • rerender: rerender 函数是⽤来重新渲染 TestComponent 的,它可以接收⼀个 newProps 作为参数,这个参数会作为组件重新渲染时的 props 值,同样 renderHook 的 callback 函数也会使⽤这个新的 props 来重新调⽤。
    • unmount: unmount 函数是⽤来卸载 TestComponent 的,它主要⽤来覆盖⼀些 useEffect cleanup 函数的场景。
act

我们知道组件状态更新的时候(setState),组件需要被重新渲染,⽽这个重渲染是需要 React 进⾏调度的,因此是个异步的过程,我们可以通过使⽤ act 函数将所有会更新到组件状态的操作封装在它的 callback ⾥⾯来保证 act 函数执⾏完之后我们定义的组件已经完成了重新渲染。

1
2
3
4
5
6
7
8
// somewhere/useCounter.js
import { useState, useCallback } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
const increment = useCallback(() => setCount(x => x + 1), [])
const decrement = useCallback(() => setCount(x => x - 1), [])
return {count, increment, decrease}
}
1
2
3
4
5
6
7
8
9
describe('decrement', () => {
it('decrease counter by 1', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(-1)
})
})

单元测试编写原则

  • 每个单元测试应该有个好名字
  • 将内部逻辑与外部请求分开测试
  • 要保证单元测试的外部环境尽量和实际使⽤时是⼀致的
  • 对服务边界(interface)的输⼊和输出进⾏严格验证
  • ⽤断⾔来代替原⽣的报错函数
  • 避免随机结果
  • 尽量避免断⾔时间的结果
  • 测试⽤例之间相互隔离,不要相互影响
  • 原⼦性,所有的测试只有两种结果:成功和失败
  • 避免测试中的逻辑,即不该包含if、switch、for、while等
  • 不要保护起来,try…catch…
  • 每个⽤例只测试⼀个关注点
  • 3A策略:arrange,action,assert

版本号管理

我们的组件库使⽤ npm 发布,版本号规范也使⽤ npm 标准的 semver 规范。

版本格式:主版本号.次版本号.修订号,版本号递增规则如下:
1.主版本号:当你做了不兼容的 API 修改,
2.次版本号:当你做了向下兼容的功能性新增,
3.修订号:当你做了向下兼容的问题修正。
4.可以使⽤lerna version交互式的选择你的版本号。

补充知识点

Demo

react-components-libs