关于javascript:从零打造组件库

4次阅读

共计 14853 个字符,预计需要花费 38 分钟才能阅读完成。

前言

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

业内优良的组件库比方 Antd Design 和 Element 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"}
  }
}

因为 @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​ 元素绑定的事件。

更多对于组件测试的细节举荐浏览以下文章:

  • The Complete Beginner’s Guide to Testing React Apps:通过简略的 ​<Counter />​ 测试讲到 ​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(<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

正文完
 0