【组件库】搭建UI组件库攻略

DEMO体验

传送门

一、需要哪些能力

  • 独立的开发环境

  • 组件交互式展示

  • 自动生成文档

  • 支持自动化测试

  • 代码提交规范限制

  • 自动化构建、部署

  • 自动管理包依赖

接下来我们逐个实现

二、新建项目、新增一个组件

新建项目

1
npx create-react-app my-app

新建组件

新增一个目录用来存放组件,例如packages,并新建一个Product组件目录,在目录下创建以下3个文件

  • product.tsx

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    import { MouseEventHandler, useCallback, useEffect, useState } from "react";
    import "./product.scss";

    export interface ProductProps {
    /**
    * 布局
    */
    layout?: "horizontal" | "vertical";
    /**
    * 标题
    */
    title: string;
    /**
    * 封面图
    */
    cover?: string;
    /**
    * 利益点
    */
    interest?: string;
    /**
    * 价格
    */
    price: number;
    /**
    * 商品标签
    */
    categorys?: Array<string>;
    /**
    * 价格标签
    */
    tags?: Array<string>;
    /**
    * 购买
    */
    handleBuy: MouseEventHandler<HTMLDivElement>;
    }

    /**
    * 商品卡片
    */
    export const Product: React.FC<ProductProps> = ({
    layout = "horizontal",
    ...props
    }) => {
    const [active, setActive] = useState(false);

    const handleClick = useCallback(() => {
    setActive(true);
    }, []);

    return (
    <div
    className={`com-product layout_${layout} ${active ? "active" : ""}`}
    onClick={handleClick}
    >
    <div className="com-product-cover">
    <img
    src={
    props.cover
    ? props.cover
    : "https://pic.imgdb.cn/item/65f9367d9f345e8d036c28cf.png"
    }
    />
    </div>
    <div className="com-product-content">
    <div className="com-product-title">
    <span className="com-product-category">
    {Array.isArray(props.categorys) &&
    props.categorys.map((category) => {
    return (
    <span key={category} className="com-product-category-item">
    {category}
    </span>
    );
    })}
    </span>
    <span className="com-product-text">{props.title}</span>
    </div>
    <div className="com-product-interest">{props.interest}</div>
    <div className="com-product-tag">
    {Array.isArray(props.tags) &&
    props.tags.map((tag) => {
    return (
    <span key={tag} className="com-product-tag-item">
    {tag}
    </span>
    );
    })}
    </div>
    <div className="com-product-buy">
    <div className="com-product-buy-price">
    {props.price > 0 ? "¥" + props.price : "免费"}
    </div>
    <div onClick={props.handleBuy} className="com-product-buy-button">
    购买
    </div>
    </div>
    </div>
    </div>
    );
    };
  • product.scss

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    .com-product {
    display: flex;
    padding: 10px;
    box-sizing: border-box;
    background: #fff;
    border-radius: 5px;
    font-size: 14px;
    font-family: 'Microsoft YaHei', 'Arial', sans-serif;
    box-shadow: 0 0 5px 2px #eee;

    &.active{
    background: #fefefe;
    }

    &.layout_horizontal {
    width: 730px;
    height: 200px;
    .com-product-cover {
    width: 180px;
    height: 180px;
    }
    .com-product-content {
    margin-left: 10px;
    flex-grow: 1;
    }
    }

    &.layout_vertical {
    width: 200px;
    height: 350px;
    flex-direction: column;
    align-items: center;
    .com-product-cover {
    width: 180px;
    height: 180px;
    }
    .com-product-content {
    margin-top: 10px;
    flex-grow: 1;
    }
    }

    &-cover {
    flex-shrink: 0;
    border-radius: 5px;
    overflow: hidden;
    img {
    height: 100%;
    width: 100%;
    object-fit: contain;
    }
    }
    &-content {
    position: relative;
    }
    &-title {
    display: -webkit-box;
    overflow: hidden;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    text-overflow: ellipsis;
    }
    &-category {
    &-item {
    font-size: 10px;
    background: #ff0000;
    color: #fff;
    margin-right: 5px;
    padding: 1px 2px;
    border-radius: 2px;
    }
    }
    &-text {
    font-weight: bold;
    }

    &-interest {
    color: #ff0000;
    margin-top: 10px;
    }

    &-tag {
    margin-top: 10px;
    &-item {
    border: 1px solid #999;
    font-size: 10px;
    margin-right: 5px;
    padding: 1px 2px;
    border-radius: 2px;
    }
    }

    &-buy {
    position: absolute;
    left: 0;
    bottom: 0;
    display: flex;
    justify-content: space-between;
    width: 100%;
    align-items: center;

    &-price {
    color: #ff0000;
    font-weight: bold;
    }

    &-button {
    color: #fff;
    background: #ff0000;
    padding: 5px 10px;
    border-radius: 5px;
    text-align: center;
    }
    }
    }
  • index.tsx

    1
    2
    3
    4
    import { Product } from "./product";

    export type { ProductProps } from "./product";
    export default Product;

此时我们得到了一个这样的UI组件

三、接入jest实现自动化测试

详见:使用Jest给组件库做单元测试

接入后,Product组件目录会多出一个product.test.tsx文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Product, ProductProps } from "./product";

const productProps: ProductProps = {
title: "测试商品",
cover: "测试封面链接",
interest: "测试利益点",
price: 100,
categorys: ["新品", "促销"],
tags: ["标签1", "标签2"],
handleBuy: function (): void {},
};

describe("Product 组件", () => {
test("渲染商品卡片的内容是否正确", () => {
render(<Product {...productProps} />);

// 检查标题是否正确渲染
const titleElement = screen.getByText(productProps.title);
expect(titleElement).toBeInTheDocument();

// 检查类目是否正确渲染
productProps.categorys?.forEach((category) => {
const categorysElement = screen.getByText(category);
expect(categorysElement).toBeInTheDocument();
});

// 检查利益点是否正确渲染
if (productProps.interest) {
const interestElement = screen.getByText(productProps.interest);
expect(interestElement).toBeInTheDocument();
}

// 检查价格是否正确渲染
const priceElement = screen.getByText(`¥${productProps.price}`);
expect(priceElement).toBeInTheDocument();

// 检查标签是否正确渲染
productProps.tags?.forEach((tag) => {
const tagElement = screen.getByText(tag);
expect(tagElement).toBeInTheDocument();
});

// 检查 "购买" 按钮是否正确渲染
const buyButtonElement = screen.getByText("购买");
expect(buyButtonElement).toBeInTheDocument();
});

test("当标题为空时,不渲染标题", () => {
const propsWithoutInterest: ProductProps = {
...productProps,
title: "",
};

render(<Product {...propsWithoutInterest} />);
const interestElement = screen.queryByTestId("interest");
expect(interestElement).toBeNull();
});

test("当类目数组为空时,不渲染任何类目", () => {
const propsWithoutTags: ProductProps = {
...productProps,
categorys: [],
};

render(<Product {...propsWithoutTags} />);
const tagElements = screen.queryAllByTestId("tag");
expect(tagElements).toHaveLength(0);
});

test("当利益点为空时,不渲染利益点", () => {
const propsWithoutInterest: ProductProps = {
...productProps,
interest: undefined,
};

render(<Product {...propsWithoutInterest} />);
const interestElement = screen.queryByTestId("interest");
expect(interestElement).toBeNull();
});

test("当价格为零时,渲染为免费", () => {
const freeProductProps: ProductProps = {
...productProps,
price: 0,
};

render(<Product {...freeProductProps} />);
const priceElement = screen.getByText("免费");
expect(priceElement).toBeInTheDocument();
});

test("当价格为负数时,渲染为免费", () => {
const negativePriceProps: ProductProps = {
...productProps,
price: -100,
};

render(<Product {...negativePriceProps} />);
const priceElement = screen.getByText("免费");
expect(priceElement).toBeInTheDocument();
});

test("当标签数组为空时,不渲染任何标签", () => {
const propsWithoutTags: ProductProps = {
...productProps,
tags: [],
};

render(<Product {...propsWithoutTags} />);
const tagElements = screen.queryAllByTestId("tag");
expect(tagElements).toHaveLength(0);
});

test("渲染商品卡片的布局是否正确", () => {
const verticalProps: ProductProps = {
...productProps,
layout: "vertical",
};

const { container } = render(<Product {...verticalProps} />);
const productElement = container.querySelector(".com-product");
expect(productElement).toHaveClass("layout_vertical");
});

test("商品标签应该是唯一的", () => {
if (productProps.tags !== undefined) {
const uniqueTags = [...new Set(productProps.tags)];
expect(uniqueTags.length).toBe(productProps.tags.length);
}
});

test("当封面链接为空时,渲染默认封面图", () => {
const propsWithoutCover: ProductProps = {
...productProps,
cover: "",
};

const { container } = render(<Product {...propsWithoutCover} />);
const coverElement = container
.querySelector(".com-product-cover")
?.querySelector("img");
expect(coverElement?.src).toEqual(
"https://pic.imgdb.cn/item/65f9367d9f345e8d036c28cf.png"
);
});

test("点击购买按钮触发购买操作", () => {
// 创建一个 mock 函数来监视回调函数的调用
const mockHandleBuy = jest.fn();

const propsWithoutCover: ProductProps = {
...productProps,
handleBuy: mockHandleBuy,
};

const { container } = render(<Product {...propsWithoutCover} />);
const buyButtonElement = container.querySelector(".com-product-buy-button");
if (buyButtonElement) {
fireEvent.click(buyButtonElement);
}
// 验证回调函数被调用
expect(mockHandleBuy).toHaveBeenCalled();
});

test("点击购买按钮触发异步购买操作", async () => {
// 创建一个 mock 函数来监视回调函数的调用
const mockHandleBuy = jest.fn(async () => {
await setTimeout(() => {
Promise.resolve();
}, 2000);
});

const propsWithoutCover: ProductProps = {
...productProps,
handleBuy: mockHandleBuy,
};

const { container } = render(<Product {...propsWithoutCover} />);
const buyButtonElement = container.querySelector(".com-product-buy-button");
if (buyButtonElement) {
fireEvent.click(buyButtonElement);
}
// 验证回调函数被调用
await waitFor(() => {
expect(mockHandleBuy).toHaveBeenCalled();
});
});

test("点击组件后,组件新增了一个特定的class", () => {
const { container } = render(<Product {...productProps} />);
const productElement = container.querySelector(".com-product");
if (productElement) {
fireEvent.click(productElement);
expect(productElement).toHaveClass("active");
}
});
});

执行自动化测试

四、接入Storybook提供独立环境、文档自动生成

Storybook 是什么

Storybook 是一个用于开发、测试和文档化 UI 组件的开源工具。它提供了一个交互式的开发环境,可以让开发人员在隔离的环境中构建和展示组件,并提供了丰富的文档生成功能。Storybook 支持多种前端框架,如 React、Vue、Angular 等,可以与各种技术栈无缝集成。

Storybook 的主要功能包括:

  • 组件开发环境:Storybook 提供了一个独立的开发环境,可以让开发人员在隔离的环境中开发和测试组件。开发人员可以通过添加故事(Stories)来展示不同状态下的组件,以及各种使用场景。

  • 交互式展示:Storybook 提供了一个交互式的用户界面,可以让用户在不编写代码的情况下浏览和测试组件。用户可以通过 Storybook 界面中的控件来与组件进行交互,查看不同状态下的组件效果。

  • 文档生成:Storybook 可以自动生成组件文档,包括组件的描述、属性列表、示例代码等。开发人员可以通过 Storybook 生成的文档来快速了解组件的用法和功能,提高开发效率。

  • 插件系统:Storybook 提供了丰富的插件系统,可以通过插件来扩展其功能。开发人员可以根据项目需求选择和定制各种插件,以满足特定的开发和测试需求。

如何使用

安装 Storybook CLI

首先,你需要安装 Storybook 的命令行工具(CLI)。你可以使用 npm 或 yarn 进行安装。在终端中执行以下命令:

1
2
3
npm install -g @storybook/cli

npx sb init

选择框架

执行初始化命令后,CLI 会提示你选择要设置的框架。选择你的前端框架(如 React、Vue、Angular 等)。

安装依赖

根据你选择的框架,CLI 会自动下载并安装所需的依赖。这些依赖包括 Storybook 本身以及与所选框架集成的必要插件。

运行 Storybook

安装完成后,执行以下命令启动 Storybook:

1
yarn storybook

访问 Storybook

启动成功后,你可以在浏览器中访问 http://localhost:6006,可以看到官方提供的组件示例。

挂载Product组件

在Product组件目录,在目录下创建以下文件

  • product.stories.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    import type { Meta, StoryObj } from "@storybook/react";
    import { Product } from "./product";

    const meta = {
    title: "Product",
    component: Product,
    parameters: {
    layout: "centered",
    },
    tags: ["autodocs"],
    argTypes: {},
    } satisfies Meta<typeof Product>;

    export default meta;

    type Story = StoryObj<typeof meta>;

    export const 基础: Story = {
    args: {
    title:
    "京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货",
    cover:
    "https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
    price: 68.5,
    handleBuy: () => {},
    },
    };
    export const 利益点: Story = {
    args: {
    title:
    "京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货",
    cover:
    "https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
    interest: "直播间专享价,下单立减2元",
    price: 68.5,
    handleBuy: () => {},
    },
    };
    export const 标签: Story = {
    args: {
    title:
    "京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货",
    cover:
    "https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
    categorys: ["百亿补贴", "新人特惠"],
    tags: ["直播间专项", "拖货包运费", "顺丰发货"],
    price: 68.5,
    handleBuy: () => {},
    },
    };
    export const 垂直布局: Story = {
    args: {
    layout: "vertical",
    title:
    "京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货京鲜生可生食标准鲜鸡蛋30枚礼盒装1.5kg不同产地随机发货",
    cover:
    "https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
    categorys: ["百亿补贴", "新人特惠"],
    tags: ["直播间专项", "拖货包运费", "顺丰发货"],
    interest: "直播间专享价,下单立减2元",
    price: 68.5,
    handleBuy: () => {},
    },
    };

再次访问

可以看到我们自己写的Product组件了,已经具备了独立环境、交互式、自动化文档

文档

故事书

五、接入husky支持代码提交规范

详见:husky 攻略

六、接入webhook或jenkins支持自动化构建或部署

webhook

详见:webhook 攻略

jenkins

详见:使用Jenkins实现自动化部署

七、接入lerna管理包依赖

详见:使用lerna管理包依赖


【组件库】搭建UI组件库攻略
https://www.cccccl.com/20240328/组件库/搭建UI组件库攻略/
作者
Jeffrey
发布于
2024年3月28日
许可协议