本文为掘金社区首发签约文章,未获授权禁止转载
紫升有一个朋友小黑最近在面试时被问到如何设计一个前端组件库。没啥经验的小黑回答了业务提取封装成库以及基于 antd 结合业务二次封装。最后小黑被 HR 以灵力不够挂掉了。其实这个问题考察的并不是假大空的概念,而是有关开发者仓库管理、组件设计、单元测试、持续集成、协作管理等等能力。那么为了赋能小黑完美回答这个问题呢,我决定带领小黑一步一步建设一个 React Native 组件库。
这是一篇干货比较多的组件库搭建实战教程,不仅有通用的代码规范、提交规范、文档维护、单元测试、GitHub Action 配置的讲解,还涉及基于 lerna 的多包管理架构、React Native 图标库建设、React Native 组件库开发调试、按需加载原理及实现。工程化的思想是通用的,所以无论是你用的框架是什么,本文都值得一读。
如果电脑前的掘友也对组件库开发感兴趣,不妨先给个点赞,再持续关注紫升和小黑的组件库开发之旅。PS:配合仓库和组件库文档阅读本文效果更佳喲!
站在 Vant Design 的肩膀上
维护开发一个组件库无疑是需要投入很多时间和精力的,Flag 立了倒,倒了又立。可谓万事开头难,首先我们要有自知之明,在没有设计师和业余开发的情况下,我选择了给现有 UI Design 实现 React Native 版本的方式开启组件库开发之旅。在调研了 vant、fishd-mobile 和 antd-mobile 后我选择了 vant。这是几个仓库的现状对比:
组件库 | 团队 | Github Star | Npm 周下载量 | 维护度 |
---|---|---|---|---|
vant | 有赞 | 17.7K | 27,789 | 维高度高,流行度也高 |
antd-mobile | Ant Design Team | 8.9K | 31,470 | 几乎不维护,据说蚂蚁内部也不用了 |
fishd-mobile | 网易云商前端 | 29 | 22 | 看起来是个 KPI 项目无疑了 |
确定了旅程的方向,就是给我们的组件库起一个合适的名字和口号,用前端工程师的方式表述就是 package.json
的 name
和 description
字段:
1 | // package.json |
由于我们的组件库定位是 vant 的 RN 版,参照 lottie-react-native、styled-react-native、jpush-react-native 的命名方式我们将组件库命名为 vant-react-native,同时也是希望组件库完成时能获得 vant 官方的支持。
基于 Lerna 的多包管理架构
Lerna 是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目。由 Lerna 管理的仓库我们一般称之为单体仓库(monorepo)。基于 Lerna 的多包管理架构的优点在于:
- 组件级别解耦,独立版本控制,每个组件都有版本记录可追溯
- 组件单独发布,支持灰度、版本回滚以及平滑升降级
- 按需引用,用户安装具体某个组件包,无需配置即可实现按需加载的效果。
- 关注点分离,降低大型复杂度、组件之间依赖清晰且可控制
- 单一职责原则,降低开源基友的参与和贡献难度
1 | . |
初始化 lerna 项目
1 | $ mkdir vant-react-native && lerna init --independent |
yarn workspaces
使用 yarn workspaces 结合 Lerna useWorkspaces
可以实现 Lerna Hoisting。这并不是多此一举,这可以让你在统一的地方(根目录)管理依赖,这即节省时间又节省空间。
配置 lerna.json
:
1 | { |
托管给 yarn wrokspace 之后,lerna 的 packages
将会被顶级 package.json
的 workspaces
覆盖:
1 | { |
lerna publish config
如果你不想在所有 package.json
文件中单独明确设置你的注册表配置,例如使用私有注册表时,设置 command.publish.registry
很有用。配置 ignoreChanges
则是为了避免不必要的版本升级。
1 | "ignoreChanges": [ |
除此之外,如果你的包名是带 scope 的,需要在那个包的
package.json
中设置publishConfig.access
为"public"
。
lerna version config
当配置 conventionalCommits
为 true
后,lerna 版本将使用 Conventional Commits Specification 来确定版本升级并 生成 CHANGELOG.md 文件。
1 | "command": { |
规范化提交
规范化 git commit
对于提高 git log
可读性、可控的版本控制和 changelog 生成都有着重要的作用。紫升之前在 一文搞定规范化Git Commit 中详细讲述了 Conventional Commits 的概念以及 commitizen、cz-customizable、@commitlint/cli、yorkie 和 commitlint-config-cz 等工具的配置。
由于配置繁琐,我在 @youngjuning/cli 中添加了 init-commit
命令一键配置 conventional commit。可以打开这个 commit 查看配置信息。
注意:husky 高版本用法不向后兼容,我在这个 commit 中用尤大的 yorkie 代替了 husky。
代码规范化
代码规范化的重要性不言而喻,代码规范化涉及的工具有 editorconfig、eslint、prettier 等,在 装它|再也不用操心ESLint配置 一文中我介绍了如何一步一步建设属于自己的 eslint config 插件并产出了 @youngjuning/eslint-config 和 @youngjuning/prettier-config。
vant-react-native 暂时使用 @youngjuning/eslint-config、@youngjuning/prettier-config 约束项目代码规范。相关配置如下文。
eslint
首先安装 react-native 所需的插件。
1 | yarn add -D eslint-plugin-react \ |
然后配置 .eslintrc.js
1 | // .eslintrc.js |
prettier
1 | // .prettierrc.js |
@youngjuning/eslint-config 计划也用 lerna 管理,产出 @youngjuning/eslint-config-react、@youngjuning/eslint-config-react-native、@youngjuning/eslint-config-vue 让开发者无需过多配置开箱即用。
editorconfig
1 | # .editorconfig |
yorkie & lint-staged
1 | $ yarn add -D yorkie lint-staged |
1 | { |
第一个组件从 Icon 开始
一个成熟的组件库都会拥有自己的一套 Icon,Icon 一般由设计师通过 Sketch 设计,然后导出 svg 文件。
ant-design-icons 的 svg 文件是 保存在本地,然后通过脚本生成 react 组件、vue 组件 和 icons-react-native 等组件,由于支持的框架比较完备我们无需自己实现,RN 我们直接使用 icons-react-native。
vant 以及 fishd-mobile 则是通过 Iconfont 维护 svg 文件,然后通过设置 @font-face
的方式实现 Icon 组件,如图所示:
有了 ttf 文件,我们可以像 @ant-design/icons-react-native 一样基于 ttf 文件使用脚本生成 Icon 组件,但是使用 ttf 字体有一个弊端,就是每次更新图标,都要相应的更新 ttf 文件,然后再次打包发布 APP。而且 ttf 不支持多种色彩的图标,导致所有图标都是单色。如果你是借助 react-native-vector-icons,该库内置了 10 多套 ttf 文件,合起来有 2M 左右;你可能用不到它们,但是它们仍然会被打包进你的 APP 里,这也是我认为 react-native-elements 这个库外强中干的一大原因。
那么只有 Iconfont 链接我们如何实现 vant-icons 的 React Native 版本呢?这里紫升没有自己写脚本,而是使用了一款叫 react-native-iconfont-cli 的工具,fwh1990 大佬针对以上痛点用纯 Javascript 实现 iconfont 到 React 组件的转换操作,不需要依赖 ttf 字体文件,不需要手动下载图标到本地。
创建 lerna 子包
1 | # 创建主包,主包用来统一导出所有的组件 |
我们的目录结构看起来是这样的:
1 | . |
生成 icons
安装插件
1 | yarn workspace @vant-react-native/icons add -D react-native-svg react-native-iconfont-cli |
生成配置文件
我们在 packages/icons
目录下使用 npx iconfont-init
命令会生成 iconfont.json
文件,自定义后内容如下:
1 | { |
生成 React Native 标准组件
执行 npx iconfont-rn
命令即可生成标准 React Native 组件。由于图标文件比较多,我们不将图标产物加入 git 管理。所以我们需要在 npm 发布前执行构建命令:
1 | { |
配置 react-native-vant
我们前面提到 packages/vant-react-native
是主包的目录,我们需要将 @vant-react-native/icons
包添加到主包的依赖中并导出。
添加依赖
1 | $ lerna add @vant-react-native/icons --scope vant-react-native |
导出 Icon 组件
1 | // packages/vant-react-native/src/index.ts |
tsconfig 配置
对与每个子包我们期望使用一样的配置,所以我们会先在整个项目的根目录新建 tsconfig. base.json,在子包继承即可。
1 | { |
配置发布脚本
和 @vant-react-native/icons
子包一样,我们需要添加 build
和 prepublishOnly
脚本:
1 | { |
发布包
第一次发布的话,注意使用的是 lerna publish 0.0.1
,因为 lerna 的发布命令没有第一次发布这个参数,所以需要显示指定初始版本。或者可以将初始版本设置为 0.0.0
然后执行 lerna publish
。
小技巧:如果发布后想查看包内容,可以通过 jsdelivr 查看。比如刚发布的 vant-react-native 和 @vant-react-native/icons
开发调试
一个完善且体验良好的调试流程不仅能够满足在开发阶段验证组件是否符合预期,还可以降低开源社区基友的参与难度。React Native 组件库的调试和其他技术栈流程大体没有区别,只不过因为 Metro 不支持软连接 以及 vant-react-native 是基于 lerna 的单体仓库项目,我们的配置会有不同。
初始化 React Native App
由于是 React Native 项目,我们需要初始化一个 React Native 项目。首先找一个地方使用 react-native init vantapp --template react-native-template-typescript
创建一个新的 React Native App。然后将生成的 App 与我们的主项目合并。合并后的项目结构如下:
1 | . |
主要冲突的是 Prettier、eslint 等工具的配置,合并没那么难。在运行项目之前,我们一般需要编译项目。我们可以借助 lerna run build
命令批量运行子包里的 build
npm script。
注意📢:由于子包之间有依赖关系,不要使用
--parallel
参数并行执行打包脚本。
现在我们编写一个九宫格 Demo 验证一下:
1 | // App.tsx |
然后执行 yarn ios
查看实际效果(之后我们就可以执行 yarn start --reset-cache
快速开始调试):
上面的示例代码中我们可以看到我们直接使用了 import { Icon } from 'vant-react-native';
而不是相对路径引用 packages 下的模块。可是我们的项目并没与安装这个依赖,编译器是怎么找到的呢?这里也没有什么银弹,这是因为 lerna 会把子包软链接到 node_modules 中,我们可以使用 ls -al
发现看到包的实际指向:
我们也可以在类型提示中看到实际指向的是 packages 下的文件:
注意📢:Metro 不支持符号链接 指的是软连接的目录不在项目根目录下,这里我们软连接指向的位置还在根目录下,所以可以正确工作✅。这个特性保证了调试与生产开发的一致性和便利性。
实时编译
现在我们的调试流程是:
- 修改代码
- 执行
lerna run build
编译每个子包 - 执行
yarn ios
调试项目 - 修改代码
- 执行
lerna run build
重新编译 - 执行
yarn start --reset-cache
运行项目 - 循环 4、5、6。
尽管 React Native 有 Fast Refresh 功能,但是由于我们的代码是需要编译的,所以我们需要重复编译运行的动作。
任何重复的工作都可以用脚本代替。首先我们需要给每个子包添加实时编译的 script,像 rollup、babel、webpack、typescript 都有参数可以实现实时编译:
1 | { |
而我们的 @vant-react-native/icons 包使用的 npx iconfont
没有实时编译选项,经过调研,我引入了 onchange 这个库可以基于 glob 模式监听文件改动后执行一个命令:
1 | { |
然后我们需要使用 lerna run dev --parallel
批量执行实时编译脚本,这里加 --parallel
是因为子包如果是实时编译,进程会卡住。为了补救,我们不得不预先编译 @vant-react-native/icons
包,然后因为同样的原因我引入了 npm-run-all
来并行执行 lerna run dev
和 react-native start
,完整脚本如下:
1 | { |
按需加载
小黑:“紫升哥哥,我之前为了使用 react-native-elements 的其中几个组件而引入了整个组件库。因为这个组件库依赖了 react-native-vector-icons 导致 bundle 包变大。如果我就是想用整套 vant-react-native,如何解决这个问题呢?”
众所周知,React Native 的打包工具 Metro 不支持 tree-shaking。解决这个问题的方式其实很简单,机智的你可能知道配合 babel-plugin-import 是可以实现按需加载的需求的。但由于我们是多包管理架构,需要针对多包的架构设计一个方案。
react-naitve bundle 包
为了比对优化前后包大小,我们需要使用 react-native bundle
命令看一下纯 JS 包的大小,我们来简单看下这个命令:
1 | react-native bundle --platform ios --entry-file index.js --bundle-output ./bundle/ios/index.ios.jsbundle --assets-dest ./bundle/ios --dev false --reset-cache |
--entry
:入口 js 文件--bundle-output
:生成的 bundle 文件路径--platform
:平台--assets-dest
:图片资源的输出目录--dev
:是否为开发版本,打正式版的安装包时我们将其赋值为 false--reset-cache
:重置缓存,避免打包使用旧的缓存
按需加载原理
前面我们提到 packages/vant-react-native
只有一个文件 src/index.ts
用来导出所有子包,现在我们添加一个新的包 Button,看上去就是这样:
1 | export { default as Icon } from '@vant-react-native/icons'; |
这种导出方式,用户只能通过 import Button from '@vant-react-native/button';
或 import Button from 'vant-react-native/lib/button';
的方式手动实现按需加载,这不仅不方便开发者使用,从打包产物来说也增加了很多字节。那么问题来了,怎么样的组织形式才能满足按需加载呢?答案就在 babel-plugin-import 插件的文档中:
从图中我们看出 babel-plugin-import 插件是在编译阶段将引用指向了模块所在文件夹。用户使用时安装插件并做如下配置就完成了按需加载。
1 | "plugins": [ |
依然没有银弹,插件做的工作只是代替了你的右手。知道了原理我们就可以按照文档要求的格式重新组织我们的 vant-react-native 包:
1 | . |
vant-react-native/src/button/index.ts:
1 | import Button from '@vant-react-native/button'; |
vant-react-native/src/icon/index.ts:
1 | import Icon from '@vant-react-native/icons'; |
vant-react-native/src/index.ts:
1 | export * from './icon'; |
然后项目中修改 babel.config.js:
1 | module.exports = { |
编写 Babel 插件?
虽然通过修改主包的导出方式可以完成需求,但是却极大地增加了项目本身的复杂度。前面我们已经知道 babel-plugin-import 的原理是转换引用路径。那么我们是不是可以通过插件动态把 import {Button} from 'vant-react-native'
转成 import Button from '@vant-react-native/button'
呢?答案是肯定的,下面是我基于 babel-plugin-import 的 customName
配置编写了一套配置并封装在 babel-plugin-import-vant 包中:
1 | import camelCase from 'camelcase'; |
在项目的 babel.config.js
配置中添加 plugins: [...require('babel-plugin-import-vant').default()]
即可实现按需加载。
还有可以优化的地方吗?机智的你可能又发现我只是通过函数导出了一个配置而已,并不是真正的插件,所以未来我会定制一个 vant-react-native 自己的按需加载 babel 插件。
name.match(/^van-icon-/)
这个判断条件是因为@vant-react-native/icons
包除了包含一个默认导出的 Icon 组件,还导出了很多单个图标组件,为了进一步减小打包体积,我们对这个子包也进行了按需加载处理。我们已经知道按需加载的原理是没有中间商赚差价直接和卖家谈,所以后面我们遇见类似的需求通过转换返回卖家地址即可。不需要破坏性地改项目结构。
成果展示
初始包大小 | 未配置按需加载(引入 Button) | 按需加载(引入 Button) | 按需加载(引入 Icon) | 按需加载(引入 VanIconAdd) |
---|---|---|---|---|
723KB | 1.8M | 725KB | 1.8M | 1.22M |
之所以 Icon 包会大,是因为 react-native-svg 这个库大,所以不建议直接使用 Icon 组件,而是使用 VanIconAdd、VanIconEye 这种单独的图标组件,少了 593KB 还是挺香的。
组件库文档
组件库文档比较重要的是有可以交互的 Demo 演示,我是 Dumi 的资深用户,借助 dumi-theme-mobile 和 umi-plugin-react-native 我们可以很好地满足 React Native 组件库文档的搭建。
集成 Dumi 到项目中
安装依赖:
1 | $ yarn add dumi dumi-theme-mobile umi-plugin-react-native -D |
配置文件:
在项目根目录添加 .umirc.ts
1 | import { defineConfig, IConfig } from 'dumi'; |
值得一提的是,Dumi 是支持 Lerna 仓库的,它默认会以 packages/[包名]/src
为基础路径搜寻所有子包的 Markdown 文档并生成路由。通过 resolve.includes
可以配置 dumi 嗅探的文档目录,dumi 会尝试在配置的目录中递归寻找 markdown 文件。
添加 NPM 脚本:
注意📢:由于实际依赖的是 packages 下的包,我们必须先编译所有的包,否则部署的时候会报
This dependency was not found:
的错误。
1 | { |
忽略文件(.gitignore):
1 | # umi |
部署到 GitHub Pages
在根目录新建 .github/workflows/gh-pages
:
1 | name: github pages |
预览
现在我们可以访问 https://youngjuning.js.org/vant-react-native/ 查看效果了:
配置优化
现在基于 dumi 的文档站点只是初始化,很多配置(.umirc.ts)可以优化,比如:
- 基于 jsdeliver 配置 CDN 加速
1 | const isProd = process.env.NODE_ENV === 'production'; |
- 增量发布和避免浏览器加载缓存
1 | { |
1 | { |
- 配置
exportStatic: {}
将所有路由输出为 HTML 目录结构,以免刷新页面时 404。
Pull Request 预发预览
考虑到后期社区会贡献代码和文档。在 pr 合进主分支之前,我们需要预览文档或组件。满足这一需求的是一个叫 surge.sh 的静态托管服务,surge 支持在命令行通过简单的命令免费发布 HTML、CSS 和 JS 文件到 web。
申请 Surge Token
安装 surge cli:
1 | npm install --global surge |
注册 surge 账号:
1 | suerge login |
获取 token:
1 | suerge token |
配置 CI
由于 GitHub 的安全问题,surge-preview Action 插件无法使用,我们参考 dumi 官方的配置自定义了 CI,首先我们拷贝下图中的三个文件到项目中。
然后修改 preview-build.yml
中的 build step
:
1 | - NODE_OPTIONS='--max-old-space-size=4096' yarn build |
添加环境变量 PREVIEW_PR=true
是为了让 dumi 打包时识别出不是生产环境打包,.umirc.ts
需要相应修改为:
1 | const isProd = |
再然后,修改 preview-deploy.yml
文件中的部署域名 dumi-preview
为 vant-react-native-preview
。
最后我们把前面获取的 Surge Token 添加到仓库的 Secrets 即可。
成果展示
正在部署 PR 预览状态:
部署成功状态:
访问 https://vant-react-native-preview-pr-1.surge.sh/ 即可验证文档的正确性✅。
单元测试
我在 使用 Jest 和 Enzyme 进行 React Native 单元测试|技术点评 一文中曾提交单元测试和文档一样,是保障程序最小单元质量的重要一环。诚然一个成熟的组件库是必然有单元测试的身影。本章就不展开讲单元测试了,主要讲 vant-react-native 是如何配置单元测试的。
安装依赖
jest、babel-jest、@types/jest 这些依赖都已经安装了,我们需要安装的是 enzyme 这个基于 jest 的单元测试框架。
1 | $ yarn add enzyme jest-enzyme enzyme-adapter-react-16 enzyme-to-json @types/enzyme react-native-mock-render -DW |
Enzyme 是用于 React 的 JavaScript 测试实用程序,可以更轻松地测试 React 组件的输出。您还可以根据给定的输出进行操作,遍历并以某种方式模拟运行时。
配置
jest.config.js:
1 | module.exports = { |
jest.setup.js:
1 | import 'react-native'; |
一个简单的示例:
1 | // packages/button/__test__/index.tsx |
执行 jest
命令后可以查看覆盖率如下:
写给勇士
能写长文的不算勇士,能坚持看到这里的才是勇士。紫升在此感谢您的阅读。然而组件库工程化这只是一个起点,如果本文反响好,组件库具体组件的设计实现、完整的 React Native 单元测试教程等等紫升会在后续的文章中展开讲。
推荐的 UI 库
当然了,vant-react-native 并不是你唯一的选择,下面的几个 UI 库都是很优秀的项目。在实现 vant-react-native 时我也多少借鉴了前人优秀的设计。