react如何自建组件库,并发布到npm?
一、为什么开发企业专属组件库?
可能有些同学会问,我们有很多开源的UI库可用,为什么还需要自己去开发组件库呢?是的,大部分业务场景下,在一些要求UI不高,或者公司体量不大的情况下,这些开源的库足以满足我们日常开发的需求,但是一但达到一定的量级,公司就会升级视觉,交互,这时候如果有多个项目,共用了一套UI视觉设计,那么你怎么办呢?总不至于每个项目拷贝一份吧,此时我们的UI库就需要独立出来,打包成私有的NPM包,供给公司每个业务系统用。
二、怎么开始开发组件库呢?
开发组件库必不可少的考量,组件库的样式,组件模块化,文档,在这之前我是使用react脚手架魔改,搭配storybook写文档,不过现在我们有了更好的方案,这些配置我们都不用做了,直接使用阿里开源的Dumi方案,他已经为我们配置好了环境和文档,只需要我们按照规范开发就行了
加上最近dumi升级到了2.0版本,使用起来更加的友好,总结起来就是,更好,更快,更便捷,当然更强!所以我们直接开撸
三、安装配置Dumi
1、环境准备
确保正确安装 Node.js 且版本为 14+ 即可。
node -v v14.19.1
2、脚手架
# 先找个地方建个空目录。
mkdir myapp && cd myapp
# 执行npx create-dumi 命令,此时进入cli命令开始操作,如下图所示
等待片刻,完成所以的依赖项目
执行命令:npm run dev会打开一个本地端口为8000的服务
到这里已经成功了30%,让我们继续下面的操作
3、改的像一点
先改个名字,我们打开.dumirc.ts文件,这个是dumi的配置文件修改代码如下
themeConfig: {
name: 'sslnui',
nav: [
{ title: '介绍', link: '/guide' },
{ title: '组件', link: '/components/Foo' },
],
},
没有问题的话就变成了这样
介绍不会写怎么办,不慌,我们去github上拷贝个antd的,
打开docs文件夹下面的guide.md,将内容复制进去,该删除的删除点,然后跑起来就变成了这个样式,是不是瞬间就变的好看了起来,不会写md文件不要怕,没有什么是一个ctrl+v解决不了的问题
四、先来个Button
通过上面的步骤我们基本上完成了文档的创建,编写完成了我们组件库的基本格式,下面让我们进入实战,写写个button组件
在src文件夹下面创建Button文件夹,在该文件夹下面创建index.tsx,index.md,index.less三个文件
1、上个全局样式定义
全局的样式我这里定义在了src/global.less中,我们先在src文件夹下面创建global.less文件
因为我们是示例工程,属性我们就定义一个主题色,如果自己开发,需要定义主题颜色,字体,字号等所有的信息
@sslnui-primary: #a862ea;
2、写个组件
下面我们开始写button组件 首先在Button/index.tsx写入下面的代码
import classNames from 'classnames';
import React, { AnchorHTMLAttributes, ButtonHTMLAttributes, FC } from 'react';
import './index.less';
export type ButtonSize = 'lg' | 'sm';
export type ButtonType = 'primary' | 'default' | 'danger' | 'link';
interface BaseButtonProps {
size?: ButtonSize;
btnType?: ButtonType;
children: React.ReactNode;
href?: string;
disabled?: boolean;
}
type NativeButtonProps = BaseButtonProps &
ButtonHTMLAttributes<HTMLButtonElement>;
type AnchorButtonProps = BaseButtonProps &
AnchorHTMLAttributes<HTMLAnchorElement>;
// 定义 Button 组件默认属性类型
interface ButtonDefaultProps {
btnType?: ButtonType;
}
export type ButtonProps = Partial<
NativeButtonProps & AnchorButtonProps & ButtonDefaultProps
>;
export const Button: FC<ButtonProps> = (props) => {
const { btnType, size, children, href, disabled, ...restProps } = props;
const classes = classNames('btn', btnType, size);
if (btnType === 'link' && href) {
return (
<a className={classes} href={href} {...restProps}>
{children}
</a>
);
} else {
return (
<button
className={classes}
disabled={disabled}
{...restProps}
type="button"
>
{children}
</button>
);
}
};
// 设置 Button 组件的默认属性
Button.defaultProps = {
btnType: 'default',
};
export default Button;
然后写less样式,代码如下:
@import '../global.less';
.btn {
// width: 100px;
padding: 8px 16px;
border-width: 0px;
cursor: pointer;
}
.primary {
border-radius: 8px;
// padding: 8px;
background: @sslnui-primary;
font-family: Source Han Sans CN;
font-size: 14px;
font-weight: 500;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
letter-spacing: 0em;
color: #ffffff;
// transition: all ease-in-out 0.15s;
&:hover {
background: @sslnui-primary;
}
}
.default {
border-radius: 8px;
// padding: 8px 14px;
background: #f0f2f5;
font-family: Source Han Sans CN;
font-size: 14px;
font-weight: 500;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
letter-spacing: 0em;
color: #5b667c;
transition: all ease-in-out 0.15s;
&:hover {
color: #0e1420 !important;
}
}
.link {
outline: none;
position: relative;
display: inline-block;
font-weight: 400;
white-space: nowrap;
text-align: center;
background-image: none;
background-color: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
user-select: none;
touch-action: manipulation;
line-height: 1.5714285714285714;
color: rgba(0, 0, 0, 0.88);
color: @sslnui-primary;
&:hover {
color: @sslnui-primary;
}
}
.danger {
border-radius: 8px;
// padding: 8px 14px;
background: #ffffff;
box-shadow: 2px 0px 6px 0px rgba(0, 0, 0, 0.07);
font-family: Source Han Sans CN;
font-size: 14px;
font-weight: 500;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
letter-spacing: 0em;
color: #0e1420;
}
.disabled {
cursor: not-allowed;
border-radius: 8px;
background: rgba(71, 92, 246, 0.4);
&:hover {
background: rgba(71, 92, 246, 0.4);
}
}
.lg {
width: 120px;
height: 38px;
}
.sm {
width: 100px;
}
最后我们在index.md文件中编写文档
# Button
按钮用于开始一个即时操作。
## 代码演示
import React from 'react';
import { Button } from 'sslnui';
export default () => {
return (
<>
<Button btnType="default">默认按钮</Button>
<Button btnType="primary">主要按钮</Button>
</>
);
};
此时让我们进入到组件目录下,点击button按钮
如果你的组件库也是这个样子,那标志着你已经学会了如何自建组件库的80%
五、代码有测试
程序有测试代码才健壮,下面让我们安装jest生态,对我们的代码进行自动化测试
cnpm i jest @testing-library/react @types/jest ts-jest jest-environment-jsdom jest-less-loader typescript@4 -D
命令执行完毕后,我们在根目录下创建jest.config.js文件
jest配置如下:
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['./src'], // 查找src目录中的文件
collectCoverage: true, // 统计覆盖率
coverageDirectory: 'coverage', // 覆盖率结果输出的文件夹
transform: {
'.(less|css)$': 'jest-less-loader', // 支持less
},
// 单元覆盖率统计的文件
collectCoverageFrom: [
'src/**/*.tsx',
'src/**/*.ts',
],
};
然后再src/Button目录下创建测试文件index.test.tsx,内容如下:
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import Button from './index';
describe('Button组件', () => {
it('能够正确渲染按钮文字', () => {
const buttonText = '正确';
const { getByRole } = render(<Button>{buttonText}</Button>);
const buttonElement = getByRole('button');
expect(buttonElement.innerHTML).toBe(buttonText);
});
it('能够正确渲染主要样式的按钮', () => {
const { getByRole } = render(<Button btnType="primary">主要按钮</Button>);
const buttonElement = getByRole('button');
expect(buttonElement.classList.contains('primary')).toBe(true);
});
it('能够触发点击事件', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<Button btnType="primary" onClick={handleClick}>
点击按钮
</Button>,
);
const buttonElement = getByRole('button');
fireEvent.click(buttonElement);
// 断言函数被调用了一次。
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
配置好后我们在控制台执行npx jest就会对代码库进行全量检查,如果你的控制台也输出这样,表示是成功的
六、修改为按需测试
上面的测试文件每次执行会进行全量测试,这样比较耗时,而且我们不想对未发生更改的组件也进行测试,只想测试我们修改过的文件
下面针对上面的问题,我们修改测试,可以使用git diff --staged --diff-filter=ACMR --name-only命令获取到本次修改的文件列表,然后进行分析需要执行哪些单元测试,通过--findRelatedTests参数去精准执行对应的单元测试文件。
我们在根目录下新建jest.staged.js
内容如下:
const fs = require('fs').promises;
const path = require('path');
const { execSync } = require('child_process');
/** 处理jest只执行本次修改到的工具方法内的测试用例 */
async function start() {
/** 1. 获取git add 的文件的列表 */
const addFiles = execSync(`git diff --staged --diff-filter=ACMR --name-only`)
.toString()
.split('\n');
/** 2. 获取文件的绝对路径 */
const diffFileList = addFiles
.filter(Boolean)
.map((item) => path.join(__dirname, item));
/** 3. 获取src源码目录 */
const srcPath = path.join(__dirname, './src');
/** 4. 记录本次修改的函数方法 */
const diffFileMap = {};
diffFileList.forEach((filePath) => {
if (
filePath.includes(srcPath) &&
(filePath.endsWith('.ts') || filePath.endsWith('.tsx'))
) {
const relativePath = path.relative(srcPath, filePath);
if (relativePath.includes('/')) {
diffFileMap[relativePath.split('/')[0]] = true;
}
}
});
console.log('本次改动的方法', Object.keys(diffFileMap));
/** 5. 找到改动方法下面所有的单元测试文件 */
const list = (
await Promise.all(
Object.keys(diffFileMap).map(async (toolPath) => {
const testsDir = path.join(srcPath, toolPath, '__tests__');
try {
const files = await fs.readdir(testsDir);
return files.map((item) => path.join(testsDir, item));
} catch (error) {
return [];
}
}),
)
).flat();
/** 6. 执行单元测试脚本 */
if (list.length) {
try {
execSync(`npx jest --bail --findRelatedTests ${list.join(/ /)}`, {
cwd: __dirname,
stdio: 'inherit',
});
} catch (error) {
process.exit(1);
}
}
}
start();
然后在package中添加命令
"scripts": { "test:staged": "node jest.staged.js" }
然后再控制台执行npm run test:staged就可以只针对变动的地方测试了
七、打包发布
打包发布很简单,打包时npm给我们配置好的,只需要执行npm run build即可打包出文件,
然后我们去npm官方注册个账号
注册成功后在控制台执行
npm login
,如果能链接到国外的npm,就可以输入账号,密码完成登陆,这里记得翻墙,
完成登陆后执行执行 npm publish
就可以发布到npm了
代码库:https://gitee.com/SongTaoo/myui
好了以上就是我的组件库方案,下面计划分享基于webpack5的微前端架构,这可能需要一些时间来写,所以需要您小小的赞来支持作者持续创作的动力
转载自:https://juejin.cn/post/7283067378258755618