React.js 组件库设计
团队开始写第二个项目的时候,组件库的需求会自然冒出来——“这个按钮上个项目写过,拷过来”、”那个表单校验逻辑别重新写了”。拷来拷去几次之后,你意识到需要一个正经的组件库了。
但”写一个组件库”和”写一个好用的组件库”之间的差距,比想象中大得多。除了把组件写出来,你还需要处理文档、构建、测试、版本管理、代码规范——每一件事单独看都不难,放在一起就是一套工程体系。
这篇文章梳理的是我搭建一个 React 组件库时踩过的坑和最终沉淀下来的选型。
有意义
- 命名准确,充分表意
- 参数准确,必要的类型检查
- 适当的注释
通⽤性
- 不要耦合特殊的业务代码
- 不要包含特定的代码处理逻辑
⽆状态,⽆副作⽤
- 状态向上层提取,尽量少用内部状态
- 解耦 IO 操作
避免过度封装
- 合理冗余
- 避免过度抽象
单⼀职责
- 一个组件只完成一个功能
- 尽量避免不同组件相互依赖、循环依赖
易于测试
- 更容易的单元测试覆盖
组件文档怎么写
文档结构
- 组件描述
- 组件示例及代码演示
- 组件入参描述
文档工具选型
主流选择有两个:
组件库架构: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:
1 | module.exports = { |
1 | module.exports = { |
1 | const fabric = require('@umijs/fabric'); |
以上是 eslint 配置,但光有配置不够——万一有人忘记手动执行怎么办?用 lint-staged 配合 husky,在 git commit 时自动检查暂存区文件:
staged 是 Git ⾥的概念,表示暂存区,lint-staged 表示只检查并矫正暂存区中的⽂件。⼀来提⾼校验效率,⼆来可以为⽼的项⽬带去巨⼤的⽅便。
1 | { |
TypeScript
类型定义是组件库质量的底线。一个没有类型提示的组件库,使用体验直接打对折。常用的 tsconfig.json 配置:
1 | { |
commitizen & commitlint & husky
commitizen 自动生成统一格式的提交前缀,commitlint 检查错误格式的 commit,husky 在 git hooks 阶段拦截不合规的提交。三者配合:
使用 lerna 搭建项目时,用 cz-lerna-changelog 规则:
1 | { |
commitlint 能够检查错误格式的commit提交。
1 | module.exports = { extends: ['@commitlint/config-conventional'] } |
husky 能拦截格式错误的 commit 提交
1 | { |
文档方案:Docz
Docz 的使用很简单,安装后在 package.json 加入以下命令即可:
1 | { |
构建工具:Rollup vs Webpack
Webpack
- 代码分割:支持按需加载,适合应用打包
- 静态资源导入:图片、CSS 等可以直接作为模块导入
Rollup
- Tree Shaking:利用 ES Module 静态特性,只抽取使用到的方法,打包体积更小
- 配置简便,生成的代码比 Webpack 更干净
- 可以输出多种模块格式(amd、commonjs、es、umd),更适合库的发布
结论:写应用用 Webpack,写库用 Rollup。组件库是库,选 Rollup。
不想手写 Rollup 配置的话,@umi/father 基于 Rollup 封装了开箱即用的组件库打包方案。最简单的配置:
1 | export default { |
Monorepo 配置
1 | import { readdirSync } from 'fs'; |
单元测试
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 | const thirdPartyModule = require('thrid-party-module') |
保证每个 describe 内部只有 mock 对象、⽣命周期钩⼦函数和 test 函数,将模拟对象都添加到 mocks 对象的适当位置,将初始化操作都添加到适当的⽣命周期函数中。
testing-library 的核心 API
React 测试库是⼀组能让你不依赖 React 组件具体实现对他们进⾏测试的辅助⼯具。它让重构⼯作变得轻⽽易举,还会推动你拥抱有关⽆障碍的最佳实现。React 测试库并不是 Jest 的替代⽅案,因为他们需要彼此,并且有不同的分⼯。
- 利⽤ react 测试库渲染APP组件
- 利⽤ react 测试库获取元素
- 利⽤ Jest 来进⾏写测试⽤例和断⾔
1 | import { render, screen } from '@testing-library/react'; |
获取元素的方法
- 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 | function Search({ value, onChange, children }) { |
我们想要测试当我们在 Search 的 Input 框内输⼊值时,onChange 是否有按预期的被调⽤,则需要通过 jest 给我们提供的 fn 函数:
1 | describe('Search', () => { |
可以看到 onChange 通过 fireEvent 触发的情况下,只调⽤了⼀次,这个时候,我们可以使⽤ userEvent 去替代 fireEvent,⽐起 fireEvent,userEvent 更加的贴近⼈类的交互⾏为,在输⼊⽂字的时候,可以看到 onChange 会被调⽤多次(这是因为 userEvent 更加模拟了⼈类的键盘输⼊,keyDown 等)
1 | describe('Search', async () => { |
测试 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 | // somewhere/useCounter.js |
1 | // useCounter.test.js |
单元测试编写原则
这几点是我在实际项目中反复验证过的:
- 每个测试用例应该有一个好名字——读名字就知道测什么
- 将内部逻辑与外部请求分开测试——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,选哪个都能把事做成。真正花时间的是把规范落下去:代码规范、提交规范、测试覆盖、版本管理流程,这些”软”的东西决定了组件库能不能被团队长期用起来,而不是搭完就吃灰。