功能预览
话不多说,先看预览:
需求背景
公司之前有过Vue开发的项目,用到了ElementUi的级联选择器控件。不得了了,产品爸爸们开始作妖了,哎呦不错哦,我要用它到我这个项目里(项目以Js + Php为架构,前后端不分离)。
日常battle:
“能用多级联动吗?”,“不可以!”
“可ElementUi依赖于Vue啊,两个项目架构不同,这个控件得单独开发”
“这个需求很简单,怎么实现我不管。”
“mmp”
功能点分析
既然battle不过,那没办法,只能写一个纯Js插件了。下面列下几个功能点:
- 不限层级,通过配置参数选择多选、单选;
- 选中父级,所有子级为选中状态 2.1. 选中子级,如果同级子级都为选中状态,那么父级选中; 2.2. 选中子级,如果同级子级中,只要有一个没选中,那么父级为半选中状态; 2.3. 取消选中子级,如果同级子级都没有被选中,那么父级为不选中状态;
- 支持回显已选中数据的完整路径 父级-父级-子级(可通过配置项自定义分隔符)
- 支持删除单个选中的数据项
- 支持一键删除所有选中数据项(可通过配置项选择是否启用)
思路整理
这里我们先分析下Vue数据绑定的原理:
data中的每个数据,都通过数据劫持的方式,绑定了setter和getter,以便使之成为响应式
同时结合观察者模式,每个数据都维护一个观察者列表,只要使用到该数据,便将其加入到观察者列表中。
当该数据发生改变时,通知所有观察者,并传递新的数据。
回到需求,父级改变子级也做相应修改,子级改变,父级也做相应修改。
结合Vue数据绑定的做法,考虑是否能借鉴其思路呢?
每一个父级都是目标对象,管理一个观察者列表,列表内为一系列子级,如果子级还有子级,子级此时变成目标对象,还要管理一个观察者列表,如此递进。
同时,子级作为目标对象,其观察者列表中不仅要有其子级,还应有其父级,当自身改变时,通知内部所有观察者进行数据更新。
也就是说,如果采用观察者模式,或发布订阅模式,子级应当同时分饰两角,既为发布者,又为订阅者。
综合考虑,采用这种形式难度比较高,整个控件不仅需要维护级联面板类,还需要为每个数据同时添加发布及订阅列表,后期维护成本也较大。pass~
其实不管思路如何,中心点不变,那就是:改变原有操作DOM,改为操作数据。即将视图驱动思维转为数据驱动的思维。先让我们看看初始数据长什么样(同ElementUi中传入的数据格式相同):
var tags = [
{
id: 1,
label: '中部',
children: [
{
id: 5,
label: '山西',
children: [
{ id: 6, label: '太原' }
]
}
]
},
{
id: 8,
label: '西北',
children: [
{
id: 9,
label: '陕西'
}
]
}
]
数据格式很简单,如果不考虑级联选择,单纯的渲染DOM,根本没有难道,递归的循环数据创建DOM即可。
可是我这是级联选择器啊,怎么知道当前数据有没有被选中,如何操作父级或子级选中状态呢?
如果是视图驱动,直接将上述数据递归的渲染到body中,点击父级,操作其下的所有子级,点击子级,获取同级状态,操作父级的同时,操作其下的所有子级。使用Jquery更简单了,无非是一系列 siblings() parent()。但这个就背离了控件的初衷,功能没有丝毫扩展可言。言归正传,看看如何通过数据来驱动。
首先看传入的数据,肯定是满足不了我们的需求,我们希望,每一个节点,都有一个是否被选中的标识,同时包含其父级,子级,当前路径等等。初步规划如下:
{
id: 1,
checked: false, // 是否被选中
hasChildren: true, // 是否有子级
indeterminate: false,
label: '中部', // 文本
level: 1, // 节点等级
parent: null, // 父级
path: [1], // 当前节点ID路径
pathLabels: ['中部'], // 当前节点文本路径
value: 1, // 同ID
children: [ // 子节点
{
id: 2,
checked: false,
hasChildren: false,
indeterminate: false,
label: '山西',
level: 1,
parent: null,
path: [1, 2],
pathLabels: ['中部', '山西'],
value: 2
}
]
},
这样一来就好操作了,同样的,递归的创建DOM,如果当前节点 checked 为 true即为选中状态。 那如何进行事件绑定呢?如何知道当前点击的节点是哪个数据?
简单,绑定DOM时,通过 setAttribute 设置当前数据对应的 checkbox (多选模式下)的 uid 为当前数据的 id,点击时在适配好的数据中找到对应ID,设置其 checked 为 !checked 即可。具体情况有三种:
一. 情况一:如果当前节点为一级节点
直接递归遍历所有子节点,设置子级 checked 属性
二. 情况二:如果当前节点不是一级节点,且有子节点,有两步:
1. 递归遍历所有子节点,设置子级 checked 属性
2. 找到与当前节点同级的节点,根据同级节点的 checked 属性,设置父级节点为 选中 || 半选中 || 取消选中
三. 情况三:如果当前节点为最后一级子节点:
找到与当前节点同级的节点,根据同级节点的 checked 属性,设置父级节点为 选中 || 半选中 || 取消选中
设计思想
上面我们说了要对原始数据进行处理,后面的所有操作都是根据处理后的数据为基础的。是不是有点适配器模式的影子? 没错,你可以这么理解,但不完全对。此例中,共创建了两个类。
一个为 节点类 CascaderNode,对原始数据中每一级节点,进行包装改造(类似于适配器),同时添加相应的方法(如:获取文本路径,id路径,设置父级 || 子级状态等等),也就是说,每一级节点,都是该 节点类 的一个实例。
一个为 级联面板类 EoCascader,这个类也是最终向外暴漏接口的类,主要作用是接受用户参数、调用上面说的 节点类,进行节点实例创建、根据处理好的节点渲染DOM、绑定 DOM 事件...
这样每个节点 和 DOM 在功能上就完全解耦,DOM不需要关心节点是怎样的,节点也不需要关心外界如何受用自身实例。
难点分析
子级如何操作父级
不知你是否注意到了上面在对原始数据处理后,每个节点都有个 indeterminate 属性,子级操作父级,就是通过这个属性设置的。默认为 false,即不选中,如果为true,即为半选中状态。
首先获取当前节点(当前点击的节点)的父级下所有子级的 length,假设为3
通过 Array 的 every 方法,得知所有子级是否被选中,如果返回 true,则说明子级全选了,返回 false,说明有一个没选中。
同时设置一个标志变量 flag ,记录子级选中的个数
如果 flag 与 子级个数(此处假设为3)相同,那么父级全选
如果 flag 不为 0,且不等于子级个数,那说明子级有一个或多个没被选中,父级为半选中状态,设置父级节点 indeterminate 属性为 true。
完了吗?没有啊,此处的父级还有父级怎么办呢?递归的设置父级的父级的 indeterminate 值即可。代码如下:
CascaderNode.prototype.onChildCheck = function onChildCheck(checked) {
this.checked = checked
var parent = this.parent
// 获取同级是否选中
var isChecked = parent.children.every(function (child) {
return child.checked
})
this.setCheckState(this.parent, isChecked);
}
// 递归函数,设置父级状态
CascaderNode.prototype.setCheckState = function setCheckState(parent, isChecked) {
parent.checked = isChecked
// 同级节点个数
var totalNum = parent.children.length;
// 记录同级节点选中个数
var checkedNum = parent.children.reduce(function (c, p) {
var num = p.checked ? 1 : p.indeterminate ? 0.5 : 0;
return c + num;
}, 0);
parent.indeterminate = checkedNum !== totalNum && checkedNum > 0;
// 如果父级还有父级,将 父级的父级传入,递归本函数即可
parent.parent && this.setCheckState(parent.parent, isChecked)
}
级联面板是如何展示?
首先想想一级数据如何展示呢?在回忆下数据格式,这里就直接伪代码了:
var data = [
{
一级数据-1
child: [
二级数据-1-1
]
},
{
一级数据-2
child: [
二级数据-2-1
]
}
]
要渲染级联面板?好说:
function render(data) {
// ...遍历
return
`
<div class="menu-wrap">
<li>一级数据-1</li>
<li>一级数据-2</li>
</div>
`
}
一级面板这么处理没问题,那你想过二级怎么渲染吗?也可以,不停递归,遍历,代码惨不忍睹。
此处不得不说下 ElementUi 的渲染思路,它内部单独维护了一个级联面板的二维数组,专门用于渲染使用,具体思路如下:
设置一个 menus 数组,存储要渲染的节点数据。初始情况下,为一级节点:
[[一级数据-1, 一级数据-2]]
// 切记,每个数据都是 * 节点类 * 处理后的,其内部包含着子节点的数据
通过render方法,渲染一级面板。当点击一级面板的某个数据时,获取当前数据下的子节点,push 到 menus 中
[[一级数据-1, 一级数据-2], [二级数据-1-1, 二级数据-1-2]]
这样做的好处是,有一个专职用于渲染的数据,不再需要通过 节点类 处理后的数据进行递归的渲染,节点类只做数据包装,返回节点实例。数据层 和 渲染层又一次解耦。
选中的数据如何存储、删除以及回显呢?
存储和删除
本例通过对象的方式存储,以当前选中的节点 ID 为键,文本路径为值进行存储。渲染已选中列表时,遍历该对象即可。
如何删除呢?千万不要以为只删除存储列表中对应ID的数据就万事大吉了,还要处理级联面板中当前项的选中状态及其父级的选中状态。依然调用上面写到的 onChildCheck 方法即可。
数据回显
如果当前页面是编辑页,进入页面级联面板应该回显上一次提交的数据
注意:这里回显包括两部分,一是选中的数据列表(及文本路径),二是级联面板及其节点选中状态
使用该控件时,开发者可以传入上次提交数据时选中的是哪些数据,如下:
var cascader = new eo_cascader(tags, {
// 其他参数...
// 非编辑页,checkedValue 传入 null
// 编辑时 checkedValue 传入最后一级的 ID 即可
checkedValue: [4, 7, 10, 11, 21, 31, 33] || null
})
既然拿到了最后一级节点的ID,那好办了。
- 递归的找到当前ID对应子节点,获取该子节点的文本路径,将其存储到数据列表中渲染已选中的文本;
- 同时获取该子节点所有父级节点数据,设置它们的选中状态(包含父级的父级,层层向上,直到一级节点),组成新的
menus数组,用于级联面板的渲染。
总结
该控件虽然并没有ElementUi中级联选择器的功能强大,比如异步获取数据,可搜索数据等功能,但基本满足的产品爸爸的需求,之后也会不断的迭代。
整个控件的完成,发现数据驱动是如此之强大。设想下,如果你基于DOM驱动,操作级联面板中的选中状态就够你受的。如果当你完成某一个功能时,不妨多从数据层入手,基于数据操作DOM,又会是另一片天地。附上控件源码,如有错误,欢迎提出,及时改正。
😘最后,必须说一句:炸酱面,加油!