前端组件设计(一)数据归属

1,258 阅读11分钟

前端组件设计(一)数据归属

前言

​ 对于前端组件的设计,我一直感到很困惑,因为很少会有相关文章去介绍。而且大部分的文章基本上没什么用,只告诉我们要高内聚,低耦合等等,并没有告诉如何去做以及如何思考组件设计。

​ 实现一个功能可以有很多种方式,差别在性能、后续的维护上,在基础组件以及核心组件上,如果没有一个好的设计,就会导致以上俩点。可能大多数人不太会遇到性能上的问题,但是维护上的问题基本上是避免不了的,所以设计一个结构简单,使用方便的组件是一件非常重要的事情。

​ 下面阐述目前我对组件设计的理解,可能会存在不对的想法。因为工作中使用Vue2开发,所以拿Vue2作为例子。但是这篇文章并不会介绍组件的详细用法,比如生命周期、事件传递等,只讨论设计层面的东西。

组件的种类

​ 组件大致可以分为基础组件、中间组件、业务组件。

​ 基础组件大体上指的是各种UI组件库提供的组件,脱离项目依然可以独立运行的组件,因为你必须按照这些组件指定的数据结构去使用,使用起来也比较容易。

​ 业务组件就是与你的业务相关,是整个系统运行的基石,脱离了系统后就不太容易可以独立运行的组件,这些组件只依赖当前业务产生的特别数据以及逻辑,因为它们只满足于实现当前项目业务逻辑,所以很难跨项目使用。

​ 中间组件,这是我自己这么称呼的,它的功能就是将多个业务组件粘合起来,并根据业务组件暴露的事件及接口去解释业务逻辑。

​ 这里我们只要考虑业务组件和中间组件怎么去设计,基础组件大多数我们只能按照它的结构去调整,并不能重新设计,除非搭建的是一个基础组件库。

什么是组件化?

​ 首先思考组件由什么组成?

​ 组件其实就是数据与过程的结合,即 组件 = 数据 + 过程。

​ 数据是组件用于描述过程的一组数据结构。

​ 过程是逻辑的集合体,由组件的UI逻辑与业务逻辑组成。

​ 看起来像是面向对象的思想,但其实组件更侧重的是函数式的思想,对于相同的输入,有相同的输出,并且易于组合。

​ 我认为实现一个易变更、便于组合的组件,需要以下几点:

  • 单一职责
    • 该组件只专注于一个功能
  • 数据归属
    • 确定好组件数据需要哪些数据
    • 不依赖外部数据也可以正常工作
  • 接口设计
    • 提供一组对当前数据操作(设置、获取)的接口
  • 事件响应
  • 易于测试
    • 一个组件是很难同时去对UI行为与业务逻辑进行测试的,应当保证的是与业务相关的逻辑是可以单独测试的

    下面我们会针对以上几点进行相应的解释。

讨论方向

  • 组件设计思考的方式

  • 数据如何进行归属

  • 不同职责类型的组件解耦的方式

  • 接口设计

  • 组件测试

    这五个点中,其实又可以引申出很多的点进行讨论,在这里只会对重要的一些点进行讨论。

    因为篇幅有限,这篇文章只对组件化思考的方式以及数据如何进行归属进行讨论。

组件设计思考的方式

如何思考?

​ 我们思考一个功能时,正常的思考方式就是从上到下,一层一层,每一层需要做什么,然后去实现。

​ 一定不能将多个层级的事情同时思考,因为当这样做时,不可避免的会将数据归属(即当前组件需要的数据)弄乱,一旦数据归属乱了,那个事件以及接口就一定会乱,整个流程就垮了,最终的效果就是你在做的时候会不停的修改,或者同样可以实现功能,但后续的变更会变的困难,又或者引起了性能问题。

​ 当实现一个新的功能时,我习惯的做法是先按原型将组件分配好,然后先不去思考每个组件的需要的数据,而是将它们先拼接起来,然后从一块功能组中开始思考这一块功能的输入与输出,该响应什么样的事件以及暴露什么接口与外部组件进行通信,大致确定好了后就可以将这个组件向下拆分成当前最小单元组件,然后从最小组件开始实现。实现好了当前层次的,就去实现上一层的。这样可以专注于当前组件的思考与设计,也更利于保证组件的单一职责。

​ 分割层级是为了更好的进行组合,所以在设计组件时应该避免父子组件(粒度为一个抽象功能)这样的设计逻辑,而是将所有的功能分割好后用一个或多个中间组件去进行组合。

抽象功能粒度指的是组件本身有一块独立的功能,与其他组件不存在关联关系

这里说的父子组件设计指的是将所有子组件所需要的状态都放在父组件中,这样会导致子组件A在更新的时候,同时会更新子组件B。

其实这里说的数据归属,就是组件应该拥有描述自身的数据结构,如果一个组件的数据结构是模糊的,那这个组件一定很难用。

​ 当你分割的组件多了以后,就会发现它不再会固定的存在于某个组件中,即使这个组件本身是一个中间组件,最终它也可以被组合,这才是组件化的目的。

业务逻辑变更

​ 这里还会涉及到业务逻辑应该如何处理的问题,所有的业务逻辑无非就是在进行一段UI交互后,对数据进行对应的操作。所以业务逻辑变化指的一定是对数据进行的操作变了,而数据操作变化我们是一定要去调整代码的,所以设计出来的组件要保证俩点:

  • 对应业务逻辑的数据操作要通过服务的方式去调用,而不是内嵌在组件中。偏小的可以使用一个js文件暴露函数来进行维护,偏大的可以封装为一个组件进行调用。

    • 偏小:比如我们有一个业务多选组件TenantSet(checkbox),数据库中存储的是一个经过位运算后的数值,到页面使用时需要转换为数组Array,所以将【位运算值转换为数组】,【数组转换为位运算值】这俩个函数封装到我们维护的js文件中。这样做的目的是当其他组件需要依赖转换后的值可以直接使用函数,而不用依赖TenantSet组件。

    • 偏大:比如我们有一个业务表格组件TenantTable,该组件可以配置按钮,每个按钮可以支持不同的行为(比如导出、批量导入、删除、跳转等),那么这些行为应该怎么处理?

      • 直接写在TenantTable中
      • 用一个js文件处理
      • 用一个行为处理组件处理

      如果某一天我们需要一个看板组件,除了展示方式不一样,对应按钮的操作和TenantTable支持的行为一致,那么我想第三种应该是更好的选择

  • 组件本身的UI事件应该尽可能的向外部传递,并且在传递动作函数中,不要做有副作用的操作,这样会导致组件很难使用。

    响应式编程的思想也可以很好地解决业务逻辑变更的问题,Vue3核心也提供了响应式API,不过这并不是响应式的全部,这方面自己也还没有理解的太深,希望以后有能力可以发篇文章。

如何确定数据(属性)的归属?

一句话:涉及到自身渲染的放在本组件中,同理,不涉及到自身渲染的不放在本组件中。

例子

场景:

我们封装了一个表格组件用于显示一些数据,类似于el-table,当因为列表cell内容因为宽度不够时,我们希望鼠标移入能够弹出一个tooltip来显示完整的信息。

代码如下:

<template>
    <div class="table-container">
        <t-header/>
        <t-body @mouseenter="handleMouseEnter"/>
        <custom-tooltip :tooltip-content="tooltipContent" />
    </div>
</template>
<script>
    import CustomTooltip from './CustomTooltip' // 自定义提示
    import THeader from './THeader'
    import TBody from './TBody'
    export default {
        components: {
            CustomTooltip,
            THeader,
            TBody
        },
        data() {
            return {
                tooltipContent: ''
            }
        }
        methods: {
            handleMouseEnter(tooltipContent) {
                this.tooltipContent = tooltipContent
            }
        }
    }
</script>

先花几秒思考一下这样做会有什么问题?

​ 从鼠标移入到显示tooltip的过程中,t-body组件emit了mouseenter事件后,在handleMouseEnter事件中设置了tooltipcontent,然后CustomToolTip组件进行显示,看起来好像没有什么问题。

​ 问题在于改变了tooltipcontent后,整个Table组件都需要重新渲染。

​ 我们结合着开头的结论来看tooltipContent到底该属于哪个组件,最关键的一点:tooltipContent改变了,应该让整个表格重新渲染吗?

​ 很明显不应该, 所以这个属性应当由CustomToolTip组件自身去维护。

那应该怎么做呢?

<template>
    <div class="table-container">
        <t-header/>
        <t-body @mouseenter="handleMouseEnter"/>
        <custom-tooltip ref="customTooltip" />
    </div>
</template>
<script>
    import CustomTooltip from './CustomTooltip' // 自定义提示
    import THeader from './THeader'
    import TBody from './TBody'
    export default {
        components: {
            CustomTooltip,
            THeader,
            TBody
        },
        data() {
            return {}
        }
        methods: {
            handleMouseEnter(tooltipContent) {
                this.$refs.customTooltip.setContent(tooltipContent)
            }
        }
    }
</script>

​ 为什么我们举这个例子呢?因为ElementUI的table组件就存在这个问题,我们的项目是一个PaaS项目,有很多的配置项,所以有很多的处理函数。当鼠标移入到table上时,会感到很明显的卡顿,因为触发了多次渲染,导致大量函数执行。正好之前处理内存泄漏,优化过el-table,所以用这种方式解决了该问题。

所以这里就会衍生出另一个问题,props、data该怎么区分?

​ 首先要确定组件的用途,一般分为俩种,如下

  • 展示类:接收数据进行展示操作,不涉及数据的修改。比如一些展示性的功能,像操作日志或者待办这种。
  • 编辑类:需要根据输入对数据进行修改。比如填写一些表单之类的。

​ 对于展示类组件,在后续执行过程中不涉及到变更的数据属性可以放入到props中,因为这样不会涉及到渲染性能问题,同时写起来会更简便一点。

​ 而对于编辑类组件, 一定要在data中具备自己的数据结构。

一部分原因是通过props的更新会导致渲染不断的发生,影响其父组件和兄弟组件。

另一部分是组件的数据结构不清晰,如果太依赖外部的传入,会导致在后续的一系列变更中,组件的能力变得模糊,封装性无法保证。所以对于此类组件,可以有一个硬性要求,就是不依赖外部的数据,依然可以正常使用。如果外部想操作组件的数据,必须通过组件自身暴露的接口去实现,一定要保证封装。

​ 其实在这一点上并没有什么绝对的定论,比如多用$refs去调用组件,少用props,你需要根据不同的情况去进行抉择,性能还是便捷,这二者常常是不能兼得的。对于小型项目,这样的做法是会更麻烦的,但对于大型项目的收益还是很大的。

​ 以这个组件引申,我们再写一个业务表格组件,它主要负责以下事情

  • 根据接口请求数据
  • 处理用户点击表格时的各种行为(比如编辑、创建、导出等等)
<template>
	<div class="template-table">
        <custom-table
          ref="table"
          :get-list="getList"
          :button-config="buttonConfig"
          @row-button-click="handleRowButtonClick"
        />
        <action-handler
          ref="actionHandler"
        />
    </div>
</template>
<script>
    import CustomTable from '@/components/CustomTable' // 表格组件
    import ActionHandler from '@/components/ActionHnadle' // 处理行为组件
    export default {
        components: {
            CustomTable, ActionHandler
        }
    	props: {
        	// 获取数据方法
            getList: {
                type: Function,
                default: () => alert('no getList method, how run?')
            },
            // 按钮配置,用于存放按钮的UI显示属性与行为
            buttonConfig: {
                type: Object,
                default: () => { name: '编辑',action: 'row.edit()' }
            }
        },
        methods: {
            handleRowButtonClick(row, button) {
                this.$refs.actionHandler.handlerSingleAction(row, button)
            }
        }
    }
</script>

​ 首先该业务表格组件分为俩大块,一个是表格组件,用于渲染数据并内聚了筛选,分页等功能。一个是行为处理组件,用于处理表格按钮的行为,比如导入、导出等。

​ 像getList、buttonConfig这种不太可能会变的属性,就通过props传入。

​ 在点击了表格上的按钮后,可能是导出、导入、编辑、删除,这包含了大量的状态,所以我们把状态内聚到ActionHandler组件中,然后通过ActionHandler提供的接口去处理对应的行为。

​ 在ActionHandler中可能包含了大量的行为处理组件,但是因为行为同一时间段只能执行一个,所以我们不需要再进行细分,直接将状态内聚到该组件中即可。

​ 通过这样的写法,这俩个组件可以分开使用,比如我的展现形式不是表格了,而是类似于看板或者其他的卡片布局进行展示,只要输入相同,对应的行为我都可以通过该组件进行处理。

custom-table又可以拆为filters,table,pagination,sort等组件,同样的,这些组件即使不在custom-table中,它们也可以正常工作。

​ 如果一个组件干的事情超过一件,就可以去考虑拆分了,这种业务核心组件一定要去这么做,因为大部分的变更会在这些组件中去做。

​ 还有一点是关于渲染的,框架虽然提供了虚拟dom和diff算法等优化渲染开销的方式,但它的目的只是在一次必要的更新中减少开销,而不是为了多次不必要的渲染减少开销。

因为Vue和React目前数据侦测的粒度都是以组件为单位的,所以如果我们把粒度控制到元素上,那么是可以避免因为属性更新导致无关组件重新渲染的。但是这并没有解决问题,因为在设计上的问题,不能通过实践去解决,设计上的问题必须在设计阶段解决。虽然那样可以避免性能问题,但一定会让我们设计组件更加随意,这一定会带来更大的麻烦,我们当然可以在以后或者就现在使用粒度控制的方式去解决性能问题,但同时我们必须兼备地去看待维护问题。

​ 这个系列主要介绍业务组件以及中间组件相关的设计,因为暂时没有设计基础组件库的经验=_=,不过后面肯定会有的,因为我有一堆关于组件库的痛点需要解决。

如果你觉得有不同的看法或者觉得不错的话,欢迎在评论区评论或者点赞让更多人看到,谢谢!