当我们谈论或讨论在 Vue 中创建用户界面组件时,经常会提到可重用性。没错,Vue 的关键原则之一就是其基于组件的架构,这促进了可重用性和模块化。但这到底意味着什么呢?
比方说,你创建了一个可重复使用的组件:
- 你或你的同事真的能在系统的另一个部分重复使用它吗?
- 有了新的需求,你可能不得不考虑修改 "可重复使用的组件"。
- 如果需要拆分 "可重用组件",以便将拆分后的组件应用到其他地方,该怎么办?
在 Vue 中创建真正的可重用组件可能很棘手。在本文中,我将探讨可重用组件的概念、应用这些组件时面临的问题,以及为什么必须尽可能克服这些问题。
什么是可重用组件?
可重用组件是用户界面构件,可用于应用程序的不同部分,甚至多个项目。它们封装了特定的功能或用户界面模式,可以轻松集成到应用程序的其他部分,而无需进行大量修改。
可重复使用组件的优势
- 效率:允许开发人员一次编写代码并多次重复使用。这样可以减少冗余,节省宝贵的开发时间。
- 标准化:促进整个 Vue 项目的一致性和标准化。它们可确保在整个应用程序中保持相同的设计模式、样式和功能。
- 可扩展性:随着项目的增长,更容易扩展和调整项目。通过将应用程序分解成更小的、可重复使用的组件,可以更容易地处理复杂功能和添加新功能。
- 协作:促进团队成员在 Vue 项目中的协作。它们提供了团队中每个人都能使用和理解的共享词汇和用户界面元素集。
应用可重复使用概念时的 3 个问题
虽然可重用性是 Vue. 组件的一个理想特性,但有几个问题会使其难以实现:
- 修改现有组件:一个问题是修改应用程序中已经使用的现有组件。可能需要对组件进行修改,以同时支持现有需求和新需求。对应用程序其他部分已经使用的组件进行修改,可能会带来意想不到的副作用,并破坏其他部分的功能。在变更需求与保持兼容性之间取得平衡可能很复杂。
- 设计组件的一致性和灵活性:另一个问题是在可重复使用组件的不同实例之间保持一致性,同时允许自定义和灵活性。可重用组件应具有足够的通用性,以适应不同的设计要求和风格。然而,在提供定制选项的同时又不牺牲组件的核心功能和一致性可能会很棘手。
- 管理组件依赖关系和状态:使用可重复使用的组件需要管理依赖关系,并确保每个组件保持自足和独立。组件不应紧密依赖外部资源或应用程序的状态管理系统。这样可以轻松集成到不同的项目中,减少冲突或意外副作用的可能性。
案例
比方说,客户想要一个内部员工目录系统。该项目采用敏捷方法,开发前无法收集所有需求。项目分为三个阶段(原型、第一阶段和第二阶段)。在本演示中,我将重点介绍一个卡片组件,如下所示:
图片
原型
作为原型阶段的一部分,我需要提供一个用户配置文件页面。用户配置文件将包含一个基本的用户卡组件,其中包括用户头像和姓名。
图片
// Prototype.vue
<script setup lang="ts">
import { defineProps, computed, Teleport, ref } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
}
const props = defineProps<Props>();
</script>
<template>
<div class="app-card">
<img
class="user-image"
:src="image"
alt="avatar" />
<div>
<div>
<label> {{ firstName }} {{ lastName }} </label>
</div>
</div>
</div>
</template>
<style scoped>
.app-card {
padding-left: 10px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
background: white;
box-shadow: 0 0 5px;
border-radius: 5px;
border: none;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
}
.app-card label {
font-weight: 600;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
}
.user-image {
width: 100px;
}
</style>
第 1 阶段
在第一阶段,客户希望在用户卡组件中添加用户详细信息(生日、年龄、电话号码和电子邮件)。
图片
//Phase1.vue
<script setup lang="ts">
import { defineProps, computed } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDay?: string;
phone?: string;
email?: string;
}
const props = defineProps<Props>();
const age = computed(() => {
if (!props.birthDay) {
return "0";
}
const birthYear = new Date(props.birthDay).getFullYear();
const currentYear = new Date().getFullYear();
return currentYear - birthYear;
});
</script>
<template>
<div
ref="cardRef"
class="app-card">
<img
class="user-image"
:src="image"
alt="avatar" />
<div>
<div>
<label> {{ firstName }} {{ lastName }} </label>
</div>
<div>
<div>
<label> Birth day: </label>
<span>
{{ birthDay }}
</span>
</div>
<div>
<label> Age: </label>
<span>
{{ age }}
</span>
</div>
<div>
<label> Phone number: </label>
<span>
{{ phone }}
</span>
</div>
<div>
<label> Email: </label>
<span>
{{ email }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.app-card {
padding-left: 10px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
background: white;
box-shadow: 0 0 5px;
border-radius: 5px;
border: none;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
}
.app-card label {
font-weight: 600;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
color: black;
}
.user-image {
width: 100px;
}
</style>
此外,客户还希望添加员工名录页面,并以卡片格式显示用户资料。
图片
// SearchPage
<template>
<div>
<SearchInput v-model:value="searchValue" />
<template
:key="item.id"
v-for="item of list">
<div style="margin-bottom: 5px; margin-top: 5px">
<UserCard v-bind="item" />
</div>
</template>
</div>
</template>
<script lang="ts">
import SearchInput from "../components/SearchInput.vue";
import UserCard from "../components/Phase1.vue";
import { ref, watch } from "vue";
export default {
name: "Search",
components: {
SearchInput,
UserCard,
},
setup() {
const searchValue = ref<string>();
const list = ref();
fetch("https://dummyjson.com/users")
.then((res) => res.json())
.then((res) => (list.value = res.users));
watch(searchValue, (v) => {
fetch(`https://dummyjson.com/users/search?q=${v}`)
.then((res) => res.json())
.then((res) => (list.value = res.users));
});
watch(list, (v) => console.log(v));
return {
searchValue,
list,
};
},
};
</script>
在这一阶段,用户卡组件可在两个页面上重复使用。
第二阶段
用户反馈 "员工名录 "页面杂乱无章。过多的信息使页面难以使用。因此,客户希望在鼠标悬停时以工具提示的形式显示用户详细信息。用户设置页面的要求保持不变。
// Phase 2
<script setup lang="ts">
import {
defineProps,
computed,
Teleport,
ref,
onMounted,
onBeforeUnmount,
} from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDate?: string;
phone?: string;
email?: string;
address?: string;
}
const props = defineProps<Props>();
const targetRef = ref<HTMLDiveElement>();
const isMouseOver = ref(false);
const dropdownRef = ref<HTMLDivElement>();
const dropdownStyle = ref({});
// add modal element in body to prevent overflow issue
const modalElement = document.createElement("div");
modalElement.id = "modal";
document.body.appendChild(modalElement);
const age = computed(() => {
if (!props.birthDate) {
return "0";
}
const birthYear = new Date(props.birthDate).getFullYear();
const currentYear = new Date().getFullYear();
return currentYear - birthYear;
});
const onMouseOver = () => {
if (isMouseOver.value) {
return;
}
isMouseOver.value = true;
const dimension = targetRef.value.getBoundingClientRect();
dropdownStyle.value = {
width: `${dimension.width}px`,
left: `${dimension.x}px`,
top: `${window.scrollY + dimension.y + dimension.height + 5}px`,
};
};
const onMouseLeave = () => {
isMouseOver.value = false;
};
</script>
<template>
<div
ref="targetRef"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
class="app-card"
>
<img class="user-image" :src="image" alt="avatar" />
<div>
<div>
<label> {{ firstName }} {{ lastName }} </label>
</div>
</div>
</div>
<Teleport to="#modal">
<div
ref="dropdownRef"
:style="dropdownStyle"
style="position: absolute"
v-show="isMouseOver"
>
<div class="app-card">
<div>
<div>
<label> Birth day: </label>
<span>
{{ birthDate }}
</span>
</div>
<div>
<label> Age: </label>
<span>
{{ age }}
</span>
</div>
<div>
<label> Phone number: </label>
<span>
{{ phone }}
</span>
</div>
<div>
<label> Email: </label>
<span>
{{ email }}
</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.app-card {
padding-left: 10px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
background: white;
box-shadow: 0 0 5px;
border-radius: 5px;
border: none;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
}
.app-card label {
font-weight: 600;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
color: black;
}
.user-image {
width: 100px;
}
</style>
这项新要求令人头疼:
- 我是否要修改现有的用户卡组件以支持工具提示要求,并冒着影响用户设置页面中的用户卡组件的风险?或者
- 是否要复制现有的用户卡组件并添加工具提示功能?
因为我们不想破坏已经投入生产的项目,所以我们倾向于选择后一种方案。起初,这可能是有道理的,但它可能会造成相当大的损害,尤其是对于大型和连续性项目而言:
- 代码库庞大:导致代码库扩大,因为每个重复的组件都会增加不必要的代码行。变得难以维护,因为每当需要更新或修复错误时,开发人员都需要在多个地方进行修改。这还会增加不一致的可能性。
- 短期收益,长期痛苦:在短期内,特别是在处理紧迫的期限或紧急需求时,这似乎是一个快速而简单的解决方案。然而,随着项目的增长,维护重复组件变得越来越困难和耗时。对重复组件的修改或更新需要在多个实例中复制,导致出错的几率增加。
- 系统性能:会对系统性能产生负面影响。冗余代码会增加应用程序的大小,导致渲染时间变慢和内存使用量增加。这会导致用户体验不佳,系统效率降低。
如何克服上述问题
在整个项目中,可重复使用的组件可能不会始终保持不变,这一点要有心理准备。这听起来可能很老套,但仔细想想,需求总是在不断变化的。你无法控制未来,只能尽力而为。当然,经验会帮助你设计出更好的组件,但这需要时间
重构可重用组件
根据我的经验,我将重新设计和重构可重用的组件。重构是一个在不改变代码原有功能的前提下重组代码的过程。我相信重构的方法有很多,对我来说,我会重构并将组件分解成更小的组件。较小的组件可以在整个系统中灵活应用。让我们看看我将如何应用上述案例研究。
注意:重构用户界面组件需要严谨的态度。此外,有时这也很具有挑战性,因为需要在项目交付期限和更简洁的代码之间取得平衡。
将解决方案应用于案例研究
首先,我将把现有的用户卡组件拆分成 4 个组件:
- Card component 卡片组件
- Avatar component 头像组件
- Name component 名称组件
- User detail component 用户详情组件
图片
** Card.vue**
// Card.vue
<template>
<div class="app-card">
<slot></slot>
</div>
</template>
<style scoped>
.app-card {
padding-left: 15px;
padding-right: 15px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 5px;
border: none;
background: white;
color: black;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
box-shadow: 0 0 5px;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
color: black;
}
</style>
Avatar.vue
// Avatar.vue
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
image: string;
}
const props = defineProps<Props>();
</script>
<template>
<img
class="user-image"
:src="image"
alt="avatar" />
</template>
<style scoped>
.user-image {
width: 100px;
}
</style>
UserName.vue
// UserName.vue
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
}
const props = defineProps<Props>();
</script>
<template>
<label> {{ firstName }} {{ lastName }} </label>
</template>
Description Item
efineProps } from "vue";
interface Props {
label: string;
value: string | number;
}
const props = defineProps<Props>();
</script>
<template>
<div>
<label> {{ label }}: </label>
<span>
{{ value }}
</span>
</div>
</template>
<style scoped>
label {
font-weight: 600;
}
</style>
UserDescription.vue
// UserDescription.vue
<script setup lang="ts">
import DescriptionItem from "./DescriptionItem.vue";
import { defineProps, computed } from "vue";
interface Props {
birthDate: string;
phone: string;
email: string;
}
const props = defineProps<Props>();
const age = computed(() => {
if (!props.birthDate) {
return "0";
}
const birthYear = new Date(props.birthDate).getFullYear();
const currentYear = new Date().getFullYear();
return currentYear - birthYear;
});
</script>
<template>
<div>
<DescriptionItem
label="Birth day"
:value="birthDate" />
<DescriptionItem
label="Age"
:value="age" />
<DescriptionItem
label="Phone number"
:value="phone" />
<DescriptionItem
label="Email"
:value="email" />
</div>
</template>
之后,我将创建一个工具提示组件。创建一个单独的工具提示可以让我在系统的其他部分重复使用它。
图片
Tooltip.vue
// Tooltip.vue
<script setup lang="ts">
import {
Teleport,
computed,
ref,
onMounted,
onBeforeUnmount,
watch,
} from "vue";
const isMouseOver = ref(false);
const targetRef = ref<HTMLDivElement>();
const dropdownStyle = ref({});
const dropdownRef = ref<HTMLDivElement>();
const existModalElement = document.getElementById("modal");
if (!existModalElement) {
// add modal element in body to prevent overflow issue
const modalElement = document.createElement("div");
modalElement.id = "modal";
document.body.appendChild(modalElement);
}
const onMouseOver = () => {
if (isMouseOver.value) {
return;
}
isMouseOver.value = true;
const dimension = targetRef.value.getBoundingClientRect();
dropdownStyle.value = {
width: `${dimension.width}px`,
left: `${dimension.x}px`,
top: `${window.scrollY + dimension.y + dimension.height + 5}px`,
};
};
const onMouseLeave = () => {
isMouseOver.value = false;
};
</script>
<template>
<div @mouseover="onMouseOver" @mouseleave="onMouseLeave" ref="targetRef">
<slot name="default" />
</div>
<Teleport to="#modal">
<div
ref="dropdownRef"
:style="dropdownStyle"
style="position: absolute"
v-show="isMouseOver"
>
<Card>
<slot name="overlay" />
</Card>
</div>
</Teleport>
</template>
最后,我将把这些组件组合在一起,如下所示。
在用户设置页面,我将使用用户卡组件,它由卡片、头像、姓名组件和用户详情组件组成。
图片
// UserWithDescription.vue
<script setup lang="ts">
import AppCard from "./Card.vue";
import DescriptionItem from "./DescriptionItem.vue";
import Avatar from "./Avatar.vue";
import UserName from "./UserName.vue";
import UserDescription from "./UserDescription.vue";
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDate?: string;
phone?: string;
email?: string;
address?: string;
}
const props = defineProps<Props>();
</script>
<template>
<AppCard>
<Avatar :image="image" />
<div>
<div>
<UserName :firstName="firstName" :lastName="lastName" />
</div>
<UserDescription v-bind="props" />
</div>
</AppCard>
</template>
至于 "员工名录 "页面,我计划由两个部分组成
- 基本用户卡组件由卡、头像和名称组件组成。
- 用户工具提示组件由卡片、工具提示和用户详情组件组成。
图片
UserCard.vue
// UserCard.vue
<script setup lang="ts">
import AppCard from "./Card.vue";
import DescriptionItem from "./DescriptionItem.vue";
import Avatar from "./Avatar.vue";
import UserName from "./UserName.vue";
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
}
const props = defineProps<Props>();
</script>
<template>
<AppCard>
<Avatar :image="image" />
<div>
<div>
<UserName
:firstName="firstName"
:lastName="lastName" />
</div>
</div>
</AppCard>
</template>
** UserCardWithTooltip.vue**
// UserCardWithTooltip.vue
<script setup lang="ts">
import ToolTip from "./Tooltip.vue";
import UserDescription from "./UserDescription.vue";
import UserCard from "./UserCard.vue";
import Card from "./Card.vue";
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDate?: string;
phone?: string;
email?: string;
}
const props = defineProps<Props>();
</script>
<template>
<ToolTip>
<UserCard v-bind="props" />
<template #overlay>
<Card>
<UserDescription v-bind="props" />
</Card>
</template>
</ToolTip>
</template>
注:你可能会注意到,所提供的解决方案基于原子设计概念。该概念首先可以将 "可重用性 "挑战降至最低。如果您对如何将其应用于 Vue.js 感兴趣,请参阅我同事的文章。
单元测试有帮助吗?
有些人可能会认为,为可重用组件编写单元测试会缓解这一问题。的确,全面的测试覆盖有助于确保对组件的修改和增强不会意外破坏功能。
然而,单元测试并不能使组件变得更可重用。它只是让组件更健壮而已。事实上,重构为更小的组件可以将任务分解为特定的部分,使单元测试的编写更易于管理。
结论
在 Vue中创建实际的可重用组件可能具有挑战性,这是因为需要解决修改现有组件、保持一致性以及管理依赖关系和状态等相关问题。然而,可重用组件的好处使得克服这些问题是值得的。可重用组件能加强代码组织、提高开发效率,并有助于创建一致的用户界面。当我们面对新的需求或任务时,我们将不断改进,以便更好地设计可重用组件。