Vue 中可重用組件的三個主要問題
當(dāng)我們談?wù)摶蛴懻撛?Vue 中創(chuàng)建用戶界面組件時,經(jīng)常會提到可重用性。沒錯,Vue 的關(guān)鍵原則之一就是其基于組件的架構(gòu),這促進(jìn)了可重用性和模塊化。但這到底意味著什么呢?
比方說,你創(chuàng)建了一個可重復(fù)使用的組件:
- 你或你的同事真的能在系統(tǒng)的另一個部分重復(fù)使用它嗎?
- 有了新的需求,你可能不得不考慮修改 "可重復(fù)使用的組件"。
- 如果需要拆分 "可重用組件",以便將拆分后的組件應(yīng)用到其他地方,該怎么辦?
在 Vue 中創(chuàng)建真正的可重用組件可能很棘手。在本文中,我將探討可重用組件的概念、應(yīng)用這些組件時面臨的問題,以及為什么必須盡可能克服這些問題。
什么是可重用組件?
可重用組件是用戶界面構(gòu)件,可用于應(yīng)用程序的不同部分,甚至多個項目。它們封裝了特定的功能或用戶界面模式,可以輕松集成到應(yīng)用程序的其他部分,而無需進(jìn)行大量修改。
可重復(fù)使用組件的優(yōu)勢
- 效率:允許開發(fā)人員一次編寫代碼并多次重復(fù)使用。這樣可以減少冗余,節(jié)省寶貴的開發(fā)時間。
- 標(biāo)準(zhǔn)化:促進(jìn)整個 Vue 項目的一致性和標(biāo)準(zhǔn)化。它們可確保在整個應(yīng)用程序中保持相同的設(shè)計模式、樣式和功能。
- 可擴(kuò)展性:隨著項目的增長,更容易擴(kuò)展和調(diào)整項目。通過將應(yīng)用程序分解成更小的、可重復(fù)使用的組件,可以更容易地處理復(fù)雜功能和添加新功能。
- 協(xié)作:促進(jìn)團(tuán)隊成員在 Vue 項目中的協(xié)作。它們提供了團(tuán)隊中每個人都能使用和理解的共享詞匯和用戶界面元素集。
應(yīng)用可重復(fù)使用概念時的 3 個問題
雖然可重用性是 Vue. 組件的一個理想特性,但有幾個問題會使其難以實(shí)現(xiàn):
- 修改現(xiàn)有組件:一個問題是修改應(yīng)用程序中已經(jīng)使用的現(xiàn)有組件??赡苄枰獙M件進(jìn)行修改,以同時支持現(xiàn)有需求和新需求。對應(yīng)用程序其他部分已經(jīng)使用的組件進(jìn)行修改,可能會帶來意想不到的副作用,并破壞其他部分的功能。在變更需求與保持兼容性之間取得平衡可能很復(fù)雜。
- 設(shè)計組件的一致性和靈活性:另一個問題是在可重復(fù)使用組件的不同實(shí)例之間保持一致性,同時允許自定義和靈活性??芍赜媒M件應(yīng)具有足夠的通用性,以適應(yīng)不同的設(shè)計要求和風(fēng)格。然而,在提供定制選項的同時又不犧牲組件的核心功能和一致性可能會很棘手。
- 管理組件依賴關(guān)系和狀態(tài):使用可重復(fù)使用的組件需要管理依賴關(guān)系,并確保每個組件保持自足和獨(dú)立。組件不應(yīng)緊密依賴外部資源或應(yīng)用程序的狀態(tài)管理系統(tǒng)。這樣可以輕松集成到不同的項目中,減少沖突或意外副作用的可能性。
案例
比方說,客戶想要一個內(nèi)部員工目錄系統(tǒng)。該項目采用敏捷方法,開發(fā)前無法收集所有需求。項目分為三個階段(原型、第一階段和第二階段)。在本演示中,我將重點(diǎn)介紹一個卡片組件,如下所示:
圖片
原型
作為原型階段的一部分,我需要提供一個用戶配置文件頁面。用戶配置文件將包含一個基本的用戶卡組件,其中包括用戶頭像和姓名。
圖片
// 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 階段
在第一階段,客戶希望在用戶卡組件中添加用戶詳細(xì)信息(生日、年齡、電話號碼和電子郵件)。
圖片
//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>
在這一階段,用戶卡組件可在兩個頁面上重復(fù)使用。
第二階段
用戶反饋 "員工名錄 "頁面雜亂無章。過多的信息使頁面難以使用。因此,客戶希望在鼠標(biāo)懸停時以工具提示的形式顯示用戶詳細(xì)信息。用戶設(shè)置頁面的要求保持不變。
// 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>
這項新要求令人頭疼:
- 我是否要修改現(xiàn)有的用戶卡組件以支持工具提示要求,并冒著影響用戶設(shè)置頁面中的用戶卡組件的風(fēng)險?或者
- 是否要復(fù)制現(xiàn)有的用戶卡組件并添加工具提示功能?
因?yàn)槲覀儾幌肫茐囊呀?jīng)投入生產(chǎn)的項目,所以我們傾向于選擇后一種方案。起初,這可能是有道理的,但它可能會造成相當(dāng)大的損害,尤其是對于大型和連續(xù)性項目而言:
- 代碼庫龐大:導(dǎo)致代碼庫擴(kuò)大,因?yàn)槊總€重復(fù)的組件都會增加不必要的代碼行。變得難以維護(hù),因?yàn)槊慨?dāng)需要更新或修復(fù)錯誤時,開發(fā)人員都需要在多個地方進(jìn)行修改。這還會增加不一致的可能性。
- 短期收益,長期痛苦:在短期內(nèi),特別是在處理緊迫的期限或緊急需求時,這似乎是一個快速而簡單的解決方案。然而,隨著項目的增長,維護(hù)重復(fù)組件變得越來越困難和耗時。對重復(fù)組件的修改或更新需要在多個實(shí)例中復(fù)制,導(dǎo)致出錯的幾率增加。
- 系統(tǒng)性能:會對系統(tǒng)性能產(chǎn)生負(fù)面影響。冗余代碼會增加應(yīng)用程序的大小,導(dǎo)致渲染時間變慢和內(nèi)存使用量增加。這會導(dǎo)致用戶體驗(yàn)不佳,系統(tǒng)效率降低。
如何克服上述問題
在整個項目中,可重復(fù)使用的組件可能不會始終保持不變,這一點(diǎn)要有心理準(zhǔn)備。這聽起來可能很老套,但仔細(xì)想想,需求總是在不斷變化的。你無法控制未來,只能盡力而為。當(dāng)然,經(jīng)驗(yàn)會幫助你設(shè)計出更好的組件,但這需要時間
重構(gòu)可重用組件
根據(jù)我的經(jīng)驗(yàn),我將重新設(shè)計和重構(gòu)可重用的組件。重構(gòu)是一個在不改變代碼原有功能的前提下重組代碼的過程。我相信重構(gòu)的方法有很多,對我來說,我會重構(gòu)并將組件分解成更小的組件。較小的組件可以在整個系統(tǒng)中靈活應(yīng)用。讓我們看看我將如何應(yīng)用上述案例研究。
注意:重構(gòu)用戶界面組件需要嚴(yán)謹(jǐn)?shù)膽B(tài)度。此外,有時這也很具有挑戰(zhàn)性,因?yàn)樾枰陧椖拷桓镀谙藓透啙嵉拇a之間取得平衡。
將解決方案應(yīng)用于案例研究
首先,我將把現(xiàn)有的用戶卡組件拆分成 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>
之后,我將創(chuàng)建一個工具提示組件。創(chuàng)建一個單獨(dú)的工具提示可以讓我在系統(tǒng)的其他部分重復(fù)使用它。
圖片
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>
最后,我將把這些組件組合在一起,如下所示。
在用戶設(shè)置頁面,我將使用用戶卡組件,它由卡片、頭像、姓名組件和用戶詳情組件組成。
圖片
// 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>
注:你可能會注意到,所提供的解決方案基于原子設(shè)計概念。該概念首先可以將 "可重用性 "挑戰(zhàn)降至最低。如果您對如何將其應(yīng)用于 Vue.js 感興趣,請參閱我同事的文章。
單元測試有幫助嗎?
有些人可能會認(rèn)為,為可重用組件編寫單元測試會緩解這一問題。的確,全面的測試覆蓋有助于確保對組件的修改和增強(qiáng)不會意外破壞功能。
然而,單元測試并不能使組件變得更可重用。它只是讓組件更健壯而已。事實(shí)上,重構(gòu)為更小的組件可以將任務(wù)分解為特定的部分,使單元測試的編寫更易于管理。
結(jié)論
在 Vue中創(chuàng)建實(shí)際的可重用組件可能具有挑戰(zhàn)性,這是因?yàn)樾枰鉀Q修改現(xiàn)有組件、保持一致性以及管理依賴關(guān)系和狀態(tài)等相關(guān)問題。然而,可重用組件的好處使得克服這些問題是值得的??芍赜媒M件能加強(qiáng)代碼組織、提高開發(fā)效率,并有助于創(chuàng)建一致的用戶界面。當(dāng)我們面對新的需求或任務(wù)時,我們將不斷改進(jìn),以便更好地設(shè)計可重用組件。