preface

Component library, a standardized set of components, is an indispensable tool for front-end engineers to develop and improve performance.

Excellent component libraries such as Antd Design and Element UI save us a lot of development time. So, is it easy to make a component library?

The answer is no, because when you do it, it’s a system. From development, compilation, testing, and finally release, each process requires a great deal of knowledge. But once you’ve actually built a component library, you’ll probably get more than you bargained for.

I hope this article can help you sort out a set of component library knowledge system, gather together to form a surface, if it can help you, please send a Star.

Sample component library online site: Frog-UI

Warehouse Address:Frog-Kits

An overview of

This paper mainly includes the following contents:

  • Environment building:Typescript​ + ​ESLint​ + ​StyleLint​ + ​Prettier​ + ​Husky
  • Component development: Standardized component development catalogs and code structures
  • Documentation site: Docz-based documentation presentation site
  • Compile package: output matchesumd​ / ​esm​ / ​cjsThree standard packaging products
  • Unit testing: Based on​jest​ 的 ​ReactComponent test solution and complete report
  • One-click publishing: integrate multiple commands, pipeline control NPM publish process
  • Online deployment: Rapid deployment online documentation site based on now

If you have any mistakes, please feel free to communicate in the comments section

Initialize the

The whole directory

├── Heavy Metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Heavy metal // Constant. Js │ ├ ─ ─ the js │ └ ─ ─ a rollup. Config. Dist., js ├ ─ ─ the components / / component source │ ├ ─ ─ Alert │ ├ ─ ─ Button │ ├ ─ ─ index. The TSX │ └ ─ ─ style ├ ─ ─ coverage / / test report │ ├ ─ ─ clover. The XML │ ├ ─ ─ coverage - final. Json │ ├ ─ ─ the lcov - report │ └ ─ ─ the lcov. Info ├ ─ ─ dist // Component library packaging product: UMD │ ├ ─ ─ frog. CSS │ ├ ─ ─ frog. Js │ ├ ─ ─ frog. Js. Map │ ├ ─ ─ frog. Min. CSS │ ├ ─ ─ frog. Min. Js │ └ ─ ─ frog. Min. Js. Map ├ ─ ─ doc / / ├─ ├─ activities.txt │ ├─ activities.txt │ ├─ activities.txt │ ├─ activities.txt │ ├─ activities.txt │ ├─ activities.txt │ ├─ activities.txt │ ├─ activities.txt ESM │ ├ ─ ─ Alert │ ├ ─ ─ Button │ ├ ─ ─ index. The js │ └ ─ ─ style ├ ─ ─ gatsby - config. Js / / docz theme configuration ├ ─ ─ gulpfile. Js/configuration/gulp ├ ─ ─ Lib // Component library packaging product: ├─ ├─ ├─ ├─ ├─ ├─ download.txt // Download.txt/download.txt/download.txt Tsconfig. json // typescript configurationCopy the code

ESLint + StyleLint + Prettier is configured

Each Lint could write a separate article, but configuration is not our focus, so using @umijs/ Fabric, a collection of configuration files containing ESLint + StyleLint + Prettier, saves us a lot of time.

Interested students can go to see its source code, in the case of time to allow their own configuration from zero as learning is also good.

The installation

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

.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"
    }
  }
}
Copy the code

Since the @umijs/ Fabric directory path for isTsProject is SRC based and cannot be modified, the component source is in the Components path, so add the relevant typescript configuration manually.

.prettierrc.js

const fabric = require('@umijs/fabric'); module.exports = { ... fabric.prettier, };Copy the code

.stylelintrc.js

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

Configure Husky + Lint-staged

Husky provides a variety of hooks to intercept Git operations, such as git commit or Git push. However, we usually take on existing projects and it would be too expensive to fix if we did Lint checks for all code, so we want to be able to check only our own submitted code so that we can restrict everyone’s development specifications from now on and check existing code as it changes.

This introduces lint-staged code, which can be checked only against the current COMMIT and regular match files can be written.

The installation

yarn add husky lint-staged -D
Copy the code

package.json

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

Configuration 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"
  ]
}
Copy the code

Component development

We are familiar with normal writing components, here we mainly look at the directory structure and part of the code:

├ ─ ─ Alert │ ├ ─ ─ __tests__ │ ├ ─ ─ index. The TSX │ └ ─ ─ style ├ ─ ─ Button │ ├ ─ ─ __tests__ │ ├ ─ ─ index. The TSX │ └ ─ ─ style ├ ─ ─ ├── ├─ less.├ ─ less.├ ─ less.├ ─ less.├ ─ less.├ ─Copy the code

Components /index.ts is the entry to the entire component library, responsible for collecting and exporting all components:

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

Components /style contains the base less file of the component library, including common styles such as core and color and variable Settings.

Each style directory contains at least two files: index. TSX and index.less:

style/index.tsx

import './index.less';
Copy the code

style/index.less

@import './core/index';
@import './color/default';
Copy the code

As you can see, style/index.tsx exists as a unique entry point for each component style reference.

__tests__ is the unit test directory for the component, which will be covered separately later. The specific Alert and Button components of the code are very simple, here is not redundant, you can go to the source code to find.

Component test

There has been a lot of discussion in the community about why tests should be written and whether they are necessary. You can make a decision based on your actual business scenario. My personal opinion is:

  • Basic tools, be sure to do unit testing, for exampleutils,hooks,components
  • Business code, because the update iteration is fast, may not have time to write a single test, according to the pace of their own decisions

But the implications of single testing are definitely positive:

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

The installation

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
Copy the code

package.json

"scripts": {
  "test": "jest",
  "test:coverage": "jest --coverage"
}
Copy the code

Add __tests__/index.test. TSX as a single test entry file under each component.

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(); }); });Copy the code

A snapshot is a snapshot of the test result generated during the test case execution and stored in the __snapshots__/index.test.tsx.snap file. The next time we execute the test case, if we modify the source code of the component, the result snapshot will be compared with the last snapshot. If the result snapshot does not match, the test fails and we need to modify the test case to update the snapshot. This ensures that each modification of the source code must be compared with the results of the last test snapshot, in order to determine whether to pass, eliminating the need to write complex logic test code, is a simplified test means.

There is also a DOM based test based on @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); }); });Copy the code

As you can see, @testing-library/ React provides methods, Render renders components into the DOM, Screen provides methods to retrieve DOM elements from the page, and fireEvent is responsible for triggering events that bind DOM elements.

The following article is recommended for more details on component testing:

  • The Complete Beginner’s Guide to Testing React Apps: By simple<Counter />Test aboutToDoAppThe complete test and comparisonEnzyme​ 和 @testing-library/reactThe difference is a good introduction to the article
  • React Unit Testing strategy and Implementation: This section explains the significance of unit testing and implementation solutions

Component library packaging

Component library packaging is our main focus, we mainly achieve the following goals:

  • Export umD/CJS/ESM specification files
  • Export the component library CSS style file
  • Support loading on demand

Here we expand around three fields in package.json: main, Module, and unpkg.

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

When we look at the source code of each component library in the industry, we can always see these three fields, so what are their functions?

  • mainIs the package entry file that we passrequireorimportloadingnpmWhen it comes to bags, it comes frommainField gets the file to load
  • module, is a field proposed by the packaging tool, not yet availablePackage. json official specificationIs responsible for specifying entry files that conform to the ESM specification. whenwebpackorrollupIn the loadnpmPack when you see onemoduleField will be loaded firstesmImport files because it can be done bettertree-shaking, reduce the code size.
  • unpkg, is also an unofficial field responsible for lettingnpmOpen the file in the packageCDNService, which means we can get throughunpkg.com/Directly obtain the file content. Here, for example, we can get throughunpkg.com/[email protected]…Direct accessumdVersion of the library file.

We used gulp to concatenate the workflow and export files in three formats using three commands:

"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"
}
Copy the code
  • build, aggregate command
  • build:esAnd the outputesmSpecification, the directory ises
  • build:libAnd the outputcjsSpecification, the directory islib
  • build:distAnd the outputumdSpecification, the directory isdist

Export the umd

To export umD files, run gulp compileDistTask. 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); / / packaging 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 (/ compile/style/index. Less for. CSS file. The path. The 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);Copy the code

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()
  ]
}
Copy the code

Packaging tools like Rollup or WebPack are good at packing one or more chunks from one or more entry files, looking for dependencies in turn, while UMD is designed to output as a JS file.

So rollup is used to package umD files. The entry is Component /index.tsx, and the output format is UMD.

To package frog.js and frog.min.js at the same time, the teser plug-in is introduced in _compileDistJS and rollup packaging is performed twice.

A component library with only JS files is not enough. It also needs style files, such as when using Antd:

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

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

Therefore, we also need to package up a component library CSS file.

_compileDistCSS traverses all less files in components, matches all index.less entry style files, compiles to CSS files with less, and aggregates them. The final output is frog.css and frog.min.css.

The final dist directory structure is as follows:

├ ─ ─ frog. CSS ├ ─ ─ frog. Js ├ ─ ─ frog. Js. Map ├ ─ ─ frog. Min. CSS ├ ─ ─ frog. Min. Js └ ─ ─ frog. Min. Js. The mapCopy the code

Export CJS and ESM

Exporting CJS or ESM, which means modularizing exports, is not an aggregate JS file, but each component is a Module, but CJS code is Commonjs standard, ESM code is ES Module standard.

So, we naturally thought of Babel, which compiles high-level code into various formats.

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);
Copy the code

babel.config.js

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

Here the code is relatively simple, scan the TSX file in the Components directory, compile it using Babel and copy it to the es or lib directory. Less files are copied directly. _copyImage is used to prevent images from being copied directly, but it is not recommended to use images in the component library. Font ICONS can be used instead.

Component document

Docz is used here to build a document site. For more specific methods of use, you can read the official website document, which is not repeated here.

doc/Alert.mdx

-- name: Alert Alert route: / Alert menu: feedback -- import {Playground, Props} from 'docz' import {Alert} from '.. /components/'; import '.. /components/Alert/style'; # Alert Displays information that needs attention. <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>Copy the code

package.json

"scripts": {
  "docz:dev": "docz dev",
  "docz:build": "docz build",
  "docz:serve": "docz build && docz serve"
}
Copy the code

Online documentation site deployment

Use now. Sh to deploy the online site, register, install command line, login successful.

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

A key release

We have many steps to go through when we release a new VERSION of the NPM package, and here is a set of scripts for one-click release.

The installation

yarn add conventional-changelog-cli -D
Copy the code

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();Copy the code

package.json

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

The code is also relatively simple, which is the basic use of some tools. By executing YARN publish, you can publish version in one click.

At the end

This article is my learning summary in the process of building component library, in the process of learning a lot of knowledge, and set up a clear knowledge system, I hope to be helpful to you, welcome to exchange in the comments section ~

Reference documentation

Tree-Shaking Performance Optimization Practices – Principles

Know ESLint and Prettier inside out

Integration configuration @umijs/ Fabric

TypeScript and React: Components

TypeScript ESLint

Caused by allowSyntheticDefaultImports thinking

Tsconfig. json Guide to getting started

React Unit test policy and implementation

The Complete Beginner’s Guide to Testing React Apps

Welcome to follow my public number