手把手教你快速搭建React元件庫

Samon發表於2022-07-12

前言

無論團隊大小,隨著時間的推進,多多少少都會有一些可提取的業務元件,沉澱元件庫和對應的文件是一條必經之路。

直接進入正題,從 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`,
      },
    },
  };
});
  1. 第五步,配置 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

擴充

使用 Vite 轉換 src 中的檔案

打包時候,Vite 會把所有涉及到的檔案打包為一個檔案,而我們常常把 src 資料夾的所有 JavaScript 或者 Typescript 檔案轉換為 es5 語法,直接提供為 Webpack、Vite 這些開發服務工具使用。

Vite 不支援多入口檔案和多輸出檔案的模式,需要自己實現一套 npm run build:file 命令。

  1. 第一步,package.json 新增如下 scripts 命令

    {
      "scripts": {
        "tsc:es": "tsc --declarationDir es",
        "tsc:lib": "tsc --declarationDir lib",
        "tsc": "npm run tsc:lib && npm run tsc:es",
        "clean": "rimraf lib es dist",
        "vite": "vite",
        "build:lib": "vite build",
        "build:file": "node ./scripts/buildFiles.js",
        "build": "npm run clean && npm run tsc && npm run build:file && npm run build:lib"
      }
    }
  2. 第二步,新增 package/components/.env 檔案,確保構建環境未 production

    NODE_ENV=production
  3. 第三步,建立 package/components/scripts/buildFile.js 檔案:

    • 通過讀取 src 資料夾中(包含子輩)的所有 js 或 ts 型別的檔案獲取所有待轉換的檔案路徑
    • 然後遍歷檔案路徑,再通過 vite 的 mode 選項傳遞入口檔案到 vite.file.config.ts 配置中
    const fs = require('fs');
    const path = require('path');
    const spawn = require('cross-spawn');
    const srcDir = path.resolve(__dirname, '../src');
    
    // 所有 src 資料夾包括子資料夾的 js、ts、jsx、tsx 檔案路徑陣列
    const srcFilePaths = getTargetDirFilePaths(srcDir);
    srcFilePaths.forEach((file) => {
     const fileRelativePath = path.relative(srcDir, file);
     spawn(
     'npm',
     ['run', 'vite', '--', 'build', '--mode', fileRelativePath, '--outDir', 'es', '--config', './vite.file.config.ts'],
     {
       stdio: 'inherit',
     },
     );
    });
    
    /**
    * 獲取 src 資料夾下的所有檔案
    * @param {String} [targetDirPath] 目標資料夾路徑
    * @return {Array} 檔案列表陣列
    */
    function getTargetDirFilePaths(targetDirPath = path.resolve(__dirname, '../src')) {
     let fileList = [];
    
     fs.readdirSync(targetDirPath).forEach((file) => {
     const filePath = path.resolve(targetDirPath, file);
     const isDirectory = fs.statSync(filePath).isDirectory();
    
     if (isDirectory) {
       fileList = fileList.concat(getTargetDirFilePaths(filePath));
     } else {
       fileList.push(filePath);
     }
     });
    
     return fileList
     .filter((f) => {
       if (/__tests__/.test(f)) {
         return false;
       }
       if (/\.d\.ts/.test(f)) {
         return false;
       }
    
       if (/\.[jt]?sx?$/.test(f)) {
         return true;
       }
    
       return false;
     })
     .map((f) =>
       // 相容 windows 路徑
       f.replace(/\\/g, '/'),
     );
    }
  4. 第四步,建立 package/components/vite.file.config.ts 檔案:

    其中需要注意的是 rollupOptions.external 欄位的配置,除了 lesscsssvg 的字尾名檔案外,都不打包進輸出檔案。

    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    import path from 'path';
    import decamelize from 'decamelize';
    
    export const commonConfig = defineConfig({
      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,
          },
        },
      },
      resolve: {
        alias: [
          // fix less import by: @import ~
          // less import no support webpack alias '~' · Issue #2185 · vitejs/vite
          { find: /^~/, replacement: '' },
        ],
      },
    });
    
    // https://vitejs.dev/config/
    export default defineConfig(({ mode }) => {
      return {
        ...commonConfig,
        build: {
          emptyOutDir: false,
          rollupOptions: {
            external: (id) => {
              if (id.includes('.less') || id.includes('.css') || id.includes('.svg')) {
                return false;
              }
              return true;
            },
            output: [
              {
                file: `es/${mode.replace(/\.[jt]?sx?$/, '.js')}`,
                indent: false,
                exports: 'named',
                format: 'es',
                dir: undefined,
              },
              {
                file: `lib/${mode.replace(/\.[jt]?sx?$/, '.js')}`,
                indent: false,
                exports: 'named',
                format: 'cjs',
                dir: undefined,
              },
            ],
          },
          lib: {
            // mode 特殊處理為檔名
            entry: path.resolve(__dirname, 'src', mode),
            name: 'noop', // 這裡設定只有在 UMD 格式才有效,避免驗證報錯才設定的,在這裡沒用
          },
          minify: false,
        },
      };
    });

新增單元測試

單元測試使用 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. 第三步,新增 .env.test,確保環境為 test 環境。

    NODE_ENV=test
  4. 第四步,新增測試用例 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 建立元件分包

建立工具函式

建立 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 專案收藏一下。

相關文章