从零打造组件库
前言
概览
环境搭建: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
yarn add @umijs/fabric prettier @typescript-eslint/eslint-plugin -D
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"
}
}
}
const fabric = require('@umijs/fabric');
module.exports = {
...fabric.prettier,
};
module.exports = {
extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};
配置 Husky + Lint-Staged
yarn add husky lint-staged -D
"lint-staged": {
"components/**/*.ts?(x)": [
"prettier --write",
"eslint --fix"
],
"components/**/**/*.less": [
"stylelint --syntax less --fix"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
配置 Typescript
{
"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
export { default as Button } from './Button';
export { default as Alert } from './Alert';
import './index.less';
@import './core/index';
@import './color/default';
组件测试
基础工具,一定要做好单元测试,比如 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
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
}
import React from 'react';
import renderer from 'react-test-renderer';
import Alert from '../index';
describe('ComponentTest', () => {
test('should render default', () => {
const component = renderer.create("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) => (
type} type={type} message={type} />
))}
>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import renderer from 'react-test-renderer';
import Button from '../index';
describe('Component Test', () => {
let testButtonClicked = false;
const onClick = () => {
testButtonClicked = true;
};
test('should render default', () => {
// snapshot test
const component = renderer.create();
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
// dom test
render();
const btn = screen.getByText('default');
fireEvent.click(btn);
expect(testButtonClicked).toEqual(true);
});
});
The Complete Beginner's Guide to Testing React Apps:通过简单的 测试讲到 ToDoApp 的完整测试,并且对比了 Enzyme 和 @testing-library/react 的区别,是很好的入门文章 React 单元测试策略及落地:系统的讲述了单元测试的意义及落地方案
组件库打包
导出 umd / cjs / esm 三种规范文件 导出组件库 css 样式文件 支持按需加载
{
"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 版本的库文件。
"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
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);
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()
]
}
import { DatePicker } from 'antd';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'
ReactDOM.render(, mountNode);
├── frog.css
├── frog.js
├── frog.js.map
├── frog.min.css
├── frog.min.js
└── frog.min.js.map
导出 cjs 和 esm
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);
module.exports = {
presets: [
"@babel/preset-react",
"@babel/preset-typescript",
"@babel/preset-env"
],
plugins: [
"@babel/plugin-proposal-class-properties"
]
};
组件文档
---
name: Alert 警告提示
route: /alert
menu: 反馈
---
import { Playground, Props } from 'docz'
import { Alert } from '../components/';
import '../components/Alert/style';
# Alert
警告提示,展现需要关注的信息。
## 基本用法
"Success Text" type="success" />
"Info Text" type="info" />
"Warning Text" type="warning" />
"Error Text" type="error" />
"scripts": {
"docz:dev": "docz dev",
"docz:build": "docz build",
"docz:serve": "docz build && docz serve"
}
线上文档站点部署
yarn docz:build
cd .docz/dist
now deploy
vercel --production
一键发版
yarn add conventional-changelog-cli -D
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();
"scripts": {
"publish": "node build/release.js"
}
评论