文章
问答
冒泡
创建一个React的UI组件库

在前端工程中,我们会普遍的使用一些第三方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;


效果如下


达到了期望效果。
感谢群里,拿哥,龟哥,旭哥等前端大佬们帮助。

react

关于作者

落雁沙
非典型码农
获得点赞
文章被阅读