从零打造组件库

SegmentFault

共 16525字,需浏览 34分钟

 · 2021-02-16

作者:liuxuan
来源:SegmentFault 思否社区



前言



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

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

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

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



概览


本文主要包括以下内容:

  • 环境搭建: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"
    }
  }
}


由于 @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 commit 或 git 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  Test', () => {
  test('should render default', () => {
    const component = renderer.create("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) => (
          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  Test', () => {
  let testButtonClicked = false;
  const onClick = () => {
    testButtonClicked = true;
  };

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

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

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

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

  • The Complete Beginner's Guide to Testing React Apps:通过简单的 测试讲到 ToDoApp 的完整测试,并且对比了 Enzyme 和 @testing-library/react 的区别,是很好的入门文章
  • React 单元测试策略及落地:系统的讲述了单元测试的意义及落地方案




组件库打包


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

  • 导出 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(, 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
警告提示,展现需要关注的信息。



## 基本用法


  "Success Text" type="success" />
  "Info Text" type="info" />
  "Warning Text" type="warning" />
  "Error Text" type="error" />


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 就可以一键发版。



点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,扫描下方”二维码“或在“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

- END -


浏览 50
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报