同步链接: https://www.shanejix.com/posts/从 0 到 1 搭建 React UI 组件库/
虽然参与了项目组的组件库架构设计和讨论,但是终究不是在自己完全愿景下实施。总想着自己造一个的组件库,于是就有了下面从 0 到 1 包含源起,构建,测试,测试,站点,发布等部分。
万物从起名开始,思来想去也没想到什么高大上的名字,姑且就叫 block-ui
所有个代码都放在 @block-org 组织下
一,源起
新建项目
mkdir block-ui && cd block-ui && yarn init -y
# 新建源码文件夹以及入口文件
mkdir src && cd src && touch index.ts
代码规范
.eslintrc
{
// ...
"extends": [
"airbnb",
"plugin:react/recommended",
"plugin:prettier/recommended",
"plugin:react-hooks/recommended",
],
// ...
}
.prettierrc
{
"arrowParens": "always",
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"printWidth": 100,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "es5"
}
.stylelintrc.js
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"customSyntax": "postcss-less",
"rules": {
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"font-family-no-missing-generic-family-keyword": null,
"block-opening-brace-space-before": "always",
"declaration-block-trailing-semicolon": null,
"declaration-colon-newline-after": null,
"indentation": null,
"selector-descendant-combinator-no-non-space": null,
"selector-class-pattern": null,
"keyframes-name-pattern": null,
"no-invalid-position-at-import-rule": null,
"number-max-precision": 6,
"color-function-notation": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
]
}
}
Commit Lint
- 进行
pre-commit
代码规范检测
yarn add husky lint-staged -D
新增 package.json
信息
"lint-staged": {
"components/**/*.ts?(x)": [
"prettier --write --ignore-unknown",
"eslint --fix",
"git add"
],
"components/**/*.less": [
"stylelint --syntax less --fix",
"git add"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
2.进行 Commit Message 检测
yarn add @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog -D
新增.commitlintrc.js 写入以下内容
module.exports = { extends: ["@commitlint/config-conventional"] };
package.json 写入以下内容:
// ...
"scripts": {
"commit": "git-cz",
}
// ...
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "lint-staged"
}
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
husky
在代码提交的时候执行一些 bash 命令,lint-staged
只针对当前提交/改动过的文件进行处理
TypeScript
yarn add typescript -D
新建tsconfig.json
并写入以下内容
{
"compilerOptions": {
"outDir": "esm",
"jsx": "react",
"module": "es6",
"target": "es5",
"lib": ["es5", "dom"],
"moduleResolution": "node",
"declaration": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"skipLibCheck": true,
"esModuleInterop": true,
"downlevelIteration": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true
},
"include": ["components/**/*.ts", "components/**/*.tsx"],
"exclude": [
"node_modules",
"components/**/*.test.tsx",
"components/**/*.test.ts"
]
}
组件
alert
├── index.ts # 入口文件 源文件
├── interface.ts # 类型声明文件
└── style
├── index.less # 样式文件
└── index.ts # 样式文件里为什么存在一个index.ts // 按需加载样式 管理样式依赖 后面章节会提到
安装 React 相关依赖:
# 开发时依赖,宿主环境一定存在
yarn add react react-dom @types/react @types/react-dom -D
src/alert/interface.ts
import { CSSProperties, ReactNode } from "react";
/**
* @title Alert
*/
export interface AlertProps {
style?: CSSProperties;
className?: string | string[];
/**
* @zh 自定义操作项
* @en Custom action item
*/
action?: ReactNode;
/**
* @zh 是否可关闭
* @en Whether Alert can be closed
*/
closable?: boolean;
closeable?: boolean; // typo
/**
* @zh 关闭的回调
* @en Callback when Alert is closed
*/
onClose?: (e) => void;
/**
* @zh 关闭动画结束后执行的回调
* @en Callback when Alert close animation is complete
*/
afterClose?: () => void;
/**
* @zh 警告的类型
* @en Type of Alert
* @defaultValue info
*/
type?: "info" | "success" | "warning" | "error";
/**
* @zh 标题
* @en Alert title
*/
title?: ReactNode;
/**
* @zh 内容
* @en Alert content
*/
content?: ReactNode;
/**
* @zh 可以指定自定义图标,`showIcon` 为 `true` 时生效。
* @en Custom icon, effective when `showIcon` is `true`
*/
icon?: ReactNode;
/**
* @zh 自定义关闭按钮
* @en Custom close button
*/
closeElement?: ReactNode;
/**
* @zh 是否显示图标
* @en Whether to show icon
* @defaultValue true
*/
showIcon?: boolean;
/**
* @zh 是否用作顶部公告
* @en Whether to show as banner
*/
banner?: boolean;
}
src/alert/index.ts
import React, { useState, useContext, ReactNode, forwardRef } from "react";
import { CSSTransition } from "react-transition-group";
import IconCheckCircleFill from "../../icon/react-icon/IconCheckCircleFill";
import IconCloseCircleFill from "../../icon/react-icon/IconCloseCircleFill";
import IconInfoCircleFill from "../../icon/react-icon/IconInfoCircleFill";
import IconExclamationCircleFill from "../../icon/react-icon/IconExclamationCircleFill";
import IconClose from "../../icon/react-icon/IconClose";
import cs from "../_util/classNames";
import { ConfigContext } from "../config-provider";
import { AlertProps } from "./interface";
import useMergeProps from "../_util/hooks/useMergeProps";
const defaultProps: AlertProps = {
showIcon: true,
type: "info",
};
function Alert(baseProps: AlertProps, ref) {
const { getPrefixCls, componentConfig } = useContext(ConfigContext);
const props = useMergeProps<AlertProps>(
baseProps,
defaultProps,
componentConfig?.Alert
);
const {
style,
className,
action,
type = "info",
title,
content,
icon,
showIcon,
closable,
closeable,
afterClose,
onClose,
closeElement,
banner,
} = props;
const prefixCls = getPrefixCls("alert");
const [visible, setVisible] = useState<boolean>(true);
function renderIcon(type: string | void): ReactNode | null {
if (icon) {
return icon;
}
switch (type) {
case "info":
return <IconInfoCircleFill />;
case "success":
return <IconCheckCircleFill />;
case "warning":
return <IconExclamationCircleFill />;
case "error":
return <IconCloseCircleFill />;
default:
return null;
}
}
function onHandleClose(e: any) {
setVisible(false);
onClose && onClose(e);
}
const classNames = cs(
prefixCls,
`${prefixCls}-${type}`,
{
[`${prefixCls}-with-title`]: title,
[`${prefixCls}-banner`]: banner,
},
className
);
const _closable = "closeable" in props ? closeable : closable;
return (
<CSSTransition
in={visible}
timeout={300}
classNames="zoomInTop"
unmountOnExit
onExited={() => {
afterClose && afterClose();
}}
>
<div ref={ref} style={style} className={classNames}>
{showIcon && (
<div className={`${prefixCls}-icon-wrapper`}>{renderIcon(type)}</div>
)}
<div className={`${prefixCls}-content-wrapper`}>
{title && <div className={`${prefixCls}-title`}>{title}</div>}
{content && <div className={`${prefixCls}-content`}>{content}</div>}
</div>
{action && <div className={`${prefixCls}-action`}>{action}</div>}
{_closable && (
<button onClick={onHandleClose} className={`${prefixCls}-close-btn`}>
{closeElement || <IconClose />}
</button>
)}
</div>
</CSSTransition>
);
}
const AlertComponent = forwardRef<unknown, AlertProps>(Alert);
AlertComponent.displayName = "Alert";
export default AlertComponent;
export { AlertProps };
src/alert/style/index.less
@import "../../style/theme/default.less";
@import "./token.less";
@alert-prefix-cls: ~"@{prefix}-alert";
.@{alert-prefix-cls} {
box-sizing: border-box;
border-radius: @alert-border-radius;
padding: (@alert-padding-vertical - @alert-border-width)
(@alert-padding-horizontal - @alert-border-width);
font-size: @alert-font-size-text-content;
overflow: hidden;
display: flex;
width: 100%;
text-align: left;
align-items: center;
line-height: @alert-line-height;
&-with-title {
padding: (@alert-padding-vertical_with_title - @alert-border-width)
(@alert-padding-horizontal_with_title - @alert-border-width);
}
&-with-title {
align-items: flex-start;
}
&-info {
border: @alert-border-width solid @alert-info-color-border;
background-color: @alert-info-color-bg;
}
&-success {
border: @alert-border-width solid @alert-success-color-border;
background-color: @alert-success-color-bg;
}
&-warning {
border: @alert-border-width solid @alert-warning-color-border;
background-color: @alert-warning-color-bg;
}
&-error {
border: @alert-border-width solid @alert-error-color-border;
background-color: @alert-error-color-bg;
}
&-banner {
border: none;
border-radius: 0;
}
&-content-wrapper {
position: relative;
flex: 1;
}
&-title {
font-size: @alert-font-size-text-title;
font-weight: @alert-font-weight-title;
line-height: @alert-title-line-height;
margin-bottom: @alert-title-margin-bottom;
}
&-info &-title {
color: @alert-info-color-text-title;
}
&-info &-content {
color: @alert-info-color-text-content;
}
&-info&-with-title &-content {
color: @alert-info-color-text-content_title;
}
&-success &-title {
color: @alert-success-color-text-title;
}
&-success &-content {
color: @alert-success-color-text-content;
}
&-success&-with-title &-content {
color: @alert-success-color-text-content_title;
}
&-warning &-title {
color: @alert-warning-color-text-title;
}
&-warning &-content {
color: @alert-warning-color-text-content;
}
&-warning&-with-title &-content {
color: @alert-warning-color-text-content_title;
}
&-error &-title {
color: @alert-error-color-text-title;
}
&-error &-content {
color: @alert-error-color-text-content;
}
&-error&-with-title &-content {
color: @alert-error-color-text-content_title;
}
&-icon-wrapper {
margin-right: @alert-margin-icon-right;
height: @alert-line-height * @alert-font-size-text-content;
display: flex;
align-items: center;
svg {
font-size: @alert-font-size-icon;
}
}
&-with-title &-icon-wrapper {
height: @alert-title-line-height * @alert-font-size-text-title;
}
&-with-title &-icon-wrapper svg {
font-size: @alert-font-size-icon_with_title;
}
&-info &-icon-wrapper svg {
color: @alert-info-color-icon;
}
&-success &-icon-wrapper svg {
color: @alert-success-color-icon;
}
&-warning &-icon-wrapper svg {
color: @alert-warning-color-icon;
}
&-error &-icon-wrapper svg {
color: @alert-error-color-icon;
}
&-close-btn {
box-sizing: border-box;
padding: 0;
border: none;
outline: none;
font-size: @alert-font-size-close-icon;
color: @alert-color-close-icon;
background-color: transparent;
cursor: pointer;
transition: color @transition-duration-1 @transition-timing-function-linear;
margin-left: @alert-margin-close-icon-left;
top: 4px;
right: 0;
&:hover {
color: @alert-color-close-icon_hover;
}
}
&-action + &-close-btn {
margin-left: @alert-margin-action-right;
}
&-action {
margin-left: @alert-margin-action-left;
}
&-with-title &-close-btn {
margin-top: 0;
margin-right: 0;
}
}
src/alert/style/index.ts
import "../../style/index.less";
import "./index.less";
src/index.ts
export { default as Alert } from "./alert";
调试开发
引入 storybook ,这个步骤依赖 build 后的产物
# Add Storybook:
npx sb init
接下来,要让这个 Alert 在 storybook 里跑起来,帮助调试组件;同时在开发不同组件功能时,可以创建不同的 demo,除了用作调试,也是极好的使用文档
修改.storybook/main.js
,并写入以下内容:
// module.exports = {
// "stories": [
// "../stories/**/*.stories.mdx",
// "../stories/**/*.stories.@(js|jsx|ts|tsx)"
// ],
// "addons": [
// "@storybook/addon-links",
// "@storybook/addon-essentials"
// ],
// "framework": "@storybook/react"
// }
const path = require("path");
const lessRegex = /\.less$/;
const lessModuleRegex = /\.module\.less$/;
function getLoaderForStyle(isCssModule) {
return [
{
loader: "style-loader",
},
{
loader: "css-loader",
options: isCssModule ? { modules: true } : {},
},
{
loader: "less-loader",
options: {
javascriptEnabled: true,
},
},
];
}
module.exports = {
stories: ["../stories/index.stories.js"],
webpackFinal: (config) => {
config.resolve.alias["@self/icon"] = path.resolve(__dirname, "../icon");
config.resolve.alias["@self"] = path.resolve(__dirname, "../esm");
// config.resolve.modules = ['node_modules', path.resolve(__dirname, '../site/node_modules')];
// 解决 webpack 编译警告
config.module.rules[0].use[0].options.plugins.push([
"@babel/plugin-proposal-private-property-in-object",
{ loose: true },
]);
// 支持 import less
config.module.rules.push({
test: lessRegex,
exclude: lessModuleRegex,
use: getLoaderForStyle(),
});
// less css modules
config.module.rules.push({
test: lessModuleRegex,
use: getLoaderForStyle(true),
});
// 支持 import svg
const fileLoaderRule = config.module.rules.find(
(rule) => rule.test && rule.test.test(".svg")
);
fileLoaderRule.exclude = /\.svg$/;
config.module.rules.push({
test: /\.svg$/,
loader: ["@svgr/webpack"],
});
return config;
},
};
添加stories/index.stories.js
,并写入以下内容:
import React from "react";
import { storiesOf } from "@storybook/react";
import "./index.less";
import "../dist/css/index.less";
import DemoAlert from "./components/alert";
const components = storiesOf("Components", module);
const componentsMap = {
Alert: () => <DemoAlert />,
};
Object.keys(componentsMap)
.sort((a, b) => (a > b ? 1 : -1))
.forEach((componentsName) => {
components.add(componentsName, componentsMap[componentsName]);
});
添加 alert 组件 demo 新增 stories/components/alert.jsx
,并写入以下内容:
import React, { Component } from "react";
import { Alert } from "@self";
import { IconBug } from "@self/icon";
class Demo extends Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
return (
<>
<Alert
showIcon
type="info"
title="Info"
content="ContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContent"
style={{ marginTop: 10 }}
/>
<Alert
showIcon
type="success"
title="Success"
content="ContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContentContent"
style={{ marginTop: 10 }}
/>
<Alert
showIcon
type="warning"
title="Warning"
content="Content~"
style={{ marginTop: 10 }}
/>
<Alert
showIcon
type="error"
title="Error"
content="Content~"
style={{ marginTop: 10 }}
/>
<Alert
icon={<IconBug style={{ color: "green" }} />}
type="normal"
title="Normal"
content="Content~"
style={{ marginTop: 10 }}
/>
</>
);
}
}
export default Demo;
最终效果如下
二,构建
组件打包逻辑已单独拆分到 block-cli
中:https://github.com/block-org/block-cli
block-cli
会** 根据宿主环境和配置的不同将源码进行相关处理 **,主要完成以下目标:
- 导出类型声明文件;
- 导出
Commonjs module
/ES module
/UMD
等多种形式产物供使用者引入; - 支持样式文件
css
引入,而非只有less
,减少使用者接入成本; - 支持组件和样式的按需加载。
需要注意的是,以下使用
cjs
指代Commonjs module
,esm
指代ES module
block-scripts
先介绍下 block-org/block-cli 中 block-scripts
的总体设计思路
"scripts": {
"build:umd": "block-scripts build:component:umd",
"build:esm": "block-scripts build:component:esm",
"build:esm:babel": "cross-env BUILD_ENV_TS_COMPILER=babel block-scripts build:component:esm",
"build:cjs": "block-scripts build:component:cjs",
"build:cjs:babel": "cross-env BUILD_ENV_TS_COMPILER=babel block-scripts build:component:cjs",
"build:css": "block-scripts build:component:css",
"build": "npm run clean && npm run build:esm && npm run build:css",
},
构建部分单独拆分为子任务,具体实现拆分如下
program.command("build:component:esm").action(() => {
component.buildESM();
});
program.command("build:component:cjs").action(() => {
component.buildCJS();
});
program.command("build:component:umd").action(() => {
component.buildUMD();
});
program.command("build:component:css").action(() => {
component.buildCSS();
});
后面介绍各部实现
导出类型声明文件
既然是使用typescript
编写的组件库,那么使用者应当享受到类型系统的好处,可以生成类型声明文件,并在package.json
中定义入口,如下:
package.json
{
"typings": "lib/index.d.ts",
"scripts": {
"build:types": "tsc -p tsconfig.build.json && cpr lib esm"
}
}
值得注意的是:此处使用
cpr
将lib
的声明文件拷贝了一份,并将文件夹重命名为esm
,用于后面存放 ES module 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。最开始的方式是将声明文件单独存放在types
文件夹,但这样只有通过@block-org/block-ui
引入才可以获取提示,而@block-orgblock-ui/esm/xxx
和@block-orgblock-ui/lib/xxx
就无法获取提示。
tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"target": "esnext",
"module": "commonjs",
"jsx": "react",
"declaration": true,
"declarationDir": "lib",
"strict": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true
"emitDeclarationOnly": true
},
"include": ["components", "typings.d.ts"],
"exclude": [
"**/__tests__/**",
"**/demo/**",
"node_modules",
"lib",
"esm"
]
}
执行yarn build:types
,可以发现根目录下已经生成了lib
文件夹(tsconfig.json
中定义的declarationDir
字段)以及esm
文件夹(拷贝而来),目录结构与components
文件夹保持一致,如下:
├── alert
│ ├── index.d.ts
│ ├── alert.d.ts
│ ├── interface.d.ts
│ └── style
│ └── index.d.ts
└── index.d.ts
这样使用者引入npm
包时,便能得到自动提示,也能够复用相关组件的类型定义。但是这样有个问题是要单独打包 types。下面采用的办法是将ts(x)
等文件处理成js
文件的同时生成类型声明文件,这样就可以将两步合并为一个步骤
导出 CJS / ESM 模块
这里将 cjs / ems 模块的处理统一到 compileTS
方法中
const buildESM = () => {
return compileTS({ outDir: DIR_PATH_ESM, type: "esm" });
};
const buildCJS = () => {
return compileTS({ outDir: DIR_PATH_CJS, type: "cjs" });
};
然后使用babel
或tsc
命令行工具进行代码编译处理(实际上很多工具库就是这样做的)
/**
* Compile typescript surport TSC and Babel and more ...
* @param options
* @returns
*/
const compileTS = (options: CompileOptions) => {
print.info("[block-scripts]", `Start to build ${options.type} module...`);
return (
BUILD_ENV_TS_COMPILER === "babel" ? withBabel(options) : withTSC(options)
).then(
() =>
print.success("[block-scripts]", `Build ${options.type} module success!`),
(error) => {
throw error;
}
);
};
这里有个问题是 ts options 从何而来,并且 如何处理 ts options ?
首先,根目录下维护了一份各种模式下输入/输出的常量(已作为默认值)
export const CWD = process.cwd();
export const DIR_NAME_ESM = "esm";
export const DIR_NAME_CJS = "lib";
export const DIR_NAME_UMD = "dist";
export const DIR_NAME_ASSET = "asset";
export const DIR_NAME_SOURCE = "src";
export const DIR_NAME_COMPONENT_LIBRARY = "components";
export const FILENAME_DIST_LESS = "index.less";
export const FILENAME_DIST_CSS = "index.css";
export const FILENAME_STYLE_ENTRY_RAW = "index.js";
export const FILENAME_STYLE_ENTRY_CSS = "css.js";
export const BLOCK_LIBRARY_PACKAGE_NAME_REACT = "@block-org/block-ui";
先看tsc
的封装withTSC
/**
* Build TS with TSC
* @param param0
* @returns
*/
function withTSC({ type, outDir, watch }: CompileOptions) {
const { compilerOptions } = getTSConfig();
let module = type === "esm" ? "es6" : "commonjs";
// Read module filed from the default configuration (es6 / es2020 / esnext)
if (type === "esm") {
const regexpESM = /^esm/i;
if (
typeof tscConfig.module === "string" &&
regexpESM.test(tscConfig.module)
) {
module = tscConfig.module;
} else if (
typeof compilerOptions?.module === "string" &&
regexpESM.test(compilerOptions.module)
) {
module = compilerOptions.module;
}
}
return tsc.compile({
...tscConfig,
module,
outDir,
watch: !!watch,
declaration: type === "esm",
});
}
ts options 的核心就是递归向上遍历 tsconfig.json
并就近合并 ,如下
/**
* Get config from pre / root project tsconfig.json ...
* @param tsconfigPath
* @param subConfig
* @returns
*/
const getTSConfig = (
tsconfigPath = path.resolve(CWD, "tsconfig.json"),
subConfig = { compilerOptions: {} }
) => {
if (fs.pathExistsSync(tsconfigPath)) {
const config = fs.readJsonSync(tsconfigPath);
const compilerOptions = (config && config.compilerOptions) || {};
const subCompilerOptions = (subConfig && subConfig.compilerOptions) || {};
// Avoid overwriting of the compilation options of subConfig
subConfig.compilerOptions = { ...compilerOptions, ...subCompilerOptions };
Object.assign(config, subConfig);
if (config.extends) {
return getTSConfig(
path.resolve(path.dirname(tsconfigPath), config.extends),
config
);
}
return config;
}
return { ...subConfig };
};
tscConfig
来自于/config/tsc.config
的 getConfigProcessor
,会根据 type 匹配 组件库(用户)目录下.config
对应类型的配置
/**
* Get project .config/ processor
* @param configType
* @returns
*/
export default function getConfigProcessor<T = Function>(
configType: "jest" | "webpack" | "babel" | "docgen" | "style" | "tsc"
): T {
const configFilePath = `${CWD}/.config/${configType}.config.js`;
let processor = null;
if (fs.existsSync(configFilePath)) {
try {
processor = require(configFilePath);
} catch (error) {
print.error(
"[block-scripts]",
`Failed to extend configuration from ${configFilePath}`
);
console.error(error);
process.exit(1);
}
}
return processor;
}
综上,配置优先级别为 组件库/用户目录
> blocks-script子包
> block-org根
下面着重看下 babel
编译 tsx
的处理,withBabel.ts
:
/**
* Build TS with babel
* @param param0
* @returns
*/
async function withBabel({ type, outDir, watch }: CompileOptions) {
const tsconfig = getTSConfig();
// The base path of the matching directory patterns
const srcPath = tsconfig.include[0].split("*")[0].replace(/\/[^/]*$/, "");
const targetPath = path.resolve(CWD, outDir);
const transform = (file) => {
// ...
return babelTransform(file.contents, {
...babelConfig,
filename: file.path,
// Ignore the external babel.config.js and directly use the current incoming configuration
configFile: false,
}).code;
};
const createStream = (globs) => {
// ...
transform(file);
};
return new Promise((resolve) => {
const patterns = [
path.resolve(srcPath, "**/*"),
`!${path.resolve(srcPath, "**/demo{,/**}")}`,
`!${path.resolve(srcPath, "**/__test__{,/**}")}`,
`!${path.resolve(srcPath, "**/*.md")}`,
`!${path.resolve(srcPath, "**/*.mdx")}`,
];
createStream(patterns).on("end", () => {
if (watch) {
print.info(
"[block-scripts]",
`Start watching file in ${srcPath.replace(`${CWD}/`, "")}...`
);
// https://www.npmjs.com/package/chokidar
const watcher = chokidar.watch(patterns, {
ignoreInitial: true,
});
const files = [];
const debouncedCompileFiles = debounce(() => {
while (files.length) {
createStream(files.pop());
}
}, 1000);
watcher.on("all", (event, fullPath) => {
print.info(
`[${event}] ${path.join(fullPath).replace(`${CWD}/`, "")}`
);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
if (!files.includes(fullPath)) {
files.push(fullPath);
}
debouncedCompileFiles();
}
});
} else {
resolve(null);
}
});
});
}
通过createStream
方法匹配所有文件模式创建流并执行编译transform
方法,如果开启了 watch
模式会在当次执行完成后监听文件的变化并重新执行 createStream
方法并做了防抖处理
createStream
方法使用 gulp
的流式处理,见下
const createStream = (globs) => {
// https://www.npmjs.com/package/vinyl-fs
return (
vfs
.src(globs, {
allowEmpty: true,
base: srcPath,
})
// https://www.npmjs.com/package/gulp-plumber
// https://www.npmjs.com/package/through2
.pipe(watch ? gulpPlumber() : through.obj())
.pipe(
gulpIf(
({ path }) => {
return /\.tsx?$/.test(path);
},
// https://www.npmjs.com/package/gulp-typescript
// Delete outDir to avoid static resource resolve errors during the babel compilation of next step
gulpTS({ ...tsconfig.compilerOptions, outDir: undefined })
)
)
.pipe(
gulpIf(
({ path }) => {
return !path.endsWith(".d.ts") && /\.(t|j)sx?$/.test(path);
},
through.obj((file, _, cb) => {
try {
file.contents = Buffer.from(transform(file));
// .jsx -> .js
file.path = file.path.replace(path.extname(file.path), ".js");
cb(null, file);
} catch (error) {
print.error("[block-scripts]", `Failed to compile ${file.path}`);
print.error(error);
cb(null);
}
})
)
)
.pipe(vfs.dest(targetPath))
);
};
为什么是
gulp
而不是webpack
或rollup
?因为要做的是代码编译而非代码打包,同时需要考虑到样式处理及其按需加载。
导出 UMD 模块
这部分使用 webpack
进行打包,相关插件和配置较为繁琐,优化细节较多,这里不赘述了,源码查看
处理样式文件
在 block-script
中入口函数是 buildCSS
:
const buildCSS = () => {
print.info("[block-scripts]", "Start to build css...");
return buildStyle().then(
() => print.success("[block-scripts]", "Build css success!"),
(error) => {
throw error;
}
);
};
核心实现都在 buildStyle
方法中:
/**
* Build style
* @returns
*/
export function build() {
return new Promise<void>((resolve) => {
gulp.series(
gulp.parallel(
copyAsset,
copyFileWatched,
compileLess,
handleStyleJSEntry
),
gulp.parallel(distLess, distCss.bind(null, false)),
gulp.parallel(() => resolve(null))
)(null);
});
}
为了同时执行多个子任务这里又借助了gulp.series()
和 gulp.parallel()
.其中编译配置会优先使用组件库目录.config
下的配置逻辑和处理 ts 文件一致。下面依次介绍各个任务的细节
copyAsset
方法:匹配文件模式到指定目录
/**
* Match the resource that matches the entry glob and copy it to the /asset
* @returns Stream
*/
function copyAsset() {
return gulp
.src(assetConfig.entry, { allowEmpty: true })
.pipe(gulp.dest(assetConfig.output));
}
copyFileWatched
方法:将监听目录的文件拷贝到 esm/lib 下 入图片字体等
**
* Copy the files that need to be monitored to the esm/lib directory
* @returns
*/
function copyFileWatched() {
const patternArray = cssConfig.watch;
const destDirs = [cssConfig.output.esm, cssConfig.output.cjs].filter((path) => !!path);
if (destDirs.length) {
return new Promise((resolve, reject) => {
let stream: NodeJS.ReadWriteStream = mergeStream(
patternArray.map((pattern) =>
gulp.src(pattern, { allowEmpty: true, base: cssConfig.watchBase[pattern] })
)
);
destDirs.forEach((dir) => {
stream = stream.pipe(gulp.dest(dir));
});
stream.on('end', resolve).on('error', (error) => {
print.error('[block-scripts]', 'Failed to build css, error in copying files');
console.error(error);
reject(error);
});
});
}
return Promise.resolve(null);
}
compileLes
方法:编译 less 文件并输出到 esm / lib 目录下
/**
* Compile less, and output css to at esm/lib
* @returns
*/
function compileLess() {
const destDirs = [cssConfig.output.esm, cssConfig.output.cjs].filter(
(path) => path
);
if (destDirs.length) {
let stream = gulp
.src(cssConfig.entry, { allowEmpty: true })
.pipe(cssConfig.compiler(cssConfig.compilerOptions))
.pipe(cleanCSS());
destDirs.forEach((dir) => {
stream = stream.pipe(gulp.dest(dir));
});
return stream.on("error", (error) => {
print.error(
"[block-scripts]",
"Failed to build css, error in compiling less"
);
console.error(error);
});
}
return Promise.resolve(null);
}
handleStyleJSEntry
方法:处理样式入口(css.js/index.js)并注入依赖的样式
export default async function handleStyleJSEntry() {
await compileCssJsEntry({
styleJSEntry: jsEntryConfig.entry,
outDirES: cssConfig.output.esm,
outDirCJS: cssConfig.output.cjs,
});
if (jsEntryConfig.autoInjectBlockDep) {
await injectBlockDepStyle(getComponentDirPattern([DIR_NAME_ESM]));
}
renameStyleEntryFilename();
}
/**
* Generate /style/css.js
*/
async function compileCssJsEntry({
styleJSEntry,
outDirES,
outDirCJS,
}: {
/** Glob of css entry file */
styleJSEntry: string[];
/** Path of ESM */
outDirES: string;
/** Path of CJS */
outDirCJS: string;
}) {
// ...
}
distLess
方法:dist 目录 less 生成
/**
* Dist all less files to dist
* @param cb
*/
function distLess(cb) {
const { path: distPath, rawFileName } = cssConfig.output.dist;
let entries = [];
cssConfig.entry.forEach((e) => {
entries = entries.concat(glob.sync(e));
});
if (entries.length) {
const texts = [];
entries.forEach((entry) => {
// Remove the first level directory
const esEntry = cssConfig.output.esm + entry.slice(entry.indexOf("/"));
const relativePath = path.relative(distPath, esEntry);
const text = `@import "${relativePath}";`;
if (esEntry.startsWith(`${cssConfig.output.esm}/style`)) {
texts.unshift(text);
} else {
texts.push(text);
}
});
fs.outputFileSync(`${distPath}/${rawFileName}`, texts.join("\n"));
}
cb();
}
distCss
方法:dist 目录 css 生成
/**
* Compile the packaged less into css
* @param isDev
* @returns
*/
function distCss(isDev: boolean) {
const { path: distPath, rawFileName, cssFileName } = cssConfig.output.dist;
const needCleanCss =
!isDev && (!BUILD_ENV_MODE || BUILD_ENV_MODE === "production");
const stream = gulp
.src(`${distPath}/${rawFileName}`, { allowEmpty: true })
.pipe(cssConfig.compiler(cssConfig.compilerOptions));
// Errors should be thrown, otherwise it will cause the program to exit
if (isDev) {
notifyLessCompileResult(stream);
}
return stream
.pipe(
// The less file in the /dist is packaged from the less file in /esm, so its static resource path must start with ../esm
replace(
new RegExp(`(\.{2}\/)+${cssConfig.output.esm}`, "g"),
path.relative(cssConfig.output.dist.path, assetConfig.output)
)
)
.pipe(gulpIf(needCleanCss, cleanCSS()))
.pipe(rename(cssFileName))
.pipe(gulp.dest(distPath))
.on("error", (error) => {
print.error(
"[block-scripts]",
"Failed to build css, error in dist all css"
);
console.error(error);
});
}
一套组合拳下来就实现了组件和样式的分离,dist 目录主要是为 umd 预留。现在用户就可以通过block-ui/esm[lib]/alert/style/index.less
的形式按需引入 less 样式。或者,通过block-ui/esm[lib]/alert/style/css.js
的形式按需引入 css 样式,如下
├── alert
│ ├── index.js
│ ├── interface.js
│ ├── interface.d.js
│ └── style
│ ├── css.js # css 依赖
│ ├── index.js # less 依赖
│ ├── index.css # css 样式
│ └── index.less # less 样式
└── index.js
值得注意的问题:为什么要提供 index.js 和 css.js 文件?若使用者没有使用
less
预处理器,使用的是sass
方案甚至原生css
方案,如何平衡开发者和用户的成本?
理由如下:
- 当然,组件库会打包出一份完整的
css
文件,进行全量引入,无法进行按需引入; - 按需 引用 less 样式(通过
block-ui/esm[lib]/alert/style/index.less
的形式按需引入 less 样式)增加less-loader
,会导致使用成本增加; - 提供一份
style/css.js
文件,引入组件css
样式依赖,而非less
依赖,可以帮助管理样式依赖 (每个组件都与自己的样式绑定,不需要使用者或组件开发者去维护样式依赖),可以以抹平组件库底层差异,既可以保障组件库开发者的开发体验 又能够使用者的使用成本
按需加载
在 package.json 中增加sideEffects
属性,配合ES module
达到tree shaking
效果(将样式依赖文件标注为side effects
,避免被误删除)。
// ...
"sideEffects": [
"dist/*",
"esm/**/style/*",
"lib/**/style/*",
"*.less"
],
// ...
使用以下方式引入,可以做到js
部分的按需加载,但需要手动引入样式:
import { Alert } from "@block-org/block-ui";
import "@block-org/block-ui/esm/alert/style";
也可以使用以下方式引入:
import Alert from "@block-org/block-uiesm/alert";
// or
// import Alert from '@block-org/block-ui/lib/alert';
import "@block-org/block-ui/esm/alert/style";
以上引入样式文件的方式不太优雅,直接入口处引入全量样式文件又和按需加载的本意相去甚远。
使用者可以借助 babel-plugin-import 来进行辅助,减少代码编写量(还是增加了使用成本)
三,测试
本节主要讲述如何在组件库中引入jest以及@testing-library/react,而不会深入单元测试的学习,也不会集成到block-ui
中
如果你对下列问题感兴趣:
- What-单元测试是什么?
- Why-为什么要写单元测试?
- How-编写单元测试的最佳实践?
那么可以看看以下文章:
- Test React apps with React Testing Library:通过一个
<Counter />
的例子延伸,阐述了选择React Testing Library
而非Enzyme
的理由,并对其进行了一些入门教学; - React Testing Library:
@testing-library/react
的官方文档,该库提供的 API 在某个程度上就是在指引开发者进行单元测试的最佳实践; - React Testing Library-examples:
@testing-library/react
的一些实例,提供了各种常见场景的测试; - React 单元测试策略及落地:如标题所示,值得一看。
相关配置
安装依赖:
yarn add jest ts-jest @testing-library/react @testing-library/jest-dom identity-obj-proxy @types/jest @types/testing-library__react -D
- jest: JavaScript 测试框架,专注于简洁明快;
- ts-jest:为
TypeScript
编写jest
测试用例提供支持; - @testing-library/react:简单而完整的
React DOM
测试工具,鼓励良好的测试实践; - @testing-library/jest-dom:自定义的
jest
匹配器(matchers
),用于测试DOM
的状态(即为jest
的except
方法返回值增加更多专注于DOM
的matchers
); - identity-obj-proxy:一个工具库,此处用来
mock
样式文件。
新建jest.config.js
,并写入相关配置,更多配置可参考jest 官方文档-配置,只看几个常用的就可以。
jest.config.js
module.exports = {
verbose: true,
roots: ["<rootDir>/src"],
moduleNameMapper: {
"\\.(css|less|scss)$": "identity-obj-proxy",
"^src$": "<rootDir>/src/index.tsx",
"^src(.*)$": "<rootDir>/src/$1",
},
testRegex: "(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
testPathIgnorePatterns: ["/node_modules/", "/lib/", "/esm/", "/dist/"],
preset: "ts-jest",
testEnvironment: "jsdom",
};
修改package.json
,增加测试相关命令,并且代码提交前,跑测试用例,如下:
package.json
"scripts": {
...
+ "test": "jest", # 执行jest
+ "test:watch": "jest --watch", # watch模式下执行
+ "test:coverage": "jest --coverage", # 生成测试覆盖率报告
+ "test:update": "jest --updateSnapshot" # 更新快照
},
...
"lint-staged": {
"src/**/*.ts?(x)": [
"prettier --write",
"eslint --fix",
+ "jest --bail --findRelatedTests",
"git add"
],
...
}
修改gulpfile.js
以及tsconfig.json
,避免打包时,把测试文件一并处理了。
gulpfile.js
const paths = {
...
- scripts: ['src/**/*.{ts,tsx}', '!src/**/demo/*.{ts,tsx}'],
+ scripts: [
+ 'src/**/*.{ts,tsx}',
+ '!src/**/demo/*.{ts,tsx}',
+ '!src/**/__tests__/*.{ts,tsx}',
+ ],
};
tsconfig.json
{
- "exclude": ["src/**/demo"]
+ "exclude": ["src/**/demo", "src/**/__tests__"]
}
编写测试用例
<Alert />
比较简单,此处只作示例用,简单进行一下快照测试。
在对应组件的文件夹下新建__tests__
文件夹,用于存放测试文件,其内新建index.test.tsx
文件,写入以下测试用例:
src/alert/tests/index.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import Alert from "../alert";
describe("<Alert />", () => {
test("should render default", () => {
const { container } = render(<Alert>default</Alert>);
expect(container).toMatchSnapshot();
});
test("should render alert with type", () => {
const types: any[] = ["info", "warning", "success", "error"];
const { getByText } = render(
<>
{types.map((k) => (
<Alert type={k} key={k}>
{k}
</Alert>
))}
</>
);
types.forEach((k) => {
expect(getByText(k)).toMatchSnapshot();
});
});
});
更新一下快照:
yarn test:update
可以看见同级目录下新增了一个__snapshots__
文件夹,里面存放对应测试用例的快照文件。
再执行测试用例:
yarn test
可以发现通过了测试用例,主要是后续我们进行迭代重构时,都会重新执行测试用例,与最近的一次快照进行比对,如果与快照不一致(结构发生了改变),那么相应的测试用例就无法通过。
对于快照测试,褒贬不一,这个例子也着实简单得很,甚至连扩展的 jest-dom
提供的 matchers
都没用上。
四,站点
站点搭建可选项挺多,国内的 Dumi
国外的 Next.js
Gatsby.js
,或者自己写一套,这里采用 Next.js
集成 Next.js
npx create-next-app@latest --typescript
# or
yarn create next-app --typescript
针对 site
目录下站点应用增加 npm scripts
至 site/package.json
"scripts": {
"dev": "npm run collect-meta && next dev",
"build": "npm run collect-meta && next build",
"start": "next start",
"lint": "next lint",
"collect-meta": "node ../scripts/collect-meta.js"
},
站点骨架
这一步主要是处理站点首页、组件文档、快速开始、指南、定制化等,页面级路由都会放在站点根目录 pages
文件夹下。
目录结构
├── lib # library 公共依赖
├── components # 公共组件
├── data # 数据
├── ... # 其他文件
├── page # 页面级路由
├── zh-cn # 国际化
├── en-us # 国际化
├── components # 组件文档
├── customization # 定制化
├── guide # 快速上手
├── index # 首页
├── _app.ts # 页面配置
└── next.config.js # 站点配置
站点启动前会执行 collect-meta
指令收集 pages
目录下的所有的 .md(x)
文件做一层转换并分组并生成一份路由 meta 信息 放在 lib/data
下
在 pages/**/*.md(x)
定义 meta 信息
//...
export const meta = {
title: "Alert",
group: "General",
};
//...
scripts/collect-meta.js
核心逻辑
(async () => {
try {
// ['en-us','zh-cn']
const locales = (await fs.readdir(pagePrefix)).filter((name) => {
const fullPath = path.join(pagePrefix, name);
return fs.statSync(fullPath).isDirectory();
});
// 收集并转换数据
const sortdMetaData = await Promise.all(
locales.map(async (name) => {
const currentLocale = metaLocales[name] || {};
// '/usr/github_repos/block-ui/site/pages/en-us'
const dir = path.join(pagePrefix, name);
// ['components', 'customization', 'guide', 'index.tsx']
const childDirs = await fs.readdir(dir);
// 递归遍历收集 meta 信息
const data = await getMetadata(childDirs, dir);
const sorted = data.sort((a, b) => weights[a.name] - weights[b.name]);
const translatedData = deepTranslate(sorted, currentLocale);
return {
name,
content: translatedData,
};
})
);
// 写入数据
await Promise.all(
sortdMetaData.map(async (data) => {
const targetPath = getTargetPath(data.name);
await fs.ensureFile(targetPath);
await fs.writeJson(targetPath, data.content);
})
);
} catch (error) {
console.log(error);
process.exit(1);
}
})();
最终按照 /local/folder/title
的格式生成路由,比如 /en-us/components/alert
;一旦pages
目录变化页面路由就会动态更新
最终站点如下
文档补全
集成 mdx
修改next.config.js
// Use MDX with Next.js
const withMDX = require("@next/mdx")({
extension: /\.(md|mdx)?$/,
options: {
rehypePlugins: [
require("@mapbox/rehype-prism"),
require("rehype-join-line"),
],
},
});
// Nextjs config -> https://nextjs.org/docs/api-reference/next.config.js/introduction
/** @type {import('next').NextConfig} */
const nextConfig = {
// ...
};
module.exports = withMDX(nextConfig);
经过前面的操作,可以愉快地进行文档的开发了,例如添加 alert
组件的文档 en-us/components/alert.mdx
import Layout from '../../../lib/components/layout';
import { Alert } from '../../../../esm';
export const meta = {
title: 'Alert',
group: 'General',
};
## Alert
Use `banner = true` to display Alert as a banner on top of the page.
<div>
<Alert
banner
type="info"
showIcon
content="General text"
style={{ marginTop: 4, marginBottom: 20 }}
/>
<Alert banner type="info" showIcon closable content="General text" style={{ marginBottom: 20 }} />
<Alert
banner
type="info"
showIcon
title="General text"
content="Here is an example text"
style={{ marginBottom: 20 }}
/>
<Alert banner type="success" showIcon title="Success text" style={{ marginBottom: 20 }} />
</div>
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;
只需再补充一些组件定义,这篇 markdown 就是所需要的最终组件文档
为什么在 markdown 中可以渲染 React 组件?可以去了解 mdx
部署
包括 Next 文档站点 和 storybook 构建的静态站点部署
Next.js
site 目录集成了 scripts
命令如下
"scripts": {
"dev": "npm run collect-meta && next dev",
"build": "npm run collect-meta && next build",
"start": "next start",
"lint": "next lint",
"collect-meta": "node ../scripts/collect-meta.js"
},
文档站点直接托管到了 vercel
文档站点:https://block-ui-alpha.vercel.app/en-us
StoryBook
执行 以下命令 构建静态产物
"scripts": {
"build-storybook": "build-storybook",
},
也可以使用 Github Actions 自动触发部署,在项目根目录新建.github/workflows/gh-pages.yml
文件,后续 master 触发 push 操作时则会自动触发站点部署,更多配置可自行参阅文档。
name: github pages
on:
push:
branches:
- master # default branch
jobs:
deploy:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- run: yarn run init
- run: yarn run build-storybook
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./storybook-static
StoryBook 站点:https://block-org.github.io/block-ui/
五,发布
前言
本节主要是讲解如何编写脚本完成以下内容:
- 版本更新
- 生成 CHANGELOG
- 推送至 git 仓库
- 组件库打包
- 发布至 npm
- 打 tag 并推送至 git
如果你对这一节不感兴趣,也可以直接使用 np 进行发布,只需要自定义配置一些钩子。
yarn add inquirer child_process util chalk semver @types/inquirer @types/semver -D
package.json
"scripts": {
+ "release": "ts-node ./scripts/release.ts"
},
scripts/release.ts
import inquirer from "inquirer";
import fs from "fs";
import path from "path";
import child_process from "child_process";
import util from "util";
import chalk from "chalk";
import semverInc from "semver/functions/inc";
import { ReleaseType } from "semver";
import pkg from "../package.json";
const exec = util.promisify(child_process.exec);
const run = async (command: string) => {
console.log(chalk.green(command));
await exec(command);
};
const currentVersion = pkg.version;
const getNextVersions = (): { [key in ReleaseType]: string | null } => ({
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 timeLog = (logInfo: string, type: "start" | "end") => {
let info = "";
if (type === "start") {
info = `=> 开始任务:${logInfo}`;
} else {
info = `✨ 结束任务:${logInfo}`;
}
const nowDate = new Date();
console.log(
`[${nowDate.toLocaleString()}.${nowDate
.getMilliseconds()
.toString()
.padStart(3, "0")}] ${info}
`
);
};
/**
* 询问获取下一次版本号
*/
async function prompt(): Promise<string> {
const nextVersions = getNextVersions();
const { nextVersion } = await inquirer.prompt([
{
type: "list",
name: "nextVersion",
message: `请选择将要发布的版本 (当前版本 ${currentVersion})`,
choices: (Object.keys(nextVersions) as Array<ReleaseType>).map(
(level) => ({
name: `${level} => ${nextVersions[level]}`,
value: nextVersions[level],
})
),
},
]);
return nextVersion;
}
/**
* 更新版本号
* @param nextVersion 新版本号
*/
async function updateVersion(nextVersion: string) {
pkg.version = nextVersion;
timeLog("修改package.json版本号", "start");
await fs.writeFileSync(
path.resolve(__dirname, "./../package.json"),
JSON.stringify(pkg)
);
await run("npx prettier package.json --write");
timeLog("修改package.json版本号", "end");
}
/**
* 生成CHANGELOG
*/
async function generateChangelog() {
timeLog("生成CHANGELOG.md", "start");
await run(" npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0");
timeLog("生成CHANGELOG.md", "end");
}
/**
* 将代码提交至git
*/
async function push(nextVersion: string) {
timeLog("推送代码至git仓库", "start");
await run("git add package.json CHANGELOG.md");
await run(`git commit -m "v${nextVersion}" -n`);
await run("git push");
timeLog("推送代码至git仓库", "end");
}
/**
* 组件库打包
*/
async function build() {
timeLog("组件库打包", "start");
await run("npm run build");
timeLog("组件库打包", "end");
}
/**
* 发布至npm
*/
async function publish() {
timeLog("发布组件库", "start");
await run("npm publish");
timeLog("发布组件库", "end");
}
/**
* 打tag提交至git
*/
async function tag(nextVersion: string) {
timeLog("打tag并推送至git", "start");
await run(`git tag v${nextVersion}`);
await run(`git push origin tag v${nextVersion}`);
timeLog("打tag并推送至git", "end");
}
async function main() {
try {
const nextVersion = await prompt();
const startTime = Date.now();
// =================== 更新版本号 ===================
await updateVersion(nextVersion);
// =================== 更新changelog ===================
await generateChangelog();
// =================== 代码推送git仓库 ===================
await push(nextVersion);
// =================== 组件库打包 ===================
await build();
// =================== 发布至npm ===================
await publish();
// =================== 打tag并推送至git ===================
await tag(nextVersion);
console.log(
`✨ 发布流程结束 共耗时${((Date.now() - startTime) / 1000).toFixed(3)}s`
);
} catch (error) {
console.log("💣 发布失败,失败原因:", error);
}
}
main();
其他
每次初始化一个组件就要新建许多文件(夹),复制粘贴也可,不过还可以使用更高级一点的偷懒方式。
思路如下:
- 创建组件模板,预留动态信息插槽(组件名称,组件描述等等);
- 基于
inquirer.js
询问动态信息; - 将信息插入模板,渲染至
components
文件夹下; - 向 components/index.ts 插入导出语句。
只需要配置好模板以及问题,至于询问以及渲染就交给plop.js
yarn add plop --dev
新增脚本命令。
package.json
"scripts": {
+ "new": "plop --plopfile ./scripts/plopfile.ts",
},
新增配置文件 scripts/plopfile.ts
const path = require("path");
module.exports = function (plop) {
plop.setGenerator("component", {
description: "新增一个新组件",
prompts: [
{
type: "input",
name: "name",
message: "请输入组件名称(多个单词以中横线命名)",
},
{ type: "input", name: "CN", message: "请输入组件中文名称" },
{ type: "input", name: "description", message: "请输入组件描述" },
],
actions: [
{
type: "add",
path: path.resolve(
__dirname,
"../components/{{kebabCase name}}/index.ts"
),
templateFile: path.resolve(
__dirname,
"../templates/component/index.hbs"
),
},
// ...
{
type: "append",
path: path.resolve(__dirname, "../components/index.ts"),
pattern: "/* PLOP_INJECT_EXPORT */",
template:
"export { default as {{pascalCase name}} } from './{{kebabCase name}}';",
},
],
});
};
新增组件模板templates/component
见 https://github.com/block-org/block-ui/tree/master/templates/component
references
- https://dev.to/alexeagleson/how-to-create-and-publish-a-react-component-library-2oe
- https://github.com/worldzhao/blog/issues/3
- https://github.com/worldzhao/blog/issues/4
- https://github.com/worldzhao/blog/issues/5
- https://github.com/worldzhao/blog/issues/6
- https://github.com/worldzhao/blog/issues/7
- https://arco.design/react/docs/start
作者:shanejix
出处:https://www.shanejix.com/posts/从 0 到 1 搭建 React UI 组件库/
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
声明:转载请注明出处!