1.0.0 • Published 5 years ago

iuc v1.0.0

Weekly downloads
-
License
MIT
Repository
-
Last release
5 years ago

组件库开发环境搭建

概览

本文包含以下内容:

  • prepare: 组件库前期开发准备工作。eslint/commit lint/typescript等等;
  • dev: 使用dumi进行开发调试以及文档编写;
  • build: cjs/esm、types、polyfill 以及按需加载;
  • release: 组件库发布流程;
  • other: 使用plop.js快速创建组件模板。

准备工作

初始化项目

新建一个iuc文件夹,并初始化。

mkdir iuc

cd iuc

npm init

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

代码规范

此处直接使用@umijs/fabric的配置。

yarn add @umijs/fabric --dev

yarn add prettier --dev # 因为@umijs/fabric没有将prettier作为依赖 所以我们需要手动安装

.eslintrc.js

module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/eslint')],
};

.prettierrc.js

const fabric = require('@umijs/fabric');

module.exports = {
  ...fabric.prettier,
};

.stylelintrc.js

module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};

想自行配置的同学可以参考以下文章:

Commit Lint

进行pre-commit代码规范检测。

yarn add husky lint-staged --dev

package.json

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

进行 Commit Message 检测。

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

新增.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"
  }
}

后续使用 yarn commit 替代 git commit生成规范的 Commit Message,当然为了效率你可以选择手写,但是要符合规范。

TypeScript

yarn add typescript --dev

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

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

测试

src文件夹下新建button文件夹,目录结构如下:

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

安装React相关依赖:

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

src/button/types.ts

import { ReactNode } from 'react';

export type ButtonType = 'default' | 'primary' | 'ghost' | 'dashed' | 'link' | 'text';
export type ButtonSize = 'default' | 'large' | 'small';

export types ButtonProps {
  children: ReactNode
  type?: ButtonType,
  size?: ButtonSize,
  className?: string
}

sc/button/button.tsx

import React, { FC } from 'react';
import classnames from 'classnames';
import { ButtonProps } from './types';

const prefixCls = 'iuc-btn';

/**
 *
 * @param ButtonProps
 * @returns ReactNode
 */
const Button: FC<ButtonProps> = ({ type, size, children, className, ...rest }) => {
  const classes = classnames(prefixCls, className, {
    [`${prefixCls}-${type}`]: type,
    [`${prefixCls}-${size}`]: size,
  });
  return (
    <button className={classes} type="button" {...rest}>
      {children}
    </button>
  );
};

export default Button;

src/button/index.ts

import Button from './button';

export default Button;

export * from './types';

src/button/style/index.less

@btn-prefix-cls: iuc-btn;

.@{btn-prefix-cls} {
  height: 32px;
  margin: 10px;
  padding: 4px 15px;
  line-height: 1.5715;
  font-size: 14px;
  color: rgba(0, 0, 0, 0.85);
  border: 1px solid #d9d9d9;
  background-color: #fff;
  border-radius: 2px;
  outline: none;
  cursor: pointer;
  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015);
  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  user-select: none;
  touch-action: manipulation;
  &:hover {
    color: #40a9ff;
    background: #fff;
    border-color: #40a9ff;
  }
  &-primary {
    color: #fff;
    border-color: #1890ff;
    background-color: #1890ff;
    &:hover {
      color: #fff;
      background: #40a9ff;
      border-color: #40a9ff;
    }
  }
  &-dashed {
    border-style: dashed;
    &:hover {
      color: #40a9ff;
      background: #fff;
      border-color: #40a9ff;
    }
  }
  &-text {
    border-color: transparent;
    &:hover {
      color: rgba(0, 0, 0, 0.85);
      background: rgba(0, 0, 0, 0.018);
      border-color: transparent;
    }
  }
  &-link {
    color: #1890ff;
    border-color: transparent;
    &:hover {
      color: #40a9ff;
      border-color: transparent;
    }
  }
  &-large {
    height: 40px;
    padding: 6.4px 15px;
    font-size: 16px;
  }
  &-small {
    height: 24px;
    padding: 0 7px;
    font-size: 14px;
  }
}

src/button/style/index.ts

import '../../style/index.less';
import './index.less';

src/index.ts

export { default as Button } from './button';

准备工作完成。

开发与调试

本节解决开发组件时的预览以及调试问题,顺路解决文档编写。

此处选择dumi来辅助预览调试。

安装dumi以及自定义配置

yarn add dumi --dev

增加 npm scriptspackage.json

"scripts": {
  "doc:dev": "npx dumi dev"
},

注意:本节所有操作都是针对站点应用。打包指代文档站点打包,而非组件库。

新建.umirc.ts配置文件,并写入以下内容:

.umirc.js

export default {
  mode: 'doc',
  title: 'iuc',
  extraBabelPlugins: [
    [
      'import',
      {
        libraryName: 'iuc',
        libraryDirectory: "lib",
        customStyleName: name => {
          return `./style/index.less`; // 注意:这里 ./ 不可省略
        },
      }
    ],
  ],
}

编写文档

新建src/button/index.md,并写入以下内容:

---
title: Button
---

# Button 按钮

按钮用于开始一个即时操作。

## 代码演示

/```tsx
import React, { FC } from 'react';
import { Button } from 'iuc';

const App: FC = () => {
  return <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
    <Button type="dashed">Dashed Button</Button>
    <br />
    <Button type="text">Text Button</Button>
    <Button type="link">Link Button</Button>
    <br />
    <Button type="primary" size="large">Large</Button>
    <Button type="primary">Default</Button>
    <Button type="primary" size="small">Small</Button>
  </>
};

export default App;
/```

## API
| 属性  | 说明       | 类型                                                               | 默认值     |
| ---- | ----       | ---                                                               | --------- |
| type | 设置按钮类型 | `primary` \| `ghost` \| `dashed` \| `link` \| `text` \| `default` | `default` |
| size | 设置按钮大小 | `large` \| `middle` \| `small `                                   | `default` |

执行脚本命令:

yarn doc:dev

现在可以在index.md中愉快地进行文档编写和调试了!

组件库打包

宿主环境各不相同,需要将源码进行相关处理后发布至 npm。

明确以下目标:

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

导出类型声明文件

既然是使用typescript编写的组件库,那么使用者应当享受到类型系统的好处。

我们可以生成类型声明文件,并在package.json中定义入口,如下:

package.json

{
  "typings": "lib/index.d.ts", // 定义类型入口文件
  "scripts": {
    "build:types": "tsc -p tsconfig.build.json && cpr lib esm" // 执行tsc命令生成类型声明文件
  }
}

值得注意的是:此处使用cpr(需要手动安装)将lib的声明文件拷贝了一份,并将文件夹重命名为esm,用于后面存放 ES module 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。

tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "emitDeclarationOnly": true }, // 只生成声明文件
  "exclude": ["**/__tests__/**", "**/demo/**", "node_modules", "lib", "esm"] // 排除示例、测试以及打包好的文件夹
}

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

lib

├── button
│   ├── button.d.ts
│   ├── index.d.ts
│   ├── types.d.ts
│   └── style
│       └── index.d.ts
└── index.d.ts

这样使用者引入npm 包时,便能得到自动提示,也能够复用相关组件的类型定义。

接下来将ts(x)等文件处理成js文件。

需要注意的是,我们需要输出Commonjs module以及ES module两种模块类型的文件(暂不考虑umd),以下使用cjs指代Commonjs moduleesm指代ES module 对此有疑问的同学推荐阅读:import、require、export、module.exports 混合详解

导出 Commonjs 模块

其实完全可以使用babeltsc命令行工具进行代码编译处理(实际上很多工具库就是这样做的),但考虑到还要样式处理及其按需加载,我们借助 gulp 来串起这个流程。

babel 配置

首先安装babel及其相关依赖

yarn add @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-class-properties  @babel/plugin-transform-runtime --dev
yarn add @babel/runtime-corejs3

新建.babelrc.js文件,写入以下内容:

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  plugins: [
    '@babel/proposal-class-properties',
    [
      '@babel/plugin-transform-runtime',
      {
        corejs: 3,
        helpers: true,
      },
    ],
  ],
};

关于@babel/plugin-transform-runtime@babel/runtime-corejs3

  • helpers选项设置为true,可抽离代码编译过程重复生成的 helper 函数(classCallCheck,extends等),减小生成的代码体积;
  • corejs设置为3,可引入不污染全局的按需polyfill,常用于类库编写(我更推荐:不引入polyfill,转而告知使用者需要引入何种polyfill,避免重复引入或产生冲突,后面会详细提到)。

更多参见官方文档-@babel/plugin-transform-runtime

配置目标环境

为了避免转译浏览器原生支持的语法,新建.browserslistrc文件,根据适配需求,写入支持浏览器范围,作用于@babel/preset-env

.browserslistrc

>0.2%
not dead
not op_mini all

很遗憾的是,@babel/runtime-corejs3无法在按需引入的基础上根据目标浏览器支持程度再次减少polyfill的引入,参见@babel/runtime for target environment

这意味着@babel/runtime-corejs3 甚至会在针对现代引擎的情况下注入所有可能的 polyfill:不必要地增加了最终捆绑包的大小。

对于组件库(代码量可能很大),个人建议将polyfill的选择权交还给使用者,在宿主环境进行polyfill。若使用者具有兼容性要求,自然会使用@babel/preset-env + core-js + .browserslistrc进行全局polyfill,这套组合拳引入了最低目标浏览器不支持API的全部 polyfill

顺带一提,业务开发中,若将@babel/preset-envuseBuiltIns选项值设置为 usage,同时把node_modulesbabel-loaderexclude,会导致babel 无法检测到nodes_modules中所需要的polyfill"useBuiltIns: usage" for node_modules without transpiling #9419,在未支持该issue提到的内容之前,请将useBuiltIns设置为entry,或者不要把node_modulesbabel-loaderexclude

所以组件库不用画蛇添足,引入多余的polyfill,写好文档说明,比什么都重要(就像zentantd这样)。

现在@babel/runtime-corejs3更换为@babel/runtime,只进行helper函数抽离。

yarn remove @babel/runtime-corejs3

yarn add @babel/runtime

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
};

@babel/transform-runtimehelper选项默认为true

gulp 配置

再来安装gulp相关依赖

yarn add gulp gulp-babel --dev

新建gulpfile.js,写入以下内容:

gulpfile.js

const gulp = require('gulp');
const babel = require('gulp-babel');

const paths = {
  dest: {
    lib: 'lib', // commonjs 文件存放的目录名 - 本块关注
    esm: 'esm', // ES module 文件存放的目录名 - 暂时不关心
    dist: 'dist', // umd文件存放的目录名 - 暂时不关心
  },
  styles: 'src/**/*.less', // 样式文件路径 - 暂时不关心
  scripts: ['src/**/*.{ts,tsx}', '!src/**/demo/*.{ts,tsx}', '!src/.umi/**/*.{ts,tsx}'], // 脚本文件路径
};

function compileCJS() {
  const { dest, scripts } = paths;
  return gulp
    .src(scripts)
    .pipe(babel()) // 使用gulp-babel处理
    .pipe(gulp.dest(dest.lib));
}

// 并行任务 后续加入样式处理 可以并行处理
const build = gulp.parallel(compileCJS);

exports.build = build;

exports.default = build;

修改package.json

package.json

{
- "main": "index.js",
+ "main": "lib/index.js",
  "scripts": {
    ...
+   "clean": "rimraf lib esm dist",
+   "build": "npm run clean && npm run build:types && gulp",
    ...
  },
}

执行yarn build,得到如下内容:

lib

├── button
│   ├── button.js
│   ├── index.js
│   ├── types.js
│   └── style
│       └── index.js
└── index.js

观察编译后的源码,可以发现:诸多helper方法已被抽离至@babel/runtime中,模块导入导出形式也是commonjs规范。

导出 ES module

生成ES module可以更好地进行tree shaking,基于上一步的babel配置,更新以下内容:

  1. 配置@babel/preset-envmodules选项为false,关闭模块转换;
  2. 配置@babel/plugin-transform-runtimeuseESModules选项为true,使用ES module形式引入helper函数。

.babelrc.js

module.exports = {
  presets: [
    [
      '@babel/env',
      {
        modules: false, // 关闭模块转换
      },
    ],
    '@babel/typescript',
    '@babel/react',
  ],
  plugins: [
    '@babel/proposal-class-properties',
    [
      '@babel/plugin-transform-runtime',
      {
        useESModules: true, // 使用esm形式的helper
      },
    ],
  ],
};

目标达成,我们再使用环境变量区分esmcjs(执行任务时设置对应的环境变量即可),最终babel配置如下:

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
  env: {
    esm: {
      presets: [
        [
          '@babel/env',
          {
            modules: false,
          },
        ],
      ],
      plugins: [
        [
          '@babel/plugin-transform-runtime',
          {
            useESModules: true,
          },
        ],
      ],
    },
  },
};

接下来修改gulp相关配置,抽离compileScripts任务,增加compileESM任务。

gulpfile.js

// ...

/**
 * 编译脚本文件
 * @param {string} babelEnv babel环境变量
 * @param {string} destDir 目标目录
 */
function compileScripts(babelEnv, destDir) {
  const { scripts } = paths;
  // 设置环境变量
  process.env.BABEL_ENV = babelEnv;
  return gulp
    .src(scripts)
    .pipe(babel()) // 使用gulp-babel处理
    .pipe(gulp.dest(destDir));
}

/**
 * 编译cjs
 */
function compileCJS() {
  const { dest } = paths;
  return compileScripts('cjs', dest.lib);
}

/**
 * 编译esm
 */
function compileESM() {
  const { dest } = paths;
  return compileScripts('esm', dest.esm);
}

// 串行执行编译脚本任务(cjs,esm) 避免环境变量影响
const buildScripts = gulp.series(compileCJS, compileESM);

// 整体并行执行任务
const build = gulp.parallel(buildScripts);

// ...

执行yarn build,可以发现生成了lib/esm两个文件夹,观察esm目录,结构同lib一致,js 文件都是以ES module模块形式导入导出。

别忘了给package.json增加相关入口。

package.json

{
+ "module": "esm/index.js"
}

处理样式文件

拷贝 less 文件

我们会将less文件包含在npm包中,用户可以通过iuc/lib/button/style/index.js的形式按需引入less文件,此处可以直接将 less 文件拷贝至目标文件夹。

gulpfile.js中新建copyLess任务。

gulpfile.js

// ...

/**
 * 拷贝less文件
 */
function copyLess() {
  return gulp.src(paths.styles).pipe(gulp.dest(paths.dest.lib)).pipe(gulp.dest(paths.dest.esm));
}

const build = gulp.parallel(buildScripts, copyLess);

// ...

观察lib目录,可以发现 less 文件已被拷贝至button/style目录下。

lib

├── button
│   ├── button.js
│   ├── index.js
│   ├── types.js
│   └── style
│       ├── index.js
│       └── index.less # less文件
└── index.js

可能有些同学已经发现问题:若使用者没有使用less预处理器,使用的是sass方案甚至原生css方案,那现有方案就搞不定了。经分析,有以下 4 种预选方案:

  1. 告知业务方增加less-loader。会导致业务方使用成本增加;
  2. 打包出一份完整的 css 文件,进行全量引入。无法进行按需引入;
  3. css in js方案;
  4. 提供一份style/css.js文件,引入组件 css样式依赖,而非 less 依赖,组件库底层抹平差异。

重点看一看方案 3 以及方案 4。

css in js除了赋予样式编写更多的可能性之外,在编写第三方组件库时更是利器。

如果我们写一个react-use这种hooks工具库,不涉及到样式,只需要在package.json中设置sideEffectsfalse,业务方使用 webpack 进行打包时,只会打包被使用到的 hooks(优先使用 ES module)。

入口文件index.js中导出的但未被使用的其他 hooks 会被tree shaking,第一次使用这个库的时候我很好奇,为什么没有按需引入的使用方式,结果打包分析时我傻了,原来人家天生支持按需引入。

可能常用的antd以及lodash都要配一配,导致产生了惯性思维。

回到正题。如果将样式使用javascript来编写,在某种维度上讲,组件库和工具库一致了,配好sideEffects,就可以自动按需引入。

而且每个组件都与自己的样式绑定,不需要业务方或组件开发者去维护样式依赖,什么是样式依赖,后面会讲到。

缺点:

  1. 样式无法单独缓存;
  2. styled-components 自身体积较大;
  3. 复写组件样式需要使用属性选择器或者使用styled-components自带方法。

需要看取舍了,偷偷说一句styled-components做主题定制也极其方便。

方案 4 是antd使用的这种方案。

在搭建组件库的过程中,有一个问题困扰了我很久:为什么需要button/style/index.js引入less文件或button/style/css.js引入css文件?

答案是管理样式依赖

因为我们的组件是没有引入样式文件的,需要使用者去手动引入。

假设存在以下场景:使用者引入<Button /><Button />依赖了<Icon />,则需要手动去引入调用组件的样式(<Button />)及其依赖的组件样式(<Icon />),遇到复杂组件极其麻烦,所以组件库开发者可以提供一份这样的js文件,使用者手动引入这个js文件,就能引入对应组件及其依赖组件的样式。

那么问题又来了,为什么组件不能自己去import './index.less'呢?

可以,但业务方需要配置less-loader,什么,业务方不想配,要你import './index.css'?🙃

可以,业务方爽了,组件开发者不开心。

所以我们要找一个大家都爽的方案:

  1. 组件开发者能够开心的使用预处理器;
  2. 业务方不需要额外的使用成本。

答案就是:单独提供一份style/css.js文件,引入的是组件 css样式文件依赖,而非 less 依赖,组件库底层抹平差异。

生成 css 文件

安装相关依赖。

yarn add gulp-less gulp-autoprefixer gulp-cssnano --dev

less文件生成对应的css文件,在gulpfile.js中增加less2css任务。

// ...

/**
 * 生成css文件
 */
function less2css() {
  return gulp
    .src(paths.styles)
    .pipe(less()) // 处理less文件
    .pipe(autoprefixer()) // 根据browserslistrc增加前缀
    .pipe(cssnano({ zindex: false, reduceIdents: false })) // 压缩
    .pipe(gulp.dest(paths.dest.lib))
    .pipe(gulp.dest(paths.dest.esm));
}

const build = gulp.parallel(buildScripts, copyLess, less2css);

// ...

执行yarn build,组件style目录下已经存在css文件了。

接下来我们需要一个button/style/css.js来帮用户引入css文件。

生成 css.js

此处参考antd-tools的实现方式:在处理scripts任务中,截住style/index.js,生成style/css.js,并通过正则将引入的less文件后缀改成css

安装相关依赖。

yarn add through2 --dev

gulpfile.js

// ...

/**
 * 编译脚本文件
 * @param {*} babelEnv babel环境变量
 * @param {*} destDir 目标目录
 */
function compileScripts(babelEnv, destDir) {
  const { scripts } = paths;
  process.env.BABEL_ENV = babelEnv;
  return gulp
    .src(scripts)
    .pipe(babel()) // 使用gulp-babel处理
    .pipe(
      through2.obj(function z(file, encoding, next) {
        this.push(file.clone());
        // 找到目标
        if (file.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
          const content = file.contents.toString(encoding);
          file.contents = Buffer.from(cssInjection(content)); // 文件内容处理
          file.path = file.path.replace(/index\.js/, 'css.js'); // 文件重命名
          this.push(file); // 新增该文件
          next();
        } else {
          next();
        }
      })
    )
    .pipe(gulp.dest(destDir));
}

// ...

cssInjection的实现:

gulpfile.js

/**
 * 当前组件样式 import './index.less' => import './index.css'
 * 依赖的其他组件样式 import '../test-comp/style' => import '../test-comp/style/css.js'
 * 依赖的其他组件样式 import '../test-comp/style/index.js' => import '../test-comp/style/css.js'
 * @param {string} content
 */
function cssInjection(content) {
  return content
    .replace(/\/style\/?'/g, "/style/css'")
    .replace(/\/style\/?"/g, '/style/css"')
    .replace(/\.less/g, '.css');
}

再进行打包,可以看见组件style目录下生成了css.js文件,引入的也是上一步less转换而来的css文件。

lib/button

├── button.js
├── index.js
├── types.js
└── style
    ├── css.js # 引入index.css
    ├── index.css
    ├── index.js
    └── index.less

按需加载

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

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

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

import { button } from 'iuc';
import 'iuc/esm/button/style';

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

import button from 'iuc/esm/button'; // or import button from 'iuc/lib/button';
import 'iuc/esm/button/style'; // or import button from 'iuc/lib/button';

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

使用者可以借助babel-plugin-import来进行辅助,减少代码编写量。

import { button } from 'iuc';

⬇️

import button from 'iuc/lib/button';
import 'iuc/lib/button/style';

标准化发布流程

本节主要是讲解如何通过一行命令完成以下六点内容:

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

package.json

"scripts": {
+ "release": "ts-node ./scripts/release.ts"
},
/* eslint-disable  import/no-extraneous-dependencies,@typescript-eslint/camelcase, no-console */
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();

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

初始化组件

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

思路如下:

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

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

yarn add plop --dev

新增脚本命令。

package.json

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

新增配置文件以及组件模板,详情可见: