前言: 搭建属于自己的组件库,方便在项目中导入组件,避免重复造轮子。
搭建组件库的主要步骤:
项目初始化:使用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:
新建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作为组件文档站点工具,并兼具开发调试功能
增加npm scripts到package.json
1 2 3 4 5 "scripts" : { "dev" : "dumi dev" , "build:site" : "rimraf doc-site && dumi build" , "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 : {}, 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区分环境变量
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 : {}, dynamicImport : {}, base, publicPath, });
下载gh-pages完成一键部署:
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 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" } }
配置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文件,同时需要考虑到样式处理及其按需加载
Gulp的配置更加简单直观,不需要像Webpack那样复杂的配置文件,更易于上手和使用。
Gulp可以更加灵活地处理各种文件类型,不仅仅局限于JavaScript,也可以处理CSS、HTML、图片等文件,更适合于组件库的打包。
Gulp的插件生态也很丰富,可以满足各种需求,比如压缩、合并、重命名等操作,而且插件之间可以组合使用,更加灵活。
Gulp的执行速度更快,因为它是基于流式处理的,可以避免不必要的IO操作,提高打包速度。
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" );const paths = { dest : { lib : "lib" , esm : "esm" , dist : "dist" , }, styles : "src/**/*.scss" , scripts : [ "src/**/*.{ts,tsx}" , "!src/**/demo/*.{ts,tsx}" , "!src/**/__tests__/*.{ts,tsx}" , ], }; 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()) .pipe( through2.obj(function z (chunk, encoding, next ) { this .push(chunk.clone()); 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); } function compileESM ( ) { const { dest } = paths; return compileScripts("esm" , dest.esm); } function copyScss ( ) { return gulp .src(paths.styles) .pipe(gulp.dest(paths.dest.lib)) .pipe(gulp.dest(paths.dest.esm)); } function Scss2css ( ) { return gulp .src(paths.styles) .pipe(sass()) .pipe(autoprefixer()) .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" );const paths = { dest : { lib : "lib" , esm : "esm" , dist : "dist" , }, styles : "src/**/*.less" , scripts : ["src/**/*.{ts,tsx}" , "!src/**/demo/*.{ts,tsx}" ,"!src/**/__tests__/*.{ts,tsx}" ], }; 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()) .pipe( through2.obj(function z (chunk, encoding, next ) { this .push(chunk.clone()); 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); } function compileESM ( ) { const { dest } = paths; return compileScripts("esm" , dest.esm); } function copyLess ( ) { return gulp .src(paths.styles) .pipe(gulp.dest(paths.dest.lib)) .pipe(gulp.dest(paths.dest.esm)); } function less2css ( ) { return gulp .src(paths.styles) .pipe(less()) .pipe(autoprefixer()) .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 function Scss2css ( ) { return gulp .src(paths.styles) .pipe(scss()) .pipe(autoprefixer()) .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
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' ); } function compileScripts (babelEnv, destDir ) { const { scripts } = paths; process.env.BABEL_ENV = babelEnv; return gulp .src(scripts) .pipe(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' ;