且谈前端组件封装

1,957 阅读18分钟

什么是组件封装?

组件封装是将前端代码中某一特定功能模块、视图部分或逻辑处理抽象成独立的可复用代码块,以便在不同场景中复用,同时提升开发效率和代码一致性。 主要有以下类型:

功能型组件:纯逻辑处理,不涉及 UI。比如表单验证组件、请求封装组件。

UI组件:单纯的视图展示,具备一定交互功能,如按钮(Button)、输入框(Input)。

业务组件:结合具体业务场景的复杂组件,通常是 UI 和逻辑的结合,如用户登录面板、商品卡片组件。

高阶组件(HOC) :用于逻辑复用的一种模式,接收一个组件作为输入,返回一个新的组件。

组合组件:通过组合多个基础组件或业务组件,形成更复杂的组件。

组件封装是为了什么?

上面我们讲了,组件的类型主要分为功能组件,ui组件,以及业务组件等等。这些组件的封装能够:

  • 提升开发效率: 通过组件的复用,避免重复编写相似的代码。

  • 代码维护性: 将复杂代码分解为易于理解和维护的小块,降低维护成本,降低复杂性

  • 增强一致性: 统一的组件库确保应用的风格一致,符合设计规范。

  • 提升扩展性: 组件封装良好时,可以轻松适配新的需求,通过扩展接口而不是改动原有代码。

  • 降低耦合性: 通过明确的接口设计,解耦组件的内部逻辑和外部使用。

那么第三方组件库诸如,vant ant-design elemnt ,不是也能满足以上的需求吗?为什么很多公司要自己再封装组件库呢?

但主要的原因还是:第三方组件不管从业务上还是ui交互上都不能提供完全个性化定制的需求。

第三方组件库一般提供通用的功能,而公司自己的业务往往具有特殊性。例如,某些组件需要根据公司内部的业务逻辑进行高度定制,而通用组件库无法完全满足这些需求。

自定义组件库可以完全匹配公司的品牌设计语言(如配色方案、字体、边框样式等),确保所有的 UI 风格和交互体验一致。

某些复杂业务场景需要跨多个模块的协作(例如企业级数据分析组件),这些场景可能超出了第三方组件库的设计范围。

还有一些比较重要的原因是:

  • 第三方组件库的迭代和维护不可控,如果组件库停止维护或更新可能影响到项目的正常开发。例如一些pdf展示的组件,我就遇到过,引用的字体资源过期了,但是组件很久没人维护了,导致线上的pdf不能展示。

  • 项目可能需要更高的代码稳定性和兼容性,而第三方库的更新可能引入破坏性变更,影响现有功能。

  • 公司内部的技术栈可能不完全与第三方库兼容,自定义组件库可以更加贴合公司的技术生态。

在开发效率方面,自己封装组件库,肯定就有更高的权限,那能做的事情就更多:

轻量化: 第三方组件库功能广泛,但并非所有功能都会被用到。自定义组件库可以裁剪掉不必要的功能,减小项目体积。

统一规范: 自定义组件库能更好地结合团队的编码规范、交互模式和设计体系,避免团队开发过程中风格和实现不统一的问题。

二次封装: 在使用第三方组件库时,可能需要频繁地进行二次封装。相比之下,直接封装自己的组件库能够减少重复工作,提高开发效率。

按需加载优化: 自定义组件库可以根据实际需求更高效地设计按需加载机制,避免引入不必要的依赖和资源。

性能调优: 可以针对具体场景进行性能优化,如减少重渲染、提升复杂组件的渲染效率等,这些优化在第三方库中可能较难实现。

代码质量保证: 自定义组件库可以完全由内部开发团队维护和测试,保证代码质量和安全性。

减少安全风险: 第三方组件库可能存在潜在的安全问题,而自定义组件库能避免这些外部风险。

因此有了第三方组件库后,封装自己的组件库主要是为了 更贴合业务需求、提升效率、减少依赖、优化性能、强化品牌统一性 等。通过建立自己的组件库,公司可以实现技术与业务的深度结合,提供更加优质的用户体验,同时增强团队协作和开发能力。

但是但是但是,上面阐述了那么多自己封装组件库的好处,我们要扪心自问自己一个问题:对于所有的公司,组件封装真的是一件需要去做的事情吗? 什么情况下,我们才需要去投入人力去维护一个组件库呢?组件库开发投入的成本真的小于带来的收益吗? 这个问题,我们下一节讨论。


组件封装真的好吗?

我们上面讨论了封装组件库带来的好处,但是我们忘记了,封装组件库是要投入人力成本的,且还要看团队内成员的水平水平能够支撑得起组件开发这件事。否则就是变成花了很多人去写了很多很难维护甚至会出现问题的的代码,最后项目越来越大,越来越难用,只能走向重构,费时费力。

很多第三方的组件库都是大公司出品,先不说技术水平,迭代别人都迭代了多少版本了,自然问题会更少。

所以,当你想要封装一个组件库的时候,问问自己几个问题:

第三方组件库不行吗?团队实力允许吗?要花多少时间,投入多少人力?后期是不是要持续投入人力去维护?二次封装第三方的行不行?到底能不能提升开发效率?是否能复用和减少单个文件代码量,降低复杂度?

如果你在一家小公司,我个人认为封装组件库大概率是一件投入产出比很低的事情,还不如: 优先采用成熟的开源组件库, 如 Ant Design、Element Plus 等成熟的开源组件库。这些库功能齐全,生态完善,能够快速适应大多数业务需求,帮助团队节省时间和精力。

如果当开源组件库不能完全满足需求时,退而求其次,可通过二次封装第三方组件或样式定制的方式进行改造,而不是从零开始开发全新的组件库。

对于小公司而言,开发团队的首要任务是快速响应业务需求。组件库的建设应是对效率的提升,而不应成为团队负担或拖延业务进度的因素,优先级不应该这么高。

况且在很多时候,封装的业务组件,是因为很多业务逻辑看似相同,但其实只是暂时一致。随着业务的不断发展,这些逻辑往往会逐渐出现差异。过早地进行抽象,可能会导致后续需求的实现变得极其复杂。因此,在进行抽象时,必须明确抽象的部分是否是真正的共性,而不是因为产品设计时间有限而暂时设定为相同的。不然就会随时需求变动而不断加东西,所谓的业务组件越来越冗余,最后也没有人再会去用。

如果你经过详实的考察后,面对是否应该封装公司内部的自定义组件库这个问题,你的答案还是是yes。那么我们下一节来探讨,我们应该遵循怎样的原则和规范来更好的封装一个自定义的组件库。

如何的封装组件?

我们来判断是否需要封装组件,除了上述的考量外,我们要去具体实施的时候要分两种情况去考虑

假如我们是要把日常的重复业务和ui交互逻辑封装到一块,那么我们要考虑:

  • 要把这坨代码进行封装,那这坨代码能否更好的使得后续的开发可以复用,又或者是是否能减少单个文件的代码量,降低复杂度?不能满足这两个条件否就不能轻易封装。
  • 要封装的这个组件是业务组件还是ui组件,是为了满足什么需求而封装。封装的组件是全局使用还是局部使用就可以。当你拿不定主意的时候最好是优先把封装的组件放到当前模块的 components 文件夹,如果有其他模块也公用了,再移动到项目根目录的 components文件夹中

假如我们是要封装一个通用的组件库,类似于 antd vant 等,那么我们要考虑:

  • 二次封装 antd 这类组件能满足吗?
  • 如果是通用组件库,我们要不要单起一个工程还是放到项目文件夹中。

1. 组件封装的核心理念

不同的考虑决定了我们最终采用的技术方案。不管是哪一种情况,组件的封装还是会遵循已经存在广泛共识的理念,这些理念帮助确保组件的可维护性、可复用性以及与业务的契合度:

单一职责原则(Single Responsibility Principle, SRP)

每个组件应只负责一个明确的功能或任务。一个组件承担过多的责任会导致复杂性增加,降低复用性和可维护性。封装时,要确保组件的功能单一,便于后续扩展和修改。

高内聚,低耦合

  • 高内聚:组件内部的功能和逻辑应该紧密相关,避免无关的代码混杂在一起。
  • 低耦合:组件与其他组件或系统的依赖应尽可能少,确保组件的独立性和可复用性。尽量通过明确的 API 和接口与外部交互。

可复用性

组件应该是通用的、独立的,能在不同的场景下重复使用。避免为每个业务场景写一个特定的组件,而是通过配置、参数化、插槽等机制让组件具备灵活性。

封装与抽象

组件应该隐藏内部实现细节,只暴露必要的 API 给外部。通过抽象,让使用组件的开发者不需要关心其内部的具体实现逻辑,从而简化了使用过程。

灵活性与扩展性

组件要具备一定的灵活性,能够适应业务需求的变化。通过提供配置选项、插槽、事件等机制,让组件能够根据不同需求进行定制化。 组件设计时要考虑未来可能的扩展,避免过度封装和过早抽象。

易用性

  • 组件应设计为易于使用的形式,尽量避免复杂的 API,确保使用者能够快速理解如何使用和配置该组件。
  • 提供清晰的文档、示例和说明,帮助开发者快速上手。

一致性

  • 组件的设计应该与项目的整体设计风格保持一致,包括 UI 风格、交互逻辑、命名规则等方面的一致性。
  • 在整个项目中,使用统一的设计规范和编码规范,保持一致性以减少团队开发时的沟通成本。

性能优化

  • 在封装组件时,要时刻关注组件的性能,避免不必要的渲染、复杂计算和性能开销。例如,避免不必要的组件重渲染,使用虚拟滚动、懒加载等技术来提升性能。
  • 对于一些高频操作的组件(如表格、列表),要特别注重性能的优化,确保在大数据量或复杂场景下依然表现良好。

易于维护与测试

  • 封装后的组件应易于维护和扩展。每个组件要具备明确的功能,并且代码要清晰、注释完善。
  • 编写单元测试和集成测试,确保组件在不同场景下的功能正常,并能在后续版本中保持稳定性。

遵循设计模式

  • 在封装组件时,考虑使用适当的设计模式(如工厂模式、观察者模式、策略模式等)来简化组件的实现,提升代码的可理解性和可扩展性。
  • 通过设计模式,避免重复代码,提升系统的灵活性和可维护性。

先本地后共享

就是你要封装一个组件,你得想,这个东西是不是有必要封装,是不是一个更通用的,还是只是你当前文件夹的业务使用。如果不是通用的,优先第一步先在你的文件夹建一个 components文件夹,把你的组件放到里头,不要啥都放到根目录的文件夹,除非这个组件有别的业务可使用,它更通用了,否则应该是就近原则去封装,后续再迁移到根目录也不迟。

是否够通用,业务是否变动

当我们封装一些通用的业务组件的时候,这往往会比较危险,因为,业务变动是很快的,然后在不断迭代的过程中要去给组件传递一堆的 props, 然后组件内部一堆的 if else 的判断,就问大家有没有看到过这种组件吧,后续根本没法用,组件会越来越冗余,又得重构。所以封装业务组件得慎重,团队应该有组件封装规范的沉淀,告诉大家,如果要封装,要去做业务变更的思考,要去做属性传递的和拓展的思考。

2. 我是如何封装组件的?

2.1 封装业务组件

我们举几个例子:

在移动端,我们经常会有很多上下滑动的列表,这个列表里会有一个一个的card来展示信息。

image.png

我们会看到很多人会把这个card封装成一个组件。这里底部的按钮往往会有很多权限控制或者条件判断。一开始,封装的同学就会想,就这几个按钮,我们在父级调用的时候传入一个状态status给子组件,不同的status展示不同的按钮就好了。

但是随着需求的变化,这些按钮还会增加:如 查看合同转发转派...并且会变成不只是一个status来控制这个按钮的显隐,于是这个组件代码越来越臃肿,越来越多的if-else,后续根本没办法维护。

以上的典型糟糕的封装案例就是违背了高内聚低耦合以及应当具备良好拓展性的原则。

那针对上面的情况有什么好办法吗? 有两种方案:

  • 底部提供一个插槽,父级调动,直接放按钮。
  • 通过配置的方式(跟传入单个状态来判断有区别,这里的配置是显示的告诉组件应该渲染什么)

还有一个常见的犯错的例子如:

<MyCard
  :title="post.title"
  :date="post.date"
  :layout="currentLayout"
  :image="post.imageUrl"
  <!-- more props -->
/>

这样的方式后期会变成,越来越臃肿的props,组件外组件内的增删都会变得臃肿,当业务发生变化,十分难维护。好的方案是把属性props进行归类,以对象的形式传递:

<MyCard :info="info" :layout="layoutInfo" />

总之,组件封装需要遵循一定的规范和理念,想好了再下手效率永远是最高的。

2.2 二次封装第三方组件

2.2.1 理念

在封装第三方前端组件库(如 Ant Design、Vant、Element 等)的组件时,目标是基于现有组件库进行二次封装,使其更好地满足你的业务需求。封装过程中需要遵循一定的原则和注意事项,以确保组件的可维护性、复用性和灵活性:

封装的必要性:

业务需求驱动:只有当第三方组件的原生功能无法完全满足你的业务需求,或者需要频繁重复调整时,才考虑封装。

避免重复代码:如果多处业务使用同样的逻辑或样式调整,封装可以提升开发效率。

封装的原则

非侵入性:尽量在不改变组件库核心逻辑的基础上封装,避免对组件库本身做修改,方便后续升级。就是保持原有组件提供的接口(属性、方法、事件、插槽)不变。

保持扩展性:封装后组件仍然支持传递原生的 props 和事件,以便未来调整或添加功能。

复用优先:封装的组件应该是通用的,能适配多种场景,减少针对性过强的代码。

避免过度封装:不要为了封装而封装,如果简单的组合使用组件即可满足需求,尽量避免复杂封装。

统一样式与行为:确保封装后的组件风格与项目整体风格保持一致。

设计接口

  • 保持接口简单且清晰,避免封装后接口过于复杂。
  • 支持组件库原生的 props,同时新增业务所需的自定义 props。
  • 提供事件回调,允许业务代码灵活处理逻辑。

适配团队规范

  • 确保封装的组件符合团队的开发规范,包括代码风格、测试覆盖率、文档编写等。
  • 为封装的组件提供清晰的文档,方便团队成员快速上手。
2.2.2 封装实践

那我们如何去实现上述的原则呢,常见的几点如:

  • $attrs(属性透传)来解决挨个给第三方组件传递属性的问题

    //v-bind="$attrs" 传入第三方组件中
    <el-input v-bind="$attrs"></el-input>
    
  • 继承第三方组件的事件

    跟上面的属性传递一样,如果我们往子组件传入一些事件,并且想要将这些事件传给第三方组件,这里需要用到 $listeners

    //父组件
    <MyComponent @change="change" @focus="focus" @input="input"></MyComponent>
    
    
    //子组件
    
    // Vue2
    <template>
      <div class="my-input">
        <el-input v-bind="$attrs" v-on="$listeners"></el-input>
      </div>
    </template>
    
    // Vue3 在 
    //Vue3 中,取消了$listeners这个组件实例的属性,将其事件的监听都整合到了$attrs上
    <template>
      <div class="my-input">
        <el-input v-bind="$attrs"></el-input>
      </div>
    </template>
    
    
  • 使用第三方组件的 Slots

    $slots可以获取到父组件传递过来的插槽,循环遍历放到第三方组件里

    <template>
      <div class="my-input">
        <el-input
          v-model="childSelectedValue"
          v-bind="attrs"
          v-on="$listeners"
        >
          <template #[slotName]="slotProps" v-for="(slot, slotName) in $slots" >
              <slot :name="slotName" v-bind="slotProps"></slot>
          </template>
        </el-input>
      </div>
    </template>
    
    
  • 使用第三方组件的Methods方法

    vue3中使用defineExpose暴露方法

    //子组件暴露
    <template>
      <div class="my-table">
        <el-table ref="table"></el-table>
      </div>
    </template>
    
    <script lang="ts" setup>
    import { ref, onMounted } from 'vue'
    import { ElTable } from 'element-plus'
    
    const table = ref();
    
    onMounted(() => { 
        const entries = Object.entries(table.value); 
        for (const [method, fn] of entries) { 
            expose[method] = fn; 
        } 
    }); 
    defineExpose(expose);
    
    
    
    //父组件调用
    <template>
      <MyTable ref="tableRef"></MyInput>
    </template>
    
    <script lang="ts" setup>
    import { ref,onMounted } from 'vue'
    
    const tableRef = ref()
    
    onMounted(() => {
      console.log(tableRef.value);
      // 调用子组件中table的方法
      tableRef.value.clearSort()
    
    })
    </script>
    
    

举个栗子

我们来举一个例子,我们将封装一个通用表单组件,支持输入框和选择框。


<!-- components/CustomForm.vue -->
<template>
  <a-form :model="formData" @finish="handleFinish">
    <!-- 渲染表单字段 -->
    <a-form-item
      v-for="(field, index) in fields"
      :key="index"
      :label="field.label"
      :name="field.name"
      :rules="field.rules"
    >
      <component
        :is="field.type"
        v-bind="field.props"
        v-model="formData[field.name]"
      />
    </a-form-item>
    <!-- 提交按钮 -->
    <a-form-item>
      <a-button type="primary" html-type="submit">Submit</a-button>
    </a-form-item>
  </a-form>
</template>

<script>
import { defineComponent, reactive } from "vue";
import { Form as AForm, Input as AInput, Select as ASelect, Button as AButton } from "ant-design-vue";

export default defineComponent({
  components: {
    AForm,
    AInput,
    ASelect,
    AButton,
  },
  props: {
    fields: {
      type: Array,
      required: true,
    },
    onSubmit: {
      type: Function,
      required: true,
    },
  },
  setup(props) {
    // 初始化表单数据
    const formData = reactive(
      props.fields.reduce((acc, field) => {
        acc[field.name] = field.value || ""; // 初始化字段
        return acc;
      }, {})
    );

    const handleFinish = () => {
      // 将表单数据传递给父组件的 onSubmit 方法
      props.onSubmit(formData);
    };

    return {
      formData,
      handleFinish,
    };
  },
});
</script>

如何在父组件中使用这个封装的表单组件。

<!-- App.vue -->
<template>
  <div>
    <h1>Vue 3 Custom Form Example</h1>
    <CustomForm :fields="formFields" :onSubmit="handleSubmit" />
  </div>
</template>

<script>
import CustomForm from './components/CustomForm.vue';

export default {
  components: {
    CustomForm,
  },
  data() {
    return {
      formFields: [
        {
          type: 'a-input',
          name: 'username',
          label: 'Username',
          rules: [{ required: true, message: 'Please input your username!' }],
          props: { placeholder: 'Enter your username' },
        },
        {
          type: 'a-select',
          name: 'role',
          label: 'Role',
          rules: [{ required: true, message: 'Please select a role!' }],
          props: {
            placeholder: 'Select a role',
            options: [
              { label: 'Admin', value: 'admin' },
              { label: 'User', value: 'user' },
            ],
          },
        },
      ],
    };
  },
  methods: {
    handleSubmit(formData) {
      console.log('Form data submitted:', formData);
    },
  },
};
</script>
  • 组件接口设计

    • 通过 fields 属性接收一个字段配置数组,包含字段的类型、名称、标签、验证规则等信息。
    • 使用 v-bindv-model 动态绑定每个表单项的属性和数据,确保组件的灵活性。
  • 表单提交

    • 通过 @finish 监听表单提交事件,在 handleFinish 中将表单数据传递给父组件的 onSubmit 方法。
    • 父组件通过 onSubmit 处理提交的数据。
  • 动态渲染字段

    • 使用 v-for 遍历 fields 数组,动态渲染每个表单项。根据字段类型(a-inputa-select 等),使用 Vue 的动态组件功能(<component :is="field.type">)来选择相应的组件。
  • 灵活性与扩展性

    • CustomForm 组件支持多个表单项类型,可以根据实际需求添加更多组件类型(如 a-date-pickera-checkbox 等)。
    • 支持 props 传递字段的配置,使得表单字段的个性化需求能够灵活应对。

2.3 自定义组件库的封装

这里要展开讲的话内容就太多了,涉及到工程管理,封装思路等等,不再赘述。