xiaowei-ui11 v1.0.0
React 组件库开发
准备工作
初始化项目
新建项目文件夹并初始化
mkdir xiaowei-ui
cd xiaowei-ui
npm init -y
git init
mkdir components
touch components/index.ts # 组件入口文件
代码规范
vscode 必要插件
- eslint
- Prettier - Code formatter
- stylelint
在 TypeScript 中使用 ESLint
这部分的参考文档:https://juejin.cn/post/6844903513202409485#heading-9, 大部分我都是直接引用的,写的非常好。
安装 ESLint
项目中使用 eslint 无法识别 TypeScript 的一些语法,故我们需要安装 @typescript-eslint/parser
,替代掉默认的解析器, 同时也要安装typescript
,@typescript-eslint/eslint-plugin 它作为 eslint 默认规则的补充,提供了一些额外的适用于 ts 语法的规则。
yarn add eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin -D
创建配置文件
配置文件的名称一般是 .eslintrc.js
或 .eslintrc.json
。
in .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
// 这里定义规则,根据项目问题情况,按需补充即可
}
};
在 VSCode 中集成 ESLint 检查
新增配置文件.vscode/settings.json,内容如下:
{
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true # stylelint规则自动格式化
}
}
使用 Prettier 修复格式错误
ESLint 包含了一些代码格式的检查,比如空格、分号等。但前端社区中有一个更先进的工具可以用来格式化代码,那就是 Prettier。
Prettier 聚焦于代码的格式化,通过语法分析,重新整理代码的格式,让所有人的代码都保持同样的风格。
首先需要安装 Prettier:
yarn add prettier -D
然后创建一个 prettier.config.js
文件,里面包含 Prettier 的配置项。Prettier 的配置项很少,这里我推荐大家一个配置规则,作为参考:
// prettier.config.js or .prettierrc.js
module.exports = {
// 一行最多 100 字符
printWidth: 100,
// 使用 2 个空格缩进
tabWidth: 2,
// 不使用缩进符,而使用空格
useTabs: false,
// 行尾需要有分号
semi: true,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾不需要逗号
trailingComma: 'none',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'always',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// 换行符使用 lf
endOfLine: 'lf'
};
将Prettier 作为 ESLint 规则加入,这样可以在 vscode 中直接看到错误提示
yarn add eslint-plugin-prettier -D
修改 eslint 配置增加
{
"plugins": ["prettier"],
"rules": {
"prettier/prettier": "error",
}
}
这样就实现了保存文件时自动格式化并且自动修复 ESLint 错误。
需要注意的是,由于 ESLint 也可以检查一些代码格式的问题,所以在和 Prettier 配合使用时,我们一般会把 ESLint 中的代码格式相关的规则禁用掉,否则就会有冲突了。
我们可以使用 eslint-config-prettier 关闭有冲突的 eslint 配置
yarn add eslint-config-prettier -D
在.eslintrc.js
中修改
{
...
- "plugins": ["prettier"],
+ "extends": ["plugin:prettier/recommended"]
...
}
使用 AlloyTeam 的 ESLint 配置
ESLint 原生的规则和 @typescript-eslint/eslint-plugin
的规则太多了,而且原生的规则有一些在 TypeScript 中支持的不好,需要禁用掉。
这里我推荐使用 AlloyTeam ESLint 规则中的 TypeScript 版本,它已经为我们提供了一套完善的配置规则,并且与 Prettier
是完全兼容的(eslint-config-alloy 不包含任何代码格式的规则,代码格式的问题交给更专业的 Prettier 去处理)。
注意:这里我们不需要 eslint-config-prettier ,因为 AlloyTeam
和Prettier
不冲突。
yarn add eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react eslint-config-alloy -D
这里是最终配置.eslintrc.js
module.exports = {
extends: ['alloy', 'alloy/react', 'alloy/typescript'],
plugins: ['prettier'],
env: {
browser: true,
jest: true
},
globals: {
// 您的全局变量(设置为 false 表示它不允许被重新赋值)
// myGlobal: false
},
rules: {
'prettier/prettier': 'error'
// 自定义您的规则
}
};
VSCode 没有显示出 ESLint 的报错
- 检查「文件 => 首选项 => 设置」中有没有配置正确
- 检查必要的 npm 包有没有安装
- 检查
.eslintrc.js
有没有配置 - 检查文件是不是在
.eslintignore
中
如果以上步骤都不奏效,则可以在「文件 => 首选项 => 设置」中配置 "eslint.trace.server": "messages"
,按 Ctrl
+Shift
+U
打开输出面板,然后选择 ESLint 输出,查看具体错误。
TS 中有些类型变量定义了没有使用,ESLint 却没有报错?
因为无法支持这种变量定义的检查。建议在 tsconfig.json
中添加以下配置,使 tsc
编译过程能够检查出定义了未使用的变量:
{
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
配置 Stylelint
我们使用 stylelint 来格式化 scss 文件
yarn add --dev stylelint stylelint-scss stylelint-config-sass-guidelines
in .stylelintrc.js
module.exports = {
extends: ['stylelint-scss', 'stylelint-config-sass-guidelines'],
rules: {
'declaration-colon-space-after': 'always-single-line',
'declaration-colon-space-before': 'never',
'declaration-block-trailing-semicolon': 'always',
'rule-empty-line-before': [
'always',
{
ignore: ['after-comment', 'first-nested']
}
]
}
};
git hooks
代码提交前合规性检查和提交信息规范配置
husky
可以让 git hooks 变得更简单,在特定的重要动作触发自定义脚本。
yarn add husky is-ci -D
in package.json
"scripts": {
"prepare": "is-ci || husky install"
},
lint-staged
lint-staged
在我们提交代码时,只会对修改的文件进行检查、修复处理,以保证提交的代码没有语法错误,不会影响其他伙伴在更新代码无法运行的问题。
yarn add lint-staged -D
In .lintstagedrc
{
"*.{js,jsx,tx,tsx}": "eslint --fix",
"*.{html,css,scss,sass}": "stylelint --fix"
}
增加 pre-commit 钩子
yarn husky add .husky/pre-commit 'yarn lint-staged --allow-empty "$1"'
commitlint
git 提交信息规范化,用来帮助我们在多人开发时,遵守 git 提交约定。
yarn add @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog -D
in .commitlintrc.js
module.exports = { extends: ['@commitlint/config-conventional'] };
in package.json
// ...
"scripts": {
"commit": "git-cz",
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
增加 commit-msg 钩子
yarn husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
此时可以使用 yarn commit 还代替 git commit 生成提交信息
开发与联调
我们使用Storybook进行开发联调。Storybook
是用于 UI 开发的工具。 通过隔离组件,可以更快,更轻松地进行开发。 这使您可以一次处理一个组件。 您可以开发整个 UI,而无需启动复杂的开发堆栈。
安装Storybook
# 在当前项目中执行
npx sb init
修改配置文件.storybook/main.js
const path = require('path');
module.exports = {
stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
{
name: '@storybook/addon-postcss', // 需要单独安装,转化css(自动添加前缀等),需要提供配置文件
options: {
postcssLoaderOptions: {
implementation: require('postcss') // 需要单独安装
}
}
}
],
babel: async (options) => ({
...options
// 这里重写babel配置
}),
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// Make whatever fine-grained changes you need
config.module.rules.push({
// 支持解析scss, 需要单独安装sass-loader、node-sass
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
include: [path.resolve(__dirname, '../components'), path.resolve(__dirname, '../stories')]
});
// 这里重写webpack配置
return config;
}
};
移动端的样式适配
在 scss 里我们使用 vw/vh 方案进行适配。
定义适配函数
$vw-base: 750;
@function vw($px) {
@return ($px / $vw-base) * 100vw;
}
编写样式文件
.test {
font-size: vw(36);
font-weight: bold;
line-height: vw(50);
padding-top: vw(30);
}
编写第一个组件
在 component 中新建一个文件夹 mask
类型定义
编写组件参数类型interface.ts
, storybook 可以通过这里的定义自动生成属性描述,必须规范编写。
import type React from 'react';
export interface MaskProps {
/**
* 样式前缀
*/
prefixCls?: string;
/**
* 类名
*/
className?: string;
/**
* style
*/
style?: React.CSSProperties;
/**
* 控制蒙层显示和隐藏
*/
visible?: boolean;
/**
* 背景
*/
backgroundColor?: string;
/**
* z轴层级
*/
zIndex?: number;
/**
* 锁定背景不可滑动
*/
closeBody?: boolean;
/**
* 神策埋点传递的名称
*/
name?: string;
/**
* 点击回调, 可以在该回调中关闭蒙层
*/
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
组件逻辑
组件内容,新建mask.tsx
import React, { useEffect, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import { emptyObj, setBgScrollStatus } from '../utils/tools';
import { MaskProps } from './interface';
export const Mask: React.FC<MaskProps> = ({
prefixCls = 'xiaowei-ui',
className = '',
style = {},
backgroundColor,
zIndex,
visible = false,
closeBody = true,
onClick = () => {},
...props
}) => {
const [show, setShow] = useState(false);
useEffect(() => {
setShow(visible);
}, [visible]);
useEffect(() => {
if (closeBody) {
setBgScrollStatus(show);
}
return () => {
if (closeBody) {
setBgScrollStatus(false);
}
};
}, [show, closeBody]);
return (
<CSSTransition in={show} timeout={300} classNames="mask">
<div
style={emptyObj({ ...style, backgroundColor, zIndex })}
onClick={onClick}
className={`${prefixCls}-mask ${className}`}
{...props}
/>
</CSSTransition>
);
};
export default Mask;
新建index.tsx
导出组件
import Mask from './mask';
export default Mask;
组件样式
编写组件样style/index.scss
@import '../../style/index';
.#{$prefix-cls}-mask {
background-color: rgba(0, 0, 0, 0.5);
height: 0%;
left: 0;
overflow: hidden;
position: fixed;
top: 0;
width: 100%;
z-index: 999;
&.mask-enter {
height: 100%;
opacity: 0;
}
&.mask-exit-done {
height: 0;
opacity: 0;
}
&.mask-enter-active {
opacity: 1;
transition: opacity 300ms;
}
&.mask-enter-done,
&.mask-exit {
height: 100%;
opacity: 1;
}
&.mask-exit-active {
opacity: 0;
transition: opacity 300ms;
}
}
新建一个 index.ts 文件导入样式, 这个文件是用来独立编译组件样式,进行按需加载时使用的。
import './index.scss';
编写 stories
编写一个 stories,stories/mask/Mask.stories.tsx
import React from 'react';
import type { Story, Meta } from '@storybook/react';
import type { MaskProps } from '../../components/mask/interface';
import { Mask } from '../../components';
import '../../components/mask/style';
const metaData: Meta = {
title: 'Mask',
component: Mask,
parameters: { actions: { argTypesRegex: '^on.*' } },
argTypes: {
backgroundColor: { control: 'color' },
zIndex: { control: 'number' },
closeBody: {
defaultValue: false,
control: 'boolean'
},
style: {
defaultValue: {
position: 'absolute'
},
control: 'object'
}
}
};
export default metaData;
const Template: Story<MaskProps> = (args) => (
<div style={{ width: '100%', height: '200px', position: 'relative' }}>
<Mask {...args} />
</div>
);
export const Basic = Template.bind({});
Basic.args = {
visible: false
};
plop创建组件模板
- 安装依赖包
yarn add plop -D
- 准备模板文件
在当前目录下新建文件夹 plop-templates,将模板文件放入其中。这里的模板需要根据实际情况去添加。
In plop-templates/component/index.tsx
import ComponentName from './ComponentName';
export default ComponentName;
In plop-templates/component/interface.ts
import type React from 'react';
export interface ComponentNameProps {
/**
* 样式前缀
*/
prefixCls?: string;
/**
* 组件className
*/
className?: string;
}
In plop-templates/component/ComponentName.tsx
import React from 'react';
import { ComponentNameProps } from './interface';
export const ComponentName: React.FC<ComponentNameProps> = ({
prefixCls = 'xiaowei-ui',
className = '',
...props
}) => {
return (
<div className={`${prefixCls}-camel2Dash ${className}`} {...props}>
ComponentName
</div>
);
};
export default ComponentName;
In plop-templates/component/style/index.scss
@import '../../style/index'; // 公共样式、变量等内容
.#{$prefix-cls}-camel2Dash {
display: flex;
}
In plop-templates/component/style/index.ts
import './index.scss';
- 在项目根目录下创建文件
plopfile.js
const camelCase = (text) => {
return text
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/(^[-])/, '');
};
const transform = (fileContents, { ComponentName }) => {
const CamelCase = camelCase(ComponentName);
return fileContents.replace(/ComponentName/g, ComponentName).replace(/camel2Dash/g, CamelCase);
};
module.exports = function (plop) {
plop.setHelper('CamelCase', camelCase);
plop.setGenerator('controller', {
description: '新建组件模板',
prompts: [
{
type: 'input',
name: 'ComponentName',
message: '请输入组件名称, 例如:DatePicker:',
validate: (name) => {
return /^[a-zA-Z]+$/.test(name);
}
}
],
actions: [
{
type: 'addMany',
destination: 'components/{{CamelCase ComponentName}}/',
base: 'plop-templates/component',
templateFiles: [
'plop-templates/component/**/*',
'!plop-templates/component/ComponentName.tsx'
],
force: true,
transform
},
{
type: 'add',
path: 'components/{{CamelCase ComponentName}}/{{ComponentName}}.tsx',
templateFile: 'plop-templates/component/ComponentName.tsx',
force: true,
transform
},
{
type: 'add',
path: 'stories/{{CamelCase ComponentName}}/{{ComponentName}}.stories.tsx',
templateFile: 'plop-templates/storie/ComponentName.stories.tsx',
force: true,
transform
},
{
type: 'modify',
path: 'components/index.ts',
transform: (fileContents, { ComponentName }) => {
const CamelCase = camelCase(ComponentName);
return fileContents.replace(
/(export.*) \}/,
`import ${ComponentName} from './${CamelCase}';
$1, ${ComponentName} }`
);
}
}
]
});
};
- 在
package.json
{
...,
"scripts": {
"new": "plop"
},
...
}
- 执行脚本,输入组件名称,将通过模板创建组件
yarn new
组件测试
TODO
组件库打包
库文件入口
main
是包入口,Node 环境下使用的入口,可以是 CommonJS 格式或者是 umd(兼容了 AMD和
CommonJS)格式unpkg
定义浏览器环境使用的入口,命名:name.min.jsmodule
定义 ES6 模块打包的入口,格式是 ES6,命名:name.es.js
如果这三个入口都配置了,相当于我们同时发布了三个模块规范的版本,当打包工具遇到我们的模块的时候,会通过判断是否支持 pkg.module(ES6),而优先使用 ES6 模块。
打包工具分析
Webpack
通过库的打包方式,可以将组件库打包成 umd 格式的一个文件,如果要实现按需加载,可以将每一个组件单独打包成 umd 格式,Webpack
不支持 ES6 模块的导出。{ entry: { alert:'', button:'' }, output: { path: 'lib', filename: '[name]/index.js', library: 'xiaowei-ui', libraryTarget: 'umd' } }
Rollup
可以通过 format 参数将代码 打包成 任何你想要的格式(AMD, CommonJS, ES6, UMD 等等)。但是 Rollup 无法支持代码拆分和运行时态的动态导入 dynamic imports at runtime。可以打包的格式参数
amd
– 异步模块定义,用于像 RequireJS 这样的模块加载器cjs
– CommonJS,适用于 Node 和 Browserify/Webpackes
– 将软件包保存为 ES 模块文件,在现代浏览器中可以通过<script type=module>
标签引入iife
– 一个自动执行的功能,适合作为<script>
标签。(如果要为应用程序创建一个捆绑包,您可能想要使用它,因为它会使文件大小变小。)umd
– 通用模块定义,以amd
,cjs
和iife
为一体system
- SystemJS 加载器格式
打包配置文件中使用 format 字段指定打包格式
export default { input: 'src/main.js', output: { file: 'bundle.js', format: 'es' } };
在组件库的构建上一般情况下Rollup
是最好的选择,但是在应用程序的构建上Webpack
是最好的选择。
Tree Shaking
这是 webpack 打包工具的官方描述:
tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import
和 export
。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。
webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony modules)和未使用模块检测能力。新的 webpack 4 正式版本扩展了此检测能力,通过 package.json
的 "sideEffects"
属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 "pure(纯正 ES2015 模块)",由此可以安全地删除文件中未使用的部分。
其他的配置参考:https://webpack.docschina.org/guides/tree-shaking/#root
明确打包目标
- 如果组件的宿主环境是
js
环境,需要将ts
处理成js
,并生成声明文件。 - 如果组件的宿主环境不想编译
scss
,需要将scss
编译成css
。 - 如果组件的宿主环境不想再对
js
进行编译,需要将 js 编译并压缩成目标浏览器支持的语法。 - 如果宿主环境不支持
es6
格式,需要将js
语法编译成其他格式,比如CommonJS(cjs)
。 - 如果宿主环境想要按需加载组件,又不支持
ES
模块,需要将组件单独打包。 - 如果宿主环境想要按需加载组件,支持
ES
模块,只需要打包组件为ES
模块版本。 - 由于
Tree Shaking
只对 js生效所以样式文件要按需加载,只能单独编译,独立引入。
由于我开发的是内部组件库,我们本身的项目都是用scss,基于此我选择在项目中编译scss,不会对scss进行额外的编译操作。
我们的使用环境有js有ts所有我需要将ts编译成js,并追加声明文件。由于现代的打包工具都支持esm
,所以我只发布了esm
的版本,但是对于eslint检测工具就会报找不到模块,在略检测后webpack打包是正常的。
我本来计划是在组件中导入样式,在使用组件的时候直接引入(import { Modal } from 'xiaowei-uiui'),但是打包后发现,组件会通过Tree Shaking
实现按需,样式文件会全部提取一遍。所以才需要在styles文件夹下定义一个index.ts, 来索引当前组件需要的所有样式文件,进行独立引入。
我最终的打包目标是,编译ts为js(esm和commonjs两个版本),不做额外压缩和目标浏览器处理,复制样式文件到对应的组件下,不做编译处理, 所有的编译转化都交给宿主环境统一处理。
编译 TS 为 ES6 的模块
修改配置tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"target": "esnext",
"jsx": "react",
"strict": true,
"declaration": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["components", "stories", "global.d.ts"]
}
添加配置文件tsconfig.build.json
排除 stories 目录,
{
"extends": "./tsconfig.json",
"include": ["components", "global.d.ts"] // 重写排除 stories
}
增加 gulp 任务复制 scss 文件,新建gulpfile.js
const gulp = require('gulp');
const paths = {
dest: {
es: 'es',
lib: 'lib'
},
styles: ['components/**/*.scss']
};
/**
* 拷贝SCSS文件
*/
function copyScss() {
const { styles, dest } = paths;
return gulp.src(styles).pipe(gulp.dest(dest.es)).pipe(gulp.dest(dest.lib));
}
exports.default = copyScss;
安装gulp
依赖
yarn add gulp -D
修改package.json
...,
"module": "es/index.js", // 指定es模块的入口文件
"main": "lib/index.js",
"types": "es/index.d.ts", // 指定类型声明入口文件
"files": [
"es",
"lib"
],
"scripts": {
"clean": "rimraf es", // 编译前清空目录, 需要安装依赖 yarn add rimraf -D
"build:es": "tsc -m ES6 --declarationDir es --outDir es -p tsconfig.build.json",
"build:cjs": "tsc -m CommonJS --declarationDir lib --outDir lib -p tsconfig.build.json",
"build": "yarn clean && yarn build:es && yarn build:cjs && gulp",
},
"peerDependencies": { // 指定外部依赖的包, 外部依赖的包不要在 dependencies 中配置
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"sass-loader": "^10.1.0"
}
...
组件库发布
- 在npm官网(https://www.npmjs.com/)创建自己的帐户
- npm login
- npm publish // 发布
- npm unpublish 包名 --force // 撤包
发布失败,请按照下面步骤检查
- 用了淘宝镜像源 - 换成 npm 的源。
- 包名重复 - 删掉之前的包,改个名字。
- npm 账户没有验证邮箱 - 验证邮箱。
- vpn 冲突 - 关掉所有 vpn 再次尝试。
快速上手
- 安装
yarn add xiaowei-ui -D
- 使用
import { Modal } from 'xiaowei-ui';
ReactDOM.render(<Modal />, mountNode);
- babel配置
安装 babel-plugin-import ,配置插件。
{
"presets": [["@babel/preset-env", // 只转换新的语法,并不对新的api进行处理
{
"useBuiltIns":"usage", // 按需注入新的API
"corejs": 3,
"modules": false // 编译后不会转化成CommonJS
}
], "@babel/preset-react" ],
"plugins": [
["import", {
"libraryName": "xiaowei-ui",
"libraryDirectory": "es",
"style": true,
"camel2DashComponentName": true
},
"xiaowei-ui"
],
],
}
这个配置会将
import { Modal } from 'xiaowei-ui';
转化为:
import "xiaowei-ui/es/modal/style";// 样式文件按需加载
import _Modal from "xiaowei-ui/es/modal";
- webpack 配置,不能排除 node_modules, 且需要增加 scss-loader
...
{
test: /\.jsx?$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
},
{
test: /\.scss/,
use: [
{ loader: env.buildType === 'dev' ? 'style-loader' : MiniCssExtractPlugin.loader },
{ loader: 'css-loader', options: { sourceMap: env.buildType === 'dev' } },
{ loader: 'postcss-loader', options: { sourceMap: env.buildType === 'dev' } },
{ loader: 'sass-loader', options: { sourceMap: env.buildType === 'dev' } },
],
},
...
参考文档
https://www.npmjs.com/package/lint-staged
https://typicode.github.io/husky/#/
https://juejin.cn/post/6844904160568016910#heading-0
https://juejin.cn/post/6844903513202409485#heading-9
https://github.com/AlloyTeam/eslint-config-alloy#typescript-react
https://blog.csdn.net/qq_39919114/article/details/110238225
https://storybook.js.org/tutorials/intro-to-storybook/react/zh-CN/get-started/
3 years ago