在前端工程中,我们会普遍的使用一些第三方UI组件库,但是,由于项目的需要,或多或少的我们需要对第三方组件进行一些封装,或者开发自己的组件。那么,这个时候考虑到多项目的复用,我们就需要开发自己的组件库了。
如果我们要创建一个React的组件库,我们需要先要确定几个方向
1.用什么语言?js,ts
2.要达到什么样的效果?类似antd的按需加载
3.便于调试
语言选择
如今ts已经是大势所趋,所以,我们同样选择ts来编写
打包工具的选择
在应用层面,我们大多是基于webpack进行构建的,webpack会根据入口文件把依赖的文件都打到一个文件中,如果要根据组件进行打包就要设置多入口。扒了下ant-design的仓库,看到ant-design的构建是基于 @ant-design/tools 来做的,于是又去扒@ant-design/tools的代码,可以看到 用的是gulp和webpack,gulp对组件进行分别的打包,webpack进行合并打包。考虑到我们的场景与antd不一样,并且不想用webpack 进行打包,所以选择了gulp和rollup来做。
@ant-design/tools 地址 https://github.com/ant-design/antd-tools
这里,我们以一个antd的扩展组件库为例,写一个组件库
1. 如果脱离工程,写一个单纯的组件库,那么调试就会比较麻烦,所以,这里直接用一个create-react-app工程来做。
yarn create react-app @moensun/antd-react-extension --template typescript
2. 使用@craco/craco 来替代scripts
yarn add @craco/craco
/* package.json */
"scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
+ "start": "craco start",
+ "build": "craco build",
+ "test": "craco test",
}
由于这个组件库是多antd的一个封装扩展,所以我们还需要安装 craco-antd
3. 确定工程结构
上面讲这是一个基于create-react-app创建的工程,但是,我们的目的是作为一个react的组件库,所以,我们在src平级创建一个components文件夹来放组件文件。工程结构如下:
如此,我们的craco.config.js配置文件调整如下,将components加入构建
const path = require("path")
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin")
const CracoAntDesignPlugin = require("craco-antd");
const {getLoader, loaderByName} = require("@craco/craco");
module.exports = {
plugins: [
{
plugin: CracoAntDesignPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: {},
javascriptEnabled: true,
},
},
}
},
],
webpack: {
configure: (webpackConfig, {env, paths}) => {
webpackConfig.resolve.plugins = webpackConfig.resolve.plugins.map((item) => {
if (item instanceof ModuleScopePlugin) {
item.appSrcs.push(path.resolve(__dirname, "components"))
}
return item;
});
const {isFound, match} = getLoader(webpackConfig, loaderByName("babel-loader"));
if (isFound) {
match.loader.include = [
path.resolve(__dirname, "src"),
path.resolve(__dirname, "components")
]
}
return webpackConfig;
}
}
}
同样,对tsconfig,json进行调整
{
"compilerOptions": {
"baseUrl": "./",
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src",
"components"
],
"exclude": [
"node_modules",
"tools",
"lib",
"es",
"dist"
]
}
4. 构建打包脚本
组件库的开发与应用的开发有点不太一样,就是在生成的时候,不要带上node_modules中的代码。并且要对一些路径进行处理,构建这块,大部分是扒的@ant-design/tools的代码,这里就直接上代码了
package.json
{
"name": "@moensun/antd-react-extension",
"version": "0.1.1",
"author": "Bane.Shi",
"license": "MIT",
"files": [
"dist",
"lib",
"es"
],
"sideEffects": [
"dist/*",
"es/**/style/*",
"lib/**/style/*",
"*.less"
],
"main": "lib/index.js",
"module": "es/index.js",
"unpkg": "dist/index.min.js",
"typings": "lib/index.d.ts",
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "craco eject",
"build-dist": "rollup -c && gulp less",
"build-compile": "gulp compile",
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@ant-design/icons": "^4.7.0",
"@babel/core": "^7.16.5",
"@babel/plugin-proposal-class-properties": "^7.16.5",
"@babel/plugin-proposal-decorators": "^7.16.5",
"@babel/plugin-proposal-export-default-from": "^7.16.5",
"@babel/plugin-proposal-export-namespace-from": "^7.16.5",
"@babel/plugin-proposal-object-rest-spread": "^7.16.5",
"@babel/plugin-transform-member-expression-literals": "^7.16.5",
"@babel/plugin-transform-object-assign": "^7.16.5",
"@babel/plugin-transform-property-literals": "^7.16.5",
"@babel/plugin-transform-runtime": "^7.16.5",
"@babel/plugin-transform-spread": "^7.16.5",
"@babel/plugin-transform-template-literals": "^7.16.5",
"@babel/plugin-transform-typescript": "^7.16.1",
"@babel/preset-env": "^7.16.5",
"@babel/preset-react": "^7.16.5",
"@craco/craco": "^6.4.3",
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.1.1",
"@rollup/plugin-typescript": "^8.3.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"antd": "^4.20.6",
"autoprefixer": "^10.4.0",
"babel-plugin-inline-import-data-uri": "^1.0.1",
"craco-antd": "^2.0.0",
"craco-less": "^1.20.0",
"gulp": "^4.0.2",
"gulp-babel": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-csso": "^4.0.1",
"gulp-less": "^5.0.0",
"gulp-merge": "^0.1.1",
"gulp-shell": "^0.8.0",
"gulp-typescript": "^5.0.1",
"is-windows": "^1.0.2",
"less": "^4.1.2",
"less-plugin-npm-import": "^2.1.0",
"merge2": "^1.4.1",
"postcss": "^8.4.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-scripts": "4.0.3",
"rollup": "^2.61.1",
"rollup-plugin-less": "^1.1.3",
"rollup-plugin-postcss": "^4.0.2",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"dependencies": {},
"peerDependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"tnpm": {
"mode": "npm"
}
}
从依赖可以看出,其实是大量依赖bable的。
tools文件夹中的内容就是扒了@ant-design/tools的代码,只做了少量修改。这里的代码就不贴了,可以直接去看代码库。 https://github.com/ant-design/antd-tools
gulpfile.js 这里出于是对组件进行模块化打包的
const gulp = require("gulp");
const ts = require("gulp-typescript");
const merge2 = require('merge2');
const babel = require('gulp-babel');
const less = require('gulp-less');
const minifyCSS = require('gulp-csso');
const NpmImportPlugin = require('less-plugin-npm-import');
const concat = require('gulp-concat');
const getBabelCommonConfig = require('./tools/getBabelCommonConfig');
const through2 = require("through2");
const rimraf = require('rimraf');
const shell = require('gulp-shell')
const { cssInjection } = require('./tools/utils/styleUtil');
const transformLess = require('./tools/transformLess');
const tsDefaultReporter = ts.reporter.defaultReporter();
const tsConfig = Object.assign({
target: 'es6',
jsx: 'preserve',
moduleResolution: 'node',
declaration: true,
allowSyntheticDefaultImports: true,
})
const tsFiles = ["components/**/*.tsx", "components/**/*.ts"]
const compileDir = (modules) => {
return modules === false ? "es":"lib";
}
function babelify(js, modules) {
const babelConfig = getBabelCommonConfig(modules);
delete babelConfig.cacheDirectory;
if (modules === false) {
// babelConfig.plugins.push(replaceLib);
}
const stream = js.pipe(babel(babelConfig)).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);
if (content.indexOf("'react-native'") !== -1) {
next();
return;
}
file.contents = Buffer.from(cssInjection(content));
file.path = file.path.replace(/index\.js/, 'css.js');
this.push(file);
next();
} else {
next();
}
})
);
return stream.pipe(gulp.dest(compileDir(modules)));
}
function compile(modules) {
rimraf.sync(compileDir(modules));
const less = gulp
.src(['components/**/*.less'])
.pipe(
through2.obj(function (file, encoding, next) {
// Replace content
const cloneFile = file.clone();
const content = file.contents.toString().replace(/^\uFEFF/, '');
cloneFile.contents = Buffer.from(content);
// Clone for css here since `this.push` will modify file.path
const cloneCssFile = cloneFile.clone();
this.push(cloneFile);
// Transform less file
if (
file.path.match(/(\/|\\)style(\/|\\)index\.less$/)
) {
transformLess(cloneCssFile.contents.toString(), cloneCssFile.path)
.then(css => {
cloneCssFile.contents = Buffer.from(css);
cloneCssFile.path = cloneCssFile.path.replace(/\.less$/, '.css');
this.push(cloneCssFile);
next();
})
.catch(e => {
console.error(e);
});
} else {
next();
}
})
)
.pipe(gulp.dest(compileDir(modules)));
const assets = gulp
.src(['components/**/*.@(png|svg)'])
.pipe(gulp.dest(compileDir(modules)));
let error = 0;
var tsResult = gulp.src(tsFiles)
.pipe(ts(tsConfig, {
error(e) {
tsDefaultReporter.error(e);
error = 1;
},
finish: tsDefaultReporter.finish,
}));
function check() {
if (error) {
process.exit(1);
}
}
tsResult.on('finish', check);
tsResult.on('end', check);
const tsFilesStream = babelify(tsResult.js, modules);
const tsd = tsResult.dts.pipe(gulp.dest(compileDir(modules)));
return merge2([less,assets,tsd,tsFilesStream].filter(s => s));
}
gulp.task('compile-with-es', done => {
console.log('[Parallel] Compile to es...');
compile(false).on('finish', done);
});
gulp.task('compile-with-lib', done => {
console.log('[Parallel] Compile to js...');
compile().on('finish', done);
});
gulp.task(
'compile',
gulp.parallel('compile-with-es', 'compile-with-lib')
);
gulp.task("less",()=>{
return gulp.src('components/**/*.less')
.pipe(less({
plugins: [new NpmImportPlugin({ prefix: '~' })],
javascriptEnabled: true
}))
.pipe(concat('index.css'))
.pipe(minifyCSS())
.pipe(gulp.dest('dist/style'))
});
rollup.config.js 对组件库进行整体打包
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import babel from "@rollup/plugin-babel"
// eslint-disable-next-line import/no-anonymous-default-export
export default {
input: 'components/index.tsx',
output:[
{
name:'index.min',
format:'umd',
file:'dist/index.min.js'
},
],
plugins: [
resolve(),
typescript({outDir:'./dist',include:["components/**/*.ts","components/**/*.tsx"]}),
commonjs(),
babel({
"presets": ['@babel/preset-env']
}),
],
external: ['react','antd']
}
5. 组件开发
其实,antd的按需加载是依赖了babel-plugin-import ,而babel-plugin-import的加载其实是根据组件路径来加载的,所以,我们需要按照规范来设置文件夹结构。
以image-upload为例
image-upload.tsx文件中是组件的内容
import * as React from "react";
import {Button, Divider, Upload, Image} from "antd";
import {UploadOutlined} from "@ant-design/icons";
export interface ImageUploadProps {
value?: string,
onChange?: (value?: string) => void,
name?: string,
uploadRequest?: (form?: any) => Promise<any>,
onUpload?: (form?: any) => void
changeText?: string,
deleteText?: string
className?: string
minHeight?: number | string | undefined
preview?: boolean
}
const ImageUpload: React.FC<ImageUploadProps> = ({
value,
onChange,
name = "file",
uploadRequest,
onUpload,
changeText = '更换',
deleteText = '删除',
className,
minHeight,
preview
}) => {
...
}
export default ImageUpload
style/index.less 是样式文件,注意要把对antd的依赖的样式文件依赖进来
@import "~antd/lib/image/style/index";
@import "~antd/lib/button/style/index";
@import "~antd/lib/divider/style/index";
@import "~antd/lib/upload/style/index";
.ms-antd-image-upload {
...
}
style/index.tsx 是引入了index.less文件
import "./index.less";
index.tsx 就是对这个组件的对外export
import ImageUpload,{ImageUploadProps} from "./image-upload";
export type {ImageUploadProps}
export default ImageUpload
components下的index.tsx 则是整个组件库的入口
export {default as ImageUpload} from "./image-upload"
6. 打包组件库
"build-dist": "rollup -c && gulp less",
"build-compile": "gulp compile",
生成dist,es,lib 3个文件夹
7. 如何使用组件库
使用组件库一般是上传到npmjs,但是考虑到更新的便捷,我们选择上传到coding的制品库。
制品库中的包是需要指定npm仓库的,否则无法找到,这里我们在.npmrc文件中指定
registry=http://mirrors.cloud.tencent.com/npm/
@moensun:registry=https://moensun-npm.pkg.coding.net/npm/xxxx/
调整应用中的craco.config.js配置,其实就是添加一个babel-plugin-import
module.exports = {
plugins: [
{
plugin: CracoAntDesignPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: {},
javascriptEnabled: true,
},
},
}
},
],
babel: {
plugins: [
["import", { "libraryName": "@moensun/antd-react-extension", "libraryDirectory": "lib","style":true}, "@moensun/antd-react-extension"]
]
},
}
用ImageUpload组件试试
import React from 'react';
import {ImageUpload} from "@moensun/antd-react-extension";
function App() {
return (
<div className="App">
<ImageUpload value={"https://img.baidu.com/article/article/cover/2021/12/1/DOHIQggCdixNfZGAXktGCpWLLISadVKI.jpg"} />
</div>
);
}
export default App;
效果如下
达到了期望效果。
感谢群里,拿哥,龟哥,旭哥等前端大佬们帮助。