React 组件库搭建指南-打包输出

重头戏来了。

概览

宿主环境各不相同,为了保证使用者良好的开发体验,需要将源码进行相关处理后发布至 npm。

明确以下目标:

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

本节所有代码可在仓库 chapter-3 分支中获取。

导出类型声明文件

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

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

package.json

{
  "typings": "types/index.d.ts", // 定义类型入口文件
  "scripts": {
    "build:types": "tsc --emitDeclarationOnly" // 执行tsc命令 只生成声明文件
  }
}

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

types

├── alert
│   ├── alert.d.ts
│   ├── index.d.ts
│   ├── interface.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 模块

其实完全可以使用 babel 以及 tsc 命令行工具进行代码编译处理(实际上很多工具库就是这样做的),但考虑到还要 处理样式及其按需加载 ,我们借助 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 :不必要地增加了最终捆绑包的大小。

开发过程中尤其需要注意。

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: 'components/**/*.less', // 样式文件路径 - 暂时不关心
  scripts: ['components/**/*.{ts,tsx}', '!components/**/demo/*.{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 types lib esm dist",
+   "build": "npm run clean && npm run build:types && gulp",
    ...
  },
}

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

lib

├── alert
│   ├── alert.js
│   ├── index.js
│   ├── interface.js
│   └── style
│       └── index.js
└── index.js

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

lib/alert/alert.js

导出 ES module

基于上一步的 babel 配置,生成 ES module ,我们需要更新以下内容:

  1. 配置 @babel/preset-envmodules 选项为 false ,关闭模块转换;
  2. 配置 @babel/runtime-corejs3useESModules 选项为 true ,使用 ES module 形式引入 helper 函数以及相关 polyfill

.babelrc.js

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

得到生成 esm 的配置,也不能丢失之前的配置,此处可以使用环境变量进行区分(执行任务时设置对应的环境变量即可),最终 babel 配置如下:

.babelrc.js

module.exports = {
  presets: ['@babel/typescript', '@babel/react'],
  plugins: ['@babel/proposal-class-properties'],
  env: {
    CJS: {
      presets: [['@babel/env']],
      plugins: [
        [
          '@babel/plugin-transform-runtime',
          {
            corejs: 3,
            helpers: true,
          },
        ],
      ],
    },
    ESM: {
      presets: [
        [
          '@babel/env',
          {
            modules: false,
          },
        ],
      ],
      plugins: [
        [
          '@babel/plugin-transform-runtime',
          {
            corejs: 3,
            helpers: true,
            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 ,可以发现生成了 types / lib / esm 三个文件夹,观察 esm 目录,结构同 lib / types 一致,js 文件都是以 ES module 模块形式导入导出。

esm/alert/alert.js

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

package.json

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

处理样式文件

拷贝 less 文件

我们会将 less 文件包含在 npm 包中,用户可以通过 happy-ui/lib/alert/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 文件已被拷贝至 alert/style 目录下。

lib

├── alert
│   ├── alert.js
│   ├── index.js
│   ├── interface.js
│   └── style
│       ├── index.js
│       └── index.less # less文件
└── index.js

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

  1. 告知用户增加 less-loader
  2. 打包出一份完整的 css 文件,进行 全量 引入;
  3. 单独提供一份 style/css.js 文件,引入的是组件 css 文件依赖,而非 less 依赖,组件库底层抹平差异。

方案 1 会导致使用成本增加。

方案 2 无法对样式文件进行按需引入(后续在 umd 打包时我们也会提供该样式文件)。

以上两种方案实为下策(画外音:如果使用 css in js 就没有这么多屁事了)。

方案 3 比较符合此时的的场景, antd 使用的也是这种方案。

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

答案是管理样式依赖。

假设存在以下场景:引入