手把手教你搭建React组件库(超详细)

在线预览:戳我

本地预览:

git clone git@github.com:worldzhao/react-ui-library-tutorial.git
cd react-ui-library-tutorial
yarn
yarn start

按顺序执行完命令后,即可在 localhost:3000 端口看到以下内容:

手把手教你搭建React组件库(超详细)_第1张图片

概览

本文包含以下内容:

  • prepare: 组件库前期开发准备工作。eslint/commit lint/typescript等等;
  • dev: 使用docz进行开发调试以及文档编写;
  • build: umd/cjs/esm、types、polyfill 以及按需加载;
  • test: 组件测试;
  • release: 组件库发布流程;
  • deploy: 使用now部署文档站点,待补充;
  • other: 使用plop.js快速创建组件模板。

如果本文帮助到了你请给仓库 一颗 ✨✨。

如果有错误烦请在评论区指正交流,谢谢。

准备工作

初始化项目

新建一个happy-ui文件夹,并初始化。

mkdir happy-ui

cd happy-ui

npm init --y

mkdir components && cd components && 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": {
  "components/**/*.ts?(x)": [
    "prettier --write",
    "eslint --fix",
    "git add"
  ],
  "components/**/*.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"]
}

测试

components文件夹下新建alert文件夹,目录结构如下:

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

安装React相关依赖:

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

yarn add prop-types            # 运行时依赖,宿主环境可能不存在 安装本组件库时一起安装
此处依旧安装了 prop-types这个库,因为无法保证宿主环境也使用 typescript,从而能够进行静态检查,故使用 prop-types保证 javascript用户也能得到友好的运行时报错信息。

components/alert/interface.ts

export type Kind = 'info' | 'positive' | 'negative' | 'warning';
export type KindMap = Record;

export interface AlertProps {
  /**
   * Set this to change alert kind
   * @default info
   */
  kind?: 'info' | 'positive' | 'negative' | 'warning';
}

components/alert/alter.tsx

import React from 'react';
import t from 'prop-types';

import { AlertProps, KindMap } from './interface';

const prefixCls = 'happy-alert';

const kinds: KindMap = {
  info: '#5352ED',
  positive: '#2ED573',
  negative: '#FF4757',
  warning: '#FFA502',
};

const Alert: React.FC = ({ children, kind = 'info', ...rest }) => (
  
{children}
); Alert.propTypes = { kind: t.oneOf(['info', 'positive', 'negative', 'warning']), }; export default Alert;

components/alert/index.ts

import Alert from './alert';

export default Alert;

export * from './interface';

components/alert/style/index.less

@popupPrefix: happy-alert;

.@{popupPrefix} {
  padding: 20px;
  background: white;
  border-radius: 3px;
  color: white;
}

components/alert/style/index.ts

import './index.less';

components/index.ts

export { default as Alert } from './alert';
此处组件参考的 docz项目 typescript以及 less示例。

git 一把梭,可以看到控制台已经进行钩子检测了。

git add .

yarn commit  # 或 git commit -m'feat: chapter-1 准备工作'

git push

准备工作完成。代码可以在仓库chapter-1分支获取,若存在与本文内容不符的地方,以master分支以及文章为准。

开发与调试

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

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

docz基于 MDX(Markdown + JSX),可以在 Markdown 中引入 React 组件,使得一边编写文档,一边预览调试成为了可能。而且得益于 React 组件生态,我们可以像编写应用一般编写文档,不仅仅是枯燥的文字。 docz 也内置了一些组件,比如

安装 docz 以及自定义配置

yarn add docz --dev

yarn add rimraf --dev # 清空目录的一个辅助库

增加 npm scriptspackage.json

"scripts": {
  "dev": "docz dev", // 启动本地开发环境
  "start": "npm run dev", // dev命令别名
  "build:doc": "rimraf doc-site && docz build", // 后续会配置打包出来的文件目录名为doc-site,故每次build前删除
  "preview:doc": "docz serve" // 预览文档站点
},
注意:本节所有操作都是针对站点应用。 打包指代文档站点打包,而非组件库。

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

doczrc.js

export default {
  files: './components/**/*.{md,markdown,mdx}', // 识别的文件后缀
  dest: 'doc-site', // 打包出来的文件目录名
  title: 'happy-ui', // 站点标题
  typescript: true, // 组件源文件是通过typescript开发,需要打开此选项
};

由于使用了less作为样式预处理器,故需要安装 less 插件。

yarn add less gatsby-plugin-less --dev

新建gatsby-config.js,并写入以下内容:

gatsby-config.js

module.exports = {
  plugins: ['gatsby-theme-docz', 'gatsby-plugin-less'],
};

编写文档

新建components/alert/index.mdx,并写入以下内容:

---
name: Alert 警告提示
route: /Alert
menu: 组件
---

import { Playground } from 'docz'; import Alert from './alert'; // 引入组件 import './style'; // 引入组件样式

# Alert 警告提示

警告提示,展现需要关注的信息。

## 代码演示

### 基本用法


  这是一条警告提示


## API

| 属性 | 说明     | 类型                                         | 默认值 |
| ---- | -------- | -------------------------------------------- | ------ |
| kind | 警告类型 | 'info'/'positive'/'negative'/'warning'非必填 | 'info' |

执行脚本命令:

yarn start # or yarn dev

可以在localhost:3000看到如下页面 :

手把手教你搭建React组件库(超详细)_第2张图片

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

优化文档编写

代码演示部分的demo较多(比如基本用法、高级用法以及各种用法等等),在组件复杂的情况下,会导致文档源文件很长难以维护。那就抽离吧。

components/alert/文件夹下新建demo文件夹,存放我们在编写文档时需要引用的 demo

components/alert/demo/1-demo-basic.tsx

import React from 'react';
import Alert from '../alert';
import '../style';

export default () => ;

components/alert/index.mdx

- import Alert from './alert'; // 引入组件
- import './style'; // 引入组件样式
+ import BasicDemo from './demo/1-demo-basic';

...


- 这是一条警告提示
+ 

这样我们就将 demo 与文档进行了分隔。预览如下:

手把手教你搭建React组件库(超详细)_第3张图片

等等,代码区域显示的是,而非demo源码。

组件暂时无法支持上述形式的展示:自定义下方展示的代码,而非内部的代码。相关讨论如下:

其实第一条 PR 已经解决了问题,但是被关闭了,无奈。

不过既然都能引入 React 组件了,在MDX的环境下自定义一个Playground组件又有何难呢,无非就是渲染组件(MDX 自带)和展示源码,简单开放的东西大家都是喜闻乐见的,就叫HappyBox吧。

优化代码展示

编写 组件

安装依赖:

yarn add react-use react-tooltip react-feather react-simple-code-editor prismjs react-copy-to-clipboard raw-loader styled-components --dev
这些依赖都是服务于文档站点应用,和组件库自身毫无关联。

最终效果如下:

手把手教你搭建React组件库(超详细)_第4张图片

根目录下新建doc-comps文件夹,存放文档中使用的一些工具组件,比如

doc-comps

├── happy-box
│   ├── style.ts
│   └── index.tsx
└── index.ts

components/doc-comps/happy-box/index.tsx


展开查看代码

import React from 'react';
import Editor from 'react-simple-code-editor';
import CopyToClipboard from 'react-copy-to-clipboard';
import { useToggle } from 'react-use';
import ReactTooltip from 'react-tooltip';
import IconCopy from 'react-feather/dist/icons/clipboard';
import IconCode from 'react-feather/dist/icons/code';
import { highlight, languages } from 'prismjs/components/prism-core';
import { StyledContainer, StyledIconWrapper } from './style';

import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';

require('prismjs/components/prism-jsx');

interface Props {
  code: string;
  title?: React.ReactNode;
  desc?: React.ReactNode;
}

export const HappyBox: React.FC = ({ code, title, desc, children }) => {
  const [isEditVisible, toggleEditVisible] = useToggle(false);

  return (
    
      
{children}
{title || '示例'}

{desc || '暂无描述'}

alert('复制成功')}>
{renderEditor()}
); function renderEditor() { if (!isEditVisible) return null; return (
{}} highlight={code => highlight(code, languages.jsx)} padding={10} className="container__editor" style={{ fontFamily: '"Fira code", "Fira Mono", monospace', fontSize: 14, }} />
); } }; export default HappyBox;

components/doc-comps/happy-box/style.ts


展开查看代码

import styled from 'styled-components';

export const StyledIconWrapper = styled.div`
  display: flex;
  align-items: center;
  margin-left: 10px;
`;

export const StyledContainer = styled.div`
  position: relative;
  display: inline-block;
  width: 100%;
  margin: 0 0 16px;
  border: 1px solid #ebedf0;
  border-radius: 2px;
  transition: all 0.2s;

  .text-divider {
    display: table;

    &::before,
    &::after {
      content: '';
      position: relative;
      display: table-cell;
      transform: translateY(50%);
      content: '';
      border-top: 1px solid #e8e8e8;
    }

    &::before {
      top: 50%;
      width: 5%;
    }

    &::after {
      width: 95%;
      top: 50%;
      width: 95%;
    }

    & > span {
      display: inline-block;
      padding: 0 10px;
      font-weight: 500;
      font-size: 16px;
      white-space: nowrap;
      text-align: center;
      font-variant: tabular-nums;
      line-height: 1.5;
    }
  }

  .divider {
    margin: 0;
    background: none;
    border: dashed #e8e8e8;
    border-width: 1px 0 0;
    display: block;
    clear: both;
    width: 100%;
    min-width: 100%;
    height: 1px;
    position: relative;
    top: -0.06em;
    box-sizing: border-box;
    padding: 0;
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5;
    list-style: none;
    font-feature-settings: 'tnum';
  }

  .code-box-demo {
    transition: all 0.2s;
    padding: 42px 24px 50px;
  }

  .code-box-meta {
    font-size: 14px;
    line-height: 2;
  }

  .code-box .ant-divider {
    margin: 0;
  }

  .code-box-description {
    padding: 18px 24px 12px;
  }

  .code-box-action {
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 16px;
  }

  .code-box-action .anticon {
    margin: 0 8px;
    cursor: pointer;
  }

  .container_editor_area {
    border-top: 1px solid rgb(232, 232, 232);
    padding: 16px;
  }

  .container__editor {
    font-variant-ligatures: common-ligatures;
    border-radius: 3px;
  }

  .container__editor textarea {
    outline: 0;
    background-color: none;
  }

  .button {
    display: inline-block;
    padding: 0 6px;
    text-decoration: none;
    background: #000;
    color: #fff;
  }

  .button:hover {
    background: linear-gradient(45deg, #e42b66, #e2433f);
  }

  /* Syntax highlighting */
  .token.comment,
  .token.prolog,
  .token.doctype,
  .token.cdata {
    color: #90a4ae;
  }
  .token.punctuation {
    color: #9e9e9e;
  }
  .namespace {
    opacity: 0.7;
  }
  .token.property,
  .token.tag,
  .token.boolean,
  .token.number,
  .token.constant,
  .token.symbol,
  .token.deleted {
    color: #e91e63;
  }
  .token.selector,
  .token.attr-name,
  .token.string,
  .token.char,
  .token.builtin,
  .token.inserted {
    color: #4caf50;
  }
  .token.operator,
  .token.entity,
  .token.url,
  .language-css .token.string,
  .style .token.string {
    color: #795548;
  }
  .token.atrule,
  .token.attr-value,
  .token.keyword {
    color: #3f51b5;
  }
  .token.function {
    color: #f44336;
  }
  .token.regex,
  .token.important,
  .token.variable {
    color: #ff9800;
  }
  .token.important,
  .token.bold {
    font-weight: bold;
  }
  .token.italic {
    font-style: italic;
  }
  .token.entity {
    cursor: help;
  }
`;

相关配置

  • 增加 alias别名,样例源码展示相对路径不够友好,让用户直接拷贝才够省心

新建gatsby-node.js,写入以下内容以开启alias

const path = require('path');

exports.onCreateWebpackConfig = args => {
  args.actions.setWebpackConfig({
    resolve: {
      modules: [path.resolve(__dirname, '../src'), 'node_modules'],
      alias: {
        'happy-ui/lib': path.resolve(__dirname, '../components/'),
        'happy-ui/esm': path.resolve(__dirname, '../components/'),
        'happy-ui': path.resolve(__dirname, '../components/'),
      },
    },
  });
};

tsconfig.json 打包时需要忽略demo,避免组件库打包生成types时包含其中,同时增加paths属性用于 vscode 自动提示:

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
+   "paths": {
+     "happy-ui": ["components/index.ts"],
+     "happy-ui/esm/*": ["components/*"],
+     "happy-ui/lib/*": ["components/*"]
+    },
    "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"]
+ "exclude": ["node_modules",  "**/demo/**"]
}

新的问题出现了,vscode 的 alias 提示依赖 tsconfig.json,忽略 demo 文件夹后,demo 内的文件模块类型找不到声明(paths 失效),所以不能将 demo 在 tsconfig.json 中移除:

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

新建一个 tsconfig.build.json 文件:

tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "exclude": ["**/demo/**", "node_modules"]
}

后续使用 tsc 生成类型声明文件指定tsconfig.build.json即可。

改造相关文件

components/alert/demo/1-demo-basic.tsx

- import Alert from '../alert';
+ import Alert from 'happy-ui/lib/alert';

- import '../style';
+ import 'happy-ui/lib/alert/style';

components/alert/index.mdx

- import { Playground } from 'docz';
+ import { HappyBox } from '../../doc-comps';

+ import BasicDemoCode from '!raw-loader!./demo/1-demo-basic.tsx';

...

- 
-   
- 

+ 
+  
+ 
yarn start卡住时尝试删除根目录 .docz文件夹,而后重新执行命令。

现在可以愉快地开发组件了。代码可以在仓库chapter-2分支获取,若存在与本文内容不符的地方,以master分支以及文章为准。

组件库打包

宿主环境各不相同,需要将源码进行相关处理后发布至 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 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。

最开始的方式是将声明文件单独存放在types文件夹,但这样只有通过'happy-ui'引入才可以获取提示,而'happy-ui/esm/xxx'和'happy-ui/lib/xxx'就无法获取提示。

tsconfig.build.json

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

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

lib

├── 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 模块

其实完全可以使用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: '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 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中,模块导入导出形式也是commonjs规范。

lib/alert/alert.js

手把手教你搭建React组件库(超详细)_第5张图片

导出 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模块形式导入导出。

esm/alert/alert.js

手把手教你搭建React组件库(超详细)_第6张图片

别忘了给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方案,那现有方案就搞不定了。经分析,有以下 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使用的这种方案。

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

答案是管理样式依赖

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

假设存在以下场景:使用者引入

你可能感兴趣的