從零打造組件庫
前言

概覽
環(huán)境搭建:Typescript + ESLint + StyleLint + Prettier + Husky 組件開發(fā):標準化的組件開發(fā)目錄及代碼結(jié)構(gòu) 文檔站點:基于 docz 的文檔演示站點 編譯打包:輸出符合 umd / esm / cjs 三種規(guī)范的打包產(chǎn)物 單元測試:基于 jest 的 React 組件測試方案及完整報告 一鍵發(fā)版:整合多條命令,流水線控制 npm publish 全部過程 線上部署:基于 now 快速部署線上文檔站點
初始化
整體目錄
├──?CHANGELOG.md????//?CHANGELOG
├──?README.md????//?README
├──?babel.config.js????//?babel?配置
├──?build????//?編譯發(fā)布相關(guān)
│???├──?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 ???//?組件庫打包產(chǎn)物: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 ???//?組件庫打包產(chǎn)物:ESM
│???├──?Alert
│???├──?Button
│???├──?index.js
│???└──?style
├──?gatsby-config.js????//?docz?主題配置
├──?gulpfile.js????//?gulp?配置
├── lib ???//?組件庫打包產(chǎn)物:CJS
│???├──?Alert
│???├──?Button
│???├──?index.js
│???└──?style
├──?package-lock.json
├──?package.json????//?package.json
└──?tsconfig.json????//?typescript?配置
配置 ESLint + StyleLint + Prettier
yarn?add?@umijs/fabric?prettier?@typescript-eslint/eslint-plugin?-D
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"
????}
??}
}
const?fabric?=?require('@umijs/fabric');
module.exports?=?{
??...fabric.prettier,
};
module.exports?=?{
??extends:?[require.resolve('@umijs/fabric/dist/stylelint')],
};
配置 Husky + Lint-Staged
yarn?add?husky?lint-staged?-D
"lint-staged":?{
??"components/**/*.ts?(x)":?[
????"prettier?--write",
????"eslint?--fix"
??],
??"components/**/**/*.less":?[
????"stylelint?--syntax?less?--fix"
??]
},
"husky":?{
??"hooks":?{
????"pre-commit":?"lint-staged"
??}
}
配置 Typescript
{
??"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"
??]
}
組件開發(fā)
├──?Alert
│???├──?__tests__
│???├──?index.tsx
│???└──?style
├──?Button
│???├──?__tests__
│???├──?index.tsx
│???└──?style
├──?index.tsx
└──?style
????├──?color
????├──?core
????├──?index.less
????└──?index.tsx
export?{?default?as?Button?}?from?'./Button';
export?{?default?as?Alert?}?from?'./Alert';
import?'./index.less';
@import?'./core/index';
@import?'./color/default';
組件測試
基礎(chǔ)工具,一定要做好單元測試,比如 utils、hooks、components 業(yè)務(wù)代碼,由于更新迭代快,不一定有時間去寫單測,根據(jù)節(jié)奏自行決定
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
"scripts":?{
??"test":?"jest",
??"test:coverage":?"jest?--coverage"
}
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();
??});
});
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();
????const?tree?=?component.toJSON();
????expect(tree).toMatchSnapshot();
????//?dom?test
????render();
????const?btn?=?screen.getByText('default');
????fireEvent.click(btn);
????expect(testButtonClicked).toEqual(true);
??});
});
The Complete Beginner's Guide to Testing React Apps:通過簡單的 測試講到 ToDoApp 的完整測試,并且對比了 Enzyme 和?@testing-library/react 的區(qū)別,是很好的入門文章 React 單元測試策略及落地:系統(tǒng)的講述了單元測試的意義及落地方案
組件庫打包
導(dǎo)出 umd / cjs / esm 三種規(guī)范文件 導(dǎo)出組件庫 css 樣式文件 支持按需加載
{
??"main":?"lib/index.js",
??"module":?"es/index.js",
??"unpkg":?"dist/frog.min.js"
}
main,是包的入口文件,我們通過 require 或者 import 加載 npm 包的時候,會從 main 字段獲取需要加載的文件 module,是由打包工具提出的一個字段,目前還不在 package.json 官方規(guī)范中,負責指定符合 esm 規(guī)范的入口文件。當 webpack 或者 rollup 在加載 npm 包的時候,如果看到有 module 字段,會優(yōu)先加載 esm 入口文件,因為可以更好的做 tree-shaking,減小代碼體積。 unpkg,也是一個非官方字段,負責讓 npm 包中的文件開啟 CDN 服務(wù),意味著我們可以通過 https://unpkg.com/?直接獲取到文件內(nèi)容。比如這里我們就可以通過 https://unpkg.com/[email protected]... 直接獲取到 umd 版本的庫文件。
"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 規(guī)范,目錄為 es build:lib,輸出?cjs?規(guī)范,目錄為?lib build:dist,輸出 umd 規(guī)范,目錄為 dist
導(dǎo)出 umd
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);
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()
??]
}
import?{?DatePicker?}?from?'antd';
import?'antd/dist/antd.css';?//?or?'antd/dist/antd.less'
ReactDOM.render(,?mountNode);
├──?frog.css
├──?frog.js
├──?frog.js.map
├──?frog.min.css
├──?frog.min.js
└──?frog.min.js.map
導(dǎo)出 cjs 和 esm
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);
module.exports?=?{
??presets:?[
????"@babel/preset-react",
????"@babel/preset-typescript",
????"@babel/preset-env"
??],
??plugins:?[
????"@babel/plugin-proposal-class-properties"
??]
};
組件文檔
---
name:?Alert?警告提示
route:?/alert
menu:?反饋
---
import?{?Playground,?Props?}?from?'docz'
import?{?Alert?}?from?'../components/';
import?'../components/Alert/style';
#?Alert
警告提示,展現(xiàn)需要關(guān)注的信息。
##?基本用法
??"Success?Text"?type="success"?/>
??"Info?Text"?type="info"?/>
??"Warning?Text"?type="warning"?/>
??"Error?Text"?type="error"?/>
"scripts":?{
??"docz:dev":?"docz?dev",
??"docz:build":?"docz?build",
??"docz:serve":?"docz?build?&&?docz?serve"
}
線上文檔站點部署
yarn?docz:build
cd?.docz/dist
now?deploy
vercel?--production
一鍵發(fā)版
yarn?add?conventional-changelog-cli?-D
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();
"scripts":?{
??"publish":?"node?build/release.js"
}

評論
圖片
表情
