從 0 到 1 搭建 React UI 組件庫


同步鏈接: 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 國際」許可協議進行許可。
聲明:轉載請注明出處!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM