NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

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:076323

评论

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

【中秋特辑】嫦娥妹妹,你别着急~

阿里巴巴云原生

阿里云 RocketMQ 云原生 消息队列

LeetCode-316. 去除重复字母&&1081.不同字符的最小子序列(Java实现)

bug菌

9月日更 Leet Code 9月月更

企业如何规划SRM供应商协同平台?实现最佳应用价值

数商云

数字化转型 企业数字化

2022 WAIC 闭幕,融云提供分论坛元宇宙直播技术支持

融云 RongCloud

直播 元宇宙

ERP是什么?

优秀

ERP

当代用电行为大赏:有人心疼电费,有人靠屋顶光伏“理财”

脑极体

2022-09-09:给定一个正整数 n,返回 连续正整数满足所有数字之和为 n 的组数 。 示例 1: 输入: n = 5 输出: 2 解释: 5 = 2 + 3,共有两组连续整数([5],[2,

福大大架构师每日一题

算法 rust 福大大

Python 教程之变量(1)—— 变量、表达式、条件和函数

海拥(haiyong.site)

Python 9月月更

百度工程师教你玩转设计模式(工厂模式)

百度Geek说

Java 设计模式 企业号九月金秋榜

云渲染为设计行业带来哪些福利?

3DCAT实时渲染

Java进阶(十)tomcat中context配置

No Silver Bullet

tomcat Context 9月月更

哈希索引

急需上岸的小谢

9月月更

社招前端二面常见面试题

coder2028

JavaScript 前端

C++学习------cmath头文件的源码学习02

桑榆

c++ 9月月更

跟我一起学mybatis

楠羽

mybatis 笔记 9月月更

【C语言深度剖析】重点详解函数的形参和实参、传值和传址

Albert Edison

开发语言 传值 C语音 9月月更 传址

WAIC|九章云极DataCanvas公司携因果学习技术成果精彩亮相!

九章云极DataCanvas

人工智能 开源 因果学习

基于threejs中秋佳节之际带你遨游星空🌃

南城FE

前端 中秋 three.js

Python 教程之数据分析(7)—— Jupyter Notebook 入门

海拥(haiyong.site)

Python 9月月更

这个中秋,国潮元宇宙的A新玩法是……?

文心大模型

热点直播 | 财务共享中心数字化转型,推动央企构建一流财务管理体系

望繁信科技

直播 技术干货

「趣学前端」今日祝福不限量,批量导入在路上

叶一一

前端 设计思维 9月月更

DevSecOps 落地三部曲|小孩子才做选择,极狐GitLab 安全、高效全都要

极狐GitLab

DevOps 运维 安全 DevSecOps 极狐GitLab

中移链DDC-SDK技术对接全流程(二)

BSN研习社

区块链、

【JS】两种实现-懒加载的方式-附无限滚动案例

Sam9029

JavaScript 前端 懒加载 9月月更

支撑全产业AI,需要怎样的算力服务?

脑极体

「九章云极DataCanvas」完成C+轮融资,用云中云战略引领数据智能基础软件升级

九章云极DataCanvas

机器学习 数据智能

LeetCode-448. 找到所有数组中消失的数字(Java)

bug菌

9月日更 Leet Code 9月月更

为什么低代码和专业代码走向融合才能破解低代码困境?

牛刀专业低代码

低代码 低代码平台

Web & Electron 平台即时通讯产品的技术选型

融云 RongCloud

Web Electron 即时通讯

「工作小记」多个页面的相似操作公共化设计方案

叶一一

前端 设计思维 9月月更

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