一、技术栈
我们先简单了解一下要搭建一个团队的 UI 组件库,会涉及到哪些技术栈:
Create React App:官方支持的 CLI 脚手架,提供一个零配置的现代构建设置;
React: 用于构建用户界面的 JavaScript 库;
Ant Design:一套企业级 UI 设计语言和 React 组件库;
Storybook: 辅助 UI 控件开发的工具,通过story创建独立的控件,让每个控件开发都有一个独立的开发调试环境;
TypeScript:2020 最火的前端语言,是JavaScript类型的超集;
ESLint && husky:统一团队代码风格;
Jest:JavaScript 测试框架,用于组件库的单元测试;
Travis CI: 提供持续集成服务,用于进行项目的持续集成以及持续部署;
二、项目准备
2.1 创建组件库的工程环境
使用 Create React App
创建 UI 组件库的前端工程环境。
npx create-react-app ii-admin-base --typescript
2.2 安装 Storybook
采用自动方式安装 Storybook,命令如下:
npx -p @storybook/cli sb init --type react_scripts
参数
react_scripts
用来告诉 Storybook 当前项目使用Create React App
创建的,Storybook会根据该参数来自动安装合适的包。
2.3 安装 Storybook 插件
2.3.1 addon-info
插件
addon-info
插件会自动识别组件传递的 props 生成表格。
yarn add @storybook/addon-info -Dyarn add @types/storybook__addon-info -D
三、配置 Storybook
在配置 Storybook 之前,先简单了解下 stories 的加载流程。
stories 的加载是在 .storybook/main.js
或 .storybook/preview.js
这两个文件中进行。加载 stories 的最简便方式是按文件名进行加载。假设你的 stories 文件位于 src/components
目录,则可以通过如下方式进行加载:
// .storybook/main.jsmodule.exports = { stories: ['../src/components/**/*.stories.js'],};
或者可以在 .storybook/preview.js
中加载所有的 stories
:
import { configure } from '@storybook/react';configure(require.context('../src/components', true, /\.stories\.js$/), module);
注意:在
.storybook/preview.js
文件中,只能调用一次configure
函数。
configure
函数接收参数为:
单个 require.context “req”
从多个地方加载文件的 “req”s 数组;
返回值是
void
或an array of module exports
的加载函数;
如果想从多个地方进行加载,可采用数组方式,如下所示:
import { configure } from '@storybook/react';configure( [ require.context('../src/components', true, /\.stories\.js$/), require.context('../lib', true, /\.stories\.js$/), ], module);
注:
如果想引入一个文件夹下面的所有文件,或者引入能匹配一个正则表达式的所有文件,可以使用函数require.context()
。require.context()
函数有 3 个参数:
要搜索的文件夹目录;
是否还应该搜索它的子目录;
以及一个匹配文件的正则表达式;
3.1 配置 stories 显示顺序
若想改变 stories 的显示顺序,该如何操作?示例如下,将 welcome.stories.tsx
先添加至数组中,从而改变 stories 的显示顺序:
import { configure } from '@storybook/react';// 将 welcome 文档说明置于顶部const loaderFn = () => { const allExports = [require('../src/welcome.stories.tsx')]; const req = require.context('../src/components', true, /\.stories\.tsx$/); req.keys().forEach((fname) => allExports.push(req(fname))); return allExports;};// automatically import all files ending in *.stories.tsx
configure(loaderFn, module);
3.2 支持 Typescript
要搭建的基础组件库是基于 Typescript 进行编写的,因此还需添加 Typescript 支持。配置 ./storybook/main.js
文件,内容如下:
webpackFinal: async (config) => { config.module.rules.push({ test: /\.(ts|tsx)$/, use: [ { loader: require.resolve('babel-loader'), options: { presets: [require.resolve('babel-preset-react-app')], }, }, ], }); return config; }
3.3 配置 less
要搭建的基础组件库是基于 Ant Design 做的二次封装,因此就不得不支持 less。针对 less,配置 ./storybook/main.js
文件,内容如下:
// .storybook/main.js webpackFinal: async (config) => { config.module.rules.push({ test: /\.(ts|tsx)$/, use: [ { loader: require.resolve('babel-loader'), options: { presets: [require.resolve('babel-preset-react-app')], }, }, ], }); config.module.rules.push({ test: /\.less$/, loaders: [ 'style-loader', 'css-loader', { loader: 'less-loader', options: { lessOptions: { javascriptEnabled: true, }, }, }, ], include: [path.resolve(__dirname, '../src'), /[\\/]node_modules[\\/].*antd/], }); return config; },
完成上述内容配置,发现导入的 less 文件不生效。针对这一问题,进行了以下几点排查。
问题1: 如果 less-loader
版本是 6.0 以上,则如下配置会报错:
{ loader: "less-loader", options: { javascriptEnabled: true }}
需修改成:
{ loader: 'less-loader', options: { lessOptions: { javascriptEnabled: true, }, }, }
问题2: storybook 5.3.0 与 storybook 5.2.x 存在一些差异,见参考链接。cra(create-react-app)的 file-loader 会拦截所有其他文件,导致less 文件不能进入less-loader中。针对这一问题,需配置 @storybook/preset-create-react-app,配置内容如下:
{
name: '@storybook/preset-create-react-app',
options: {
craOverrides: {
fileLoaderExcludes: ['less'],
},
},
}
问题3: 此次搭建的基础组件库是基于 Ant Design 做的二次封装,再对 Ant Design 组件进行引用时,发现样式不生效。针对这一问题,可以在 preview.tsx
进行如下配置:
import { configure } from '@storybook/react';
import 'antd/dist/antd.less' // 引入 antd 样式
3.4 添加全局装饰器
启动 Storybook,会发现右侧 stories 内容紧靠着左侧菜单栏,整体感觉非常紧凑、不美观。针对这种情况通常可以通过添加 padding 来解决。那么该如何让 padding 对 Storybook 中的所有 stories 进行生效呢?这个时候就需使用到全局装饰器。
在 .storybook
目录下,创建全局装饰器,如下所示:
// .storybook/decorators/WrapperDecorator/index.tsx
import React from 'react';
const wrapperStyle: React.CSSProperties = {
padding: '20px 40px',
};
// 创建一个样式包裹的装饰器
const WrapperDecorator = (storyFn) =>
{storyFn()}
;
export default WrapperDecorator;
然后在 preview.tsx 添加该装饰器即可。
// .storybook/preview.tsx
import { addDecorator, configure } from '@storybook/react';
import WrapperDecorator from './decorators/WrapperDecorator';
import 'antd/dist/antd.less';
// 通过addDecorator添加插件
addDecorator(WrapperDecorator);
最后效果如下所示。
四、组件开发
4.1 验证码输入组件
此次要示例的验证码输入组件是一个带验证码发送功能的 Input 组件,如下图所示。
整个组件是在 Ant Design 的 Input 组件上进行的二次开发,详细代码如下图示所示:
import React, { useState, FC } from 'react';
import { Input } from 'antd';
import { InputProps } from 'antd/lib/input';
import classNames from 'classnames';
export interface InputVerifyProps extends InputProps {
/** 发送验证码接口函数 */
sendCode: () => void;
/** 倒计时时间 */
countDown?: number;
/** 初始验证码文本内容 */
initCodeText?: string;
/** 重新发送验证码文本内容 */
reCodeText?: string;
/** 验证码类名 */
codeClassname?: string;
}
export const InputVerify: FC = (props) => {
const { sendCode, countDown, initCodeText, reCodeText, codeClassname, ...restProps } = props;
const [codeText, setCodeText] = useState(initCodeText);
const [codeStatus, setCodeStatus] = useState(false);
const handleCountDown = (timer: ReturnType | null, count: number) => {
if (timer) {
clearTimeout(timer);
}
if (count <= 0) {
setCodeText(reCodeText);
setCodeStatus(false);
} else {
setCodeText(`${count} s`);
const newTimer: ReturnType = setTimeout(() => {
handleCountDown(newTimer, count - 1);
}, 1000);
}
};
const handleCodeClick = () => {
if (codeStatus) return;
sendCode && sendCode();
setCodeStatus(true);
handleCountDown(null, countDown as number);
};
const codeCls = classNames('ii-verify-button', codeClassname, {
'ii-verify-button-disabled': codeStatus,
});
return (
{...restProps}
suffix={
{codeText}
}
/>
);
};
InputVerify.defaultProps = {
countDown: 60,
initCodeText: '发送验证码',
reCodeText: '重新发送',
};
export default InputVerify;
4.2 添加单元测试
完成组件开发任务后,接下来就需添加单元测试。针对验证码输入组件,单元测试主要分两个方面,一方面测试 antd 原生 Input 组件是否正常工作,另一方面则是测试验证码输入组件是否正常工作。
import React from 'react';import { render, fireEvent, wait, RenderResult } from '@testing-library/react';import '@testing-library/jest-dom';import '@testing-library/jest-dom/extend-expect';import InputVerify, { InputVerifyProps } from './InputVerify';const antdProps: InputVerifyProps = { placeholder: 'antd input placeholder', size: 'large', sendCode: jest.fn(), onPressEnter: jest.fn(), onChange: jest.fn(),};const selfProps: InputVerifyProps = { countDown: 3, initCodeText: '发送验证码', reCodeText: '再次发送', sendCode: jest.fn(),};let wrapper: RenderResult, inputElement: HTMLInputElement;describe("Test InputVerify component on the props of antd's input component", () => { beforeEach(() => { wrapper = render(<InputVerify {...antdProps} />); inputElement = wrapper.getByTestId('test-input-verify') as HTMLInputElement; }); it("should have the input's class of antd", () => { expect(inputElement).toBeInTheDocument(); expect(inputElement).toHaveClass('ant-input'); }); it('should support size', () => { expect(inputElement).toHaveClass('ant-input-lg'); }); it('should trigger onChange event correctly', () => { fireEvent.change(inputElement, { target: { value: 'input test' } }); expect(antdProps.onChange).toHaveBeenCalled(); expect(inputElement.value).toEqual('input test'); });});describe("Test InputVerify component on the self's props", () => { beforeEach(() => { wrapper = render(<InputVerify {...selfProps} />); }); it('should render the correct InputVerify component', () => { const suffixElement = wrapper.getByText('发送验证码'); expect(suffixElement).toBeInTheDocument(); expect(suffixElement).toHaveClass('ii-verify-button'); }); it('click verify button should call the right callback ', async () => { const suffixElement = wrapper.getByText('发送验证码'); fireEvent.click(suffixElement); expect(selfProps.sendCode).toHaveBeenCalled(); await wait( () => { expect(wrapper.getByText('再次发送')).toBeInTheDocument(); }, { timeout: 4000 } ); });});
4.3 组件说明文档
当开发完单个组件,还需添加相应的文档说明,告诉其他人该如何使用这个组件。
4.3.1 自动生成说明文档
如果想通过注释方式来自动生成组件的说明文档,这个时候就需借助 react-docgen
插件。由于 @storybook/addon-info
依赖包对 react-docgen
插件已进行了集成,所以编写注释的时候只需按照 JSDoc 标准来编写就会生成相应的说明文档。
/**
* 带验证码功能的输入组件,适用于要发送验证码的场景。
*
* ## 引用方法
*
* ~~~javascript
* import { InputVerfiy } from 'ii-admin-base'
* ~~~
*/
export const InputVerify: FC = (props) => {
注意:react-docgen
插件要求组件还需通过 export
方式进行导出。
4.3.2 过滤 Prop Types
@storybook/addon-info
插件在自动生成 Prop Types 的说明文档时,会连组件继承的 Props 也自动生成,这里面不仅包括了第三方依赖包携带的 props,还可能包括 HTML 元素的原生 Props。若要过滤这些 Props,就需借助依赖包 react-docgen-typescript-loader
。
先安装该依赖:
yarn add react-docgen-typescript-loader -D
然后配置 main.js
文件,配置内容如下:
// .storybook/main.jsconfig.module.rules.push({ test: /\.(ts|tsx)$/, use: [ { loader: require.resolve('babel-loader'), options: { presets: [require.resolve('babel-preset-react-app')], }, }, // 过滤 node_modules 中的 props { loader: require.resolve('react-docgen-typescript-loader'), options: { // 将枚举或者联合类型转换成字符串形式,避免字符串字面量显示别名。 shouldExtractLiteralValuesFromEnum: true, // 避免显示原生内置属性 propFilter: (prop) => { if (prop.parent) { return !prop.parent.fileName.includes('node_modules'); } return true; }, }, }, ],});
注意:
在使用最新的 Storybook v5.3.19 版本时,发现上述配置并不生效。针对这一问题,可以将 Storybook 版本降至 5.3.18 来进行规避。
五、构建及测试
5.1 打包构建
5.1.1 创建组件库的模块入口文件
在 src/index.tsx
文件中将所有组件都导入,再导出。这样就可以从入口文件直接导入所有组件。
export { default as InputVerfiy } from './components/InputVerify';
5.1.2 编译 TS 文件
使用 CRA(Create-React-App) 默认会创建一个 tsconfig.json
文件,该配置文件是与开发环境相关的。要针对组件库进行打包编译,并生成标准的 ES modules,还需单独创建一个 tsconfig.build.json
文件。
/**
* 用于最后打包编译
*/
{
"compilerOptions": {
// 文件输出目录
"outDir": "dist",
// ESNext: 是标准的ES Modules形式
"module": "esnext",
// 指定编译以后符合什么样的ES标准
"target": "es5",
// 为每一个js文件生成一个对应的.d.ts类型文件,方便使用组件库的用户可以获得类型检查和ts提示
"declaration": true,
// jsx 是一种语法糖,是React.createElement的缩写。此处置为react,编译出来的文件就可以用React.createElement来代替JSX语法的过程
"jsx": "react",
// tsc 处理模块的方式和node不一样,默认处理方式是"classic",针对绝对路径有的时候会找不到文件(一直向上找文件),所以需设置成'node'。
"moduleResolution": "node",
// 默认不支持 import React from 'react',只支持 import * as React from 'react'
"allowSyntheticDefaultImports": true
},
// 要编译哪些文件
"include": ["src"],
"exclude": ["src/**/*.test.tsx", "src/**/*.stories.tsx"]
}
然后在 package.json
文件中添加 build:ts
脚本,用于将 TS 文件编译成 ES modules 文件。
"build:ts": "tsc -p tsconfig.build.json",
5.1.3 编译 less 文件
在 package.json
文件中添加 build:css
脚本,用于将 less 文件编译成 css 。
"build:css": "lessc ./src/styles/index.less ./dist/index.css"
5.1.4 配置最终构建脚本
在 package.json 中配置最终的构建脚本 build
,如下所示:
"clean": "rimraf ./dist",
"build:ts": "tsc -p tsconfig.build.json",
"build:css": "lessc ./src/styles/index.less ./dist/index.css",
"build": "npm run clean && npm run build:ts && npm run build:css",
使用
rimraf
来完成跨平台文件的删除;
5.2 本地测试组件库
5.2.1 添加入口文件
在进行本地组件库测试之前,还需添加组件库的入口文件。配置 package.json
文件,添加如下字段:
"main": "dist/index.js","module": "dist/index.js","types": "dist/index.d.ts",
其中:
main
字段:定义了npm
包的入口文件;module
字段:定义了npm
包的 ES6 模块规范的入口文件;
注:此处使用 main
字段和 module
字段,相当于在一个包内同时发布了两种模块规范的版本。当打包工具遇到我们的模块时:
如果它已经支持 pkg.module 字段则会优先使用 ES6 模块规范的版本,这样可以启用 Tree Shaking 机制;
如果它还不识别 pkg.module 字段则会使用我们已经编译成 CommonJS 规范的版本,也不会阻碍打包流程。
5.2.2 使用 npm link
测试本地组件库
在组件库目录下,运行 npm link
命令,即创建软链接到全局的 node_modules 下。
/Users/xxx/.nvm/versions/node/v12.14.0/lib/node_modules/ii-admin-base -> /Users/xxx/Job/ii-admin-base
在项目外层,创建一个测试目录 test-ii-admin-base
,然后在该目录下运行 npm link ii-admin-base
命令,将测试目录的组件库 ii-admin-base
链接到全局:
➜ test-ii-admin-base npm link ii-admin-base/Users/xxx/Job/test-ii-admin-base/node_modules/ii-admin-base -> /Users/xxx/.nvm/versions/node/v12.14.0/lib/node_modules/ii-admin-base -> /Users/xxx/Job/ii-admin-base
然后修改测试目录 test-ii-admin-base
的 package.json文件,手动添加依赖:
"dependencies": {
...,
"ii-admin-base": "0.1.0"
这样就可以在测试目录中引用组件库 ii-admin-base
。
import { InputVerfiy } from 'ii-admin-base'
import 'ii-admin-base/dist/index.css'
import 'antd/dist/antd.css'
注:
如果在测试的过程中,报如下错误:这是因为我们开发组件库时使用了一个React版本,测试目录又使用了一个React版本,当一个项目中如果出现多个 React 版本就会报上述错误。针对这种情况,只需要在组件库目录下运行如下命令:
npm link ../test-ii-admin-base/node_modules/react
, 即将组件库的 react 版本链接到测试组件目录下,然后重新运行项目即可。
六、发布至 NPM
6.1 登录 NPM 账号
先切换官方镜像源。
npm config set registry http://registry.npmjs.org/
检测当前账号是否登录:
npm whoami
如果未登录,则使用 npm adduser
进行账号登录。
6.2 发布至 NPM
6.2.1 添加描述信息
在发布到 NPM 之前,还需配置 package.json
文件,添加一些必要的描述信息:
{ "name": "ii-admin-base", "version": "0.1.0", "private": false, "description": "A library of react components, which mainly stores components that can be reused by all business lines of AI-Indeeded Company.", "author": "ShiMu", "license": "MIT", "keywords": [ "React", "Component" ], "homepage": "https://lagrangelabs.github.io/ii-admin-base", "repository": { "type": "git", "url": "https://github.com/LagrangeLabs/ii-admin-base.git" }, "files": [ "build" ], "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "storybook": "start-storybook -p 9009 -s public", "build-storybook": "build-storybook -s public", "prepublishOnly": "npm run build" }, ...}
其中:
将
private
字段置为false
, 表示非私有包;添加
description
、author
、license
、keywords
等相关字段;添加
homepage
字段,即项目主页URL;添加
repository
字段,即项目仓库地址URL;添加
files
字段,表示要将哪些文件上传到 npm 上去。如果什么都不写,则默认会使用.gitignore
里面的信息。但要注意,不管.gitignore
怎么配置,一些文件会始终发布到 package 上,这些文件包括package.json
、README.md
、LICENSE
等等;添加
prepublishOnly
钩子函数,在该钩子函数中运行npm run build
,用来确保 NPM 包发布之前采用的是最新编译的代码;
6.2.2 配置 peerDependencies
字段
此次搭建的组件库是在 React 基础上对 Ant Design 进行的二次封装。为了减少组件库体积,通常不会将React、Ant Design 等第三方依赖打包进去,其次若打包进去,可能还会造成各个版本之间的冲突。
针对这种情况,可以提示用户如果要想使用当前组件库,还需安装以下核心依赖,如 react
、 Ant Design
等,这个时候就需利用 package.json
中的 peerDependencies
字段。当使用 npm install
安装依赖时, peerDependencies
声明的依赖不会被自动安装,而是通过输出 warining 日志告诉用户需安装以下依赖。
"peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0", "antd": ">= 4.3.5" },
react 版本需大于等于 v16.8.0,因为在 v16.8.0 以上版本才引入了 React Hooks;
6.2.3 代码规范检查和单元测试检查
对于一个组件库来说,代码质量是非常重要的。为了防止不符合团队规范的代码或未通过单元测试的代码被commit 亦或者被 publish,需要使用一些钩子函数来验证开发者是否通过代码规范检查和单元测试检查。
6.2.3.1 添加代码规范检查
在 package.json
文件中,添加 lint 脚本,针对 src
目录下的文件进行 eslint 检查。
"lint": "eslint --ext js,ts,tsx, src --max-warnings 5",
--max-warnings 5
: 表示最大允许的 warnings 警告是 5;
6.2.3.2 添加单元测试检查
在使用 CRA 创建项目时,默认会创建 test
脚本,但该脚本是用于开发环境,执行完后不会返回执行结果(即不会返回执行通过还是未通过),而是一直处于 watch 模式下。针对这一情况,可以设置环境变量 CI=true
,即可返回测试运行结果。
"test:nowatch": "cross-env CI=true npm run test"
在不同的操作系统环境下,设置环境变量方式不一样。故需借助
cross-env
依赖包完成跨平台的环境变量设置。
6.2.3.3 commit 或 publish 前的流程检查
针对 commit 代码,安装 husky
依赖,在代码提交前先进行单元测试检查和代码规范检测,如下所示。在发布NPM 包之前也进行同样配置。
"scripts": { ..., "prepublishOnly": "npm run test:nowatch && npm run lint && npm run build" }, "husky": { "hooks": { "pre-commit": "npm run test:nowatch && npm run lint" } },
完成上述配置后,运行命令 npm publish
即可完成 NPM 包的发布。
七、配置持续集成环境
通常从各个业务线上抽离的基础组件库,会是各个业务线的团队成员一起来维护,这个时候就可以利用 Github 提供的 Github Organization 来共同维护这个组件库。
在这种情况下,使用 Travis CI 进行持续集成就没有那么简单了。之前写过一篇文章如何使用Travis CI对Github Organization下的代码进行持续集成,可以参照该文章完成基础组件库的持续集成环境配置。
八、持续发布
Travis CI 还可以自动将组件库发布到 NPM 上,具体设置如下:
进入 npm 个人账号中心,生成一个新的Token(权限选择 Read and Publish),用于Travis CI 进行 npm 包的发布。
在组件库目录下,运行
travis setup npm --force
命令,注意该命令会改写之前的travis.yml
文件。此时会提示输入 NPM api key,如下所示,将刚才生成的Token值复制粘贴此处即可。
NPM api key: ************
改写后的 travis.yml
文件:
language: node_jsnode_js: - stablecache: directories: - node_modulesenv: matrix: - CI=truescript: - npm run build-storybookdeploy: # 发布到 gh-pages 上 - provider: script skip_cleanup: true script: bash scripts/deploy.sh on: branch: master # 发布到 npm 上 - provider: npm skip_cleanup: true email: xxxx@qq.com api_key: secure: Lsb1/coESXgnDgcotaObyV7QKDVeZJWpAcduyZt/bxAqspN/EdOR2duraPpBHKzme7tOHT4ybIAQusJqSl36K/WX2WFXqhKHw+FoFOobK1aa/azQDkpwllgdxrlx0fCbLpxBPDdKxbJspXwphSgCi2rjY8F/PBdy4+g8IEh/FJQckuFHAEhpTuk+SZPJT5eAqhctxXSaNKB712x4vX9AJLHRT791nB388dsjKOz2NWGNJ14arxukvnb/Yt02hHWKpGQPgQQY9QjfENYnspFYBXYssKV2nhC+0EFoXNn6UK3C4gXo96hV2yqFbP0AhZdHiYxOJ/v1KN7xt+I3popw+puETFyno4TgepGqU/EvkB5r3DnB9CrYsOpeN4+wZtfVtwxMxxxJ8q/EbC7RH45b39056B0i7PnJViIHLWps3XxFQ/bi1CgWdiFyzNofwCYVV6uT0UNR0XZDqUzre10GBrvDogMNWPKMaTmJCWVA8c6AkB4XjfU/jY1xaWxbNuD+Z+p3uLSTKm+c2xrUJFl5KW4/ocyS8No/J+e/9uNkXYcTEdkwnBioWfT7OaBrIpzrkKL9RftkDzjkeUo8h9/XpXNHEUGMK6ZDO0n3zlQ8/qcMHJvS5dXbKmvwZ9GNnOS1EvR1X32MlTfcW0EzDgCXufyAK6UdUGm7jm+dfJJkD60g= on: branch: master repo: LagrangeLabs/ii-admin-base
注:
Travis CI 在对组件库进行持续发布的时候,如果报如下问题:
sh: 1: cross-env: not found
npm ERR! code ELIFECYCLE
npm ERR! syscall spawn
npm ERR! file sh
针对这一问题,将 package.json
文件中的 cross-env
换成 ./node_mdoules/.bin/cross-env
即可。
"test:nowatch": "./node_modules/.bin/cross-env CI=true npm run test"
九、小结
至此,就完成了团队组件库的整个搭建过程。