【JS】从零打造组件库

从零打造组件库

liuxuan发布于 今天 13:52

前言

【JS】从零打造组件库

组件库,一套标准化的组件集合,是前端工程师开发提效不可或缺的工具。

业内优秀的组件库比如 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 commitgit 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
  • 业务代码,由于更新迭代快,不一定有时间去写单测,根据节奏自行决定

但是单测的意义肯定是正向的:

安装

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/[email protected] 直接获取到 ​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/**/*[email protected](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 [email protected]${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 [email protected]${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

javascripttypescriptreact.jsrollup组件库

阅读 117更新于 今天 13:55

本作品系原创,采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议


前端学习

记录并探讨WEB前端相关知识

avatar

liuxuan

stay hungry, stay foolish

3.7k 声望

228 粉丝

0 条评论

得票时间

avatar

liuxuan

stay hungry, stay foolish

3.7k 声望

228 粉丝

宣传栏

前言

【JS】从零打造组件库

组件库,一套标准化的组件集合,是前端工程师开发提效不可或缺的工具。

业内优秀的组件库比如 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 commitgit 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
  • 业务代码,由于更新迭代快,不一定有时间去写单测,根据节奏自行决定

但是单测的意义肯定是正向的:

安装

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/[email protected] 直接获取到 ​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/**/*[email protected](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 [email protected]${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 [email protected]${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

以上是 【JS】从零打造组件库 的全部内容, 来源链接: utcz.com/a/112245.html

回到顶部