8
头图

前言

这天突然收到了 UI 修改设计稿的消息通知:"xxx 已修改 xxx 项目并 @ 了你,请及时查看变更内容",一条、两条、三条 ......,修改消息铺天盖地而来,然后就什么都看不到了(因为我选择开启消息免打扰)!00922DE0.png,但没多久产品就非常贴心的询问是否已经收到了对应的消息,自然免不了还要介绍一下本次修改的内容和原因(消息免打扰失效)。

于是,我(心甘情愿的)打开了设计稿正打算好好欣赏欣赏,不曾想一道光芒一闪而过(是谁拉开了窗帘),看到了这样的内容:

image.png

而这个显示方式之前使用的是 Steps 步骤条 的方式展示的,类似于:
image.png

意味着要从一个 Steps 步骤条 改变成一个 progress-step 流程节点 的形式,这很难实现吗?不难!很容易实现吗?倒也未必!

4EB0FC64.jpg

【扩展】假设现在有个面试官就用这个 progress-step 组件作为场景题,想想你该如何描述对这个组件的设计思路!!!

组件设计基本原则

撸起袖子一把梭?

千万先别急着撸起袖子开干,咱们先来聊聊组件设计的基本原则,往往一把梭哈的代码容易形成一个糅杂着各种逻辑的组件,因为这样的设计是脆弱的,很容易带来副作用和难以预知的结果,需求不变更还好,需求要是频繁变更,那么主要问题就会出现,可能因为一个原因去改变组件,就会破坏其他的职责(行为),于是你不得不在多处都进行修改。

怎么知道一个组件复不复杂,这就要看组件内部到底维护了多少和自身状态相关的数据,即非 props 传入的数据,对于现在的 Vue / React 而言都是可以通过 数据来驱动视图 的,用一个函数式表示即 UI = render(data)UI = f(data),这意味着一个组件至少需要做到 数据(data)视图(UI) 的解耦。

单一职责

单一职责原则(SRP - single responsibility principle) 中的 职责 是什么,你可以理解为是组件的 行为,这个行为可以是渲染一个列表、展示一张图片、发起一个请求等等,当组件的这个行为发生修改,意味着这个组件本身发生了变化,此时该组件就是 单一职责 的。

为什么不能多职责?

大部分人经常会选择忽视一个组件的多个职责带来的缺陷,这里举个常见查询页面的例子:

  • 一部分是和查询条件相关的表单部分

    • 负责和用户进行各种交互,比如表单联动、表单重置、表单折叠等等,同时它需要收集、提供用户已经填入的数据
  • 一部分是展示查询结果的表格部分

    • 将接口响应的数据展示在表格中,同时也要支持与用户的各种交互,比如切换页码、页大小、上一页、下一页、跳转页等
  • 一部分是负责将表单数据发送给服务器进行数据交互,即发起请求

假设此时按照 撸起袖子一把梭 的原则去编写这个组件(页面),一定会将表单交互、表格交互、发起请求、处理响应等逻辑混在一个组件(页面)当中:

  • 表单的交互逻辑需要维护(行为 1)
  • 表格的交互逻辑需要维护(行为 2)
  • 请求的发起和响应的处理也需要维护(行为 3)

假设现在接口响应的数据结构发生了变化,在当前的组件(页面)中,你可能需要重新修改处理判断不同响应状态码下页面的表现,同时还得修改表格的展示方式,又或者需要根据响应的数据内容自动填充表单,但由于数据结构的变化还得去调整表单相关的部分,此时仅仅一个数据结构改变的原因就可能影响了三个部分的内容,因为它们耦合了。

多职责的缺陷:

  • 组件内容相互耦合,当需求变更时存在相互破坏的可能
  • 组件内容各个部分的逻辑杂糅,不易于阅读和维护
  • 经过不断的迭代会使得单个组件代码量不断增大

    让组件具有单一职责

    关于这一点,你完全可以从你平时在项目中使用到的 UI 组件库中去看看,比如 Element UI 中表单相关的组件就分为:

  • Form 组件负责整体表单的展示形式和对整体数据的处理
  • FormItem 组件单个具体的表单组件的展示形式和单个表单的数据处理
  • Input、Select、Option 等等具体的表单组件

基于此还是将上述的例子改变成单一职责:

  • 表单部分需要单独封装成组件,主要提供表单展示形式、交互能力、数据处理能力
  • 表格部分需要单独封装成组件,主要负责提供对数据展示、交互的能力
  • 查询页面(组件)负责提供发起请求数据和处理数据响应的能力,并将表单组件和表格组件组合在一起

这样一来,即便后续响应的数据结构发生改变,也不需要再去修改表单和表格部分的内容,而是在当前的查询页面(组件)中处理好对应的数据格式即可,比如将接收到的数据处理成符合表格或表格组件需要的格式即可。

单一职责的优点:

  • 单个组件的内容独立分离,需求变更时只需要修改对应组件
  • 单个组件逻辑只存在于该组件中,保证组件代码的简洁性、易读性和维护性
  • 在不断迭代中,只需要修改对应组件的内容,不会导致页面中的其他组件代码量增大

    通用性

    组件的核心是需要体现在业务中的,在项目中的大多数组件都属于业务组件,不具备通用性,因为具有通用性的组件不应该只满足于某个业务,因此组件的设计要考虑从业务中抽离。

设计通用性组件应该考虑什么

  • 数据(data)视图(UI) 的解耦

    • 见过不少人封装组件就真的只是将原页面内容单纯的剪切出去,视图部分大多是硬编码,除此之外组件维护了七七八八的状态,巴不得这个组件在外部使用的时候什么都不用传递,这样的组件显然不具备通用性,因此一定要将数据和视图进行解耦
  • 保证组件的 单一职责(行为)

    • 复用一个组件时,是为了重复使用其职责(行为),而只有单一职责的组件才能够被更好的复用,多个单一职责的组件就能更好的实现 组合性,如果一个组件错误地拥有多个职责时,就会增加复用时的开销
  • 组件只提供 最基础的 DOM 和 交互逻辑

    • 不要让一个组件将视图相关的 DOM 完全掌控在内部,应该将容易变化的部分交给使用者自己去定义(如利用插槽),比如 Element UI 中的 Table 组件就提供了 render-header(Table-column Attributes)、header(Table-column Scoped Slot)、append(Table Slot) 用于自定义渲染
  • 组件封装应该 隐藏内部细节和实现意义,通过 props 控制具体行为和输出

    • 保证组件的纯度,减少组件的副作用,比如不要在组件中直接使用一些全局变量等,因为这不符合封装的特性,还容易带来不可预测的行为
  • 组件封装保证 可测试性

    • 组件的可测试性也决定着组件封装的通用性,如果在为一个组件编写测试用例时需要非常复杂,那么大致是组件设计存在问题

设计 progress-step 组件

image.png

接下来,就该看看到底该如何分析和设计这个 progress-step 组件了,上面我们提到了最基本的要做到 数据(data)视图(UI) 的解耦,那实际上也就意味着要从这两个大的方向去考虑。

视图(UI)

确定视图展示方式

拿到视图肯定要进行分析,UI 图展示的不一定全面,因此必须要确定所有可能的展示方式,在和产品沟通的过程中确认了这个视图的三种展示形态:

  • 串行展示,类似于:

    image.png

  • 并行展示,类似于:

    image.png

  • 混合展示,类似于:

    image.png

但你仔细查看 UI 稿的设计,其实完全可以将它归类为第二种展示方式,否则你还得为这个 UI 图单独实现另一种展示方式,并且在和产品的沟通中也得到了同意。

确定实现方式

首先,肯定得去看看社区中是否有类似的方案可以直接使用,或者经过小的改动可以被使用的,奈何业务就是业务,果然没找到合适的,但在查找方案的时候也了解到了几种实现方案:

  • 基于普通 DOM 元素实现
  • 基于 SVG 实现
  • 基于 Canvas 实现

最后两种方案,我看到的大多是需要支持各种可比较复杂的拖拽、复制、连接的方式,并且其具体实现也是比较复杂,如果其其不是很熟悉的话,无论在实现还是在后续的各种调整上会花费大量时间,而且如上的一个需求无非是一些展示和简单的交互,并不需要涉及如此复杂的各种自定义操作。

再举个例子,比如前面提到的 Steps 步骤条 也是基于普通 DOM 元素实现的,并且也确实没有太多需要用户自定义的操作。因此,可以将 progress-step 组件当做 Steps 步骤条 的升级版,另外考虑开发时间的限制,选择方案一是最合适的。

确定实现细节

上述展示方式虽然有三种,但实现时可以先实现最简单的 串行展示 方式, 而 并行展示 其实相当于三条串行展示的合并,分别是上边、中间、下边的串行方式,最后的 混合展示 其实只要你实现了前面两种方式,这种方式无非是相当于组件的递归渲染,只不过位置上需要做一些处理:

  • 串行展示

    • 单节点的 虚线 和 箭头 可以借助元素的伪元素 ::before、::after 并通过定位实现
  • 并行展示

    • 上下边的部分仍然可以看做是串行节点的展示
    • 中间的部分可以通过一个元素来实现边框效果,并且可以认为是前后节点与这个边框的串行版本
  • 混合展示

    • 实现了前面两种模式,混合展示方式其实就只是根据数据来进行组件递归渲染

数据(data)

作为前端肯定要具备看到 UI 就能大致设想出其对应 data 的基本结构,而且上述经过视图分析之后,已经得到其对应的三种具体展示形式,在真正开始写代码前,请先把需要封装的组件涉及的核心数据结构给设计好,毕竟这个数据最终是需要从外部传入的:

  • 串行展示

    • 多个节点,意味着整体应该设计为一个数组
    • 单个节点即对应数组的每一项元素信息

      data = [
      {status: 'completed' , name: '完成' }, 
      {status: 'processing' , name: '当前处理节点' }, 
      {status: 'pending' , name: '待处理' }
      ]

      image.png

  • 并行展示

    • 可以看做是上下两个串行展示的合并,可将数据带有并行的节点也使用数组来表示

       data = [
      {status: 'completed' , name: '开始' }, 
      [ // 1. 这个数组表示是并行节点
       [ // 1.1 这个数组表示是并行节点中,上边 串行节点的数据
          {status: 'completed' , name: '完成' }, 
          {status: 'processing' , name: '当前处理节点' }, 
          {status: 'pending' , name: '待处理' }
       ],
       [ // 1.2 这个数组表示是并行节点中,下边 串行节点的数据
          {status: 'completed' , name: '完成' }, 
          {status: 'processing' , name: '当前处理节点' }, 
          {status: 'pending' , name: '待处理' }
       ]
      ],
      {status: 'pending' , name: '结束' }, 
      ]

    image.png

    • 混合展示

      • 相当于并行节点中又包含并行节点,类似于:

         data = [
          {status: 'completed' , name: '开始' }, 
          [ // 1. 这个数组表示是并行节点
           [ // 1.1 这个数组表示是并行节点中,上边 串行节点的数据
              {status: 'completed' , name: '完成' }, 
              [// 1.1 这个数组表示是并行节点中的并行节点
                [// 1.1.1 这个数组表示是并行节点中,上边 串行节点的数据
                    {status: 'processing' , name: '当前处理节点' }
                ], 
                [// 1.1.2 这个数组表示是并行节点中,下边 串行节点的数据
                    {status: 'processing' , name: '当前处理节点' }
                ], 
              ],
              {status: 'pending' , name: '待处理' }
           ],
           [ // 1.2 这个数组表示是并行节点中,下边 串行节点的数据
              {status: 'completed' , name: '完成' }, 
              {status: 'processing' , name: '当前处理节点' }, 
              {status: 'pending' , name: '待处理' }
           ]
          ],
          {status: 'pending' , name: '结束' }, 
        ]

      image.png

实现 progress-step 组件

其大部分的实现思路已经在上面介绍过了,这里就不再额外介绍一些样式计算相关的内容,下面直接展示效果和源码。

props 简介

  • data:要展示的节点数据
  • colors:不同节点状态的颜色
  • status:不同节点状态的值
  • size:节点图标的大小
  • stepWidth:节点占据的宽度
  • space:节点之间的间距

    const props = withDefaults(defineProps<PropsType>(), {
    data: () => [],
    colors: () => ["#d2d2d2", "#3a84fb", "#67d36f"],
    status: () => ["pending", "processing", "completed"],
    size: 25,
    stepWidth: 80,
    space: 20,
    });

效果展示

串行展示

const data = [
  { status: "completed", title: "开始", description: "这是描述" },
  { status: "processing", title: "处理中", description: "这是描述" },
  { status: "pending", title: "待处理", description: "这是描述" },
  { status: "pending", title: "结束", description: "这是描述" },
]

image.png

并行展示

const data = [
  { status: "completed", title: "开始", description: "这是描述" },
   [
    [
      { status: "completed", title: "已完成", description: "这是描述" },
      { status: "completed", title: "已完成", description: "这是描述" },
      { status: "pending", title: "待处理", description: "这是描述" },
    ],
    [
      { status: "completed", title: "已完成", description: "这是描述" },
      { status: "pending", title: "待处理", description: "这是描述" },
    ],
   ],
   { status: "pending", title: "结束", description: "这是描述" },
]

image.png

混合展示

const data = [
  { status: "completed", title: "开始", description: "这是描述" },
  [
    [
      { status: "completed", title: "已完成", description: "这是描述" },
      { status: "completed", title: "已完成", description: "这是描述" },
      [
        [
          { status: "completed", title: "已完成", description: "这是描述" },
          { status: "pending", title: "待处理", description: "这是描述" },
        ],
        [
          { status: "completed", title: "已完成", description: "这是描述" },
          { status: "processing", title: "处理中", description: "这是描述" },
        ]
      ],
      { status: "pending", title: "待处理", description: "这是描述" },
    ],
    [
      { status: "completed", title: "已完成", description: "这是描述" },
      { status: "completed", title: "已完成", description: "这是描述" },
      [
        [
          { status: "completed", title: "已完成", description: "这是描述" },
          { status: "pending", title: "待处理", description: "这是描述" },
        ],
        [
          { status: "completed", title: "已完成", description: "这是描述" },
          { status: "processing", title: "处理中", description: "这是描述" },
        ]
      ],
      { status: "pending", title: "待处理", description: "这是描述" },
    ],
  ],
  { status: "pending", title: "结束", description: "这是描述" },
]

image.png

不足

现存缺点比较明显:

  • 当流程节点超过视图容器,很难动态调整好各个节点间的距离,现在暂时使用滚动条代替
  • 不能无限制渲染节点层数(最大层数为 3 ,如混合模式所示),动态计算方式仍存在缺陷
  • 节点内容变化导致高度、位置的计算问题
  • ...

源代码

需要查看代码的可通过此处查阅:源代码

代码目录

image.png

最后

在实际实现过程中涉及到动态计算的部分很容易理不清,包括动态计算矩形框高度、宽度以及节点位置等,以上仅仅算是一个实现思路(不要害怕写出不完美的代码),期望各位大佬能够在评论区给出更优质的方案!!!

20769C0D.gif


熊的猫
922 声望331 粉丝

业精于勤立不易方,而后鹏程万里!