硬核干货——《中小企业 AI 实战指南》免费下载! 了解详情
写点什么

Vue.js 组件的复用性:真正可复用还是伪装的可复用?

作者 | Fang Tanbamrung

  • 2023-10-27
    北京
  • 本文字数:10146 字

    阅读完需:约 33 分钟

Vue.js组件的复用性:真正可复用还是伪装的可复用?

大家讨论在 Vue.js 中创建 UI 组件时,总会提到可复用性的问题。没错,Vue.js 的一大核心原则就是其基于组件的架构,相应的好处自然是有助于可复用性和模块化。但这俩时髦词汇到底该怎么理解?


假设我们创建了一个可复用的组件:


  • 那我或我的同事真能在系统的其他部分复用这个组件吗?

  • 面对新需求,我们可能还得修改这个“可复用组件”。

  • 如果需要拆分这个“可复用组件”,以便把拆分出来的新组件应用到其他位置,又该如何操作?


在 Vue.js 中创建可复用组件的具体过程其实颇为棘手。在本文中,我们将具体探讨可复用组件的概念、实际应用时面临的问题,以及为什么有必要花心思克服这一道道难关。


可复用组件是个啥?


可复用组件是指一个个 UI 构建块,它们能在应用程序的各个位置、甚至是不同应用的相应位置上发挥作用。它们封装有特定的功能或 UI 模式,能够轻松集成至应用程序的其他部分,而且无需进行重大修改。


可复用组件的优势


通过在 Vue.js 中使用可复用组件,我们可以获得以下好处。


  • 提升效率:允许开发人员一次编写代码并多次重复使用,减少冗余内容并节约下宝贵的开发时间。

  • 贯彻标准化:促进各 Vue.js 项目之间的一致性和标准化,确保整个应用程序当中贯彻相同的设计模式、样式与功能。

  • 增强可扩展性:随着项目发展,我们可以轻松实现扩展和调整。通过将应用程序拆分成更小且可复用的组件,复杂功能的处理和新功能的添加也将变得更容易管理。

  • 促进协作:有助于各 Vue.js 项目团队成员之间的协作。可复用组件将帮助每一位团队成员使用并理解相同的表达和 UI 元素集。


运用可复用概念时的三个关键问题


虽然 Vue.js 组件将可复用性作为一大原则特性,但以下几个现实问题却往往会阻碍其具体实现。


  1. 修改现有组件:第一个问题,就是需要修改应用程序中正在使用的现有组件。该组件可能需要进行调整,从而同时支持原有和新的需求。但对应用程序中其他部分组件进行变更,有可能带来意想不到的副作用并破坏其他位置上的功能。在变更需求与保持兼容性之间寻求平衡往往相当复杂。

  2. 在组件设计中考虑一致性和灵活性:第二个问题,就是如何在可复用组件的不同实例之间保持一致性,同时保留灵活的可定制空间。可复用组件应当具备充分的通用性,从而适应不同的设计要求和风格。当然,在不牺牲组件核心功能与一致性的情况下提供定制选项也绝非易事。

  3. 管理组件依赖项和状态:要想让可复用组件发挥作用,就必须管理好相关依赖项并保证各个组件独立且自包含。具体来讲,各组件不应紧密依赖于外部资源或应用程序的状态管理系统。只有这样,我们才能将可复用组件轻松整合至不同项目当中,减少引发冲突或意外副作用的可能性。


案例研究


比方说,客户想为内部员工创建一套目录系统。这个项目基于敏捷开发方法,在开发之前没法充分收集需求。项目进度共分为三个阶段(原型设计阶段、第一阶段和第二阶段),出于演示需要,下面我将重点关注其中的 Card 组件。



原型设计阶段


在原型设计阶段,我们需要提供一个 User Profile 用户资料页面。用户的个人资料将包含最基本的 User Card 组件,其中又分为 User Avatar 头像和 Name 姓名。



// 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>
复制代码


第一阶段


在第一阶段,客户那边希望能在 User Card 组件上添加 User Detail 客户细节(包括出生日期、年龄、手机号码和邮箱地址)。



//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>
复制代码


此外,客户还希望添加 Employee Directory 员工目录页面,其中以卡片格式显示用户资料。



// 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> 
复制代码


在此阶段,User Card 组件在两个页面上均可复用。


第二阶段


用户反馈称,员工目录页面过于混乱,大量信息导致阅读起来非常难受。因此,客户希望在鼠标悬停时通过提示来展现用户资料。而 User Setting 用户设置页面部分的内容则比较合理,可以不加变动。



// 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 issueconst 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>
复制代码


感兴趣的朋友可以通过实时演示,查看原型设计阶段、第一阶段和第二阶段的代码。


https://codesandbox.io/s/reusable-components-in-vue-wwsd5y?file=/src/components/Prototype.vue


这个新要求着实令人头痛:


  • 我们要不要修改现有 User Card 组件来支持弹出提示?但这可能会影响到 User Setting 页面中的 User Card 组件,给应用程序造成意外干扰;

  • 或者,我们可以直接复制现有 User Card 组件,再向其中添加弹出提示?但这意味着 User Card 组件将不可复用。



因为我们实在不想破坏已经能够正常起效的东西,所以这里更倾向于后一种选择。这乍看之下似乎是个完美的解决方案,但在大规模、长期运行的项目当中,却也有可能带来巨大的破坏性影响:


  1. 代码库膨胀:这种方式会令代码库变得越来越大,因为对组件的每次复制都会增加不必要的代码行。这将使得项目愈发难以维护,因为每当需要更新或者做 bug 修复时,开发人员都得在多个位置上进行更改。这样还增加了引发一致性冲突的可能性。

  2. 短期收益,长期痛苦:短期之内,这似乎是个快速且简单的解决方案,能很好地应对紧迫的时间期限或者急切的突发需求。但随着项目的发展,对大量重复组件的维护会变得越来越困难且耗时。对重复组件的修改或更新都将需要在多个实例之间反复进行,大大拉高产生错误的几率。

  3. 影响系统性能:这种方式会对系统性能产生负面影响。冗余代码会增加应用程序的体积,导致渲染时间变长并增加内存占用量。最终,这一切会致使用户体验不佳、降低系统运行效率。


如何克服上述难题


请做好心理准备,可复用组件在整个项目周期中往往需要适应和调整,很难长时间保持不变。这看似是句废话,但如果认真想想,大家就会发现这真的是句至理名言——因为需求在变,所以围绕需求建立的整个体系都必须得变。当然,丰富的经验能帮助大家在初步设计时留出更大的适应空间,但也会相应影响开发速度。


重构可复用组件


就个人经验来讲,我更倾向于重新设计和重构这些可复用组件。所谓重构,就是在不改变其原始功能的情况下对代码做重新整理。可以选择的重构方法有很多,我个人会将组件重构并拆分成更多小型组件。这些更小的组件能在整个系统中灵活发挥作用。有了这个基本思路,下面来看我们要如何具体解决前文案例中提出的问题。


请注意:重构 UI 组件也需要遵守一定原则。另外,重构可能也会面临挑战,特别是如何在项目交付期限和代码清晰度之间取得平衡。


尝试解决前文案例


首先,我们将现有 User Card 组件拆分成 4 个组件:


  • Card 卡片组件

  • Avatar 头像组件

  • Name 姓名组件

  • User Detail 用户资料组件



// 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<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<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<script setup lang="ts">    import { defineProps } 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<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 弹出提示组件。将其设定为独立组件之后,我也可以在系统的其他位置对它进行复用。



// 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>
复制代码


最后,我把这些组件整合起来,如下所示。在其中的 User Setting 用户设置页面中,我会使用 User Card 组件,其中包含 Card、Avatar、Name 和 User Detail 组件。



// 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>
复制代码


至于 Employee Directory 员工目录页面,我打算使用 2 个组合组件:


  • 其中的基本 User Card 组件由 Card、Avatar 和 Name 组件构成。

  • User Tooltip 弹出提示组件则由 Card、Tooltip 和 User Detail 组件构成。



// 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<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>
复制代码


关于本案例中代码重构的更多细节信息,请参阅我分享的完整解决方案:


https://codesandbox.io/s/reusable-components-in-vue-wwsd5y?file=/src/components/solution/UserCardWithTooltip.vue


请注意:很多朋友可能发现,我提供的解决方案基于原子设计思路。这个概念本身就能最大限度缓解“可复用”方面的挑战。如果您对 Vue.js 中的原子设计思维感兴趣,这里向大家推荐另外一篇文章:


https://dev.to/berryjam/introducing-atomic-design-in-vuejs-1l2h


单元测试能帮上忙吗?


很多朋友可能觉得,为那些可复用组件编写单元测试应该能帮得上忙。毕竟在全面覆盖之后,单元测试有助于保证针对各组件的修改和增强不会意外破坏其原有功能。


但这里要给大家泼点冷水,单元测试本身并不会让组件变得更可复用,而只是让组件更加健壮。而且根据任务中的特定部分将组件进一步拆解,倒是可以让单元测试变得更易于编写和管理。


总结


受到修改现有组件、保持一致性、管理依赖项和系统状态等一系列现实问题的影响,在 Vue.js 中实际构建可复用组件往往充满挑战。但考虑到可复用组件所带来的种种助益,我们当然有理由迎难而上、努力克服这些障碍。可复用组件能够增强代码组织结构、提高开发效率,也有助于建立起更趋一致的用户界面。面对新的任务或者要求,我们不妨从设计可复用组件入手,从当下开始改善整个项目的结构和质量。


欢迎评论区说出你的建议。



原文链接:


https://dev.to/fangtanbamrung/challenges-of-reusable-components-in-vuejs-and-how-to-overcome-them-4g6d

相关阅读:


Vue.js 最佳静态站点生成器对比

强烈推荐:一个 Vue.js 在线挑战平台

Vue.js 前后端同构方案之准备篇—代码优化

从 React 切换到 Vue.js

2023-10-27 16:077099

评论

发布
暂无评论
发现更多内容

堪称神级的阿里巴巴“高并发”教程《基础+实战+源码+面试+架构》

Java 编程 程序员 高并发

网络篇夺命连环12问

冇先生

HTTP与HTTPS,HTTPS更加安全。

喀拉峻

网络安全 安全 信息安全 HTTP

腾讯Q3财报:腾讯企点服务超100万家企业,显著降低客服成本

科技热闻

腾讯AI专家整理最全Python学习体系(视频+源码+案例+路线)

小Q

Python AI 面试 爬虫

[架构实战营] 模块三作业

张祥

架构实战营

详细架构设计文档模板(笔记)

张祥

Apache Pulsar 在能源互联网领域的落地实践

Apache Pulsar

架构 云原生 Apache Pulsar 消息系统 用户案例 能源互联网

吐血整理:常用的大数据采集工具,你不可不知

小术晓术

大数据 数据采集

专题|防宕机,Pulsar 跨机房复制了解一下?

Apache Pulsar

云原生 中间件 Apache Pulsar 消息系统 防宕机 跨地域复制

ShowMeBug 中如何科学的识别用户浏览器?

ShowMeBug

大前端 浏览器 WebRTC

外包学生管理系统架构文档

Steven

架构实战营

电脑数据恢复用哪款软件比较好

淋雨

EasyRecovery

腾讯云开源百万级服务发现和治理中心“北极星”,打造可持续微服务生态

科技热闻

ShowMeBug 黑科技丨一招快速实现架构绘图之鼠标同步

ShowMeBug

思维导图 实时同步 绘图库

纳尼?华为首席架构师只用434页笔记,就将网络协议给拿下了

编程 程序员 面试 网络协议

IM开发基础知识补课(十):大型IM系统有多难?万字长文,搞懂异地多活!

JackJiang

架构设计 即时通讯 灾备 IM 异地多活

基于SVDD算法的半监督风控模型

索信达控股

算法 风控模型 半督导算法

Aeron是如何实现的?—— Conductor

BUG侦探

Aeron Conductor

安全漏洞之经典上传漏洞

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 安全漏洞

还在刷面试题?NO!这份阿里内网Java面试通关手册才是你急需的

Java spring 面试 JVM mybatis

视频通信关键技术探索及实践

网易云信

音视频 通信云

❤️这应该是Postman最详细的中文使用教程了❤️(新手使用,简单明了)

六十七点五

软件测试 性能测试 Postman 自动化测试 接口测试

Python代码阅读(第56篇):获取列表的前n个最大/最小值

Felix

Python 编程 列表 阅读代码 Python初学者

智能的统一哲学

木铎

人工智能 机器学习 智能

千万不要小瞧复杂度分析,代码详解复杂度的重要性

小Q

Java Python 学习 数据结构 算法

绝绝子!美团大牛吐血整理总结“消息队列核心知识笔记”是真的吊

编程 程序员 MQ 队列

腾讯安全推出御界NDR「横移检测版」,全面检测域渗透攻击

腾讯安全

架构实战营-模块三作业

无名

架构实战营 「架构实战营」

今天面了个腾讯拿38K出来的大佬,让我见识到了基础的天花板

收到请回复

Java 程序员 后端

第三阶段总结

张靖

#架构实战营

Vue.js组件的复用性:真正可复用还是伪装的可复用?_架构/框架_InfoQ精选文章