0%

搭建react组件库

前言:

搭建属于自己的组件库,方便在项目中导入组件,避免重复造轮子。

搭建组件库的主要步骤:

  • 项目初始化:使用eslint/commit lint/typeScript进行代码规范
  • 文档编写:使用dumi1进行开发调试以及文档编写
  • 打包阶段:输出umd/cjs/esm产物并支持按需加载
  • 组件测试:使用jest库,@test-library/react相关生态进行组件测试
  • 发布npm:编写脚本完成发布
  • 部署文档站点:使用github pages以及github actions完成文档站点自动部署

技术栈:

组件编写:react,typescript

代码规范:eslint,prettier

打包编译:gulp,babel

文档编写:dumi

样式处理:scss

整个项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
.github
-- workflows
-- !gh-pages.yml//master触发push操作时则会自动站点部署
doc-site//部署文档
-- demos
-- alert
-- alert__index.md.chunk.css
-- ...
esm
--alert
-- __tests__
-- index.test.js
-- style
-- css.js
-- index.css
-- index.d.ts
-- index.js
-- index.scss
-- index.d.ts
-- index.js
lib
--alert
-- __tests__
-- index.test.js
-- style
-- css.js
-- index.css
-- index.d.ts
-- index.js
-- index.scss
-- index.d.ts
-- index.js
--node_modules
--src//组件源码
-- .umi
--.umi-production
--alert
-- __tests__
-- index.test.tsx
--demo
--basic.tsx
-- style
-- index.css
-- index.ts
-- index.scss
-- index.md
-- index.tsx
--alert.js
--.babelrc.js//用于编译代码
--browerserslistrc//设置兼容浏览器
--commitlintrc.js//规范commit message
--.stylelintrc.js//规范样式代码
--.umirc.ts
--eslintrc.js//规范代码
--gatsby-config.js
--gulpfile.js//代码打包
--jest.config.js//配置测试
--prettierrc.js//规范代码格式
--package.json
--package.lock.json
--tsconfig.build.json
--tsconfig.json




初始化项目:

1
2
3
4
mkdir my-ui
cd my-ui
npm init --y
mkdir src && cd src && touch index.ts//新建源码文件以及入口文件

代码规范:

直接使用@umijs.fabric的配置

1
npm i --dev @umi/fabric prettier

.eslintrc.js:

1
2
3
module.exports = {
extends: [require.resolve('@umijs/fabric/dist/eslint')],
};

.prettierrc.js:

1
2
3
4
5
const fabric = require('@umijs/fabric');

module.exports = {
...fabric.prettier,
};

.stylelintrc.js:

1
2
3
module.exports = {
extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};

关于eslint和prettier的配置:

使用ESlint+Prettier规范React+TypeScript项目:https://zhuanlan.zhihu.com/p/62401626

代码规范检测需要用husky:

1
npm i husky lint-staged --dev

commit message检测:

1
npm i @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog --dev

新增.commitlintrc.js写入一下内容:

1
module.exports = { extends: ['@commitlint/config-conventional'] };

package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//...
"scripts": {
"commit": "git-cz",
}
//...
"lint-staged": {
"src/**/*.ts?(x)": [
"prettier --write",
"eslint --fix",
"git add"
],
"src/**/*.less": [
"stylelint --syntax less --fix",
"git add"
]
},
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "lint-staged"
}
}
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}

后面使用npm run commit代替git commit生成规范的commit message,也可以手写,但是要符合规范

配置typeScript:

1
npm i typescript --dev

新建tsconfig.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"compilerOptions": {
"baseUrl": "./",
"target": "esnext",
"module": "commonjs",
"jsx": "react",
"declaration": true,
"declarationDir": "lib",
"strict": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src", "typings.d.ts"],
"exclude": ["node_modules"]
}

编写组件代码:

1
2
3
4
5
alert
├── index.tsx # 源文件
└── style
├── index.less # 样式文件
└── index.ts # 样式文件里为什么存在一个index.ts - 按需加载样式 管理样式依赖 后面章节会提到

下载react相关依赖:

1
2
3
npm i react react-dom @types/react @types/react-dom --dev # 开发时依赖,宿主环境一定存在

npm i prop-types # 运行时依赖,宿主环境可能不存在 安装本组件库时一起安装

组件文档:

用dumi作为组件文档站点工具,并兼具开发调试功能

1
npm i dumi serve --dev

增加npm scripts到package.json

1
2
3
4
5
"scripts": {
"dev": "dumi dev", // 启动开发环境 在文档站点中调试组件
"build:site": "rimraf doc-site && dumi build", // 构建文档站点 后续会部署到 github pages
"preview:site": "npm run build:site && serve doc-site" // 本地预览构建后的文档站点
},

配置.umirc.ts配置文件:

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'dumi';

export default defineConfig({
title: 'My UI', // 站点名称
mode: 'site',
outputPath: 'doc-site', // 输出文件夹
exportStatic: {}, // 后续会部署到 github pages 直接全部生成静态页面 不走前端路由
dynamicImport: {}, // 拆包 站点过大时可以优化首屏加载速度
});

首页配置:

docs/index.md:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---
title: My UI
hero:
title: My UI
desc: 文档站点基于 dumi 生成
actions:
- text: 快速上手
link: /getting-started
features:
- icon: https://gw.alipayobjects.com/zos/bmw-prod/881dc458-f20b-407b-947a-95104b5ec82b/k79dm8ih_w144_h144.png
title: 特性 1
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d60657df-0822-4631-9d7c-e7a869c2f21c/k79dmz3q_w126_h126.png
title: 特性 2
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d1ee0c6f-5aed-4a45-a507-339a4bfe076c/k7bjsocq_w144_h144.png
title: 特性 3
desc: Balabala
footer: Open-source MIT Licensed | Copyright © 2020<br />Powered by [dumi](https://d.umijs.org)
---

可以参考dumi文档进行配置

index.md:

1
2
3
4
5
6
7
8
9
10
-- __tests__
-- index.test.js
-- style
-- css.js
-- index.css
-- index.d.ts
-- index.js
-- index.less
-- index.d.ts
-- index.js

部署文档站点:

将文档部署到github pages

下载cross–env区分环境变量

1
npm i cross-env --dev

package.json:

1
2
3
"scripts": {
"preview:site": "cross-env SITE_BUILD_ENV=PREVIEW npm run build:site && serve doc-site",
},

.umirc.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { defineConfig } from 'dumi';
let base = '/my-react-components';
let publicPath = '/my-react-components/';
if(process.env.SITE_BUID_ENV === 'PREVIEW'){
base = undefined;
publicPath = undefined;
}
export default defineConfig({
title: 'My UI', // 站点名称
mode: 'site',
outputPath: 'doc-site', // 输出文件夹
exportStatic: {}, // 后续会部署到 github pages 直接全部生成静态页面 不走前端路由
dynamicImport: {}, // 拆包 站点过大时可以优化首屏加载速度
base,
publicPath,
});

下载gh-pages完成一键部署:

1
npm i gh-pages --dev

package.json:

1
2
3
"scripts":{
"deploy:site":"npm run build:site && gh-pages -d doc-site",
}

执行npm run deploy:site后就能在${username}.github.io/${repo}看到自己的组件库站点

使用Github Actions自动触发部署:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: github pages

on:
push:
branches:
- master # default branch

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm install
- run: npm run build:site
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./doc-site

编译打包:

1.导出类型声明文件:

先下载cpr,这样运行下面的npm run build:typs时会将lib的声明文件拷贝一份,并将文件夹重命名为esm,后面存放ES module形式的组件,这样做的原因是保证用户手动按需引入组件的时候可以获取自动提示

**使用typescript编写的组件库,应该利用类型系统的好处,我们可以生成类型声明文件,**并在package.json中定义入口:

1
2
3
4
5
6
{
"typings": "lib/index.d.ts", // 定义类型入口文件
"scripts": {
"build:types": "tsc -p tsconfig.build.json && cpr lib esm" // 执行tsc命令生成类型声明文件
}
}

配置tsconfig.build.json:

1
2
3
4
5
{
"extends": "./tsconfig.json",
"compilerOptions": { "emitDeclarationOnly": true }, // 只生成声明文件
"exclude": ["**/__tests__/**", "**/demo/**", "node_modules", "lib", "esm"] // 排除示例、测试以及打包好的文件夹
}

执行npm run build:types后根目录生成了lib文件夹(tsconfig.json中定义的declarationDir字段)以及esm文件夹(拷贝而来),目录结构与src文件夹保持一致,这样使用者引入npm包时,便能得到自动提示,也能复用相关组件的类型定义**

接下来将ts(x)等文件处理成js文件,需要配置.babelrc.js,因为我们需要输出commonjs module和es module,再者,考虑到样式处理及其按需加载,用gulp

配置目标环境:

为了避免转译浏览器原生支持多的语法,新建.browserslistrc文件,根据适配需求,写入支持浏览器范围,作用于@babel/preset-env

.browserslistrc

1
2
3
>0.2%
not dead
not op_mini all

babel配置:

1
npm i @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-class-properties  @babel/plugin-transform-runtime  @babel/runtime-corejs3 --dev

.babelrc.js:

1
2
3
4
module.exports = {
presets: ['@babel/env', '@babel/typescript', '@babel/react'],
plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
};

2.导出ES module和Common js产物供使用者使用

完全可以使用babel或者tsc命令行工具进行代码编译处理,前面已经通过ts.config.json生成类型声明文件以及生成lib和esm文件夹,后序打包成的esm和cjs两种形式的代码分别输出到esm和lib文件夹,此处选择用gulp串起这个流程

先安装gulp相关依赖对代码进行合并优化和压缩

gulp会自动执行指定的任务,就像流水线,把资源放上去然后通过不同插件进行加工

为什么用gulp而不是webpack或者rollup?因为我们要做的是代码编译而非代码打包,(在搭建组件库的过程,即将ts代码转为js代码,将scss文件转为css文件,同时需要考虑到样式处理及其按需加载

  1. Gulp的配置更加简单直观,不需要像Webpack那样复杂的配置文件,更易于上手和使用。
  2. Gulp可以更加灵活地处理各种文件类型,不仅仅局限于JavaScript,也可以处理CSS、HTML、图片等文件,更适合于组件库的打包。
  3. Gulp的插件生态也很丰富,可以满足各种需求,比如压缩、合并、重命名等操作,而且插件之间可以组合使用,更加灵活。
  4. Gulp的执行速度更快,因为它是基于流式处理的,可以避免不必要的IO操作,提高打包速度。
  5. Gulp的社区活跃,有很多教程和案例可以参考,问题也更容易得到解决。

gulp官网:https://www.gulpjs.com.cn/docs/getting-started/creating-tasks/

1
npm install gulp gulp-babel --dev

新建gulpfile.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
const gulp = require("gulp");
const babel = require("gulp-babel");
const sass = require("gulp-sass")(require("sass"));
const autoprefixer = require("gulp-autoprefixer");
const cssnano = require("gulp-cssnano");
const through2 = require("through2");
// 编译脚本文件
// @param {string} babelEnv babel环境变量
// @param {string} destDir 目标目录

const paths = {
dest: {
lib: "lib", //commonjs文件存放的目录名,关注
esm: "esm", //ES module文件存放的目录名,暂时不关心
dist: "dist", //umd文件存放的目录名,暂时不关心
},
styles: "src/**/*.scss", //样式文件路径
scripts: [
"src/**/*.{ts,tsx}",
"!src/**/demo/*.{ts,tsx}",
"!src/**/__tests__/*.{ts,tsx}",
], //脚本路径,glob详细https://www.gulpjs.com.cn/docs/getting-started/explaining-globs/
};
//当前组件样式import './index.less'=>import './index.css'
//依赖的其他组件样式 import "../test-comp/style"=>import "../test-comp/style/css.js"
//依赖的其他组件样式 import '../test-comp/style/index.js'=>import '../test-comp/style/css.js'

function cssInction(content) {
return content
.replace(/\/style\/?'/g, "/style/css'")
.replace(/\/style\/?"/g, '/style/css"')
.replace(/\.scss/g, ".css");
}
function compileScripts(babelEnv, destDir) {
const { scripts } = paths;
process.env.BABEL_ENV = babelEnv;
return gulp
.src(scripts)
.pipe(babel()) //使用gulp-babel处理
.pipe(
through2.obj(function z(chunk, encoding, next) {
this.push(chunk.clone());
//找到style/index.js
if (chunk.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
const content = chunk.contents.toString(encoding);
chunk.contents = Buffer.from(cssInction(content)); //文件内容处理
chunk.path = chunk.path.replace(/index\.js/, "css.js"); //文件重命名
this.push(chunk); //新增该文件
next();
} else {
next();
}
})
)
.pipe(gulp.dest(destDir)); //输出
}
function compileCJS() {
const { dest } = paths;
return compileScripts("cjs", dest.lib);
}
//编译esm
function compileESM() {
const { dest } = paths;
return compileScripts("esm", dest.esm);
}
//拷贝scss文件
function copyScss() {
return gulp
.src(paths.styles)
.pipe(gulp.dest(paths.dest.lib))
.pipe(gulp.dest(paths.dest.esm));
}
//生成css文件
function Scss2css() {
return gulp
.src(paths.styles)
.pipe(sass()) //处理scss文件
.pipe(autoprefixer()) //根据browserslistrc增加前缀
.pipe(cssnano({ zindex: false, reduceIdents: false })) //压缩
.pipe(gulp.dest(paths.dest.lib))
.pipe(gulp.dest(paths.dest.esm));
}
//串行执行编译脚本任务,避免环境变量影响
const buildScripts = gulp.series(compileCJS, compileESM);
//整体并行任务,后续加入样式处理 可以并行处理
const build = gulp.parallel(buildScripts, copyScss, Scss2css);
exports.build = build;
exports.default = build;
const gulp = require("gulp");
const babel = require("gulp-babel");
const less = require("gulp-less");
const autoprefixer = require("gulp-autoprefixer");
const cssnano = require("gulp-cssnano");
const through2 = require("through2");
// 编译脚本文件
// @param {string} babelEnv babel环境变量
// @param {string} destDir 目标目录

const paths = {
dest: {
lib: "lib", //commonjs文件存放的目录名,关注
esm: "esm", //ES module文件存放的目录名,暂时不关心
dist: "dist", //umd文件存放的目录名,暂时不关心
},
styles: "src/**/*.less", //样式文件路径
scripts: ["src/**/*.{ts,tsx}", "!src/**/demo/*.{ts,tsx}","!src/**/__tests__/*.{ts,tsx}"], //脚本路径,glob详细https://www.gulpjs.com.cn/docs/getting-started/explaining-globs/
};
//当前组件样式import './index.less'=>import './index.css'
//依赖的其他组件样式 import "../test-comp/style"=>import "../test-comp/style/css.js"
//依赖的其他组件样式 import '../test-comp/style/index.js'=>import '../test-comp/style/css.js'

function cssInction(content) {
return content
.replace(/\/style\/?'/g, "/style/css'")
.replace(/\/style\/?"/g, '/style/css"')
.replace(/\.less/g, ".css");
}
function compileScripts(babelEnv, destDir) {
const { scripts } = paths;
process.env.BABEL_ENV = babelEnv;
return gulp
.src(scripts)
.pipe(babel()) //使用gulp-babel处理
.pipe(
through2.obj(function z(chunk, encoding, next) {
this.push(chunk.clone());
//找到style/index.js
if (chunk.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
const content = chunk.contents.toString(encoding);
chunk.contents = Buffer.from(cssInction(content)); //文件内容处理
chunk.path = chunk.path.replace(/index\.js/, "css.js"); //文件重命名
this.push(chunk); //新增该文件
next();
} else {
next();
}
})
)
.pipe(gulp.dest(destDir)); //输出
}
function compileCJS() {
const { dest } = paths;
return compileScripts("cjs", dest.lib);
}
//编译esm
function compileESM() {
const { dest } = paths;
return compileScripts("esm", dest.esm);
}
//拷贝less文件
function copyLess() {
return gulp
.src(paths.styles)
.pipe(gulp.dest(paths.dest.lib))
.pipe(gulp.dest(paths.dest.esm));
}
//生成css文件
function less2css() {
return gulp
.src(paths.styles)
.pipe(less()) //处理less文件
.pipe(autoprefixer()) //根据browserslistrc增加前缀
.pipe(cssnano({ zindex: false, reduceIdents: false })) //压缩
.pipe(gulp.dest(paths.dest.lib))
.pipe(gulp.dest(paths.dest.esm));
}
//串行执行编译脚本任务,避免环境变量影响
const buildScripts = gulp.series(compileCJS, compileESM);
//整体并行任务,后续加入样式处理 可以并行处理
const build = gulp.parallel(buildScripts, copyLess, less2css);
exports.build = build;
exports.default = build;

使用环境变量区分esm和cjs:

.babelrc.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module.exports = {
presets: ['@babel/env', '@babel/typescript', '@babel/react'],
plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
env: {
esm: {
presets: [
[
'@babel/env',
{
modules: false,
},
],
],
plugins: [
[
'@babel/plugin-transform-runtime',
{
useESModules: true,
},
],
],
},
},
};

配置package.json:

1
2
3
4
5
6
7
8
9
10
11
{
- "main": "index.js",
+ "main": "lib/index.js",
+ "module": "esm/index.js"
"scripts": {
...
+ "clean": "rimraf lib esm dist",
+ "build": "npm run clean && npm run build:types && gulp",
...
},
}

npm run build用来输出esm和lib两种方式的文件再使用gulp进行压缩优化

配置目标环境:

为了避免转译浏览器原生语法,新建.browserslistrc文件,写入适配要求,写入支持浏览器范围,作用域@babel/preset-env,

.browserslistrc:

1
2
3
>0.2%
not dead
not op_mini all

处理样式文件:

这里有个问题就是如果使用者没有使用scss预处理器,使用的是less或者css原生方案,那现有方案就搞不定,有以下4种预选方案:

1 告知业务方增加sass-loader,会导致业务方使用成本增加

2 打包出一份完整的css文件,全量引入,无法按需引入

3 css in js

4 提供一份style/css.js文件,引入组件css样式依赖,而不是scss依赖,组件库底层抹平差异

第3个方法优点是会让每个组件与自己的样式绑定,不需要开发者去维护样式依赖,但是缺点是:

(1)样式无法单独缓存

(2)style-components自身体积较大

(3)复写组件样式需要使用属性选择器或者使用styled-components自带方法

https://styled-components.com/docs/basics#installation

讲下第4点,这也是antd使用的方案,在搭建组件库过程中需要在alert/style/index.js中引入scss文件,是为了管理样式依赖,因为组件没有引入样式文件,需要使用者手动引入,假如:使用者引入Button,Button依赖了Icon,则需要手动引入调用组件的样式(Button)及其依赖的组件样式(Icon),遇到复杂的组件更麻烦,所以组件开发者提供了一份这样的js文件,使用者手动引入这个js文件,就能引入对应组件及其依赖组件的样式。

为什么组件不直接 import ‘./index.scss’,这样业务方可能不使用scss,就需要配置scss-loader,因此单独提供一份style/css.js文件,引入的是组件css样式文件依赖,组件库底层抹平差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...

/**
* 生成css文件
*/
function Scss2css() {
return gulp
.src(paths.styles)
.pipe(scss()) // 处理scss文件
.pipe(autoprefixer()) // 根据browserslistrc增加前缀
.pipe(cssnano({ zindex: false, reduceIdents: false })) // 压缩
.pipe(gulp.dest(paths.dest.lib))
.pipe(gulp.dest(paths.dest.esm));
}

const build = gulp.parallel(buildScripts, copyScss, Scss2css);

// ...

执行npm run build,组件style目录下已经存在css文件,接下来我们需要一个alert/style/css.js来帮用户引入css文件:可以在gulpfile.js文件中处理scripts任务中截住style/index.js,生成style/css.js,并通过正则将引入的scss文件后缀改为css

1
npm i through2 --dev

gulpfile.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ...
function cssInjection(content) {
return content
.replace(/\/style\/?'/g, "/style/css'")
.replace(/\/style\/?"/g, '/style/css"')
.replace(/\.scss/g, '.css');
}
/**
* 编译脚本文件
* @param {*} babelEnv babel环境变量
* @param {*} destDir 目标目录
*/
function compileScripts(babelEnv, destDir) {
const { scripts } = paths;
process.env.BABEL_ENV = babelEnv;
return gulp
.src(scripts)
.pipe(babel()) // 使用gulp-babel处理
.pipe(
through2.obj(function z(file, encoding, next) {
this.push(file.clone());
// 找到目标
if (file.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
const content = file.contents.toString(encoding);
file.contents = Buffer.from(cssInjection(content)); // 文件内容处理
file.path = file.path.replace(/index\.js/, 'css.js'); // 文件重命名
this.push(file); // 新增该文件
next();
} else {
next();
}
}),
)
.pipe(gulp.dest(destDir));
}

// ...

再次打包运行npm run build,组件style目录下生成css.js文件,引入的index.css是由上一步scss转换来的css文件

按需加载:

在package.json中增加sideEffects属性,配合ES module达到tree shaking,(将样式依赖文件标注为side effects,避免被误删除)

1
2
3
4
5
6
7
8
//...
"sideEffects": [
"dist/*",
"esm/**/style/*",
"lib/**/style/*",
"*.scss"
]
//...

使用以下方式引入,可以做到js部分按需加载:

1
2
import { Alert } from 'july-design';
import 'july-design/esm/alert/style';