React.js 组件库设计

团队开始写第二个项目的时候,组件库的需求会自然冒出来——“这个按钮上个项目写过,拷过来”、”那个表单校验逻辑别重新写了”。拷来拷去几次之后,你意识到需要一个正经的组件库了。

但”写一个组件库”和”写一个好用的组件库”之间的差距,比想象中大得多。除了把组件写出来,你还需要处理文档、构建、测试、版本管理、代码规范——每一件事单独看都不难,放在一起就是一套工程体系。

这篇文章梳理的是我搭建一个 React 组件库时踩过的坑和最终沉淀下来的选型。

有意义

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

通⽤性

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

⽆状态,⽆副作⽤

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

避免过度封装

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

单⼀职责

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

易于测试

  • 更容易的单元测试覆盖

组件文档怎么写

文档结构

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

文档工具选型

主流选择有两个:

  • Storybook:社区最活跃的方案,生态丰富,适合需要交互式组件展示和视觉测试的场景
  • dumi:Umi 生态出品,Markdown 即文档,适合偏重文档化展示的组件库

组件库架构:Multirepo vs Monorepo

Multirepo

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

优点:项目结构简单,调试安装方便。

缺点:项目变大后构建和发布耗时长;使用时需要整体引入(虽然可以靠 ES Module 的 tree shaking 缓解)。

典型案例:ant design

Monorepo

一个仓库管理多个项目,以多个 npm 包方式发布。依赖集中管理,每个组件可以独立发版、独立安装。

优点:依赖统一管理,版本控制方便;支持组件单独发布和构建;使用方可以按需引入。

缺点:搭建复杂度高,lerna/yarn workspace/pnpm 的配置和 CI 流程比 Multirepo 复杂不少。

典型案例:React 官方仓库

管理工具

  • lerna
  • yarn workspace
  • pnpm

我自己的项目最终选了 lerna + yarn workspace 的组合——lerna 负责版本管理和发布,yarn workspace 处理依赖提升。如果你新起一个项目,pnpm 是现在更推荐的选择,它的依赖管理更严格,磁盘利用率也更好。

Eslint & Prettier

一个高质量的组件库,eslint 和 prettier 是必须的——它们统一了整个仓库的代码风格。

不想从头配的话,可以直接用社区成熟的预设,比如 @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-staged 配合 husky,在 git commit 时自动检查暂存区文件:

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

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

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 自动生成统一格式的提交前缀,commitlint 检查错误格式的 commit,husky 在 git hooks 阶段拦截不合规的提交。三者配合:

使用 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
  • 代码分割:支持按需加载,适合应用打包
  • 静态资源导入:图片、CSS 等可以直接作为模块导入
Rollup
  • Tree Shaking:利用 ES Module 静态特性,只抽取使用到的方法,打包体积更小
  • 配置简便,生成的代码比 Webpack 更干净
  • 可以输出多种模块格式(amd、commonjs、es、umd),更适合库的发布

结论:写应用用 Webpack,写库用 Rollup。组件库是库,选 Rollup。

不想手写 Rollup 配置的话,@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 组件——不依赖组件内部实现,更接近用户的使用方式。配套的 react-hooks-testing-library 专门测试自定义 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 的核心 API

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">

除此之外,还有 queryBy*(查不到返回 null)和 findBy*(异步查询,等待元素出现)。什么时候用哪个:

  • getBy*:元素一定存在时使用,找不到直接报错——有助于尽早暴露问题

  • queryBy*:元素可能不存在时使用

  • findBy*:元素异步出现时使用

  • queryByText/findByText

  • queryByRole/findByRole

  • queryByLabelText/findByLabelText

  • queryByPlaceholderText/findByPlaceholderText

  • queryByAltText/findByAltText

  • queryByDisplayValue/findByDisplayValue

断言函数

jest 的内置断言之外,testing-library 扩展了这些 DOM 相关的断言:

  • toBeDisabled
  • toBeEnabled
  • toBeEmpty
  • toBeEmptyDOMElement
  • toBeInTheDocument
  • toBeInvalid
  • toBeRequired
  • toBeValid
  • toBeVisible
  • toContainElement
  • toContainHTML ● toHaveAttribute
  • toHaveClass
  • toHaveFocus
  • toHaveFormValues
  • toHaveStyle
  • toHaveTextContent
  • toHaveValue
  • toHaveDisplayValue
  • toBeChecked
  • toBePartiallyChecked
  • toHaveDescription
事件模拟

通过 fireEvent 模拟用户交互:
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);
})
})
测试 Hooks:react-hooks-testing-library

Hook 虽然是一个函数,但不能用测试普通函数的方法来测——它依赖 React 运行时。react-hooks-testing-library 在内部渲染一个 TestComponent 来承载 Hook 的执行,对外暴露简易 API。

核心 API:

  • renderHook(callback, options?):渲染一个 TestComponent 并执行 callback(callback 中调用要测试的 Hook)。返回 { result, rerender, unmount }
  • 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
10
// useCounter.test.js
describe('decrement', () => {
it('decrease counter by 1', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(-1)
})
})

单元测试编写原则

这几点是我在实际项目中反复验证过的:

  • 每个测试用例应该有一个好名字——读名字就知道测什么
  • 将内部逻辑与外部请求分开测试——mock 外部依赖
  • 保证测试环境尽量和实际使用一致
  • 对接口的输入输出进行严格验证
  • 用断言代替原生报错函数
  • 避免随机结果——测试必须可复现
  • 避免断言时间的结果——时间相关测试用 mock timer
  • 测试用例之间相互隔离,不要共享状态
  • 原子性:每个测试只有成功和失败两种结果
  • 避免测试中的逻辑——不该包含 if、switch、for、while
  • 不要用 try…catch 把错误吞掉
  • 每个用例只测试一个关注点
  • 3A 策略:Arrange(准备)、Act(执行)、Assert(断言)

版本号管理

npm 的 semver 规范:主版本号.次版本号.修订号

  • 主版本号:不兼容的 API 修改
  • 次版本号:向下兼容的功能新增
  • 修订号:向下兼容的问题修复

使用 lerna 的话,lerna version 可以交互式选择版本号。

参考

一个完整的组件库 Demo:react-components-libs


搭建组件库这件事,技术选型反而不是最难的——Rollup 还是 Webpack、lerna 还是 pnpm,选哪个都能把事做成。真正花时间的是把规范落下去:代码规范、提交规范、测试覆盖、版本管理流程,这些”软”的东西决定了组件库能不能被团队长期用起来,而不是搭完就吃灰。