Shane Jix

从 0 到 1 搭建 React UI 组件库

create:October 18, 2021  update:April 12, 2022  🍱🍱🍱 35 min read

同步链接: https://www.shanejix.com/posts/从 0 到 1 搭建 React UI 组件库/

虽然参与了项目组的组件库架构设计和讨论,但是终究不是在自己完全愿景下实施。总想着自己造一个的组件库,于是就有了下面从 0 到 1 包含源起,构建,测试,测试,站点,发布等部分。

万物从起名开始,思来想去也没想到什么高大上的名字,姑且就叫 block-ui所有个代码都放在 @block-org 组织下

一,源起

新建项目

mkdir block-ui && cd block-ui && yarn init -y

# 新建源码文件夹以及入口文件
mkdir src && cd src && touch index.ts

代码规范

.eslintrc

{
  // ...

  "extends": [
    "airbnb",
    "plugin:react/recommended",
    "plugin:prettier/recommended",
    "plugin:react-hooks/recommended",
  ],

   // ...
}

.prettierrc

{
  "arrowParens": "always",
  "semi": true,
  "singleQuote": true,
  "jsxSingleQuote": false,
  "printWidth": 100,
  "useTabs": false,
  "tabWidth": 2,
  "trailingComma": "es5"
}

.stylelintrc.js

{
  "extends": ["stylelint-config-standard", "stylelint-config-prettier"],
  "customSyntax": "postcss-less",
  "rules": {
    "no-descending-specificity": null,
    "no-duplicate-selectors": null,
    "font-family-no-missing-generic-family-keyword": null,
    "block-opening-brace-space-before": "always",
    "declaration-block-trailing-semicolon": null,
    "declaration-colon-newline-after": null,
    "indentation": null,
    "selector-descendant-combinator-no-non-space": null,
    "selector-class-pattern": null,
    "keyframes-name-pattern": null,
    "no-invalid-position-at-import-rule": null,
    "number-max-precision": 6,
    "color-function-notation": null,
    "selector-pseudo-class-no-unknown": [
      true,
      {
        "ignorePseudoClasses": ["global"]
      }
    ]
  }
}

Commit Lint

  1. 进行 pre-commit 代码规范检测
yarn add husky lint-staged -D

新增 package.json 信息

"lint-staged": {
  "components/**/*.ts?(x)": [
    "prettier --write --ignore-unknown",
    "eslint --fix",
    "git add"
  ],
  "components/**/*.less": [
    "stylelint --syntax less --fix",
    "git add"
  ]
},
"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
}

2.进行 Commit Message 检测

yarn add @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog -D

新增.commitlintrc.js 写入以下内容

module.exports = { extends: ["@commitlint/config-conventional"] };

package.json 写入以下内容:

// ...

"scripts": {
  "commit": "git-cz",
}

// ...

"husky": {
  "hooks": {
    "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
    "pre-commit": "lint-staged"
  }
},
"config": {
  "commitizen": {
    "path": "cz-conventional-changelog"
  }
}

husky在代码提交的时候执行一些 bash 命令,lint-staged只针对当前提交/改动过的文件进行处理

TypeScript

yarn add typescript -D

新建tsconfig.json并写入以下内容

{
  "compilerOptions": {
    "outDir": "esm",
    "jsx": "react",
    "module": "es6",
    "target": "es5",
    "lib": ["es5", "dom"],
    "moduleResolution": "node",
    "declaration": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["components/**/*.ts", "components/**/*.tsx"],
  "exclude": [
    "node_modules",
    "components/**/*.test.tsx",
    "components/**/*.test.ts"
  ]
}

组件

alert
    ├── index.ts            # 入口文件 源文件
    ├── interface.ts        # 类型声明文件
    └── style
        ├── index.less      # 样式文件
        └── index.ts        # 样式文件里为什么存在一个index.ts  // 按需加载样式 管理样式依赖 后面章节会提到

安装 React 相关依赖:

# 开发时依赖,宿主环境一定存在
yarn add react react-dom @types/react @types/react-dom -D

src/alert/interface.ts

import { CSSProperties, ReactNode } from "react";

/**
 * @title Alert
 */
export interface AlertProps {
  style?: CSSProperties;
  className?: string | string[];
  /**
   * @zh 自定义操作项
   * @en Custom action item
   */
  action?: ReactNode;
  /**
   * @zh 是否可关闭
   * @en Whether Alert can be closed
   */
  closable?: boolean;
  closeable?: boolean; // typo
  /**
   * @zh 关闭的回调
   * @en Callback when Alert is closed
   */
  onClose?: (e) => void;
  /**
   * @zh 关闭动画结束后执行的回调
   * @en Callback when Alert close animation is complete
   */
  afterClose?: () => void;
  /**
   * @zh 警告的类型
   * @en Type of Alert
   * @defaultValue info
   */
  type?: "info" | "success" | "warning" | "error";
  /**
   * @zh 标题
   * @en Alert title
   */
  title?: ReactNode;
  /**
   * @zh 内容
   * @en Alert content
   */
  content?: ReactNode;
  /**
   * @zh 可以指定自定义图标,`showIcon` 为 `true` 时生效。
   * @en Custom icon, effective when `showIcon` is `true`
   */
  icon?: ReactNode;
  /**
   * @zh 自定义关闭按钮
   * @en Custom close button
   */
  closeElement?: ReactNode;
  /**
   * @zh 是否显示图标
   * @en Whether to show icon
   * @defaultValue true
   */
  showIcon?: boolean;
  /**
   * @zh 是否用作顶部公告
   * @en Whether to show as banner
   */
  banner?: boolean;
}

src/alert/index.ts

import React, { useState, useContext, ReactNode, forwardRef } from "react";
import { CSSTransition } from "react-transition-group";
import IconCheckCircleFill from "../../icon/react-icon/IconCheckCircleFill";
import IconCloseCircleFill from "../../icon/react-icon/IconCloseCircleFill";
import IconInfoCircleFill from "../../icon/react-icon/IconInfoCircleFill";
import IconExclamationCircleFill from "../../icon/react-icon/IconExclamationCircleFill";
import IconClose from "../../icon/react-icon/IconClose";
import cs from "../_util/classNames";
import { ConfigContext } from "../config-provider";
import { AlertProps } from "./interface";
import useMergeProps from "../_util/hooks/useMergeProps";

const defaultProps: AlertProps = {
  showIcon: true,
  type: "info",
};

function Alert(baseProps: AlertProps, ref) {
  const { getPrefixCls, componentConfig } = useContext(ConfigContext);

  const props = useMergeProps<AlertProps>(
    baseProps,
    defaultProps,
    componentConfig?.Alert
  );

  const {
    style,
    className,
    action,
    type = "info",
    title,
    content,
    icon,
    showIcon,
    closable,
    closeable,
    afterClose,
    onClose,
    closeElement,
    banner,
  } = props;

  const prefixCls = getPrefixCls("alert");

  const [visible, setVisible] = useState<boolean>(true);

  function renderIcon(type: string | void): ReactNode | null {
    if (icon) {
      return icon;
    }
    switch (type) {
      case "info":
        return <IconInfoCircleFill />;
      case "success":
        return <IconCheckCircleFill />;
      case "warning":
        return <IconExclamationCircleFill />;
      case "error":
        return <IconCloseCircleFill />;
      default:
        return null;
    }
  }

  function onHandleClose(e: any) {
    setVisible(false);
    onClose && onClose(e);
  }

  const classNames = cs(
    prefixCls,
    `${prefixCls}-${type}`,
    {
      [`${prefixCls}-with-title`]: title,
      [`${prefixCls}-banner`]: banner,
    },
    className
  );

  const _closable = "closeable" in props ? closeable : closable;

  return (
    <CSSTransition
      in={visible}
      timeout={300}
      classNames="zoomInTop"
      unmountOnExit
      onExited={() => {
        afterClose && afterClose();
      }}
    >
      <div ref={ref} style={style} className={classNames}>
        {showIcon && (
          <div className={`${prefixCls}-icon-wrapper`}>{renderIcon(type)}</div>
        )}
        <div className={`${prefixCls}-content-wrapper`}>
          {title && <div className={`${prefixCls}-title`}>{title}</div>}
          {content && <div className={`${prefixCls}-content`}>{content}</div>}
        </div>
        {action && <div className={`${prefixCls}-action`}>{action}</div>}
        {_closable && (
          <button onClick={onHandleClose} className={`${prefixCls}-close-btn`}>
            {closeElement || <IconClose />}
          </button>
        )}
      </div>
    </CSSTransition>
  );
}

const AlertComponent = forwardRef<unknown, AlertProps>(Alert);

AlertComponent.displayName = "Alert";

export default AlertComponent;

export { AlertProps };

src/alert/style/index.less

@import "../../style/theme/default.less";
@import "./token.less";

@alert-prefix-cls: ~"@{prefix}-alert";

.@{alert-prefix-cls} {
  box-sizing: border-box;
  border-radius: @alert-border-radius;
  padding: (@alert-padding-vertical - @alert-border-width)
    (@alert-padding-horizontal - @alert-border-width);
  font-size: @alert-font-size-text-content;
  overflow: hidden;
  display: flex;
  width: 100%;
  text-align: left;
  align-items: center;
  line-height: @alert-line-height;

  &-with-title {
    padding: (@alert-padding-vertical_with_title - @alert-border-width)
      (@alert-padding-horizontal_with_title - @alert-border-width);
  }

  &-with-title {
    align-items: flex-start;
  }

  &-info {
    border: @alert-border-width solid @alert-info-color-border;
    background-color: @alert-info-color-bg;
  }

  &-success {
    border: @alert-border-width solid @alert-success-color-border;
    background-color: @alert-success-color-bg;
  }

  &-warning {
    border: @alert-border-width solid @alert-warning-color-border;
    background-color: @alert-warning-color-bg;
  }

  &-error {
    border: @alert-border-width solid @alert-error-color-border;
    background-color: @alert-error-color-bg;
  }

  &-banner {
    border: none;
    border-radius: 0;
  }

  &-content-wrapper {
    position: relative;
    flex: 1;
  }

  &-title {
    font-size: @alert-font-size-text-title;
    font-weight: @alert-font-weight-title;
    line-height: @alert-title-line-height;
    margin-bottom: @alert-title-margin-bottom;
  }

  &-info &-title {
    color: @alert-info-color-text-title;
  }

  &-info &-content {
    color: @alert-info-color-text-content;
  }

  &-info&-with-title &-content {
    color: @alert-info-color-text-content_title;
  }

  &-success &-title {
    color: @alert-success-color-text-title;
  }

  &-success &-content {
    color: @alert-success-color-text-content;
  }

  &-success&-with-title &-content {
    color: @alert-success-color-text-content_title;
  }

  &-warning &-title {
    color: @alert-warning-color-text-title;
  }

  &-warning &-content {
    color: @alert-warning-color-text-content;
  }

  &-warning&-with-title &-content {
    color: @alert-warning-color-text-content_title;
  }

  &-error &-title {
    color: @alert-error-color-text-title;
  }

  &-error &-content {
    color: @alert-error-color-text-content;
  }

  &-error&-with-title &-content {
    color: @alert-error-color-text-content_title;
  }

  &-icon-wrapper {
    margin-right: @alert-margin-icon-right;
    height: @alert-line-height * @alert-font-size-text-content;
    display: flex;
    align-items: center;

    svg {
      font-size: @alert-font-size-icon;
    }
  }

  &-with-title &-icon-wrapper {
    height: @alert-title-line-height * @alert-font-size-text-title;
  }

  &-with-title &-icon-wrapper svg {
    font-size: @alert-font-size-icon_with_title;
  }

  &-info &-icon-wrapper svg {
    color: @alert-info-color-icon;
  }

  &-success &-icon-wrapper svg {
    color: @alert-success-color-icon;
  }

  &-warning &-icon-wrapper svg {
    color: @alert-warning-color-icon;
  }

  &-error &-icon-wrapper svg {
    color: @alert-error-color-icon;
  }

  &-close-btn {
    box-sizing: border-box;
    padding: 0;
    border: none;
    outline: none;
    font-size: @alert-font-size-close-icon;
    color: @alert-color-close-icon;
    background-color: transparent;
    cursor: pointer;
    transition: color @transition-duration-1 @transition-timing-function-linear;
    margin-left: @alert-margin-close-icon-left;
    top: 4px;
    right: 0;

    &:hover {
      color: @alert-color-close-icon_hover;
    }
  }

  &-action + &-close-btn {
    margin-left: @alert-margin-action-right;
  }

  &-action {
    margin-left: @alert-margin-action-left;
  }

  &-with-title &-close-btn {
    margin-top: 0;
    margin-right: 0;
  }
}

src/alert/style/index.ts

import "../../style/index.less";
import "./index.less";

src/index.ts

export { default as Alert } from "./alert";

调试开发

引入 storybook ,这个步骤依赖 build 后的产物

# Add Storybook:
npx sb init

接下来,要让这个 Alert 在 storybook 里跑起来,帮助调试组件;同时在开发不同组件功能时,可以创建不同的 demo,除了用作调试,也是极好的使用文档

修改.storybook/main.js,并写入以下内容:

// module.exports = {
//   "stories": [
//     "../stories/**/*.stories.mdx",
//     "../stories/**/*.stories.@(js|jsx|ts|tsx)"
//   ],
//   "addons": [
//     "@storybook/addon-links",
//     "@storybook/addon-essentials"
//   ],
//   "framework": "@storybook/react"
// }
const path = require("path");

const lessRegex = /\.less$/;
const lessModuleRegex = /\.module\.less$/;

function getLoaderForStyle(isCssModule) {
  return [
    {
      loader: "style-loader",
    },
    {
      loader: "css-loader",
      options: isCssModule ? { modules: true } : {},
    },
    {
      loader: "less-loader",
      options: {
        javascriptEnabled: true,
      },
    },
  ];
}

module.exports = {
  stories: ["../stories/index.stories.js"],
  webpackFinal: (config) => {
    config.resolve.alias["@self/icon"] = path.resolve(__dirname, "../icon");
    config.resolve.alias["@self"] = path.resolve(__dirname, "../esm");

    // config.resolve.modules = ['node_modules', path.resolve(__dirname, '../site/node_modules')];
    // 解决 webpack 编译警告
    config.module.rules[0].use[0].options.plugins.push([
      "@babel/plugin-proposal-private-property-in-object",
      { loose: true },
    ]);

    // 支持 import less
    config.module.rules.push({
      test: lessRegex,
      exclude: lessModuleRegex,
      use: getLoaderForStyle(),
    });

    // less css modules
    config.module.rules.push({
      test: lessModuleRegex,
      use: getLoaderForStyle(true),
    });

    // 支持 import svg
    const fileLoaderRule = config.module.rules.find(
      (rule) => rule.test && rule.test.test(".svg")
    );
    fileLoaderRule.exclude = /\.svg$/;
    config.module.rules.push({
      test: /\.svg$/,
      loader: ["@svgr/webpack"],
    });

    return config;
  },
};

添加stories/index.stories.js,并写入以下内容:

import React from "react";
import { storiesOf } from "@storybook/react";

import "./index.less";
import "../dist/css/index.less";

import DemoAlert from "./components/alert";

const components = storiesOf("Components", module);
const componentsMap = {
  Alert: () => <DemoAlert />,
};

Object.keys(componentsMap)
  .sort((a, b) => (a > b ? 1 : -1))
  .forEach((componentsName) => {
    components.add(componentsName, componentsMap[componentsName]);
  });

添加 alert 组件 demo 新增 stories/components/alert.jsx,并写入以下内容:

import React, { Component } from "react";
import { Alert } from "@self";
import { IconBug } from "@self/icon";

class Demo extends Component {
  constructor(props) {
    super(props);

    this.state = {};
  }

  render() {
    return (
      <>
        <Alert
          showIcon
          type="info"
          title="Info"
          content="ContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContent"
          style={{ marginTop: 10 }}
        />
        <Alert
          showIcon
          type="success"
          title="Success"
          content="ContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContent"
          style={{ marginTop: 10 }}
        />
        <Alert
          showIcon
          type="warning"
          title="Warning"
          content="Content~"
          style={{ marginTop: 10 }}
        />
        <Alert
          showIcon
          type="error"
          title="Error"
          content="Content~"
          style={{ marginTop: 10 }}
        />
        <Alert
          icon={<IconBug style={{ color: "green" }} />}
          type="normal"
          title="Normal"
          content="Content~"
          style={{ marginTop: 10 }}
        />
      </>
    );
  }
}

export default Demo;

最终效果如下

image.png

二,构建

组件打包逻辑已单独拆分到 block-cli中:https://github.com/block-org/block-cli

block-cli 会** 根据宿主环境和配置的不同将源码进行相关处理 **,主要完成以下目标:

  1. 导出类型声明文件;
  2. 导出 Commonjs module/ES module / UMD等多种形式产物供使用者引入;
  3. 支持样式文件 css 引入,而非只有less,减少使用者接入成本;
  4. 支持组件和样式的按需加载。

    需要注意的是,以下使用cjs指代Commonjs moduleesm指代ES module

block-scripts

先介绍下 block-org/block-cliblock-scripts的总体设计思路

  "scripts": {
    "build:umd": "block-scripts build:component:umd",
    "build:esm": "block-scripts build:component:esm",
    "build:esm:babel": "cross-env BUILD_ENV_TS_COMPILER=babel block-scripts build:component:esm",
    "build:cjs": "block-scripts build:component:cjs",
    "build:cjs:babel": "cross-env BUILD_ENV_TS_COMPILER=babel block-scripts build:component:cjs",
    "build:css": "block-scripts build:component:css",
    "build": "npm run clean && npm run build:esm && npm run build:css",
  },

构建部分单独拆分为子任务,具体实现拆分如下

program.command("build:component:esm").action(() => {
  component.buildESM();
});

program.command("build:component:cjs").action(() => {
  component.buildCJS();
});

program.command("build:component:umd").action(() => {
  component.buildUMD();
});

program.command("build:component:css").action(() => {
  component.buildCSS();
});

后面介绍各部实现

导出类型声明文件

既然是使用typescript编写的组件库,那么使用者应当享受到类型系统的好处,可以生成类型声明文件,并在package.json中定义入口,如下:

package.json

{
  "typings": "lib/index.d.ts",
  "scripts": {
    "build:types": "tsc -p tsconfig.build.json && cpr lib esm"
  }
}

值得注意的是:此处使用cprlib的声明文件拷贝了一份,并将文件夹重命名为esm,用于后面存放 ES module 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。最开始的方式是将声明文件单独存放在types文件夹,但这样只有通过@block-org/block-ui引入才可以获取提示,而@block-orgblock-ui/esm/xxx@block-orgblock-ui/lib/xxx就无法获取提示。

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "target": "esnext",
    "module": "commonjs",
    "jsx": "react",
    "declaration": true,
    "declarationDir": "lib",
    "strict": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
    "emitDeclarationOnly": true
  },
  "include": ["components", "typings.d.ts"],
  "exclude": [
    "**/__tests__/**",
    "**/demo/**",
    "node_modules",
    "lib",
    "esm"
  ]
}

执行yarn build:types,可以发现根目录下已经生成了lib文件夹(tsconfig.json中定义的declarationDir字段)以及esm文件夹(拷贝而来),目录结构与components文件夹保持一致,如下:

├── alert
│   ├── index.d.ts
│   ├── alert.d.ts
│   ├── interface.d.ts
│   └── style
│       └── index.d.ts
└── index.d.ts

这样使用者引入npm 包时,便能得到自动提示,也能够复用相关组件的类型定义。但是这样有个问题是要单独打包 types。下面采用的办法是将ts(x)等文件处理成js文件的同时生成类型声明文件,这样就可以将两步合并为一个步骤

导出 CJS / ESM 模块

这里将 cjs / ems 模块的处理统一到 compileTS方法中

const buildESM = () => {
  return compileTS({ outDir: DIR_PATH_ESM, type: "esm" });
};

const buildCJS = () => {
  return compileTS({ outDir: DIR_PATH_CJS, type: "cjs" });
};

然后使用babeltsc命令行工具进行代码编译处理(实际上很多工具库就是这样做的)

/**
 * Compile typescript surport TSC and Babel and more ...
 * @param options
 * @returns
 */
const compileTS = (options: CompileOptions) => {
  print.info("[block-scripts]", `Start to build ${options.type} module...`);

  return (
    BUILD_ENV_TS_COMPILER === "babel" ? withBabel(options) : withTSC(options)
  ).then(
    () =>
      print.success("[block-scripts]", `Build ${options.type} module success!`),
    (error) => {
      throw error;
    }
  );
};

这里有个问题是 ts options 从何而来,并且 如何处理 ts options ?

首先,根目录下维护了一份各种模式下输入/输出的常量(已作为默认值)

export const CWD = process.cwd();

export const DIR_NAME_ESM = "esm";

export const DIR_NAME_CJS = "lib";

export const DIR_NAME_UMD = "dist";

export const DIR_NAME_ASSET = "asset";

export const DIR_NAME_SOURCE = "src";

export const DIR_NAME_COMPONENT_LIBRARY = "components";

export const FILENAME_DIST_LESS = "index.less";

export const FILENAME_DIST_CSS = "index.css";

export const FILENAME_STYLE_ENTRY_RAW = "index.js";

export const FILENAME_STYLE_ENTRY_CSS = "css.js";

export const BLOCK_LIBRARY_PACKAGE_NAME_REACT = "@block-org/block-ui";

先看tsc的封装withTSC

/**
 * Build TS with TSC
 * @param param0
 * @returns
 */
function withTSC({ type, outDir, watch }: CompileOptions) {
  const { compilerOptions } = getTSConfig();
  let module = type === "esm" ? "es6" : "commonjs";

  // Read module filed from the default configuration (es6 / es2020 / esnext)
  if (type === "esm") {
    const regexpESM = /^esm/i;

    if (
      typeof tscConfig.module === "string" &&
      regexpESM.test(tscConfig.module)
    ) {
      module = tscConfig.module;
    } else if (
      typeof compilerOptions?.module === "string" &&
      regexpESM.test(compilerOptions.module)
    ) {
      module = compilerOptions.module;
    }
  }

  return tsc.compile({
    ...tscConfig,
    module,
    outDir,
    watch: !!watch,
    declaration: type === "esm",
  });
}

ts options 的核心就是递归向上遍历 tsconfig.json并就近合并 ,如下

/**
 * Get config from pre / root project tsconfig.json ...
 * @param tsconfigPath
 * @param subConfig
 * @returns
 */
const getTSConfig = (
  tsconfigPath = path.resolve(CWD, "tsconfig.json"),
  subConfig = { compilerOptions: {} }
) => {
  if (fs.pathExistsSync(tsconfigPath)) {
    const config = fs.readJsonSync(tsconfigPath);
    const compilerOptions = (config && config.compilerOptions) || {};
    const subCompilerOptions = (subConfig && subConfig.compilerOptions) || {};

    // Avoid overwriting of the compilation options of subConfig
    subConfig.compilerOptions = { ...compilerOptions, ...subCompilerOptions };
    Object.assign(config, subConfig);

    if (config.extends) {
      return getTSConfig(
        path.resolve(path.dirname(tsconfigPath), config.extends),
        config
      );
    }

    return config;
  }
  return { ...subConfig };
};

tscConfig 来自于/config/tsc.configgetConfigProcessor,会根据 type 匹配 组件库(用户)目录下.config对应类型的配置

/**
 * Get project .config/ processor
 * @param configType
 * @returns
 */
export default function getConfigProcessor<T = Function>(
  configType: "jest" | "webpack" | "babel" | "docgen" | "style" | "tsc"
): T {
  const configFilePath = `${CWD}/.config/${configType}.config.js`;
  let processor = null;
  if (fs.existsSync(configFilePath)) {
    try {
      processor = require(configFilePath);
    } catch (error) {
      print.error(
        "[block-scripts]",
        `Failed to extend configuration from ${configFilePath}`
      );
      console.error(error);
      process.exit(1);
    }
  }
  return processor;
}

综上,配置优先级别为 组件库/用户目录> blocks-script子包> block-org根

下面着重看下 babel编译 tsx的处理,withBabel.ts:

/**
 * Build TS with babel
 * @param param0
 * @returns
 */
async function withBabel({ type, outDir, watch }: CompileOptions) {
  const tsconfig = getTSConfig();
  // The base path of the matching directory patterns
  const srcPath = tsconfig.include[0].split("*")[0].replace(/\/[^/]*$/, "");
  const targetPath = path.resolve(CWD, outDir);

  const transform = (file) => {
    // ...

    return babelTransform(file.contents, {
      ...babelConfig,
      filename: file.path,
      // Ignore the external babel.config.js and directly use the current incoming configuration
      configFile: false,
    }).code;
  };

  const createStream = (globs) => {
    // ...

    transform(file);
  };

  return new Promise((resolve) => {
    const patterns = [
      path.resolve(srcPath, "**/*"),
      `!${path.resolve(srcPath, "**/demo{,/**}")}`,
      `!${path.resolve(srcPath, "**/__test__{,/**}")}`,
      `!${path.resolve(srcPath, "**/*.md")}`,
      `!${path.resolve(srcPath, "**/*.mdx")}`,
    ];

    createStream(patterns).on("end", () => {
      if (watch) {
        print.info(
          "[block-scripts]",
          `Start watching file in ${srcPath.replace(`${CWD}/`, "")}...`
        );

        // https://www.npmjs.com/package/chokidar
        const watcher = chokidar.watch(patterns, {
          ignoreInitial: true,
        });

        const files = [];

        const debouncedCompileFiles = debounce(() => {
          while (files.length) {
            createStream(files.pop());
          }
        }, 1000);

        watcher.on("all", (event, fullPath) => {
          print.info(
            `[${event}] ${path.join(fullPath).replace(`${CWD}/`, "")}`
          );
          if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
            if (!files.includes(fullPath)) {
              files.push(fullPath);
            }
            debouncedCompileFiles();
          }
        });
      } else {
        resolve(null);
      }
    });
  });
}

通过createStream方法匹配所有文件模式创建流并执行编译transform方法,如果开启了 watch模式会在当次执行完成后监听文件的变化并重新执行 createStream方法并做了防抖处理

createStream方法使用 gulp的流式处理,见下

const createStream = (globs) => {
  // https://www.npmjs.com/package/vinyl-fs
  return (
    vfs
      .src(globs, {
        allowEmpty: true,
        base: srcPath,
      })
      // https://www.npmjs.com/package/gulp-plumber
      // https://www.npmjs.com/package/through2
      .pipe(watch ? gulpPlumber() : through.obj())
      .pipe(
        gulpIf(
          ({ path }) => {
            return /\.tsx?$/.test(path);
          },
          // https://www.npmjs.com/package/gulp-typescript
          // Delete outDir to avoid static resource resolve errors during the babel compilation of next step
          gulpTS({ ...tsconfig.compilerOptions, outDir: undefined })
        )
      )
      .pipe(
        gulpIf(
          ({ path }) => {
            return !path.endsWith(".d.ts") && /\.(t|j)sx?$/.test(path);
          },
          through.obj((file, _, cb) => {
            try {
              file.contents = Buffer.from(transform(file));
              // .jsx -> .js
              file.path = file.path.replace(path.extname(file.path), ".js");
              cb(null, file);
            } catch (error) {
              print.error("[block-scripts]", `Failed to compile ${file.path}`);
              print.error(error);
              cb(null);
            }
          })
        )
      )
      .pipe(vfs.dest(targetPath))
  );
};

为什么是 gulp 而不是 webpackrollup ?因为要做的是代码编译而非代码打包,同时需要考虑到样式处理及其按需加载

导出 UMD 模块

这部分使用 webpack进行打包,相关插件和配置较为繁琐,优化细节较多,这里不赘述了,源码查看

处理样式文件

block-script中入口函数是 buildCSS:

const buildCSS = () => {
  print.info("[block-scripts]", "Start to build css...");
  return buildStyle().then(
    () => print.success("[block-scripts]", "Build css success!"),
    (error) => {
      throw error;
    }
  );
};

核心实现都在 buildStyle方法中:

/**
 * Build style
 * @returns
 */
export function build() {
  return new Promise<void>((resolve) => {
    gulp.series(
      gulp.parallel(
        copyAsset,
        copyFileWatched,
        compileLess,
        handleStyleJSEntry
      ),
      gulp.parallel(distLess, distCss.bind(null, false)),
      gulp.parallel(() => resolve(null))
    )(null);
  });
}

为了同时执行多个子任务这里又借助了gulp.series()gulp.parallel().其中编译配置会优先使用组件库目录.config下的配置逻辑和处理 ts 文件一致。下面依次介绍各个任务的细节

copyAsset方法:匹配文件模式到指定目录

/**
 * Match the resource that matches the entry glob and copy it to the /asset
 * @returns Stream
 */
function copyAsset() {
  return gulp
    .src(assetConfig.entry, { allowEmpty: true })
    .pipe(gulp.dest(assetConfig.output));
}

copyFileWatched方法:将监听目录的文件拷贝到 esm/lib 下 入图片字体等

**
 * Copy the files that need to be monitored to the esm/lib directory
 * @returns
 */
function copyFileWatched() {
  const patternArray = cssConfig.watch;
  const destDirs = [cssConfig.output.esm, cssConfig.output.cjs].filter((path) => !!path);

  if (destDirs.length) {
    return new Promise((resolve, reject) => {
      let stream: NodeJS.ReadWriteStream = mergeStream(
        patternArray.map((pattern) =>
          gulp.src(pattern, { allowEmpty: true, base: cssConfig.watchBase[pattern] })
        )
      );

      destDirs.forEach((dir) => {
        stream = stream.pipe(gulp.dest(dir));
      });

      stream.on('end', resolve).on('error', (error) => {
        print.error('[block-scripts]', 'Failed to build css, error in copying files');
        console.error(error);
        reject(error);
      });
    });
  }

  return Promise.resolve(null);
}

compileLes方法:编译 less 文件并输出到 esm / lib 目录下

/**
 * Compile less, and output css to at esm/lib
 * @returns
 */
function compileLess() {
  const destDirs = [cssConfig.output.esm, cssConfig.output.cjs].filter(
    (path) => path
  );

  if (destDirs.length) {
    let stream = gulp
      .src(cssConfig.entry, { allowEmpty: true })
      .pipe(cssConfig.compiler(cssConfig.compilerOptions))
      .pipe(cleanCSS());

    destDirs.forEach((dir) => {
      stream = stream.pipe(gulp.dest(dir));
    });

    return stream.on("error", (error) => {
      print.error(
        "[block-scripts]",
        "Failed to build css, error in compiling less"
      );
      console.error(error);
    });
  }

  return Promise.resolve(null);
}

handleStyleJSEntry方法:处理样式入口(css.js/index.js)并注入依赖的样式

export default async function handleStyleJSEntry() {
  await compileCssJsEntry({
    styleJSEntry: jsEntryConfig.entry,
    outDirES: cssConfig.output.esm,
    outDirCJS: cssConfig.output.cjs,
  });

  if (jsEntryConfig.autoInjectBlockDep) {
    await injectBlockDepStyle(getComponentDirPattern([DIR_NAME_ESM]));
  }

  renameStyleEntryFilename();
}

/**
 * Generate /style/css.js
 */
async function compileCssJsEntry({
  styleJSEntry,
  outDirES,
  outDirCJS,
}: {
  /** Glob of css entry file */
  styleJSEntry: string[];
  /** Path of ESM */
  outDirES: string;
  /** Path of CJS */
  outDirCJS: string;
}) {
  // ...
}

distLess方法:dist 目录 less 生成

/**
 * Dist all less files to dist
 * @param cb
 */
function distLess(cb) {
  const { path: distPath, rawFileName } = cssConfig.output.dist;
  let entries = [];

  cssConfig.entry.forEach((e) => {
    entries = entries.concat(glob.sync(e));
  });

  if (entries.length) {
    const texts = [];

    entries.forEach((entry) => {
      // Remove the first level directory
      const esEntry = cssConfig.output.esm + entry.slice(entry.indexOf("/"));
      const relativePath = path.relative(distPath, esEntry);
      const text = `@import "${relativePath}";`;

      if (esEntry.startsWith(`${cssConfig.output.esm}/style`)) {
        texts.unshift(text);
      } else {
        texts.push(text);
      }
    });

    fs.outputFileSync(`${distPath}/${rawFileName}`, texts.join("\n"));
  }

  cb();
}

distCss方法:dist 目录 css 生成

/**
 * Compile the packaged less into css
 * @param isDev
 * @returns
 */
function distCss(isDev: boolean) {
  const { path: distPath, rawFileName, cssFileName } = cssConfig.output.dist;
  const needCleanCss =
    !isDev && (!BUILD_ENV_MODE || BUILD_ENV_MODE === "production");

  const stream = gulp
    .src(`${distPath}/${rawFileName}`, { allowEmpty: true })
    .pipe(cssConfig.compiler(cssConfig.compilerOptions));

  // Errors should be thrown, otherwise it will cause the program to exit
  if (isDev) {
    notifyLessCompileResult(stream);
  }

  return stream
    .pipe(
      // The less file in the /dist is packaged from the less file in /esm, so its static resource path must start with ../esm
      replace(
        new RegExp(`(\.{2}\/)+${cssConfig.output.esm}`, "g"),
        path.relative(cssConfig.output.dist.path, assetConfig.output)
      )
    )
    .pipe(gulpIf(needCleanCss, cleanCSS()))
    .pipe(rename(cssFileName))
    .pipe(gulp.dest(distPath))
    .on("error", (error) => {
      print.error(
        "[block-scripts]",
        "Failed to build css, error in dist all css"
      );
      console.error(error);
    });
}

一套组合拳下来就实现了组件和样式的分离,dist 目录主要是为 umd 预留。现在用户就可以通过block-ui/esm[lib]/alert/style/index.less的形式按需引入 less 样式。或者,通过block-ui/esm[lib]/alert/style/css.js的形式按需引入 css 样式,如下

├── alert
│   ├── index.js
│   ├── interface.js
│   ├── interface.d.js
│   └── style
│       ├── css.js # css 依赖
│       ├── index.js # less 依赖
│       ├── index.css # css 样式
│       └── index.less # less 样式
└── index.js

值得注意的问题:为什么要提供 index.js 和 css.js 文件?若使用者没有使用less预处理器,使用的是sass方案甚至原生css方案,如何平衡开发者和用户的成本?

理由如下:

  1. 当然,组件库会打包出一份完整的 css 文件,进行全量引入,无法进行按需引入
  2. 按需 引用 less 样式(通过block-ui/esm[lib]/alert/style/index.less的形式按需引入 less 样式)增加less-loader,会导致使用成本增加;
  3. 提供一份style/css.js文件,引入组件 css样式依赖,而非 less 依赖,可以帮助管理样式依赖 **(每个组件都与自己的样式绑定,不需要使用者或组件开发者去维护样式依赖**),可以以抹平组件库底层差异,既可以保障组件库开发者的开发体验 又能够使用者的使用成本

按需加载

在 package.json 中增加sideEffects属性,配合ES module达到tree shaking效果(将样式依赖文件标注为side effects,避免被误删除)。

// ...
"sideEffects": [
  "dist/*",
  "esm/**/style/*",
  "lib/**/style/*",
  "*.less"
],
// ...

使用以下方式引入,可以做到js部分的按需加载,但需要手动引入样式:

import { Alert } from "@block-org/block-ui";
import "@block-org/block-ui/esm/alert/style";

也可以使用以下方式引入:

import Alert from "@block-org/block-uiesm/alert";
// or
// import Alert from '@block-org/block-ui/lib/alert';

import "@block-org/block-ui/esm/alert/style";

以上引入样式文件的方式不太优雅,直接入口处引入全量样式文件又和按需加载的本意相去甚远。

使用者可以借助 babel-plugin-import 来进行辅助,减少代码编写量(还是增加了使用成本)

三,测试

本节主要讲述如何在组件库中引入jest以及@testing-library/react,而不会深入单元测试的学习,也不会集成到block-ui

如果你对下列问题感兴趣:

  1. What-单元测试是什么?
  2. Why-为什么要写单元测试?
  3. How-编写单元测试的最佳实践?

那么可以看看以下文章:

相关配置

安装依赖:

yarn add jest ts-jest @testing-library/react @testing-library/jest-dom identity-obj-proxy @types/jest @types/testing-library__react -D
  • jest: JavaScript 测试框架,专注于简洁明快;
  • ts-jest:为TypeScript编写jest测试用例提供支持;
  • @testing-library/react:简单而完整的React DOM测试工具,鼓励良好的测试实践;
  • @testing-library/jest-dom:自定义的jest匹配器(matchers),用于测试DOM的状态(即为jestexcept方法返回值增加更多专注于DOMmatchers);
  • identity-obj-proxy:一个工具库,此处用来mock样式文件。

新建jest.config.js,并写入相关配置,更多配置可参考jest 官方文档-配置,只看几个常用的就可以。

jest.config.js

module.exports = {
  verbose: true,
  roots: ["<rootDir>/src"],
  moduleNameMapper: {
    "\\.(css|less|scss)$": "identity-obj-proxy",
    "^src$": "<rootDir>/src/index.tsx",
    "^src(.*)$": "<rootDir>/src/$1",
  },
  testRegex: "(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$",
  moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
  testPathIgnorePatterns: ["/node_modules/", "/lib/", "/esm/", "/dist/"],
  preset: "ts-jest",
  testEnvironment: "jsdom",
};

修改package.json,增加测试相关命令,并且代码提交前,跑测试用例,如下: package.json

"scripts": {
  ...
+  "test": "jest",                         # 执行jest
+  "test:watch": "jest --watch",           # watch模式下执行
+  "test:coverage": "jest --coverage",     # 生成测试覆盖率报告
+  "test:update": "jest --updateSnapshot"  # 更新快照
},
...
"lint-staged": {
  "src/**/*.ts?(x)": [
    "prettier --write",
    "eslint --fix",
+   "jest --bail --findRelatedTests",
    "git add"
  ],
  ...
}

修改gulpfile.js以及tsconfig.json,避免打包时,把测试文件一并处理了。 gulpfile.js

const paths = {
  ...
- scripts: ['src/**/*.{ts,tsx}', '!src/**/demo/*.{ts,tsx}'],
+ scripts: [
+   'src/**/*.{ts,tsx}',
+   '!src/**/demo/*.{ts,tsx}',
+   '!src/**/__tests__/*.{ts,tsx}',
+ ],
};

tsconfig.json

{
- "exclude": ["src/**/demo"]
+ "exclude": ["src/**/demo", "src/**/__tests__"]
}

编写测试用例

<Alert />比较简单,此处只作示例用,简单进行一下快照测试。

在对应组件的文件夹下新建__tests__文件夹,用于存放测试文件,其内新建index.test.tsx文件,写入以下测试用例:

src/alert/tests/index.test.tsx

import React from "react";
import { render } from "@testing-library/react";
import Alert from "../alert";

describe("<Alert />", () => {
  test("should render default", () => {
    const { container } = render(<Alert>default</Alert>);
    expect(container).toMatchSnapshot();
  });

  test("should render alert with type", () => {
    const types: any[] = ["info", "warning", "success", "error"];

    const { getByText } = render(
      <>
        {types.map((k) => (
          <Alert type={k} key={k}>
            {k}
          </Alert>
        ))}
      </>
    );

    types.forEach((k) => {
      expect(getByText(k)).toMatchSnapshot();
    });
  });
});

更新一下快照:

yarn test:update

可以看见同级目录下新增了一个__snapshots__文件夹,里面存放对应测试用例的快照文件。 再执行测试用例:

yarn test

可以发现通过了测试用例,主要是后续我们进行迭代重构时,都会重新执行测试用例,与最近的一次快照进行比对,如果与快照不一致(结构发生了改变),那么相应的测试用例就无法通过。

对于快照测试,褒贬不一,这个例子也着实简单得很,甚至连扩展的 jest-dom提供的 matchers 都没用上。

四,站点

站点搭建可选项挺多,国内的 Dumi 国外的 Next.js Gatsby.js ,或者自己写一套,这里采用 Next.js

集成 Next.js

npx create-next-app@latest --typescript
# or
yarn create next-app --typescript

针对 site 目录下站点应用增加 npm scriptssite/package.json

"scripts": {
  "dev": "npm run collect-meta && next dev",
  "build": "npm run collect-meta && next build",
  "start": "next start",
  "lint": "next lint",
  "collect-meta": "node ../scripts/collect-meta.js"
},

站点骨架

这一步主要是处理站点首页、组件文档、快速开始、指南、定制化等,页面级路由都会放在站点根目录 pages 文件夹下。

目录结构

├── lib                       # library 公共依赖
    ├── components            # 公共组件
    ├── data                  # 数据
    ├── ...                   # 其他文件
├── page                      # 页面级路由
    ├── zh-cn                 # 国际化
    ├── en-us                 # 国际化
        ├── components        # 组件文档
        ├── customization     # 定制化
        ├── guide             # 快速上手
        ├── index             # 首页
    ├── _app.ts               # 页面配置
└── next.config.js            # 站点配置

站点启动前会执行 collect-meta指令收集 pages目录下的所有的 .md(x)文件做一层转换并分组并生成一份路由 meta 信息 放在 lib/data

pages/**/*.md(x)定义 meta 信息

//...

export const meta = {
  title: "Alert",
  group: "General",
};

//...

scripts/collect-meta.js核心逻辑

(async () => {
  try {
    // ['en-us','zh-cn']
    const locales = (await fs.readdir(pagePrefix)).filter((name) => {
      const fullPath = path.join(pagePrefix, name);
      return fs.statSync(fullPath).isDirectory();
    });

    // 收集并转换数据
    const sortdMetaData = await Promise.all(
      locales.map(async (name) => {
        const currentLocale = metaLocales[name] || {};
        // '/usr/github_repos/block-ui/site/pages/en-us'
        const dir = path.join(pagePrefix, name);
        // ['components', 'customization', 'guide', 'index.tsx']
        const childDirs = await fs.readdir(dir);

        // 递归遍历收集 meta 信息
        const data = await getMetadata(childDirs, dir);

        const sorted = data.sort((a, b) => weights[a.name] - weights[b.name]);
        const translatedData = deepTranslate(sorted, currentLocale);

        return {
          name,
          content: translatedData,
        };
      })
    );

    // 写入数据
    await Promise.all(
      sortdMetaData.map(async (data) => {
        const targetPath = getTargetPath(data.name);
        await fs.ensureFile(targetPath);
        await fs.writeJson(targetPath, data.content);
      })
    );
  } catch (error) {
    console.log(error);
    process.exit(1);
  }
})();

最终按照 /local/folder/title的格式生成路由,比如 /en-us/components/alert;一旦pages目录变化页面路由就会动态更新

最终站点如下

image.png

文档补全

集成 mdx修改next.config.js

// Use MDX with Next.js
const withMDX = require("@next/mdx")({
  extension: /\.(md|mdx)?$/,
  options: {
    rehypePlugins: [
      require("@mapbox/rehype-prism"),
      require("rehype-join-line"),
    ],
  },
});

// Nextjs config -> https://nextjs.org/docs/api-reference/next.config.js/introduction
/** @type {import('next').NextConfig} */
const nextConfig = {
  // ...
};

module.exports = withMDX(nextConfig);

经过前面的操作,可以愉快地进行文档的开发了,例如添加 alert组件的文档 en-us/components/alert.mdx

import Layout from '../../../lib/components/layout';
import { Alert } from '../../../../esm';

export const meta = {
title: 'Alert',
group: 'General',
};

## Alert

Use `banner = true` to display Alert as a banner on top of the page.

<div>
  <Alert
    banner
    type="info"
    showIcon
    content="General text"
    style={{ marginTop: 4, marginBottom: 20 }}
  />
  <Alert banner type="info" showIcon closable content="General text" style={{ marginBottom: 20 }} />
  <Alert
    banner
    type="info"
    showIcon
    title="General text"
    content="Here is an example text"
    style={{ marginBottom: 20 }}
  />
  <Alert banner type="success" showIcon title="Success text" style={{ marginBottom: 20 }} />
</div>

export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

只需再补充一些组件定义,这篇 markdown 就是所需要的最终组件文档

为什么在 markdown 中可以渲染 React 组件?可以去了解 mdx

部署

包括 Next 文档站点 和 storybook 构建的静态站点部署

Next.js

site 目录集成了 scripts命令如下

  "scripts": {
    "dev": "npm run collect-meta && next dev",
    "build": "npm run collect-meta && next build",
    "start": "next start",
    "lint": "next lint",
    "collect-meta": "node ../scripts/collect-meta.js"
  },

文档站点直接托管到了 vercel

文档站点:https://block-ui-alpha.vercel.app/en-us

StoryBook

执行 以下命令 构建静态产物

  "scripts": {
    "build-storybook": "build-storybook",
  },

也可以使用 Github Actions 自动触发部署,在项目根目录新建.github/workflows/gh-pages.yml文件,后续 master 触发 push 操作时则会自动触发站点部署,更多配置可自行参阅文档。

name: github pages

on:
  push:
    branches:
      - master # default branch

jobs:
  deploy:
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
      - run: yarn run init
      - run: yarn run build-storybook
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./storybook-static

StoryBook 站点:https://block-org.github.io/block-ui/

五,发布

前言

本节主要是讲解如何编写脚本完成以下内容:

  1. 版本更新
  2. 生成 CHANGELOG
  3. 推送至 git 仓库
  4. 组件库打包
  5. 发布至 npm
  6. 打 tag 并推送至 git

如果你对这一节不感兴趣,也可以直接使用 np 进行发布,只需要自定义配置一些钩子。

yarn add inquirer child_process util chalk semver @types/inquirer @types/semver -D

package.json

"scripts": {
+ "release": "ts-node ./scripts/release.ts"
},

scripts/release.ts

import inquirer from "inquirer";
import fs from "fs";
import path from "path";
import child_process from "child_process";
import util from "util";
import chalk from "chalk";
import semverInc from "semver/functions/inc";
import { ReleaseType } from "semver";

import pkg from "../package.json";

const exec = util.promisify(child_process.exec);

const run = async (command: string) => {
  console.log(chalk.green(command));
  await exec(command);
};

const currentVersion = pkg.version;

const getNextVersions = (): { [key in ReleaseType]: string | null } => ({
  major: semverInc(currentVersion, "major"),
  minor: semverInc(currentVersion, "minor"),
  patch: semverInc(currentVersion, "patch"),
  premajor: semverInc(currentVersion, "premajor"),
  preminor: semverInc(currentVersion, "preminor"),
  prepatch: semverInc(currentVersion, "prepatch"),
  prerelease: semverInc(currentVersion, "prerelease"),
});

const timeLog = (logInfo: string, type: "start" | "end") => {
  let info = "";
  if (type === "start") {
    info = `=> 开始任务:${logInfo}`;
  } else {
    info = `✨ 结束任务:${logInfo}`;
  }
  const nowDate = new Date();
  console.log(
    `[${nowDate.toLocaleString()}.${nowDate
      .getMilliseconds()
      .toString()
      .padStart(3, "0")}] ${info}
    `
  );
};

/**
 * 询问获取下一次版本号
 */
async function prompt(): Promise<string> {
  const nextVersions = getNextVersions();
  const { nextVersion } = await inquirer.prompt([
    {
      type: "list",
      name: "nextVersion",
      message: `请选择将要发布的版本 (当前版本 ${currentVersion})`,
      choices: (Object.keys(nextVersions) as Array<ReleaseType>).map(
        (level) => ({
          name: `${level} => ${nextVersions[level]}`,
          value: nextVersions[level],
        })
      ),
    },
  ]);
  return nextVersion;
}

/**
 * 更新版本号
 * @param nextVersion 新版本号
 */
async function updateVersion(nextVersion: string) {
  pkg.version = nextVersion;
  timeLog("修改package.json版本号", "start");
  await fs.writeFileSync(
    path.resolve(__dirname, "./../package.json"),
    JSON.stringify(pkg)
  );
  await run("npx prettier package.json --write");
  timeLog("修改package.json版本号", "end");
}

/**
 * 生成CHANGELOG
 */
async function generateChangelog() {
  timeLog("生成CHANGELOG.md", "start");
  await run(" npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0");
  timeLog("生成CHANGELOG.md", "end");
}

/**
 * 将代码提交至git
 */
async function push(nextVersion: string) {
  timeLog("推送代码至git仓库", "start");
  await run("git add package.json CHANGELOG.md");
  await run(`git commit -m "v${nextVersion}" -n`);
  await run("git push");
  timeLog("推送代码至git仓库", "end");
}

/**
 * 组件库打包
 */
async function build() {
  timeLog("组件库打包", "start");
  await run("npm run build");
  timeLog("组件库打包", "end");
}

/**
 * 发布至npm
 */
async function publish() {
  timeLog("发布组件库", "start");
  await run("npm publish");
  timeLog("发布组件库", "end");
}

/**
 * 打tag提交至git
 */
async function tag(nextVersion: string) {
  timeLog("打tag并推送至git", "start");
  await run(`git tag v${nextVersion}`);
  await run(`git push origin tag v${nextVersion}`);
  timeLog("打tag并推送至git", "end");
}

async function main() {
  try {
    const nextVersion = await prompt();
    const startTime = Date.now();
    // =================== 更新版本号 ===================
    await updateVersion(nextVersion);
    // =================== 更新changelog ===================
    await generateChangelog();
    // =================== 代码推送git仓库 ===================
    await push(nextVersion);
    // =================== 组件库打包 ===================
    await build();
    // =================== 发布至npm ===================
    await publish();
    // =================== 打tag并推送至git ===================
    await tag(nextVersion);
    console.log(
      `✨ 发布流程结束 共耗时${((Date.now() - startTime) / 1000).toFixed(3)}s`
    );
  } catch (error) {
    console.log("💣 发布失败,失败原因:", error);
  }
}

main();

其他

每次初始化一个组件就要新建许多文件(夹),复制粘贴也可,不过还可以使用更高级一点的偷懒方式。

思路如下:

  1. 创建组件模板,预留动态信息插槽(组件名称,组件描述等等);
  2. 基于inquirer.js询问动态信息;
  3. 将信息插入模板,渲染至components文件夹下;
  4. 向 components/index.ts 插入导出语句。

只需要配置好模板以及问题,至于询问以及渲染就交给plop.js

yarn add plop --dev

新增脚本命令。

package.json

"scripts": {
+ "new": "plop --plopfile ./scripts/plopfile.ts",
},

新增配置文件 scripts/plopfile.ts

const path = require("path");

module.exports = function (plop) {
  plop.setGenerator("component", {
    description: "新增一个新组件",
    prompts: [
      {
        type: "input",
        name: "name",
        message: "请输入组件名称(多个单词以中横线命名)",
      },
      { type: "input", name: "CN", message: "请输入组件中文名称" },
      { type: "input", name: "description", message: "请输入组件描述" },
    ],
    actions: [
      {
        type: "add",
        path: path.resolve(
          __dirname,
          "../components/{{kebabCase name}}/index.ts"
        ),
        templateFile: path.resolve(
          __dirname,
          "../templates/component/index.hbs"
        ),
      },

      // ...

      {
        type: "append",
        path: path.resolve(__dirname, "../components/index.ts"),
        pattern: "/* PLOP_INJECT_EXPORT */",
        template:
          "export { default as {{pascalCase name}} } from './{{kebabCase name}}';",
      },
    ],
  });
};

新增组件模板templates/componenthttps://github.com/block-org/block-ui/tree/master/templates/component

references

作者:shanejix 出处:https://www.shanejix.com/posts/从 0 到 1 搭建 React UI 组件库/ 版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。 声明:转载请注明出处!

Edit on GitHubDiscuss on GitHub


Shane Jix

Personal blog by Shane Jix. I explain with words and code.

LinksTools
© 2019 - 2022, Built withGatsby