从零打造组件库

liuxuan

前言

image.png

组件库,一套标准化的组件集合,是前端工程师开发提效不可或缺的工具。

业内优秀的组件库比如 Antd DesignElement UI,大大节省了我们的开发时间。那么,做一套组件库,容易吗?

答案肯定是不容易,当你去做这件事的时候,会发现它其实是一套体系。从开发、编译、测试,到最后发布,每一个流程都需要大量的知识积累。但是当你真正完成了一个组件库的搭建后,会发现收获的也许比想象中更多。

希望能够通过本文帮助大家梳理一套组件库搭建的知识体系,聚点成面,如果能够帮助到你,也请送上一颗 Star 吧。

示例组件库线上站点: Frog-UI

仓库地址:Frog-Kits

概览

本文主要包括以下内容:

  • 环境搭建:​Typescript​ + ​ESLint​ + ​StyleLint​ + ​Prettier​ + ​Husky
  • 组件开发:标准化的组件开发目录及代码结构
  • 文档站点:基于 docz 的文档演示站点
  • 编译打包:输出符合 ​umd​ / ​esm​ / ​cjs​ 三种规范的打包产物
  • 单元测试:基于 jest 的 ​React​ 组件测试方案及完整报告
  • 一键发版:整合多条命令,流水线控制 npm publish 全部过程
  • 线上部署:基于 now 快速部署线上文档站点

如有错误欢迎在评论区进行交流~

初始化

整体目录

├── CHANGELOG.md    // CHANGELOG
├── README.md    // README
├── babel.config.js    // babel 配置
├── build    // 编译发布相关
│   ├── constant.js
│   ├── release.js
│   └── rollup.config.dist.js
├── components    // 组件源码
│   ├── Alert
│   ├── Button
│   ├── index.tsx
│   └── style
├── coverage    // 测试报告
│   ├── clover.xml
│   ├── coverage-final.json
│   ├── lcov-report
│   └── lcov.info
├── dist    // 组件库打包产物:UMD
│   ├── frog.css
│   ├── frog.js
│   ├── frog.js.map
│   ├── frog.min.css
│   ├── frog.min.js
│   └── frog.min.js.map
├── doc    // 组件库文档站点
│   ├── Alert.mdx
│   └── button.mdx
├── doczrc.js    // docz 配置
├── es    // 组件库打包产物:ESM
│   ├── Alert
│   ├── Button
│   ├── index.js
│   └── style
├── gatsby-config.js    // docz 主题配置
├── gulpfile.js    // gulp 配置
├── lib    // 组件库打包产物:CJS
│   ├── Alert
│   ├── Button
│   ├── index.js
│   └── style
├── package-lock.json
├── package.json    // package.json
└── tsconfig.json    // typescript 配置

配置 ESLint + StyleLint + Prettier

每个 Lint 都可以单独拿出来写一篇文章,但配置不是我们的重点,所以这里使用 @umijs/fabric,一个包含 ESLint + StyleLint + Prettier 的配置文件合集,能够大大节省我们的时间。

感兴趣的同学可以去查看它的源码,在时间允许的情况下自己从零配置当做学习也是不错的。

安装

yarn add @umijs/fabric prettier @typescript-eslint/eslint-plugin -D

.eslintrc.js

module.exports = {
  parser: '@typescript-eslint/parser',
  extends: [
    require.resolve('@umijs/fabric/dist/eslint'),
    'prettier/@typescript-eslint',
    'plugin:react/recommended'
  ],
  rules: {
    'react/prop-types': 'off',
    "no-unused-expressions": "off",
    "@typescript-eslint/no-unused-expressions": ["error", { "allowShortCircuit": true }]
  },
  ignorePatterns: ['.eslintrc.js'],
  settings: {
    react: {
      version: "detect"
    }
  }
}

image.png

由于 @umijs/fabric 中判断 isTsProject 的目录路径如图所示是基于 src 的,且无法修改,我们这里组件源码在 components 路径下,所以这里要手动添加相关 typescript 的配置。

.prettierrc.js

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

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

.stylelintrc.js

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

配置 Husky + Lint-Staged

husky 提供了多种钩子来拦截 git 操作,比如 git commitgit push 等。但是一般情况我们都是接手已有的项目,如果对所有代码都做 Lint 检查的话修复成本太高了,所以我们希望能够只对自己提交的代码做检查,这样就可以从现在开始对大家的开发规范进行约束,已有的代码等修改的时候再做检查。

这样就引入了 lint-staged,可以只对当前 commit 的代码做检查并且可以编写正则匹配文件。

安装

yarn add husky lint-staged -D

package.json

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

配置 Typescript

typescript.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "module": "commonjs",
    "target": "es5",
    "lib": ["es6", "dom"],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "react",
    "moduleResolution": "node",
    "rootDir": "src",
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "paths": {
      "components/*": ["src/components/*"]
    }
  },
  "include": [
    "components"
  ],
  "exclude": [
    "node_modules",
    "build",
    "dist",
    "lib",
    "es"
  ]
}

组件开发

正常写组件大家都很熟悉了,这里我们主要看一下目录结构和部分代码:

├── Alert
│   ├── __tests__
│   ├── index.tsx
│   └── style
├── Button
│   ├── __tests__
│   ├── index.tsx
│   └── style
├── index.tsx
└── style
    ├── color
    ├── core
    ├── index.less
    └── index.tsx

components/index.ts​​ 是整个组件库的入口,负责收集所有组件并导出:

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

components/style​​ 包含组件库的基础 ​less​ 文件,包含 ​core、​color​ 等通用样式及变量设置。

每个 ​style​ 目录下都至少包含 ​index.tsx​ 及 ​index.less​ 两个文件:

style/index.tsx

import './index.less';

style/index.less

@import './core/index';
@import './color/default';

可以看到,​style/index.tsx​ 是作为每个组件样式引用的唯一入口而存在。

__tests__​ 是组件的单元测试目录,后续会单独讲到。具体 ​Alert​ 和 ​Button​ 组件的代码都很简单,这里就不赘述,大家可以去源码里找到。

组件测试

为什么要写测试以及是否有必要做测试,社区内已经有很多的探讨,大家可以根据自己的实际业务场景来做决定,我个人的意见是:

  • 基础工具,一定要做好单元测试,比如 ​utils、​hooks、​components
  • 业务代码,由于更新迭代快,不一定有时间去写单测,根据节奏自行决定

但是单测的意义肯定是正向的:

The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds

安装

yarn add jest babel-jest @babel/preset-env @babel/preset-react react-test-renderer @testing-library/react -D
yarn add @types/jest @types/react-test-renderer -D

package.json

"scripts": {
  "test": "jest",
  "test:coverage": "jest --coverage"
}

在每个组件下新增 ​__tests__/index.test.tsx​,作为单测入口文件。

import React from 'react';
import renderer from 'react-test-renderer';
import Alert from '../index';

describe('Component <Alert /> Test', () => {
  test('should render default', () => {
    const component = renderer.create(<Alert message="default" />);
    const tree = component.toJSON();
    expect(tree).toMatchSnapshot();
  });

  test('should render specific type', () => {
    const types: any[] = ['success', 'info', 'warning', 'error'];
    const component = renderer.create(
      <>
        {types.map((type) => (
          <Alert key={type} type={type} message={type} />
        ))}
      </>,
    );
    const tree = component.toJSON();
    expect(tree).toMatchSnapshot();
  });
});

这里采用的是 ​snapshot​ 快照的测试方式,所谓快照,就是在当前执行测试用例的时候,生成一份测试结果的快照,保存在 ​__snapshots__/index.test.tsx.snap​ 文件中。下次再执行测试用例的时候,如果我们修改了组件的源码,那么会将本次的结果快照和上次的快照进行比对,如果不匹配,则测试不通过,需要我们修改测试用例更新快照。这样就保证了每次源码的修改必须要和上次测试的结果快照做比对,才能确定是否通过,省去了写复杂的逻辑测试代码,是一种简化的测试手段。

还有一种是基于 ​DOM​ 的测试,基于 ​@testing-library/react

import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import renderer from 'react-test-renderer';
import Button from '../index';

describe('Component <Button /> Test', () => {
  let testButtonClicked = false;
  const onClick = () => {
    testButtonClicked = true;
  };

  test('should render default', () => {
    // snapshot test
    const component = renderer.create(<Button onClick={onClick}>default</Button>);
    const tree = component.toJSON();
    expect(tree).toMatchSnapshot();

    // dom test
    render(<Button onClick={onClick}>default</Button>);
    const btn = screen.getByText('default');
    fireEvent.click(btn);
    expect(testButtonClicked).toEqual(true);
  });
});

可以看到,​@testing-library/react​ 提供了一些方法,​render​ 将组件渲染到 ​DOM​ 中,​screen​ 提供了各种方法可以从页面中获取相应 ​DOM​ 元素,​fireEvent​ 负责触发 ​DOM​ 元素绑定的事件。

更多关于组件测试的细节推荐阅读以下文章:

组件库打包

组件库打包是我们的重头戏,我们主要实现以下目标:

  • 导出 umd / cjs / esm 三种规范文件
  • 导出组件库 css 样式文件
  • 支持按需加载

这里我们围绕 ​package.json​ 中的三个字段展开:​main、​module​ 以及 ​unpkg

{
  "main": "lib/index.js",
  "module": "es/index.js",
  "unpkg": "dist/frog.min.js"
}

我们去看业内各个组件库的源码时,总能看到这三个字段,那么它们的作用究竟是什么呢?

  • main​,是包的入口文件,我们通过 ​require​ 或者 ​import​ 加载 ​npm​ 包的时候,会从 ​main​ 字段获取需要加载的文件
  • module​,是由打包工具提出的一个字段,目前还不在 package.json 官方规范中,负责指定符合 esm 规范的入口文件。当 ​webpack​ 或者 ​rollup​ 在加载 ​npm​ 包的时候,如果看到有 ​module​ 字段,会优先加载 ​esm​ 入口文件,因为可以更好的做 ​tree-shaking​,减小代码体积。
  • unpkg​,也是一个非官方字段,负责让 ​npm​ 包中的文件开启 ​CDN​ 服务,意味着我们可以通过 https://unpkg.com/ 直接获取到文件内容。比如这里我们就可以通过 https://unpkg.com/frog-ui@0.1... 直接获取到 ​umd​ 版本的库文件。

我们使用 ​gulp 来串联工作流,并通过三条命令分别导出三种格式文件:

"scripts": {
  "build": "yarn build:dist && yarn build:lib && yarn build:es",
  "build:dist": "rm -rf dist && gulp compileDistTask",
  "build:lib": "rm -rf lib && gulp",
  "build:es": "rm -rf es && cross-env ENV_ES=true gulp"
}
  • build​,聚合命令
  • build:es​,输出 ​esm​ 规范,目录为 ​es
  • build:lib,输出 cjs 规范,目录为 lib
  • build:dist​,输出 ​umd​ 规范,目录为 ​dist

导出 umd

通过执行 ​gulp compileDistTask​ 来导出 ​umd​ 文件,具体看一下 gulpfile:

gulpfile

function _transformLess(lessFile, config = {}) {
  const { cwd = process.cwd() } = config;
  const resolvedLessFile = path.resolve(cwd, lessFile);

  let data = readFileSync(resolvedLessFile, 'utf-8');
  data = data.replace(/^\uFEFF/, '');

  const lessOption = {
    paths: [path.dirname(resolvedLessFile)],
    filename: resolvedLessFile,
    plugins: [new NpmImportPlugin({ prefix: '~' })],
    javascriptEnabled: true,
  };

  return less
    .render(data, lessOption)
    .then(result => postcss([autoprefixer]).process(result.css, { from: undefined }))
    .then(r => r.css);
}

async function _compileDistJS() {
  const inputOptions = rollupConfig;
  const outputOptions = rollupConfig.output;

  // 打包 frog.js
  const bundle = await rollup.rollup(inputOptions);
  await bundle.generate(outputOptions);
  await bundle.write(outputOptions);

  // 打包 frog.min.js
  inputOptions.plugins.push(terser());
  outputOptions.file = `${DIST_DIR}/${DIST_NAME}.min.js`;

  const bundleUglify = await rollup.rollup(inputOptions);
  await bundleUglify.generate(outputOptions);
  await bundleUglify.write(outputOptions);
}

function _compileDistCSS() {
  return src('components/**/*.less')
    .pipe(
      through2.obj(function (file, encoding, next) {
        if (
          // 编译 style/index.less 为 .css
          file.path.match(/(\/|\\)style(\/|\\)index\.less$/)
        ) {
          _transformLess(file.path)
            .then(css => {
              file.contents = Buffer.from(css);
              file.path = file.path.replace(/\.less$/, '.css');
              this.push(file);
              next();
            })
            .catch(e => {
              console.error(e);
            });
        } else {
          next();
        }
      }),
    )
    .pipe(concat(`./${DIST_NAME}.css`))
    .pipe(dest(DIST_DIR))
    .pipe(uglifycss())
    .pipe(rename(`./${DIST_NAME}.min.css`))
    .pipe(dest(DIST_DIR));
}

exports.compileDistTask = series(_compileDistJS, _compileDistCSS);

rollup.config.dist.js

const resolve = require('@rollup/plugin-node-resolve');
const { babel } = require('@rollup/plugin-babel');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const commonjs = require('@rollup/plugin-commonjs');
const { terser } = require('rollup-plugin-terser');
const image = require('@rollup/plugin-image');
const { DIST_DIR, DIST_NAME } = require('./constant');

module.exports = {
  input: 'components/index.tsx',
  output: {
    name: 'Frog',
    file: `${DIST_DIR}/${DIST_NAME}.js`,
    format: 'umd',
    sourcemap: true,
    globals: {
      'react': 'React',
      'react-dom': 'ReactDOM'
    }
  },
  plugins: [
    peerDepsExternal(),
    commonjs({
      include: ['node_modules/**', '../../node_modules/**'],
      namedExports: {
        'react-is': ['isForwardRef', 'isValidElementType'],
      }
    }),
    resolve({
      extensions: ['.tsx', '.ts', '.js'],
      jsnext: true,
      main: true,
      browser: true
    }),
    babel({
      exclude: 'node_modules/**',
      babelHelpers: 'bundled',
      extensions: ['.js', '.jsx', 'ts', 'tsx']
    }),
    image()
  ]
}

rollup​ 或者 ​webpack​ 这类打包工具,最擅长的就是由一个或多个入口文件,依次寻找依赖,打包成一个或多个 ​Chunk​ 文件,而 ​umd​ 就是要输出为一个 ​js​ 文件。

所以这里选用 ​rollup​ 负责打包 ​umd​ 文件,入口为 ​component/index.tsx​,输出 ​format​ 为 ​umd​ 格式。

为了同时打包 ​frog.js​ 和 ​frog.min.js​,在 ​_compileDistJS​ 中引入了 teser 插件,执行了两次 ​rollup​ 打包。

一个组件库只有 ​JS​ 文件肯定不够用,还需要有样式文件,比如使用 ​Antd​ 时:

import { DatePicker } from 'antd';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'

ReactDOM.render(<DatePicker />, mountNode);

所以,我们也要打包出一份组件库的 ​CSS​ 文件。

这里 ​_compileDistCSS​ 的作用是,遍历 ​components​ 目录下的所有 ​less​ 文件,匹配到所有的 ​index.less​ 入口样式文件,使用 ​less​ 编译为 ​CSS​ 文件,并且进行聚合,最后输出为 ​frog.css​ 和 ​frog.min.css

最终 ​dist​ 目录结构如下:

├── frog.css
├── frog.js
├── frog.js.map
├── frog.min.css
├── frog.min.js
└── frog.min.js.map

导出 cjs 和 esm

导出 ​cjs​ 或者 ​esm​,意味着模块化导出,并不是一个聚合的 ​JS​ 文件,而是每个组件是一个模块,只不过 ​cjs​ 的代码时符合 ​Commonjs​ 标准,​esm​ 的代码时 ​ES Module​ 标准。

所以,我们自然的就想到了 ​babel​,它的作用不就是编译高级别的代码到各种格式嘛。

gulpfile

function _compileJS() {
  return src(['components/**/*.{tsx, ts, js}', '!components/**/__tests__/*.{tsx, ts, js}'])
    .pipe(
      babel({
        presets: [
          [
            '@babel/preset-env',
            {
              modules: ENV_ES === 'true' ? false : 'commonjs',
            },
          ],
        ],
      }),
    )
    .pipe(dest(ENV_ES === 'true' ? ES_DIR : LIB_DIR));
}

function _copyLess() {
  return src('components/**/*.less').pipe(dest(ENV_ES === 'true' ? ES_DIR : LIB_DIR));
}

function _copyImage() {
  return src('components/**/*.@(jpg|jpeg|png|svg)').pipe(
    dest(ENV_ES === 'true' ? ES_DIR : LIB_DIR),
  );
}

exports.default = series(_compileJS, _copyLess, _copyImage);

babel.config.js

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

这里代码就相对简单了,扫描 ​components​ 目录下的 ​tsx​ 文件,使用 ​babel​ 编译后,拷贝到 ​es​ 或 ​lib​ 目录。​less​ 文件直接拷贝,这里 ​_copyImage​ 是为了防止有图片,也直接拷贝过去,但是组件库中不建议用图片,可以用字体图标代替。

组件文档

这里使用 docz 来搭建文档站点,更具体的使用方法大家可以阅读官网文档,这里不再赘述。

doc/Alert.mdx

---
name: Alert 警告提示
route: /alert
menu: 反馈
---

import { Playground, Props } from 'docz'
import { Alert } from '../components/';
import '../components/Alert/style';

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

<Props of={Alert} />

## 基本用法

<Playground>
  <Alert message="Success Text" type="success" />
  <Alert message="Info Text" type="info" />
  <Alert message="Warning Text" type="warning" />
  <Alert message="Error Text" type="error" />
</Playground>

package.json

"scripts": {
  "docz:dev": "docz dev",
  "docz:build": "docz build",
  "docz:serve": "docz build && docz serve"
}

线上文档站点部署

这里使用 now.sh 来部署线上站点,注册后安装命令行,登录成功。

yarn docz:build
cd .docz/dist
now deploy
vercel --production

一键发版

我们在发布新版 npm 包时会有很多步骤,这里提供一套脚本来实现一键发版。

安装

yarn add conventional-changelog-cli -D

release.js

const child_process = require('child_process');
const fs = require('fs');
const path = require('path');
const inquirer = require('inquirer');
const chalk = require('chalk');
const util = require('util');
const semver = require('semver');
const exec = util.promisify(child_process.exec);
const semverInc = semver.inc;
const pkg = require('../package.json');

const currentVersion = pkg.version;

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

const logTime = (logInfo, type) => {
  const info = `=> ${type}:${logInfo}`;
  console.log((chalk.blue(`[${new Date().toLocaleString()}] ${info}`)));
};

const getNextVersions = () => ({
  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 promptNextVersion = async () => {
  const nextVersions = getNextVersions();
  const { nextVersion } = await inquirer.prompt([
    {
      type: 'list',
      name: 'nextVersion',
      message: `Please select the next version (current version is ${currentVersion})`,
      choices: Object.keys(nextVersions).map(name => ({
        name: `${name} => ${nextVersions[name]}`,
        value: nextVersions[name]
      }))
    }
  ]);
  return nextVersion;
};

const updatePkgVersion = async nextVersion => {
  pkg.version = nextVersion;
  logTime('Update package.json version', 'start');
  await fs.writeFileSync(path.resolve(__dirname, '../package.json'), JSON.stringify(pkg));
  await run('npx prettier package.json --write');
  logTime('Update package.json version', 'end');
};

const test = async () => {
  logTime('Test', 'start');
  await run(`yarn test:coverage`);
  logTime('Test', 'end');
};

const genChangelog = async () => {
  logTime('Generate CHANGELOG.md', 'start');
  await run(' npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0');
  logTime('Generate CHANGELOG.md', 'end');
};

const push = async nextVersion => {
  logTime('Push Git', 'start');
  await run('git add .');
  await run(`git commit -m "publish frog-ui@${nextVersion}" -n`);
  await run('git push');
  logTime('Push Git', 'end');
};

const tag = async nextVersion => {
  logTime('Push Git', 'start');
  await run(`git tag v${nextVersion}`);
  await run(`git push origin tag frog-ui@${nextVersion}`);
  logTime('Push Git Tag', 'end');
};

const build = async () => {
  logTime('Components Build', 'start');
  await run(`yarn build`);
  logTime('Components Build', 'end');
};

const publish = async () => {
  logTime('Publish Npm', 'start');
  await run('npm publish');
  logTime('Publish Npm', 'end');
};

const main = async () => {
  try {
    const nextVersion = await promptNextVersion();
    const startTime = Date.now();

    await test();
    await updatePkgVersion(nextVersion);
    await genChangelog();
    await push(nextVersion);
    await build();
    await publish();
    await tag(nextVersion);

    console.log(chalk.green(`Publish Success, Cost ${((Date.now() - startTime) / 1000).toFixed(3)}s`));
  } catch (err) {
    console.log(chalk.red(`Publish Fail: ${err}`));
  }
}

main();

package.json

"scripts": {
  "publish": "node build/release.js"
}

代码也比较简单,都是对一些工具的基本使用,通过执行 ​yarn publish​ 就可以一键发版。

结尾

本文是我在搭建组件库过程中的学习总结,在过程中学习到了很多知识,并且搭建了清晰的知识体系,希望能够对你有所帮助,欢迎在评论区交流\~

参考文档

Tree-Shaking性能优化实践 - 原理篇

彻底搞懂 ESLint 和 Prettier

集成配置 @umijs/fabric

TypeScript and React: Components

TypeScript ESLint

由 allowSyntheticDefaultImports 引起的思考

tsconfig.json入门指南

React 单元测试策略及落地

The Complete Beginner's Guide to Testing React Apps

阅读 1.4k

前端学习
记录并探讨WEB前端相关知识

stay hungry, stay foolish

3.8k 声望
244 粉丝
0 条评论

stay hungry, stay foolish

3.8k 声望
244 粉丝
宣传栏