从零打造组件库
2021/2/3 5:10: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
这篇关于从零打造组件库的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-05-09一定要避坑:关于微信H5分享,温馨提示你不要再踩坑了!!!
- 2024-05-09本地项目放到公网访问!炒鸡煎蛋!
- 2024-04-07金融企业区域集中库的设计构想和测试验证
- 2024-03-11前端CSS的工程化——掌握Sass这四大特性就够了
- 2024-02-21h5关联css样式(html怎么和css关联)-icode9专业技术文章分享
- 2024-02-07Firefox禁止远程字体加速网页加载及图标字体补充安装
- 2024-02-07一个菜鸡前端的3年总结-「2023」
- 2024-01-18最火前端Web组态软件(可视化)
- 2024-01-12程序员提效 x10 的必备开源“神器”
- 2024-01-11前端可以监控静态资源的时间吗-icode9专业技术文章分享