共计 14853 个字符,预计需要花费 38 分钟才能阅读完成。
前言
组件库,一套标准化的组件汇合,是前端工程师开发提效不可或缺的工具。
业内优良的组件库比方 Antd Design 和 Element UI,大大节俭了咱们的开发工夫。那么,做一套组件库,容易吗?
答案必定是不容易,当你去做这件事的时候,会发现它其实是一套体系。从开发、编译、测试,到最初公布,每一个流程都须要大量的常识积攒。然而当你真正实现了一个组件库的搭建后,会发现播种的兴许比设想中更多。
心愿可能通过本文帮忙大家梳理一套组件库搭建的常识体系,聚点成面,如果可能帮忙到你,也请送上一颗 Star 吧。
示例组件库线上站点: Frog-UI
仓库地址:Frog-Kits
概览
本文次要包含以下内容:
- 环境搭建:
Typescript
+ ESLint
+ StyleLint
+ Prettier
+ Husky
- 组件开发:标准化的组件开发目录及代码构造
- 文档站点:基于
docz
的文档演示站点 - 编译打包:输入合乎
umd
/ esm
/ cjs
三种标准的打包产物 - 单元测试:基于
jest
的 React
组件测试计划及残缺报告 - 一键发版:整合多条命令,流水线管制 npm publish 全副过程
- 线上部署:基于
now
疾速部署线上文档站点
如有谬误欢送在评论区进行交换~
初始化
整体目录
├── 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
的配置文件合集,可能大大节俭咱们的工夫。
感兴趣的同学能够去查看它的源码,在工夫容许的状况下本人从零配置当做学习也是不错的。
装置
yarn add @umijs/fabric prettier @typescript-eslint/eslint-plugin -D
.eslintrc.js
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
const fabric = require('@umijs/fabric');
module.exports = {...fabric.prettier,};
.stylelintrc.js
module.exports = {extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};
配置 Husky + Lint-Staged
husky
提供了多种钩子来拦挡 git
操作,比方 git commit
或 git push
等。然而个别状况咱们都是接手已有的我的项目,如果对所有代码都做 Lint 查看的话修复老本太高了,所以咱们心愿可能只对本人提交的代码做查看,这样就能够从当初开始对大家的开发标准进行束缚,已有的代码等批改的时候再做查看。
这样就引入了 lint-staged
,能够只对以后 commit
的代码做查看并且能够编写正则匹配文件。
装置
yarn add husky lint-staged -D
package.json
"lint-staged": {"components/**/*.ts?(x)": [
"prettier --write",
"eslint --fix"
],
"components/**/**/*.less": ["stylelint --syntax less --fix"]
},
"husky": {
"hooks": {"pre-commit": "lint-staged"}
}
配置 Typescript
typescript.json
{
"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"
]
}
组件开发
失常写组件大家都很相熟了,这里咱们次要看一下目录构造和局部代码:
├── 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
import './index.less';
style/index.less
@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
装置
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
"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… 间接获取到 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
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
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
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
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
---
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
"scripts": {
"docz:dev": "docz dev",
"docz:build": "docz build",
"docz:serve": "docz build && docz serve"
}
线上文档站点部署
这里应用 now.sh 来部署线上站点,注册后装置命令行,登录胜利。
yarn docz:build
cd .docz/dist
now deploy
vercel --production
一键发版
咱们在公布新版 npm 包时会有很多步骤,这里提供一套脚本来实现一键发版。
装置
yarn add conventional-changelog-cli -D
release.js
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
"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