手把手教你快速搭建React组件库

383

前言

无论团队大小,随着时间的推进,多多少少都会有一些可提取的业务组件,沉淀组件库和对应的文档是一条必经之路。

直接进入正题,从 0 到 1 开始搞一个业务组件库(可从注释中生成)。

最终的 Demo 可看这里,请使用 Mac 或者 Linux 终端来运行,windows 兼容性未做验证。

使用到工具

这三个工具是后续业务组件库搭建使用到的,需要有一定的了解:

  • Lerna ,Lerna是一个 Npm 多包管理工具,详细可查看官方文档。

  • Docusaurus,是 Facebook 官网支持的文档工具,可以在极短时间内搭建漂亮的文档网站,详细可查看官网文档。

  • Vite,Vite 是一种新型前端构建工具,能够显著提升前端开发体验,开箱即用,用来代替 rollup 构建代码可以省掉一些繁琐的配置。

初始化项目

注意 Node 版本需要在 v16 版本以上,最好使用 v16 版本。

初始化的文件结构如下:

.
├── lerna.json
├── package.json
└── website

假设项目 root 文件夹:

  1. 第一步,初始化 Lerna 项目

    $ npx lerna@latest init
    

    lerna 会添加 package.jsonlerna.json

  2. 第二步,初始化 Docusaurus 项目(typescript 类型的)

    $ npx create-docusaurus@latest website classic --typescript
    
  3. 第三步,配置 package.json

    • npm run bootstrap 可初始化安装所有分包的依赖包。

    • npm run postinstall 是 npm 钩子命令,在依赖包完成安装后会触发 npm run postinstall 的运行。

    {
      "private": true,
      "dependencies": {
        "lerna": "^5.1.4"
      },
      "scripts": {
        "postinstall": "npm run bootstrap",
        "bootstrap": "lerna bootstrap"
      }
    }
    
  4. 第四步,配置 lerna.json

  • packages 设置分包的位置,详细配置可长 lerna 的文档。

  • npmClient 可指定使用的 npm 客户端,可以替换为内部的 npm 客户端或者 yarn

  • hoist 设置为true后,分包的同一个依赖包如果相同,会统一安装到最上层项目的根目录 root/node_modules 中,如果不相同会有警告,同一个相同的版本安装到最上层根目录,不相同的依赖包版本安装到当前分包的 node_modules 目前下。

{
  "packages": ["packages/*", "website"],
  "version": "0.0.0",
  "npmClient": "npm",
  "hoist": true
}

使用 Vite 创建组件分包

最终文件夹路径如下:

.
├── lerna.json
├── package.json
├── packages
│   └── components
│       ├── package.json
│       ├── src
│       ├── tsconfig.json
│       ├── tsconfig.node.json
│       └── vite.config.ts
└── website

  1. 第一步,创建 packages/components 文件夹

  2. 第二步,初始化 Vite 项目,选用 react-ts的模板。

    $ npm init vite@latest
    
  3. 第三步,删除不必要的文件

    由于只用 Vite 的打包功能,用不上 Vite 的服务开发功能,所以要做一些清理。

    删除 index.html 和 清空 src 文件夹。

  4. 第四步,配置 packages/components/vite.config.ts

    Vite 的详细配置可以查看官方文档,可以细看配置 Vite 的库模式,Vite 的打包其实是基于 rollup,这里说明一下需要注意的配置:

    • rollupOptions.external 配置

      确保外部化处理那些你不想打包进库的依赖,如 React 这些公共的依赖包就不需要打包进来。

    • rollupOptions.globals 配置

      在 UMD 构建模式下为这些外部化的依赖提供一个全局变量。

      Less 配置

      Vite 默认是支持 Less 的,需要再 package.json 添加 less 依赖包后才生效。也默认支持 css module 功能。

      由于是库类型,所以 less 需要配置 classs 前缀,这里还根据 src/Table.module.less 或者 src/Table/index.module.less 类型的路径获取 Table 为组件名前缀。

    import { defineConfig } from 'vite';
    import path from 'path';
    
    // 在 UMD 构建模式下为外部依赖提供一个全局变量
    export const GLOBALS = {
      react: 'React',
      'react-dom': 'ReactDOM',
    };
    // 处理类库使用到的外部依赖
    // 确保外部化处理那些你不想打包进库的依赖
    export const EXTERNAL = [
      'react',
      'react-dom',
    ];
    
    // https://vitejs.dev/config/
    export default defineConfig(() => {
      return {
        plugins: [react()],
        css: {
          modules: {
            localsConvention: 'camelCaseOnly',
            generateScopedName: (name: string, filename: string) => {
              const match = filename.replace(/\\/, '/').match(/.*\/src\/(.*)\/.*\.module\..*/);
    
              if (match) {
                return `rabc-${decamelize(match[1], '-')}__${name}`;
              }
    
              return `rabc-${name}`;
            },
          },
          preprocessorOptions: {
            less: {
              javascriptEnabled: true,
            },
          },
        },
        build: {
          rollupOptions: {
            external: EXTERNAL,
            output: { globals: GLOBALS },
          },
          lib: {
            entry: path.resolve(__dirname, 'src/index.ts'),
            name: 'RbacComponents',
            fileName: (format) => `rbac-components.${format}.js`,
          },
        },
      };
    });
    
  5. 第五步,配置 packages/components/package.json

    需要注意三个字段的配置:

    • main,如果没有设置 module 字段,Webpack、Vite 等打包工具会以此字段设置的文件为依赖包的入口文件。
    • module,一般的工具包默认优先级高于 main,此字段指向的应该是一个基于 ES6 模块规范的模块,这样打包工具才能支持 Tree Shaking 的特性。
    • files,设置发布到 npm 上的文件或者文件夹,默认 package.json 是不用做处理的。
    {
      "version": "0.0.0",
      "name": "react-antd-business-components",
      "main": "dist/rbac-components.umd.js",
      "module": "dist/rbac-components.es.js",
      "files": [
        "dist"
      ],
      "scripts": {
        "build": "vite build"
      },
      "dependencies": {},
      "devDependencies": {
        "@types/react": "^18.0.14",
        "@types/react-dom": "^18.0.5",
        "@vitejs/plugin-react": "^1.3.2",
        "classnames": "2.3.1",
        "cross-spawn": "7.0.3",
        "decamelize": "4.0.0",
        "eslint": "8.18.0",
        "less": "^4.1.3",
        "prop-types": "^15.7.2",
        "react": "^17.0.2",
        "react-dom": "^17.0.2",
        "rimraf": "^3.0.2",
        "typescript": "^4.6.4",
        "vite": "^2.9.12"
      }
    }
    

创建组件

package/components/src 目前下创建两个文件:

  • index.ts

    export { default as Test } from './Test';
    
  • Test.tsx

    import React from 'react';
    
    export interface ProContentProps {
      /**
       * 标题
       */
      title?: React.ReactNode;
      /**
       * 内容
       */
      content: React.ReactNode;
    }
    /**
     * 展示标题和内容
     */
    const Test: {
      (props: ProContentProps): JSX.Element | null;
      displayName: string;
      defaultProps?: Record<string, any>;
    } = (props: ProContentProps) => {
      const { title, content } = props;
    
      return (
        <div>
          <div>{title}</div>
          <div>{content}</div>
        </div>;
      );
    };
    
    Test.displayName = 'Card';
    
    Test.defaultProps = {
      title: '标题',
      content: "内容",
    };
    
    export default Test;
    

编写组件文档

Docusaurus 是支持 mdx 的功能,但是并不能读取注释,也有没组件可以一起展示 Demo 和 Demo 的代码。

所以在编写文档前还需要做一些准备,支持 PropsTableCodeShow 的用法,这里实现的细节就不做细说,感兴趣的可以查看 react-doc-starter

组件文档编写准备

  1. 第一步,md 文件支持直接使用 PropsTable 和 CodeShow 组件

    新建以下几个文件,同时需要添加相应的依赖包,文件内容可以在这个项目 react-doc-starter 中获取。

    website/loader/propsTable.js
    website/loader/codeShow.js
    website/plugins/mdx.js
    
  2. 第二步,支持 Less 功能

    Less 的功能需要和 Vite 打包是配置的 Less 一致。

    const decamelize = require('decamelize');
    
    module.exports = function (_, opt = {}) {
      delete opt.id;
    
      const options = {
        ...opt,
        lessOptions: {
          javascriptEnabled: true,
          ...opt.lessOptions,
        },
      };
    
      return {
        name: 'docusaurus-plugin-less',
        configureWebpack(_, isServer, utils) {
          const { getStyleLoaders } = utils;
          const isProd = process.env.NODE_ENV === 'production';
          return {
            module: {
              rules: [
                {
                  test: /\.less$/,
                  oneOf: [
                    {
                      test: /\.module\.less$/,
                      use: [
                        ...getStyleLoaders(isServer, {
                          modules: {
                            mode: 'local',
                            getLocalIdent: (context, _, localName) => {
                              const match = context.resourcePath.replace(/\\/, '/').match(/.*\/src\/(.*)\/.*\.module\..*/);
    
                              if (match) {
                                return `rabc-${decamelize(match[1], '-')}__${localName}`;
                              }
    
                              return `rabc-${localName}`;
                            },
                            exportLocalsConvention: 'camelCase',
                          },
                          importLoaders: 1,
                          sourceMap: !isProd,
                        }),
                        {
                          loader: 'less-loader',
                          options,
                        },
                      ],
                    },
                    {
                      use: [
                        ...getStyleLoaders(isServer),
                        {
                          loader: 'less-loader',
                          options,
                        },
                      ],
                    },
                  ],
                },
              ],
            },
          };
        },
      };
    };
    
  3. 第三步,支持 alias

    需要添加 website/plugin/alias.js 插件和修改 tsconfig.json

    alias.js

    const path = require('path');
    
    module.exports = function () {
      return {
        name: 'alias-docusaurus-plugin',
        configureWebpack() {
          return {
            resolve: {
              alias: {
                // 支持当前正在开发组件依赖包(这样依赖包就无需构建,可直接在文档中使用)
                'react-antd-business-components': path.resolve(__dirname, '../../packages/components/src'),
                $components: path.resolve(__dirname, '../../packages/components/src'), // 用于缩短文档路径
                $demo: path.resolve(__dirname, '../demo'), // 用于缩短文档路径
              },
            },
          };
        },
      };
    };
    
    

    tsconfig.json

    {
      // This file is not used in compilation. It is here just for a nice editor experience.
      "extends": "@tsconfig/docusaurus/tsconfig.json",
      "compilerOptions": {
        "baseUrl": ".",
        "paths": {
          "react-antd-business-components": ["../packages/components/src"]
        }
      },
      "include": ["src/", "demo/"]
    }
    
  4. 第四步,配置 website/docusaurus.config.js 使用插件:

    const config = {
      ...
      plugins: [
        './plugins/less',
        './plugins/alias',
        './plugins/mdx',
      ],
      ...
    };
    
    module.exports = config;
    
  5. 第五步,修改默认的文档路径和默认的 sidebar 路径

    由于我们还可能有其他文档如 Utils 文档,我们需要而外配置一下 website/docusaurus.config.js:

    把文档路径修改为 website/docs/components,sidebar 路径改为 webiste/componentsSidebars.js,sidebar 文件直接改名即可,无需做任何处理。

    const config = {
      ...
      presets: [
        [
          'classic',
          /** @type {import('@docusaurus/preset-classic').Options} */
          ({
            docs: {
              path: 'docs/components',
              routeBasePath: 'components',
              sidebarPath: require.resolve('./componentsSidebars.js'),
              // Please change this to your repo.
              // Remove this to remove the "edit this page" links.
              editUrl: 'https://github.com/samonxian/react-doc-starter/tree/master',
            },
          }),
        ],
      ],
      ...
    };
    module.exports = config;
    

正式编写组件文档

website/demo 文件中创建 Test/Basic.tsxTest/Basic.css ,在 website/docs/components/data-show 中创建 Test.md_category_.json 文件:

demo/Test/Basic.tsx

import React from 'react';
import { Test } from 'react-antd-business-components';
import './Basic.css';

const Default = function () {
  return (
    <div className="pro-content-demo-container">
      <Test title="标题" content="内容"/>
    </div>
  );
};

export default Default;

demo/Test/Basic.css

.test-demo-container {
  background-color: #eee;
  padding: 16px;
}

docs/components/data-show/_category_.json 是 Docusaurus 的用法,详细点击这里

{
  "position": 2,
  "label": "数据展示",
  "collapsible": true,
  "collapsed": false,
  "link": {
    "type": "generated-index"
  }
}

docs/components/data-show/Test.md

如果要定制化配置此 Markdown,如侧边栏文本、顺序等等,可细看 Markdown 前言

CodeShow 和 PropsTable 组件用法可看这里

## 使用

### 基本使用

<CodeShow fileList={['$demo/ProContent/Basic.tsx', '$demo/ProContent/Basic.css']} />

## Props

<PropsTable src="$components/ProContent" showDescriptionOnSummary />

运行文档服务

这里运行文档服务,可以查看 Demo 的效果,相当于一遍写文档一边调试组件,无需另起开发服务调试组件。

$ cd ./website
$ npm start

发布组件

在项目 root 根目录下运行:

$ npm run build:publish

此名会运行lerna buildlerna publish 命令,然后安装提示进行发布即可,详细的用法可查看 lerna 命令。

部署文档

详细可看 Docusaurus

如果是 Github 项目建议发布到 GitHub Pages,命令如下:

$ cd ./website
$ npm run deploy

拓展

转换 src 中的所有文件

打包时候,Vite 会把所有涉及到的文件打包为一个文件,而我们常常把 src 文件夹的所有 JavaScript 或者 Typescript 文件转换为 es5 语法,直接提供给 Webpack、Vite 这些开发服务工具使用。

Vite 不支持多入口文件和多输出文件的模式,本人实现了一个 vite 插件 vite-plugin-build,同时支持单文件单输出和多文件多输出的模式。

package.json scripts 修改如下:

{
  "scripts": {
    "tsc": "tsc",
    "clean": "rimraf lib es dist",
    "build": "npm run clean && npm run tsc && vite build"
  },
  "devDependencies": {
    "vite-plugin-build": "^0.2.2",
  }
}

vite.config.ts 修改如下:

import path from 'path';
import { defineConfig } from 'vite';
import { buildPlugin } from 'vite-plugin-build';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react(),
    buildPlugin({
      libBuild: {
        buildOptions: {
          rollupOptions: {
            external: ['react'],
            output: { globals: { dayjs: 'React' } },
          },
          lib: {
            entry: path.resolve(__dirname, 'src/index.ts'),
            name: 'RbacComponents',
            fileName: (format) => `rbac-components.${format}.js`,
          },
        },
      },
    }),
  ],

  css: {
    modules: {
      localsConvention: 'camelCaseOnly',
      generateScopedName: (name: string) => `rbac-${name}`,
    },
  },
});

添加单元测试

单元测试使用 Vitest,Vitest 可共用 Vite 的配置,配置也很简单,同时兼容 Jest 的绝大部分用法。

下方的步骤基于分包 packages/components 来处理。

  1. 第一步,更新 package.json

    {
      "scripts": {
        "test": "vitest",
        "coverage": "vitest run --coverage"
      },
      "devDependencies": {
        "happy-dom": "^6.0.2",
        "react-test-renderer": "^17.0.2",
        "vitest": "^0.18.0"
      }
    }
    
  2. 第二步,更新 vite.config.js

    配置 vitest 本身,需要在 Vite 配置中添加 test 属性。如果你使用 vitedefineConfig ,还需要将 三斜线指令 写在配置文件的顶部。

    配置十分简单,只需要关闭 watch 和 添加 dom 执行环境。

    /// <reference types="vitest" />
    
    export default defineConfig(() => {
      return {
        ...
        test: {
          environment: 'happy-dom',
          watch: false,
        },
        ...
      };
    });
    
    
  3. 第三步,添加测试用例 packages/components/src/__tests__/Test.spec.tsx

    import { expect, it } from 'vitest';
    import React from 'react';
    import renderer from 'react-test-renderer';
    import Test from '../index';
    
    function toJson(component: renderer.ReactTestRenderer) {
      const result = component.toJSON();
      expect(result).toBeDefined();
      return result as renderer.ReactTestRendererJSON;
    }
    
    it('ProContent rendered', () => {
      const component = renderer.create(
        <Test />,
      );
      const tree = toJson(component);
      expect(tree).toMatchSnapshot();
    });
    

使用 Vite 初始化 Utils 分包

其实除了业务组件,我们还会有业务 Utils 工具类的函数,我们也会沉淀工具类库和相应的文档。

得益于多包管理的方式,本人把组件库和 Utils 类库放在一起处理,在 package 中新建 utils 分包。

utils 分包和 components 分包大同小异,vite 和 package.json 配置就不细说了,可参考上方[使用 Vite 创建组件分包](#使用 Vite 创建组件分包)。

创建工具函数

创建 utils/src/isNumber.ts 文件(范例)。

/**
 * @param value? 检测的目标
 * @param useIsFinite 是否使用 isFinite,设置为 true 时,NaN,Infinity,-Infinity 都不算 number
 * @default true
 * @returns true or false
 * @example
 * ```ts
 * isNumber(3) // true
 * isNumber(Number.MIN_VALUE) // true
 * isNumber(Infinity) // false
 * isNumber(Infinity,false) // true
 * isNumber(NaN) // false
 * isNumber(NaN,false) // true
 * isNumber('3') // false
 * ```
 */
export default function isNumber(value?: any, useIsFinite = true) {
  if (typeof value !== 'number' || (useIsFinite && !isFinite(value))) {
    return false;
  }
  return true;
}
编写工具函数文档

由于工具类函数不适合使用 PropsTable 读取注释,手动编写 markdown 效率又低,本人基于微软 tsdoc 实现了一个 Docusaurus 插件。

  1. 第一步,md 文件支持直接使用 TsDoc 组件 添加 website/plugins/tsdoc.js,同时需要添加相应的依赖包,文件内容可以在这个项目 react-doc-starter 中获取。

    module.exports = function (context, opt = {}) {
      return {
        name: 'docusaurus-plugin-tsdoc',
        configureWebpack(config) {
          const { siteDir } = context;
    
          return {
            module: {
              rules: [
                {
                  test: /(\.mdx?)$/,
                  include: [siteDir],
                  use: [
                    {
                      loader: require.resolve('ts-doc-webpack-loader'),
                      options: { alias: config.resolve.alias, ...opt },
                    },
                  ],
                },
              ],
            },
          };
        },
      };
    };
    
    
  2. 第二步,配置 docusaurus.config.js

    配置 utils 文档路径为 docs/utils,sidebar 路径为 ./utilsSidebars,还要添加 tsdoc 插件。

    const config = {
      ...
      plugins: [
        [
          'content-docs',
          /** @type {import('@docusaurus/plugin-content-docs').Options} */
          ({
            id: 'utils',
            path: 'docs/utils',
            routeBasePath: 'utils',
            editUrl: 'https://github.com/samonxian/react-doc-starter/tree/master',
            sidebarPath: require.resolve('./utilsSidebars.js'),
          }),
        ],
        './plugins/tsdoc',
      ],
      ...
    };
    
    module.exports = config;
    
    
  3. 第三步,添加 docs/utils/isNumber.md 文件

    $utils 别名需要在 ./plugins/aliastsconfig.json 中添加对应的配置。

    sidebar_position 可以修改侧边栏同级菜单项的顺序。

    ---
    sidebar_position: 1
    ---
    
    <TsDoc src="$utils/isNumber" />
    
发布组件

同 components 分包,在项目 root 根目录下运行:

$ npm run build:publish

此名会运行lerna buildlerna publish 命令,然后安装提示进行发布即可,详细的用法可查看 lerna 命令。

部署文档

同上的部署文档

结语

经过支持 PropsTableCodeShowTsDoc 三个便捷的组件,本人搭建的文档工具可以快速编写并生成文档。

如果对你有所帮助,可以点个赞,也可以去 Github 项目收藏一下。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿