前言
组件库,一套标准化的组件集合,是前端工程师开发提效不可或缺的工具。
业内优秀的组件库比如 Antd Design 和 Element UI,大大节省了我们的开发时间。那么,做一套组件库,容易吗?
答案肯定是不容易,当你去做这件事的时候,会发现它其实是一套体系。从开发、编译、测试,到最后发布,每一个流程都需要大量的知识积累。但是当你真正完成了一个组件库的搭建后,会发现收获的也许比想象中更多。
希望能够通过本文帮助大家梳理一套组件库搭建的知识体系,聚点成面,如果能够帮助到你,也请送上一颗 Star 吧。
示例组件库线上站点: Frog-UI
仓库地址:Frog-Kits
概览
本文主要包括以下内容:
- 环境搭建:
Typescript
ESLint
StyleLint
Prettier
Husky
- 组件开发:标准化的组件开发目录及代码结构
- 文档站点:基于
docz
的文档演示站点 - 编译打包:输出符合
umd
/ esm
/ cjs
三种规范的打包产物 - 单元测试:基于
jest
的 React
组件测试方案及完整报告 - 一键发版:整合多条命令,流水线控制 npm publish 全部过程
- 线上部署:基于
now
快速部署线上文档站点
如有错误欢迎在评论区进行交流~
初始化
整体目录
代码语言:javascript复制├── CHANGELOG.md // CHANGELOG
├── README.md // README
├── babel.config.js // babel 配置
├── build // 编译发布相关
│ ├── constant.js
│ ├── release.js
│ └── rollup.config.dist.js
├── components // 组件源码
│ ├── Alert
│ ├── Button
│ ├── index.tsx
│ └── style
├── coverage // 测试报告
│ ├── clover.xml
│ ├── coverage-final.json
│ ├── lcov-report
│ └── lcov.info
├── dist // 组件库打包产物:UMD
│ ├── frog.css
│ ├── frog.js
│ ├── frog.js.map
│ ├── frog.min.css
│ ├── frog.min.js
│ └── frog.min.js.map
├── doc // 组件库文档站点
│ ├── Alert.mdx
│ └── button.mdx
├── doczrc.js // docz 配置
├── es // 组件库打包产物:ESM
│ ├── Alert
│ ├── Button
│ ├── index.js
│ └── style
├── gatsby-config.js // docz 主题配置
├── gulpfile.js // gulp 配置
├── lib // 组件库打包产物:CJS
│ ├── Alert
│ ├── Button
│ ├── index.js
│ └── style
├── package-lock.json
├── package.json // package.json
└── tsconfig.json // typescript 配置
配置 ESLint StyleLint Prettier
每个 Lint 都可以单独拿出来写一篇文章,但配置不是我们的重点,所以这里使用 @umijs/fabric,一个包含 ESLint
StyleLint
Prettier
的配置文件合集,能够大大节省我们的时间。
感兴趣的同学可以去查看它的源码,在时间允许的情况下自己从零配置当做学习也是不错的。
安装
代码语言:javascript复制yarn add @umijs/fabric prettier @typescript-eslint/eslint-plugin -D
.eslintrc.js
代码语言:javascript复制module.exports = {
parser: '@typescript-eslint/parser',
extends: [
require.resolve('@umijs/fabric/dist/eslint'),
'prettier/@typescript-eslint',
'plugin:react/recommended'
],
rules: {
'react/prop-types': 'off',
"no-unused-expressions": "off",
"@typescript-eslint/no-unused-expressions": ["error", { "allowShortCircuit": true }]
},
ignorePatterns: ['.eslintrc.js'],
settings: {
react: {
version: "detect"
}
}
}
由于 @umijs/fabric
中判断 isTsProject
的目录路径如图所示是基于 src
的,且无法修改,我们这里组件源码在 components
路径下,所以这里要手动添加相关 typescript
的配置。
.prettierrc.js
代码语言:javascript复制const fabric = require('@umijs/fabric');
module.exports = {
...fabric.prettier,
};
.stylelintrc.js
代码语言:javascript复制module.exports = {
extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};
配置 Husky Lint-Staged
husky
提供了多种钩子来拦截 git
操作,比如 git commit
或 git push
等。但是一般情况我们都是接手已有的项目,如果对所有代码都做 Lint 检查的话修复成本太高了,所以我们希望能够只对自己提交的代码做检查,这样就可以从现在开始对大家的开发规范进行约束,已有的代码等修改的时候再做检查。
这样就引入了 lint-staged
,可以只对当前 commit
的代码做检查并且可以编写正则匹配文件。
安装
代码语言:javascript复制yarn add husky lint-staged -D
package.json
代码语言:javascript复制"lint-staged": {
"components/**/*.ts?(x)": [
"prettier --write",
"eslint --fix"
],
"components/**/**/*.less": [
"stylelint --syntax less --fix"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
配置 Typescript
typescript.json
代码语言:javascript复制{
"compilerOptions": {
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"lib": ["es6", "dom"],
"sourceMap": true,
"allowJs": true,
"jsx": "react",
"moduleResolution": "node",
"rootDir": "src",
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"paths": {
"components/*": ["src/components/*"]
}
},
"include": [
"components"
],
"exclude": [
"node_modules",
"build",
"dist",
"lib",
"es"
]
}
组件开发
正常写组件大家都很熟悉了,这里我们主要看一下目录结构和部分代码:
代码语言:javascript复制├── Alert
│ ├── __tests__
│ ├── index.tsx
│ └── style
├── Button
│ ├── __tests__
│ ├── index.tsx
│ └── style
├── index.tsx
└── style
├── color
├── core
├── index.less
└── index.tsx
components/index.ts
是整个组件库的入口,负责收集所有组件并导出:
export { default as Button } from './Button';
export { default as Alert } from './Alert';
components/style
包含组件库的基础 less
文件,包含 core
、color
等通用样式及变量设置。
每个 style
目录下都至少包含 index.tsx
及 index.less
两个文件:
style/index.tsx
代码语言:javascript复制import './index.less';
style/index.less
代码语言:javascript复制@import './core/index';
@import './color/default';
可以看到,style/index.tsx
是作为每个组件样式引用的唯一入口而存在。
__tests__
是组件的单元测试目录,后续会单独讲到。具体 Alert
和 Button
组件的代码都很简单,这里就不赘述,大家可以去源码里找到。
组件测试
为什么要写测试以及是否有必要做测试,社区内已经有很多的探讨,大家可以根据自己的实际业务场景来做决定,我个人的意见是:
- 基础工具,一定要做好单元测试,比如
utils
、hooks
、components
- 业务代码,由于更新迭代快,不一定有时间去写单测,根据节奏自行决定
但是单测的意义肯定是正向的:
The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds
安装
代码语言:javascript复制yarn add jest babel-jest @babel/preset-env @babel/preset-react react-test-renderer @testing-library/react -D
yarn add @types/jest @types/react-test-renderer -D
package.json
代码语言:javascript复制"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
}
在每个组件下新增 __tests__/index.test.tsx
,作为单测入口文件。
import React from 'react';
import renderer from 'react-test-renderer';
import Alert from '../index';
describe('Component <Alert /> Test', () => {
test('should render default', () => {
const component = renderer.create(<Alert message="default" />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('should render specific type', () => {
const types: any[] = ['success', 'info', 'warning', 'error'];
const component = renderer.create(
<>
{types.map((type) => (
<Alert key={type} type={type} message={type} />
))}
</>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
这里采用的是 snapshot
快照的测试方式,所谓快照,就是在当前执行测试用例的时候,生成一份测试结果的快照,保存在 __snapshots__/index.test.tsx.snap
文件中。下次再执行测试用例的时候,如果我们修改了组件的源码,那么会将本次的结果快照和上次的快照进行比对,如果不匹配,则测试不通过,需要我们修改测试用例更新快照。这样就保证了每次源码的修改必须要和上次测试的结果快照做比对,才能确定是否通过,省去了写复杂的逻辑测试代码,是一种简化的测试手段。
还有一种是基于 DOM
的测试,基于 @testing-library/react
:
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import renderer from 'react-test-renderer';
import Button from '../index';
describe('Component <Button /> Test', () => {
let testButtonClicked = false;
const onClick = () => {
testButtonClicked = true;
};
test('should render default', () => {
// snapshot test
const component = renderer.create(<Button onClick={onClick}>default</Button>);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
// dom test
render(<Button onClick={onClick}>default</Button>);
const btn = screen.getByText('default');
fireEvent.click(btn);
expect(testButtonClicked).toEqual(true);
});
});
可以看到,@testing-library/react
提供了一些方法,render
将组件渲染到 DOM
中,screen
提供了各种方法可以从页面中获取相应 DOM
元素,fireEvent
负责触发 DOM
元素绑定的事件。
更多关于组件测试的细节推荐阅读以下文章:
- The Complete Beginner's Guide to Testing React Apps:通过简单的
<Counter />
测试讲到 ToDoApp
的完整测试,并且对比了 Enzyme
和@testing-library/react
的区别,是很好的入门文章 - React 单元测试策略及落地:系统的讲述了单元测试的意义及落地方案
组件库打包
组件库打包是我们的重头戏,我们主要实现以下目标:
- 导出 umd / cjs / esm 三种规范文件
- 导出组件库 css 样式文件
- 支持按需加载
这里我们围绕 package.json
中的三个字段展开:main
、module
以及 unpkg
。
{
"main": "lib/index.js",
"module": "es/index.js",
"unpkg": "dist/frog.min.js"
}
我们去看业内各个组件库的源码时,总能看到这三个字段,那么它们的作用究竟是什么呢?
-
main
,是包的入口文件,我们通过 require
或者 import
加载 npm
包的时候,会从 main
字段获取需要加载的文件 -
module
,是由打包工具提出的一个字段,目前还不在 package.json 官方规范中,负责指定符合 esm 规范的入口文件。当 webpack
或者 rollup
在加载 npm
包的时候,如果看到有 module
字段,会优先加载 esm
入口文件,因为可以更好的做 tree-shaking
,减小代码体积。 -
unpkg
,也是一个非官方字段,负责让 npm
包中的文件开启 CDN
服务,意味着我们可以通过 https://unpkg.com/ 直接获取到文件内容。比如这里我们就可以通过 https://unpkg.com/frog-ui@0.1.3/dist/frog.min.js 直接获取到 umd
版本的库文件。
我们使用 gulp
来串联工作流,并通过三条命令分别导出三种格式文件:
"scripts": {
"build": "yarn build:dist && yarn build:lib && yarn build:es",
"build:dist": "rm -rf dist && gulp compileDistTask",
"build:lib": "rm -rf lib && gulp",
"build:es": "rm -rf es && cross-env ENV_ES=true gulp"
}
-
build
,聚合命令 -
build:es
,输出 esm
规范,目录为 es
build:lib
,输出cjs
规范,目录为lib
-
build:dist
,输出 umd
规范,目录为 dist
导出 umd
通过执行 gulp compileDistTask
来导出 umd
文件,具体看一下 gulpfile:
gulpfile
代码语言:javascript复制function _transformLess(lessFile, config = {}) {
const { cwd = process.cwd() } = config;
const resolvedLessFile = path.resolve(cwd, lessFile);
let data = readFileSync(resolvedLessFile, 'utf-8');
data = data.replace(/^uFEFF/, '');
const lessOption = {
paths: [path.dirname(resolvedLessFile)],
filename: resolvedLessFile,
plugins: [new NpmImportPlugin({ prefix: '~' })],
javascriptEnabled: true,
};
return less
.render(data, lessOption)
.then(result => postcss([autoprefixer]).process(result.css, { from: undefined }))
.then(r => r.css);
}
async function _compileDistJS() {
const inputOptions = rollupConfig;
const outputOptions = rollupConfig.output;
// 打包 frog.js
const bundle = await rollup.rollup(inputOptions);
await bundle.generate(outputOptions);
await bundle.write(outputOptions);
// 打包 frog.min.js
inputOptions.plugins.push(terser());
outputOptions.file = `${DIST_DIR}/${DIST_NAME}.min.js`;
const bundleUglify = await rollup.rollup(inputOptions);
await bundleUglify.generate(outputOptions);
await bundleUglify.write(outputOptions);
}
function _compileDistCSS() {
return src('components/**/*.less')
.pipe(
through2.obj(function (file, encoding, next) {
if (
// 编译 style/index.less 为 .css
file.path.match(/(/|\)style(/|\)index.less$/)
) {
_transformLess(file.path)
.then(css => {
file.contents = Buffer.from(css);
file.path = file.path.replace(/.less$/, '.css');
this.push(file);
next();
})
.catch(e => {
console.error(e);
});
} else {
next();
}
}),
)
.pipe(concat(`./${DIST_NAME}.css`))
.pipe(dest(DIST_DIR))
.pipe(uglifycss())
.pipe(rename(`./${DIST_NAME}.min.css`))
.pipe(dest(DIST_DIR));
}
exports.compileDistTask = series(_compileDistJS, _compileDistCSS);
rollup.config.dist.js
代码语言:javascript复制const resolve = require('@rollup/plugin-node-resolve');
const { babel } = require('@rollup/plugin-babel');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const commonjs = require('@rollup/plugin-commonjs');
const { terser } = require('rollup-plugin-terser');
const image = require('@rollup/plugin-image');
const { DIST_DIR, DIST_NAME } = require('./constant');
module.exports = {
input: 'components/index.tsx',
output: {
name: 'Frog',
file: `${DIST_DIR}/${DIST_NAME}.js`,
format: 'umd',
sourcemap: true,
globals: {
'react': 'React',
'react-dom': 'ReactDOM'
}
},
plugins: [
peerDepsExternal(),
commonjs({
include: ['node_modules/**', '../../node_modules/**'],
namedExports: {
'react-is': ['isForwardRef', 'isValidElementType'],
}
}),
resolve({
extensions: ['.tsx', '.ts', '.js'],
jsnext: true,
main: true,
browser: true
}),
babel({
exclude: 'node_modules/**',
babelHelpers: 'bundled',
extensions: ['.js', '.jsx', 'ts', 'tsx']
}),
image()
]
}
rollup
或者 webpack
这类打包工具,最擅长的就是由一个或多个入口文件,依次寻找依赖,打包成一个或多个 Chunk
文件,而 umd
就是要输出为一个 js
文件。
所以这里选用 rollup
负责打包 umd
文件,入口为 component/index.tsx
,输出 format
为 umd
格式。
为了同时打包 frog.js
和 frog.min.js
,在 _compileDistJS
中引入了 teser
插件,执行了两次 rollup
打包。
一个组件库只有 JS
文件肯定不够用,还需要有样式文件,比如使用 Antd
时:
import { DatePicker } from 'antd';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'
ReactDOM.render(<DatePicker />, mountNode);
所以,我们也要打包出一份组件库的 CSS
文件。
这里 _compileDistCSS
的作用是,遍历 components
目录下的所有 less
文件,匹配到所有的 index.less
入口样式文件,使用 less
编译为 CSS
文件,并且进行聚合,最后输出为 frog.css
和 frog.min.css
。
最终 dist
目录结构如下:
├── frog.css
├── frog.js
├── frog.js.map
├── frog.min.css
├── frog.min.js
└── frog.min.js.map
导出 cjs 和 esm
导出 cjs
或者 esm
,意味着模块化导出,并不是一个聚合的 JS
文件,而是每个组件是一个模块,只不过 cjs
的代码时符合 Commonjs
标准,esm
的代码时 ES Module
标准。
所以,我们自然的就想到了 babel
,它的作用不就是编译高级别的代码到各种格式嘛。
gulpfile
代码语言:javascript复制function _compileJS() {
return src(['components/**/*.{tsx, ts, js}', '!components/**/__tests__/*.{tsx, ts, js}'])
.pipe(
babel({
presets: [
[
'@babel/preset-env',
{
modules: ENV_ES === 'true' ? false : 'commonjs',
},
],
],
}),
)
.pipe(dest(ENV_ES === 'true' ? ES_DIR : LIB_DIR));
}
function _copyLess() {
return src('components/**/*.less').pipe(dest(ENV_ES === 'true' ? ES_DIR : LIB_DIR));
}
function _copyImage() {
return src('components/**/*.@(jpg|jpeg|png|svg)').pipe(
dest(ENV_ES === 'true' ? ES_DIR : LIB_DIR),
);
}
exports.default = series(_compileJS, _copyLess, _copyImage);
babel.config.js
代码语言:javascript复制module.exports = {
presets: [
"@babel/preset-react",
"@babel/preset-typescript",
"@babel/preset-env"
],
plugins: [
"@babel/plugin-proposal-class-properties"
]
};
这里代码就相对简单了,扫描 components
目录下的 tsx
文件,使用 babel
编译后,拷贝到 es
或 lib
目录。less
文件直接拷贝,这里 _copyImage
是为了防止有图片,也直接拷贝过去,但是组件库中不建议用图片,可以用字体图标代替。
组件文档
这里使用 docz 来搭建文档站点,更具体的使用方法大家可以阅读官网文档,这里不再赘述。
doc/Alert.mdx
代码语言:javascript复制---
name: Alert 警告提示
route: /alert
menu: 反馈
---
import { Playground, Props } from 'docz'
import { Alert } from '../components/';
import '../components/Alert/style';
# Alert
警告提示,展现需要关注的信息。
<Props of={Alert} />
## 基本用法
<Playground>
<Alert message="Success Text" type="success" />
<Alert message="Info Text" type="info" />
<Alert message="Warning Text" type="warning" />
<Alert message="Error Text" type="error" />
</Playground>
package.json
代码语言:javascript复制"scripts": {
"docz:dev": "docz dev",
"docz:build": "docz build",
"docz:serve": "docz build && docz serve"
}
线上文档站点部署
这里使用 now.sh 来部署线上站点,注册后安装命令行,登录成功。
代码语言:javascript复制yarn docz:build
cd .docz/dist
now deploy
vercel --production
一键发版
我们在发布新版 npm 包时会有很多步骤,这里提供一套脚本来实现一键发版。
安装
代码语言:javascript复制yarn add conventional-changelog-cli -D
release.js
代码语言:javascript复制const child_process = require('child_process');
const fs = require('fs');
const path = require('path');
const inquirer = require('inquirer');
const chalk = require('chalk');
const util = require('util');
const semver = require('semver');
const exec = util.promisify(child_process.exec);
const semverInc = semver.inc;
const pkg = require('../package.json');
const currentVersion = pkg.version;
const run = async command => {
console.log(chalk.green(command));
await exec(command);
};
const logTime = (logInfo, type) => {
const info = `=> ${type}:${logInfo}`;
console.log((chalk.blue(`[${new Date().toLocaleString()}] ${info}`)));
};
const getNextVersions = () => ({
major: semverInc(currentVersion, 'major'),
minor: semverInc(currentVersion, 'minor'),
patch: semverInc(currentVersion, 'patch'),
premajor: semverInc(currentVersion, 'premajor'),
preminor: semverInc(currentVersion, 'preminor'),
prepatch: semverInc(currentVersion, 'prepatch'),
prerelease: semverInc(currentVersion, 'prerelease'),
});
const promptNextVersion = async () => {
const nextVersions = getNextVersions();
const { nextVersion } = await inquirer.prompt([
{
type: 'list',
name: 'nextVersion',
message: `Please select the next version (current version is ${currentVersion})`,
choices: Object.keys(nextVersions).map(name => ({
name: `${name} => ${nextVersions[name]}`,
value: nextVersions[name]
}))
}
]);
return nextVersion;
};
const updatePkgVersion = async nextVersion => {
pkg.version = nextVersion;
logTime('Update package.json version', 'start');
await fs.writeFileSync(path.resolve(__dirname, '../package.json'), JSON.stringify(pkg));
await run('npx prettier package.json --write');
logTime('Update package.json version', 'end');
};
const test = async () => {
logTime('Test', 'start');
await run(`yarn test:coverage`);
logTime('Test', 'end');
};
const genChangelog = async () => {
logTime('Generate CHANGELOG.md', 'start');
await run(' npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0');
logTime('Generate CHANGELOG.md', 'end');
};
const push = async nextVersion => {
logTime('Push Git', 'start');
await run('git add .');
await run(`git commit -m "publish frog-ui@${nextVersion}" -n`);
await run('git push');
logTime('Push Git', 'end');
};
const tag = async nextVersion => {
logTime('Push Git', 'start');
await run(`git tag v${nextVersion}`);
await run(`git push origin tag frog-ui@${nextVersion}`);
logTime('Push Git Tag', 'end');
};
const build = async () => {
logTime('Components Build', 'start');
await run(`yarn build`);
logTime('Components Build', 'end');
};
const publish = async () => {
logTime('Publish Npm', 'start');
await run('npm publish');
logTime('Publish Npm', 'end');
};
const main = async () => {
try {
const nextVersion = await promptNextVersion();
const startTime = Date.now();
await test();
await updatePkgVersion(nextVersion);
await genChangelog();
await push(nextVersion);
await build();
await publish();
await tag(nextVersion);
console.log(chalk.green(`Publish Success, Cost ${((Date.now() - startTime) / 1000).toFixed(3)}s`));
} catch (err) {
console.log(chalk.red(`Publish Fail: ${err}`));
}
}
main();
package.json
代码语言:javascript复制"scripts": {
"publish": "node build/release.js"
}
代码也比较简单,都是对一些工具的基本使用,通过执行 yarn publish
就可以一键发版。
结尾
本文是我在搭建组件库过程中的学习总结,在过程中学习到了很多知识,并且搭建了清晰的知识体系,希望能够对你有所帮助,欢迎在评论区交流~
参考文档
Tree-Shaking性能优化实践 - 原理篇
彻底搞懂 ESLint 和 Prettier
集成配置 @umijs/fabric
TypeScript and React: Components
TypeScript ESLint
由 allowSyntheticDefaultImports 引起的思考
tsconfig.json入门指南
React 单元测试策略及落地
The Complete Beginner's Guide to Testing React Apps