前端挑战:前端工程师任务
原帖发布于iamtk.co
这是前端挑战系列的一部分。
今天我完成了一个前端挑战,玩得非常开心。在设计和实现这个功能的过程中,我思考了很多有趣的概念,所以我很想记录并分享我在整个过程中学到的一切。
让我们来谈谈挑战:
技术
- 该项目使用 React 设置
- 他们更喜欢使用TypeScript(或 Flow)
- 他们使用 EmotionJS 作为 CSS 工具
用户界面
我们的想法是创建一个“宾客和房间”覆盖组件。用户可以打开它,添加不同的房间,选择任意数量的成人和儿童,并选择儿童的年龄。
输入规则
组件应该能够传递字符串作为默认数据。以下是规则:
- 房间之间有管道隔开
|
- 成人和儿童之间用冒号分隔
:
- 儿童年龄以逗号分隔
,
例子:
- “1:4,6|3” → 两个房间,一个房间住一名成人和两名四岁和六岁的儿童,另一个房间住三名成人,没有儿童
- “3” → 一间房,可住三名成人,无儿童
- “2:4” → 一间房住两名成人和一名四岁儿童
- “1:0,13,16” → 一个房间,住一名成人和三名儿童(分别为 0 岁、13 岁和 16 岁)
功能要求
- 最多可添加八个房间
- 每个房间至少有一名成人,最多有五名
- 每个房间可容纳零名或多名儿童,最多三名
- 需要提供每个孩子的年龄,以便我们知道提供什么样的床或婴儿床以及房间收费
- 每间客房最多可容纳五人。即每间客房1名成人加1名儿童
- 客人和房间选择器应始终提供有效的房间占用情况,使用按钮禁用来避免无效配置
- 用户可以单击
Search
将输出提交到 URL,或者单击x
顶部重置所选房间选择并将 UI 恢复到原始状态。
现在,在介绍完挑战之后,我想分享一下我将在本文中讨论的主题。主要主题包括:
- 数据结构和状态管理:在本部分中,我们将讨论如何设计 UI 的状态数据结构并在整个组件中管理它。
- UI 和样式:创建可重复使用的组件,使用媒体查询和 react-device-detect 处理响应式设计,以及处理动画。
- 单元测试和集成测试:执行测试以确保我们对功能有信心。单元测试由react-testing-library负责,集成测试由 Cypress 负责。
数据结构和状态管理
我想出了一个数据结构来表示这个 UI,它看起来像这样:
{
rooms: [
{
adultsCount: <number>,
children: [
{
age: <number>,
},
],
},
],
}
TypeScript 实现如下所示:
type Child = {
age: number;
};
type Room = {
adultsCount: number;
children: Child[];
};
type GuestRooms = {
rooms: Room[];
};
示例如下:
const GuestRooms = {
rooms: [
{
adultsCount: 2,
children: [
{
age: 4,
},
],
},
{
adultsCount: 2,
children: [
{
age: 4,
},
{
age: 10,
},
],
},
],
};
现在我们已经定义了数据结构并对其进行了简单的实现,我们可以进入下一部分,即如何使用这些数据以及我们应该提供哪些 API 来更新组件不同部分中的这种状态。
列出所有行为可以更容易地理解我们应该如何处理数据以及我们需要为每个组件提供哪些 API 来更新我们的数据结构。
我画了一张包含所有行为的小图:
让我们在这里列出它们:
- 更新房间 X 的成人人数:
updateAdultsCount
,该函数应接收房间索引和新的人数。该函数的类型约定应为:
(roomIndex: number, count: number) => void
- 更新房间 X 的儿童数量:
addChild
,该函数应接收房间索引,并在儿童列表中添加一个新儿童,其年龄值为 8(默认年龄值)。该函数的类型约定应为:
(roomIndex: number) => void
- 从房间 X :中移除一个子房间
removeChild
,该子房间应接收房间索引和子房间索引。该函数的类型约定应为:
(roomIndex: number, childIndex: number) => void
- 删除房间 X :
removeRoom
,该房间应接收房间索引。该函数的类型约定应为:
(roomIndex: number) => void
- 从房间 X :中选择一个孩子的年龄
updateChild
,该房间应接收房间索引、孩子索引和新的孩子年龄。
(roomIndex: number, childIndex: number, childAge: number) => void
- 添加新房间部分:
addRoom
,这应该只是将新房间添加到房间列表中。
() => void
- 使用选定的房间和客人进行搜索:此功能不会更新我们的数据结构,它只会接收数据结构,将其转换为字符串表示形式,并将结果作为查询参数附加到 url 中。
很好,我们已经拥有了处理组件状态所需的所有 API。现在让我们开始实现它们。
上下文提供者
当我开始实现这个解决方案时,我不想使用任何库或框架来处理状态。我希望它保持非常简单。我从一个useState
钩子开始。但很快,一些有趣(且常见)的问题开始出现。
如果我们有集中式数据,为了能够使用useState
钩子访问它,我们需要通过 props 将状态传递给所有组件。而 prop 钻取在运行时性能和用户体验方面可能是一个大问题。更新状态也存在同样的问题。我需要将这个更新函数作为 props 传递给所有组件。
我遇到的第二个解决方案是使用 Context API,并为每个被 context provider 包装的组件提供状态的数据结构和函数 API,因为我仍然不想使用库。这是我处理状态的解决方案的基础部分。
提供程序非常简单。它应该只是一个包装我们组件并为其提供值的组件。
export const GuestRoomsContext = createContext<GuestRoomsValues>(undefined);
const GUEST_ROOMS_DEFAULT = {
rooms: [
{
adultsCount: 2,
children: [],
},
],
};
type GuestRoomsProviderPropTypes = {
guestRoomsString?: string;
};
export const GuestRoomsProvider: FC<GuestRoomsProviderPropTypes> = ({
children,
guestRoomsString,
}) => {
const defaultGuestRooms = guestRoomsString
? toGuestRooms(guestRoomsString)
: GUEST_ROOMS_DEFAULT;
const [guestRooms, setGuestRooms] = useState<GuestRooms>(defaultGuestRooms);
// ...
return (
<GuestRoomsContext.Provider value={providerValue}>
{children}
</GuestRoomsContext.Provider>
);
};
因此它将接收一个children
和一个guestRoomsString
。接收guestRoomsString
使我们能够传递一个字符串作为数据结构的初始状态。您可以在 中看到GuestRoomsProviderPropTypes
,此 prop 是可选的,因此如果我们不为提供程序传递任何字符串,它应该使用默认值GUEST_ROOMS_DEFAULT
。
我们还使用一个简单的useState
,它应该是我们数据的真实来源。guestRooms
是状态,setGuestRooms
是更新状态的函数 API。
已GuestRoomsContext
创建并导出。我们将在组件中使用此上下文来访问数据和函数 API。我们还使用它来创建提供程序。children
它被此提供程序包装,我们还会看到一个 ,providerValue
稍后会讨论它。
在介绍其他实现之前,我想先简单介绍一下这个toGuestRooms
函数。它只是一个转换器,具体的作用是将字符串格式转换为GuestRooms
数据结构。
我为什么决定这样做?我的方法是为组件创建一个内部数据结构,而不是使用字符串作为状态类型。我认为设计一个更好的数据结构来表示这个 UI 的状态,在管理状态时会大有帮助。它看起来是这样的:
const ROOM_SEPARATOR = '|';
const ADULT_CHILDREN_SEPARATOR = ':';
const CHILDREN_SEPARATOR = ',';
function parseChildren(children: string) {
return children
.split(CHILDREN_SEPARATOR)
.map((age: string) => ({ age: Number(age) }));
}
function parseGuestRooms(guestRooms: GuestRooms, room: string) {
const [adultsCount, childrenString] = room.split(ADULT_CHILDREN_SEPARATOR);
const children = childrenString ? parseChildren(childrenString) : [];
guestRooms.rooms.push({
adultsCount: Number(adultsCount),
children,
});
return guestRooms;
}
export function toGuestRooms(guestRooms: string) {
const rooms = guestRooms.split(ROOM_SEPARATOR);
const guestRoomsInitialValue = { rooms: [] };
return rooms.reduce<GuestRooms>(parseGuestRooms, guestRoomsInitialValue);
}
使用分隔符获取每个有意义的数据并返回GuestRooms
数据结构。
作为一个纯函数,我们可以轻松地测试它。
describe('toGuestRooms', () => {
it('generates GuestRooms based on "1:4,6|3"', () => {
expect(toGuestRooms('1:4,6|3')).toEqual({
rooms: [
{
adultsCount: 1,
children: [
{
age: 4,
},
{
age: 6,
},
],
},
{
adultsCount: 3,
children: [],
},
],
});
});
it('generates GuestRooms based on "3"', () => {
expect(toGuestRooms('3')).toEqual({
rooms: [
{
adultsCount: 3,
children: [],
},
],
});
});
it('generates GuestRooms based on "2:4"', () => {
expect(toGuestRooms('2:4')).toEqual({
rooms: [
{
adultsCount: 2,
children: [
{
age: 4,
},
],
},
],
});
});
it('generates GuestRooms based on "1:0,13,16"', () => {
expect(toGuestRooms('1:0,13,16')).toEqual({
rooms: [
{
adultsCount: 1,
children: [
{
age: 0,
},
{
age: 13,
},
{
age: 16,
},
],
},
],
});
});
});
...以确保其有效并对实施充满信心。
数字输入
现在让我们创建NumberInput
组件,因为它将成为成人计数输入和儿童计数输入的基础。
这个组件非常简单。它只负责处理 UI,并能够接收数据和在必要时触发函数。
类型契约(或 prop 类型)应该是这样的:
type NumberInputPropTypes = {
value: number;
increaseValue: () => void;
decreaseValue: () => void;
minValue: number;
maxValue: number;
};
value
:我们想要向用户展示的价值。increaseValue
:增加数值的功能(表示成人或儿童计数)decreaseValue
:减少值的功能(表示成人或儿童计数)minValue
:组件接受的最小值。禁用“减少”按钮会很有用maxValue
:组件接受的最大值。禁用增加按钮会很有用
就是这样。
我想要使用一个简单的逻辑来禁用(或不禁用)增加和减少按钮。
const isAbleToDecreaseValue = value > minValue;
const isAbleToIncreaseValue = value < maxValue;
const isDecreaseDisabled = value === minValue;
const isIncreaseDisabled = value === maxValue;
const decreaseNumber = () => isAbleToDecreaseValue && decreaseValue();
const increaseNumber = () => isAbleToIncreaseValue && increaseValue();
const decreaseButtonVariant = isDecreaseDisabled ? 'disabled' : 'secondary';
const increaseButtonVariant = isIncreaseDisabled ? 'disabled' : 'secondary';
我不仅想disabled
为按钮添加变体并更改 UI,还想禁用状态更新,因为用户可以通过 DevTools 禁用它,然后就能点击按钮了。这第二个约束可以很好地阻止这种行为。
用户界面如下:
<div>
<Button
disabled={isDecreaseDisabled}
onClick={decreaseNumber}
variant={decreaseButtonVariant}
>
<MinusIcon />
</Button>
<span>{value}</span>
<Button
disabled={isIncreaseDisabled}
onClick={increaseNumber}
variant={increaseButtonVariant}
>
<PlusIcon />
</Button>
</div>
成人计数输入
现在我们有了这个基础组件,我们可以在它的基础上构建AdultsCountInput
和。ChildrenCountInput
实际上它应该非常简单。
type AdultsCountInputPropTypes = {
roomIndex: number;
};
export const AdultsCountInput: FC<AdultsCountInputPropTypes> = ({
roomIndex,
}) => {
const { guestRooms, updateAdultsCount } = useContext(GuestRoomsContext);
const adultsCount = getAdultsCount(guestRooms, roomIndex);
const increaseValue = () => updateAdultsCount(roomIndex, adultsCount + 1);
const decreaseValue = () => updateAdultsCount(roomIndex, adultsCount - 1);
return (
<NumberInput
value={adultsCount}
increaseValue={increaseValue}
decreaseValue={decreaseValue}
minValue={1}
maxValue={5}
/>
);
};
该AdultsCountInput
组件可以接收roomIndex
我们需要的值,以便能够更新给定房间的正确成人数量。
我们使用useContext
传递GuestRoomsContext
来获取guestRooms
和updateAdultsCount
(将在一秒钟内实现)。
但我想重点讲一下第getAdultsCount
一个。我的想法是实现一个“getter”,用来获取成年人的数量。
export function getAdultsCount(guestRooms: GuestRooms, roomIndex: number) {
return guestRooms.rooms[roomIndex].adultsCount;
}
这很简单。它接收guestRooms
和 ,并且应该从特定房间roomIndex
获取。adultsCount
这样,我们就可以使用这个值传递给NumberInput
。
我们还可以看到minValue
和maxValue
:
minValue={1}
maxValue={5}
这是商业规则的一部分。对于成年人来说,应该有这个间隔。
现在我们来谈谈updateAdultsCount
。正如我们之前提到的,它应该具有以下类型定义:
updateAdultsCount: (roomIndex: number, count: number) => void;
在提供程序中,我们可以访问guestRooms
状态以及setGuestRooms
更新状态的函数。接收roomIndex
和新的成人count
应该足以更新状态。
function updateAdultsCount(roomIndex: number, count: number) {
guestRooms.rooms[roomIndex] = {
...guestRooms.rooms[roomIndex],
adultsCount: count,
};
setGuestRooms({
rooms: guestRooms.rooms,
});
}
就是这样。我们使用扩展运算符来更新adultsCount
并保留children
值。将更新后的值传递给setGuestRooms
,它应该可以正确更新。
回到组件,我们可以使用这个新功能:
const increaseValue = () => updateAdultsCount(roomIndex, adultsCount + 1);
const decreaseValue = () => updateAdultsCount(roomIndex, adultsCount - 1);
应该increaseValue
将 加 +1 ,adultsCount
而decreaseValue
应该将 加 -1 adultsCount
。
儿童计数输入
行为ChildrenCountInput
类似,但数据结构略有不同。对于成年人来说,数据表示形式是数字。对于儿童来说,它是一个对象列表。
type ChildrenCountInputPropTypes = {
roomIndex: number;
};
export const ChildrenCountInput: FC<ChildrenCountInputPropTypes> = ({
roomIndex,
}) => {
const { guestRooms, addChild, removeChild } = useContext(GuestRoomsContext);
const childrenCount = getChildrenCount(guestRooms, roomIndex);
const increaseValue = () => addChild(roomIndex);
const decreaseValue = () => removeChild(roomIndex);
return (
<NumberInput
value={childrenCount}
increaseValue={increaseValue}
decreaseValue={decreaseValue}
minValue={0}
maxValue={3}
/>
);
};
也ChildrenCountInput
有一个roomIndex
prop。它应该接收 aminValue
和 a maxValue
。正如功能需求所述,最小值应为 0,最大值应为 3。
也getChildrenCount
非常相似。
export function getChildrenCount(guestRooms: GuestRooms, roomIndex: number) {
return guestRooms.rooms[roomIndex].children.length;
}
获取特定房间内儿童的身长。
要增加或减少孩子的数量,我们应该添加一个新的孩子或从孩子列表中删除一个孩子。让我们实现addChild
和removeChild
函数。
function addChild(roomIndex: number) {
const children = guestRooms.rooms[roomIndex].children;
children.push({
...children,
age: 8,
});
setGuestRooms({
rooms: guestRooms.rooms,
});
}
它接收roomIndex
,获取children
的列表,并推送一个年龄为 8 岁(默认年龄)的新孩子。然后我们只需更新guestRooms
状态即可。
应该removeChild
以类似的方式工作,但删除一个特定的子项。
function removeChild(roomIndex: number, childIndex: number = -1) {
const children = guestRooms.rooms[roomIndex].children;
children.splice(childIndex, 1);
setGuestRooms({
rooms: guestRooms.rooms,
});
}
我们splice
通过索引删除子项,然后更新guestRooms
状态。
它接收一个childIndex
,因为将来我们应该用它来移除特定的子元素。在本例中,我们只想移除最后一个。这就是为什么我们添加了一个默认值 -1,这样当调用 时splice
,它就会移除最后一个。
儿童选择
下一部分是关于ChildSelect
。它应该显示所有可能的年龄选项,并处理更改时的选择。
关于选项,我只是ageOptions
用一个简单的数组创建了一个。
const ageOptions = [...Array(18)];
我们用它来创建选择的所有选项。整个组件ChildSelect
将如下所示:
type ChildSelectPropTypes = {
child: Child;
roomIndex: number;
index: number;
};
export const ChildSelect: FC<ChildSelectPropTypes> = ({
child,
roomIndex,
index,
}) => {
const { updateChild } = useContext(GuestRoomsContext);
const childAgeOnChange =
(childIndex: number) => (event: ChangeEvent<HTMLSelectElement>) => {
const childAge = Number(event.target.value);
updateChild(roomIndex, childIndex, childAge);
};
return (
<select onChange={childAgeOnChange(index)} value={child.age}>
{ageOptions.map((_, age) => (
<option
value={age}
key={`${roomIndex}-child-${index}-age-option-${age}`}
>
{age ? age : '<1'}
</option>
))}
</select>
);
};
该组件接收child
(获取当前年龄)、roomIndex
(能够找到并更新特定房间中的孩子)和index
(孩子的索引以更新其年龄)。
现在我们需要updateChild
在提供程序中实现。这是类型定义:
updateChild: (
roomIndex: number,
childIndex: number,
childAge: number
) => void;
实现如下:
function updateChild(roomIndex: number, childIndex: number, childAge: number) {
const children = guestRooms.rooms[roomIndex].children;
children[childIndex] = {
age: childAge,
};
guestRooms.rooms[roomIndex] = {
...guestRooms.rooms[roomIndex],
children,
};
setGuestRooms({
rooms: guestRooms.rooms,
});
}
这里的想法是从给定的房间中获取一个特定的孩子,更新这个孩子的年龄,并更新guestRooms
状态。
该组件由使用ChildrenSelect
,我们从房间中获取所有子项并对其进行迭代:
export const ChildrenSelect = ({ roomIndex }: ChildrenSelectPropTypes) => {
const { guestRooms } = useContext(GuestRoomsContext);
const chidren = getChildren(guestRooms, roomIndex);
return (
<div className={childrenSelectWrapper}>
{chidren.map((child, index) => (
<div
className={childAgeSelectWrapper}
key={`${roomIndex}-child-${index}`}
>
<span>Child {index + 1} age</span>
<div className={selectWrapperStyle}>
<ChildSelect child={child} roomIndex={roomIndex} index={index} />
<CloseButton roomIndex={roomIndex} index={index} />
</div>
</div>
))}
</div>
);
};
这里只是对 进行迭代children
。为了获取children
,我们需要实现一个简单的 getter。
export function getChildren(guestRooms: GuestRooms, roomIndex: number) {
return guestRooms.rooms[roomIndex].children;
}
删除子项
现在我们可以添加一个新孩子并更新其年龄,我们需要能够使用关闭按钮将其删除。
type CloseButtonPropTypes = {
roomIndex: number;
index: number;
};
export const CloseButton: FC<CloseButtonPropTypes> = ({ roomIndex, index }) => {
const { removeChild } = useContext(GuestRoomsContext);
const removeOnClick = (childIndex: number) => () => {
removeChild(roomIndex, childIndex);
};
return (
<Button variant="danger" onClick={removeOnClick(index)}>
<CloseIcon />
</Button>
);
};
这实际上是一个非常简单的实现。我们需要一个按钮和一个处理按钮onClick
事件的方法。还记得我说过我们removeChild
也可以在其他地方使用吗?这个组件就是这样的。要移除它,我们将使用removeChild
之前实现的函数,但现在要childIndex
为它传递一个参数,这样我们就可以从房间中移除特定的孩子。
就是这样!
添加房间
添加新房间也非常简单。我们需要一个按钮和一个addRoom
函数,该函数会将带有默认值的新房间推送到房间列表并更新。
<Button variant="secondary" onClick={addRoom} fullWidth>
+ Add room
</Button>
实现addRoom
如下:
function addRoom() {
setGuestRooms({
rooms: [
...guestRooms.rooms,
{
adultsCount: 2,
children: [],
},
],
});
}
我们保留现有的房间,并增加了一间可容纳两名成人且不容纳儿童的新房间。
删除房间
要删除一个房间,我们需要一个按钮和房间的索引。
const { removeRoom } = useContext(GuestRoomsContext);
const removeRoomOnClick = (roomIndex: number) => () => {
removeRoom(roomIndex);
};
<Button variant="danger" onClick={removeRoomOnClick(index)}>
Remove room
</Button>;
我们有按钮和removeRoomOnClick
。现在我们应该实现这个removeRoom
功能:
function removeRoom(roomIndex: number) {
guestRooms.rooms.splice(roomIndex, 1);
setGuestRooms({
rooms: guestRooms.rooms,
});
}
这里我们使用了与从子级列表中移除子级相同的概念。使用特定的 splice 函数roomIndex
,然后更新guestRooms
状态。
搜索按钮
为了处理搜索按钮,我需要让用户(工程师)能够将一个回调函数传递给主组件,并将其传递给搜索按钮组件,以便在用户点击按钮时调用。这样,工程师就可以在当前状态下执行任何他们想做的事情。
在这个挑战中,我们需要获取状态数据结构,将其转换为字符串格式并将其附加到 url。
为了进行这种转换,我们可以创建一个简单的函数来处理这部分:
const ROOM_SEPARATOR = '|';
const ADULT_CHILDREN_SEPARATOR = ':';
const CHILDREN_SEPARATOR = ',';
function toChildrenAgesString(children: Child[]) {
return children.map(({ age }) => age).join(CHILDREN_SEPARATOR);
}
function toAdultsAndChildrenAgesString({ adultsCount, children }: Room) {
const childrenAges = toChildrenAgesString(children);
return childrenAges
? adultsCount + ADULT_CHILDREN_SEPARATOR + childrenAges
: adultsCount;
}
export function toGuestRoomsString(guestRooms: GuestRooms) {
return guestRooms.rooms
.map(toAdultsAndChildrenAgesString)
.join(ROOM_SEPARATOR);
}
AtoGuestRoomsString
将数据结构转换GuestRooms
为字符串。我们使用分隔符来构造它。为了“证明”它有效,我们可以添加一些测试来增强信心。
describe('toGuestRoomsString', () => {
it('generates "1:4,6|3"', () => {
expect(
toGuestRoomsString({
rooms: [
{
adultsCount: 1,
children: [
{
age: 4,
},
{
age: 6,
},
],
},
{
adultsCount: 3,
children: [],
},
],
}),
).toEqual('1:4,6|3');
});
it('generates "3"', () => {
expect(
toGuestRoomsString({
rooms: [
{
adultsCount: 3,
children: [],
},
],
}),
).toEqual('3');
});
it('generates "2:4"', () => {
expect(
toGuestRoomsString({
rooms: [
{
adultsCount: 2,
children: [
{
age: 4,
},
],
},
],
}),
).toEqual('2:4');
});
it('generates "1:0,13,16"', () => {
expect(
toGuestRoomsString({
rooms: [
{
adultsCount: 1,
children: [
{
age: 0,
},
{
age: 13,
},
{
age: 16,
},
],
},
],
}),
).toEqual('1:0,13,16');
});
});
就是这样!现在我们可以将其转换为字符串格式,然后再将其附加到 URL 中。为了实现该函数并调用其结果的回调函数,我创建了一个搜索函数:
function search(guestRooms: GuestRooms, callback: OnSearchFunction) {
const guestRoomsString = toGuestRoomsString(guestRooms);
return () =>
callback(
{ guestRooms: guestRoomsString },
`?guestRooms=${guestRoomsString}`,
);
}
这样,我们只需要实现一个可能的回调即可。由于我没有使用任何库或框架,所以我们可以使用 History API。
type State = any;
type Url = string | null;
export type PushStateSignature = (state: State, url?: Url) => void;
export const pushState: PushStateSignature = (state, url) => {
window.history.pushState(state, '', url);
};
它需要状态和 URL。将pushState
作为函数的回调传递search
,我们就可以将客房字符串作为查询参数附加。
用户界面和风格
我构建过许多不同的 React 应用,有些是用纯 React 开发的,有些是用 NextJS 开发的,这让我体验到了不同的 CSS 样式处理方法。虽然在 React 组件上使用内联 CSS 很简单,但我并不喜欢这种体验,因为它缺少很多“特性”,比如伪类和选择器。
所以在这次挑战中,我乐于学习和应用新的 CSS 工具。我之前听说过 emotion-js,但从未真正尝试过。它看起来非常简单,就是一些可以附加到组件上的 CSS 样式。这就像以前只写纯 CSS 的时代,但现在有了模块化的强大功能。
我不想使用样式组件,所以我只是安装了@emotion/css
。
npm i @emotion/css
按钮
我想要关注的第一个组件是<Button>
。我想创建一个可以在整个应用程序中复用的组件。通过“类型”,我可以更改组件的整体样式,所以我构建了一个 ,variant
它看起来像这样:
type ButtonVariants = 'primary' | 'secondary' | 'disabled' | 'danger' | 'close';
现在我们可以使用它作为 prop 类型:
type ButtonPropTypes = {
variant?: ButtonVariants;
};
如果用户(使用此组件的工程师)也使用 TypeScript,则要求他们在编译时使用其中一种变体。这是 TypeScript 与 React 的完美结合。
有了这个变体,我们可以为任何东西添加样式。我采用了一个对象,该对象将变体与其样式相匹配的想法。第一个是光标:
const Cursor = {
primary: 'pointer',
secondary: 'pointer',
disabled: 'not-allowed',
danger: 'pointer',
close: 'pointer',
};
使用方法很简单:
cursor: ${Cursor[variant]};
对于所有其他风格,我们会做同样的事情:
const Colors = {
primary: 'white',
secondary: '#0071f3',
disabled: '#6a7886',
danger: '#d83b3b',
close: '#6a7886',
};
const BackgroundColors = {
primary: '#0071f3',
secondary: '#f7fbff',
disabled: '#eff2F6',
danger: 'rgba(255, 255, 255, 0)',
close: 'rgba(255, 255, 255, 0)',
};
const BackgroundColorsHover = {
primary: '#0064d8',
secondary: '#e4f0fe',
disabled: '#eff2F6',
danger: 'rgba(255, 255, 255, 0)',
close: 'rgba(255, 255, 255, 0)',
};
const BoxShadow = {
primary: 'none',
secondary: '#bfdaf9 0px 0px 0px 1px inset',
disabled: 'none',
danger: 'none',
close: 'none',
};
用法和游标类似:
color: ${Colors[variant]};
background-color: ${BackgroundColors[variant]};
box-shadow: ${BoxShadow[variant]};
&:hover {
background-color: ${BackgroundColorsHover[variant]};
}
在这个组件中,我还使它能够接收这些道具:disabled
,,,和。onClick
dataTestid
children
<button
disabled={disabled}
onClick={onClick}
data-testid={dataTestid}
...
>
{children}
</button>
我还看到了根据用户需要自定义样式的需求。例如,组件有一个默认的 padding 间距。但用户可能需要不同的 padding 间距,所以我们className
也可以添加一个 prop,并将其添加到css
如下所示的样式中:
className={css`
...
${className}
`}
我们实际上赋予了用户很多权力。我们可以使用一个对象来设置 padding、margin,以及我们想要与按钮变体匹配的其他属性。
该组件的最后一部分是 prop fullWidth
。名字说明了一切。如果启用此 prop,按钮将具有全宽,否则将具有自动宽度。
width: ${fullWidth ? '100%' : 'auto'};
道具类型如下:
type ButtonVariants = 'primary' | 'secondary' | 'disabled' | 'danger' | 'close';
type ButtonPropTypes = {
disabled?: boolean;
onClick: () => void;
variant?: ButtonVariants;
className?: string;
fullWidth?: boolean;
dataTestid?: string;
};
整个组件都有这些道具、类型和样式。
export const Button: FC<ButtonPropTypes> = ({
children,
disabled = false,
onClick,
variant = 'primary',
className,
fullWidth = false,
dataTestid,
}) => (
<button
disabled={disabled}
onClick={onClick}
data-testid={dataTestid}
className={css`
display: inline-flex;
border: 0px;
border-radius: 6px;
margin: 0px;
cursor: ${Cursor[variant]};
align-items: center;
justify-content: center;
text-align: center;
vertical-align: middle;
position: relative;
text-decoration: none;
font-size: 16px;
font-weight: 600;
padding: 16px 32px;
color: ${Colors[variant]};
background-color: ${BackgroundColors[variant]};
box-shadow: ${BoxShadow[variant]};
width: ${fullWidth ? '100%' : 'auto'};
&:hover {
background-color: ${BackgroundColorsHover[variant]};
}
${className}
`}
>
{children}
</button>
);
动画片
为了确保在移动视图中打开覆盖组件时有效果,我们将使用keyframes
和animation
。
对于这个转换来说,代码看起来非常简单。
keyframes
从库中导入后,emotion
我们创建一个动画名称,从顶部 100% 到顶部 0,并设置此过渡的持续时间。
import { css, keyframes } from '@emotion/css';
const overlayFade = keyframes`
from {
top: 100%;
}
to {
top: 0;
}
`;
const modelStyle = css`
// ...
animation-name: ${overlayFade};
animation-duration: 0.3s;
// ...
`;
就这么简单。
响应式设计
为了处理响应式设计,我专注于移动优先,并针对桌面进行额外的调整。
为了能够根据特定的屏幕尺寸更改样式,我们可以使用媒体查询。使用emotion-js
方法如下:
const style = css`
border-radius: 0;
@media (min-width: 576px) {
border-radius: 6px;
}
`;
对于移动视图,它不会有border-radius
,但所有最小尺寸为的屏幕576px
都会border-radius
有6px
。
为了使其在所有组件中更加一致并且无需编写正确的媒体查询,我创建了一个mediaQuery
具有所有可能性的对象。
type Breakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type MediaQuery = Record<Breakpoints, string>;
export const mediaQuery: MediaQuery = {
xs: '@media (max-width: 576px)',
sm: '@media (min-width: 576px)',
md: '@media (min-width: 768px)',
lg: '@media (min-width: 992px)',
xl: '@media (min-width: 1200px)',
};
现在,我们可以使用这个对象,而无需了解每个查询的实现细节。重构上面的 CSS 样式代码,我们得到如下内容:
const style = css`
border-radius: 0;
${mediaQuery['sm']} {
border-radius: 6px;
}
`;
现在我们可以mediaQuery
在所有需要处理不同视图的组件中重复使用此代码。
我还为它创建了一个简单的单元测试:
describe('mediaQuery', () => {
it('returns the correct media query for each breakpoint', () => {
expect(mediaQuery['xs']).toEqual('@media (max-width: 576px)');
expect(mediaQuery['sm']).toEqual('@media (min-width: 576px)');
expect(mediaQuery['md']).toEqual('@media (min-width: 768px)');
expect(mediaQuery['lg']).toEqual('@media (min-width: 992px)');
expect(mediaQuery['xl']).toEqual('@media (min-width: 1200px)');
});
});
我还需要处理桌面和移动端视图的不同 HTML 元素和样式。因此我使用了一个名为 的库react-device-detect
。
在这种情况下,我们的桌面模态框不仅应该包含模态框组件,还应该并排放置一个背景叠加层。如果用户点击叠加层,模态框应该自动关闭。
在移动视图中,它没有这个覆盖组件。它应该只是打开一个对话框。
桌面对话框:
export const DialogBrowserView: FC<DialogBrowserViewPropTypes> = ({
guestRoomsString,
onClose,
onSearch,
}) => (
<BrowserView>
<div className={dialogStyle}>
<div onClick={onClose} className={backdropStyle} />
<Dialog
guestRoomsString={guestRoomsString}
onClose={onClose}
onSearch={onSearch}
/>
</div>
</BrowserView>
);
移动对话框:
export const DialogMobileView: FC<DialogMobileViewPropTypes> = ({
guestRoomsString,
onClose,
onSearch,
}) => (
<MobileView>
<Dialog
guestRoomsString={guestRoomsString}
onClose={onClose}
onSearch={onSearch}
/>
</MobileView>
);
并使用它们:
<DialogBrowserView
guestRoomsString={guestRoomsString}
onClose={onClose}
onSearch={onSearch}
/>
<DialogMobileView
guestRoomsString={guestRoomsString}
onClose={onClose}
onSearch={onSearch}
/>
我们也可以react-device-detect
用媒体查询来代替。
代码拆分
我做的另一件事是对对话框进行代码拆分。为移动端对话框创建一个代码块,为桌面端对话框创建另一个代码块。
这样,应用程序就不需要在桌面视图中加载移动对话框代码,反之亦然。
为此,我使用了一个名为 Loadable Components 的库。
我只需要为移动对话框执行此操作
import loadable from '@loadable/component';
export default loadable(
() => import(/* webpackChunkName: "DialogMobileView" */ './DialogMobileView'),
);
这是桌面视图
import loadable from '@loadable/component';
export default loadable(
() =>
import(/* webpackChunkName: "DialogBrowserView" */ './DialogBrowserView'),
);
现在应用程序不需要为每个屏幕尺寸加载不必要的 JavaScript 代码。
单元和集成测试
单元测试
为了验证所有变体是否都使用正确的样式,我为每个变体创建了一个测试。
describe('Button', () => {
describe('primary variant', () => {
it('verifies correct styles for primary button', () => {
render(<Button onClick={noop}>{text}</Button>);
const buttonText = screen.getByText(/Text/i);
expect(buttonText).toBeInTheDocument();
expect(buttonText).toHaveStyle('cursor: pointer');
expect(buttonText).toHaveStyle('color: white');
expect(buttonText).toHaveStyle('background-color: #0071f3');
expect(buttonText).toHaveStyle('box-shadow: none');
});
});
});
我们可以使用toHaveStyle
API 来验证每个 CSS 属性。我想测试按钮是否渲染成功,以及这四个属性:光标、颜色、背景颜色和框阴影。
并且我对所有其他变体也进行了类似的测试:secondary
、、和。disabled
danger
close
对于标题,我添加了一个非常简单的单元测试来验证标题文本以及关闭按钮是否正确触发所有内容。
const noop = jest.fn();
describe('Header', () => {
it('renders the header text', () => {
render(<Header onClose={noop} />);
const headerText = screen.getByText(/Rooms & Guests/i);
expect(headerText).toBeInTheDocument();
});
it('triggers the onClose after clicking the close button', () => {
render(<Header onClose={noop} />);
const onCloseButton = screen.getByRole('button');
userEvent.click(onCloseButton);
expect(noop).toBeCalled();
});
});
对于标题文本来说,这是一个很好的测试,但模拟该onClose
函数并不理想。我将在集成测试中对其进行适当的测试,模拟用户如何与对话框交互以及如何关闭对话框。
这个测试AdultsCountInput
非常有趣,因为我们可以按照用户使用的方式对其进行测试。
describe('AdultsCountInput', () => {
it('increases and decreases count by clicking buttons', () => {
render(
<GuestRoomsProvider>
<AdultsCountInput roomIndex={0} />
</GuestRoomsProvider>,
);
const count = screen.getByText('2');
expect(count).toBeInTheDocument();
const minusButton = screen.getAllByRole('button')[0];
userEvent.click(minusButton);
const decreasedCount = screen.getByText('1');
expect(decreasedCount).toBeInTheDocument();
const plusButton = screen.getAllByRole('button')[1];
userEvent.click(plusButton);
userEvent.click(plusButton);
const increasedCount = screen.getByText('3');
expect(increasedCount).toBeInTheDocument();
});
});
- 我们从渲染组件开始
- 验证当前计数的值是否正确
- 单击按钮减少计数并验证它是否真的减少了
- 单击按钮将计数增加两次并验证当前计数的值
我们对这个测试很有信心,因为它模拟了用户的使用方式。
对作品的测试ChildrenCountInput
方法相同:
describe('ChildrenCountInput', () => {
it('increases and decreases count by clicking buttons', () => {
render(
<GuestRoomsProvider>
<ChildrenCountInput roomIndex={0} />
</GuestRoomsProvider>,
);
const count = screen.getByText('0');
expect(count).toBeInTheDocument();
const plusButton = screen.getAllByRole('button')[1];
userEvent.click(plusButton);
userEvent.click(plusButton);
const increasedCount = screen.getByText('2');
expect(increasedCount).toBeInTheDocument();
const minusButton = screen.getAllByRole('button')[0];
userEvent.click(minusButton);
const decreasedCount = screen.getByText('1');
expect(decreasedCount).toBeInTheDocument();
});
});
选择组件也很有趣。使用体验userEvent
很流畅,并且达到了预期的效果。
但首先,让我们添加一个测试来验证ChildrenSelect
是否不会呈现任何选择,因为当前状态没有任何子项。
describe('ChildrenSelect', () => {
it("does not render a child selector when there's no child", () => {
render(
<GuestRoomsProvider>
<ChildrenSelect roomIndex={0} />
</GuestRoomsProvider>,
);
const selectLabel = screen.queryByText('Child 1 age');
expect(selectLabel).not.toBeInTheDocument();
});
});
现在我们可以创建一个测试来与选择进行交互并选择不同的年龄选项。
首先,我创建了一个辅助函数来从选择元素中获取第一个选项。
function getFirstOption(name: string) {
return screen.getAllByRole('option', {
name,
})[0] as HTMLOptionElement;
}
现在我可以使用它来验证渲染的选择并与它们中的每一个进行交互。
describe('ChildrenSelect', () => {
it('selects new option and verify selected item', () => {
render(
<GuestRoomsProvider guestRoomsString="1:4,6">
<ChildrenSelect roomIndex={0} />
</GuestRoomsProvider>,
);
const selectLabel1 = screen.getByText('Child 1 age');
expect(selectLabel1).toBeInTheDocument();
const selectLabel2 = screen.getByText('Child 2 age');
expect(selectLabel2).toBeInTheDocument();
const selectLabel3 = screen.queryByText('Child 3 age');
expect(selectLabel3).not.toBeInTheDocument();
const select = screen.getAllByRole('combobox')[0];
const selectedOption = getFirstOption('4');
expect(selectedOption.selected).toBeTruthy();
const newSelectedOption = getFirstOption('3');
userEvent.selectOptions(select, newSelectedOption);
expect(selectedOption.selected).toBeFalsy();
expect(newSelectedOption.selected).toBeTruthy();
});
});
上下文:“1:4,6”的意思是
- 1名成人
- 两个孩子:一个 4 岁,另一个 6 岁。
我们在这里测试了很多东西:
- 验证 child 1 和 child 2 是否已渲染
- 确保 child 3 不会被渲染
- 验证所选选项是否为 4 岁
- 选择新选项(3岁)
- 验证选项年龄 4 不再是所选选项,现在所选选项是年龄 3
对于NumberInput
组件来说,测试非常简单。只需渲染它并确保渲染了正确的数字即可。
describe('NumberInput', () => {
it('renders the value between buttons', () => {
const noop = () => {};
render(
<GuestRoomsProvider>
<NumberInput
value={3}
increaseValue={noop}
decreaseValue={noop}
minValue={1}
maxValue={5}
/>
</GuestRoomsProvider>,
);
expect(screen.getByText('3')).toBeInTheDocument();
});
});
该测试SearchButton
也与上面的测试类似,因为我们只是想确保使用正确的值呈现正确的组件。
describe('SearchButton', () => {
it('renders the button', () => {
render(
<GuestRoomsProvider>
<SearchButton onSearch={() => {}} />
</GuestRoomsProvider>,
);
const button = screen.getByRole('button', {
name: /Search 1 room • 2 guests/i,
});
expect(button).toBeInTheDocument();
});
});
我还为该组件创建了一个测试,GuestRooms
但它与我稍后创建的集成测试非常相似。我将在下一节中介绍这个测试。
集成测试
为了对该功能更加有信心,我使用 Cypress 创建了一个集成测试。
首先,创建一个函数来测试 URL 中的查询参数:
function verifyQueryParams(queryParams) {
cy.location().should((location) => {
expect(location.search).to.eq(queryParams);
});
}
还创建了一个单击搜索按钮并提供以下功能verifyQueryParams
:
function clickSearchButtonWithText(text) {
cy.get('button').contains(text).click();
return {
andVerifyQueryParams: verifyQueryParams,
};
}
这样我们就可以像这样使用它:
clickSearchButtonWithText('Search 1 room • 2 guests').andVerifyQueryParams(
'?guestRooms=2',
);
然后我创建了一个函数来处理成人计数选择的测试:
function selectAdultsCount() {
const adultsBlock = 'div[data-testid="adults-count-input-block"]';
cy.get(adultsBlock).within(() => {
cy.contains('2').should('exist');
const adultsMinusButton = cy.get('button[data-testid="minus-button"]');
adultsMinusButton.click();
adultsMinusButton.should('be.disabled');
cy.contains('1').should('exist');
const adultsPlusButton = cy
.get('button[data-testid="plus-button"]')
.first();
adultsPlusButton.click();
adultsPlusButton.click();
adultsPlusButton.click();
cy.contains('4').should('exist');
});
}
- 验证计数是否为 2
- 单击减少按钮,确认按钮现在已被禁用,因为这是成人的最小数量,并确认计数呈现为 1
- 然后点击增加按钮 3 次,确认当前计数为 4
现在我们需要创建一个函数来测试孩子的数量选择和他们的年龄。
function selectChildrenCountAndAges() {
const childrenBlock = 'div[data-testid="children-count-input-block"]';
cy.get(childrenBlock).within(() => {
cy.contains('0').should('exist');
const childrenMinusButton = cy.get('button[data-testid="minus-button"]');
childrenMinusButton.should('be.disabled');
cy.contains('0').should('exist');
const childrenPlusButton = cy
.get('button[data-testid="plus-button"]')
.first();
childrenPlusButton.click();
childrenPlusButton.click();
childrenPlusButton.click();
cy.contains('3').should('exist');
cy.contains('Child 1 age');
cy.contains('Child 2 age');
cy.contains('Child 3 age');
cy.get('button[data-testid="close-button-1"]').click();
cy.contains('Child 3 age').should('not.exist');
cy.get('select').first().select('3');
});
}
- 验证它从计数 0 开始,并且减少按钮应该被禁用
- 点击增加按钮 3 次,它将为每个孩子的年龄添加三个年龄选择
- 单击第三个孩子的关闭按钮并验证它是否不再存在
- 选择第一个孩子的年龄
现在我们有了所有的构建块,我们可以使用它们来为对话创建完整的测试。
function verifyGuestRoomsBehavior() {
const openDialogButton = cy.get('button');
openDialogButton.click();
clickSearchButtonWithText('Search 1 room • 2 guests').andVerifyQueryParams(
'?guestRooms=2',
);
const firstRoom = 'div[data-testid="room-key-0"]';
cy.get(firstRoom).within(() => {
selectAdultsCount();
selectChildrenCountAndAges();
});
clickSearchButtonWithText('Search 1 room • 6 guests').andVerifyQueryParams(
'?guestRooms=4:3,8',
);
cy.contains('Room 2').should('not.exist');
cy.get('button').contains('+ Add room').click();
cy.contains('Room 2').should('exist');
const secondRoom = 'div[data-testid="room-key-1"]';
cy.get(secondRoom).within(() => {
selectAdultsCount();
selectChildrenCountAndAges();
});
clickSearchButtonWithText('Search 2 rooms • 12 guests').andVerifyQueryParams(
'?guestRooms=4:3,8|4:3,8',
);
cy.get('button').contains('Remove room').click();
cy.contains('Room 2').should('not.exist');
clickSearchButtonWithText('Search 1 room • 6 guests').andVerifyQueryParams(
'?guestRooms=4:3,8',
);
}
- 单击按钮打开对话框
- 单击搜索按钮并验证 URL 中的预期查询参数
- 在第一个房间,选择成人数量和儿童数量及年龄
- 再次点击搜索按钮并验证查询参数是否正确
- 添加第二个房间,并添加成人和儿童。再次验证查询参数
- 删除第二个房间,确认它不再存在,单击搜索按钮并验证预期的查询参数
我还创建了一个函数来处理对话框关闭按钮并验证其行为。
function verifyCloseButtonBehavior() {
cy.contains('Rooms & Guests').should('exist');
cy.get('button[data-testid="dialog-close-button"]').click();
cy.contains('Rooms & Guests').should('not.exist');
}
所有内容放在一起看起来是这样的:
it('verifies guest rooms dialog behavior', () => {
verifyGuestRoomsBehavior();
verifyCloseButtonBehavior();
});
但这只是针对桌面端的测试。我还想测试它在移动端的显示效果。思路很类似,但需要添加不同的视口,然后再测试。
describe('on iPhone X', () => {
it('verifies guest rooms dialog behavior', () => {
cy.viewport('iphone-x');
verifyGuestRoomsBehavior();
verifyCloseButtonBehavior();
});
});
所有内容放在一起看起来是这样的:
describe('GuestRoomsDialog', () => {
beforeEach(() => {
cy.visit('/');
});
describe('on iPhone X', () => {
it('verifies guest rooms dialog behavior', () => {
cy.viewport('iphone-x');
verifyGuestRoomsBehavior();
verifyCloseButtonBehavior();
});
});
describe('on desktop', () => {
it('verifies guest rooms dialog behavior', () => {
verifyGuestRoomsBehavior();
verifyCloseButtonBehavior();
});
});
});
让我们看一下集成测试的实际效果预览?
就是这样!
这是我正在撰写的这个系列的第一篇文章:Frontend Challenges
。我想挑战自己在前端领域的不同挑战,看看能从中学到什么。每个挑战都会被记录下来并与大家分享。
我希望你喜欢这篇文章,并随意为你正在构建的项目和产品窃取一些想法。
再见!