前言
这天突然收到了 UI
修改设计稿的消息通知:"xxx 已修改 xxx 项目并 @ 了你,请及时查看变更内容
",一条、两条、三条 ......,修改消息铺天盖地而来,然后就什么都看不到了(),但没多久产品就非常贴心的询问是否已经收到了对应的消息,自然免不了还要介绍一下本次修改的内容和原因(因为我选择开启消息免打扰
)。消息免打扰失效
于是,我()打开了设计稿正打算好好欣赏欣赏,不曾想一道光芒一闪而过(心甘情愿的
),看到了这样的内容:是谁拉开了窗帘
而这个显示方式之前使用的是 Steps 步骤条 的方式展示的,类似于:
意味着要从一个 Steps 步骤条 改变成一个 progress-step 流程节点 的形式,这很难实现吗?不难!很容易实现吗?倒也未必!
【扩展】假设现在有个面试官就用这个 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 组件
接下来,就该看看到底该如何分析和设计这个 progress-step 组件了,上面我们提到了最基本的要做到 数据(data) 和 视图(UI) 的解耦,那实际上也就意味着要从这两个大的方向去考虑。
视图(UI)
确定视图展示方式
拿到视图肯定要进行分析,UI 图展示的不一定全面,因此必须要确定所有可能的展示方式,在和产品沟通的过程中确认了这个视图的三种展示形态:
- 串行展示,类似于:
- 并行展示,类似于:
- 混合展示,类似于:
但你仔细查看 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: '待处理' } ] 复制代码
- 并行展示
- 可以看做是上下两个串行展示的合并,可将数据带有并行的节点也使用数组来表示
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: '结束' }, ] 复制代码
- 混合展示
- 相当于并行节点中又包含并行节点,类似于:
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: '结束' }, ] 复制代码
实现 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: "这是描述" }, ] 复制代码
并行展示
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: "这是描述" }, ] 复制代码
混合展示
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: "这是描述" }, ] 复制代码
不足
现存缺点比较明显:
- 当流程节点超过视图容器,很难动态调整好各个节点间的距离,现在暂时使用滚动条代替
- 不能无限制渲染节点层数(最大层数为 3 ,如混合模式所示),动态计算方式仍存在缺陷
- 节点内容变化导致高度、位置的计算问题
- ...
源代码
需要查看代码的可通过此处查阅:源代码
代码目录
最后
在实际实现过程中涉及到动态计算的部分很容易理不清,包括动态计算矩形框高度、宽度以及节点位置等,以上仅仅算是一个实现思路(不要害怕写出不完美的代码
),期望各位大佬能够在评论区给出更优质的方案!!!