纯JS打造级联选择器控件,仿ElementUi(附源码)

8,999 阅读10分钟

功能预览

话不多说,先看预览:

需求背景

公司之前有过Vue开发的项目,用到了ElementUi的级联选择器控件。不得了了,产品爸爸们开始作妖了,哎呦不错哦,我要用它到我这个项目里(项目以Js + Php为架构,前后端不分离)。

日常battle:

“能用多级联动吗?”,“不可以!”

“可ElementUi依赖于Vue啊,两个项目架构不同,这个控件得单独开发”

“这个需求很简单,怎么实现我不管。”

“mmp”

功能点分析

既然battle不过,那没办法,只能写一个纯Js插件了。下面列下几个功能点:

  1. 不限层级,通过配置参数选择多选、单选;
  2. 选中父级,所有子级为选中状态 2.1. 选中子级,如果同级子级都为选中状态,那么父级选中; 2.2. 选中子级,如果同级子级中,只要有一个没选中,那么父级为半选中状态; 2.3. 取消选中子级,如果同级子级都没有被选中,那么父级为不选中状态;
  3. 支持回显已选中数据的完整路径 父级-父级-子级(可通过配置项自定义分隔符)
  4. 支持删除单个选中的数据项
  5. 支持一键删除所有选中数据项(可通过配置项选择是否启用)

思路整理

这里我们先分析下Vue数据绑定的原理:

data中的每个数据,都通过数据劫持的方式,绑定了settergetter,以便使之成为响应式
同时结合观察者模式,每个数据都维护一个观察者列表,只要使用到该数据,便将其加入到观察者列表中。
当该数据发生改变时,通知所有观察者,并传递新的数据。

回到需求,父级改变子级也做相应修改,子级改变,父级也做相应修改。

结合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,那好办了。

  1. 递归的找到当前ID对应子节点,获取该子节点的文本路径,将其存储到数据列表中渲染已选中的文本;
  2. 同时获取该子节点所有父级节点数据,设置它们的选中状态(包含父级的父级,层层向上,直到一级节点),组成新的 menus数组,用于级联面板的渲染。

总结

该控件虽然并没有ElementUi中级联选择器的功能强大,比如异步获取数据,可搜索数据等功能,但基本满足的产品爸爸的需求,之后也会不断的迭代。

整个控件的完成,发现数据驱动是如此之强大。设想下,如果你基于DOM驱动,操作级联面板中的选中状态就够你受的。如果当你完成某一个功能时,不妨多从数据层入手,基于数据操作DOM,又会是另一片天地。附上控件源码,如有错误,欢迎提出,及时改正。

😘最后,必须说一句:炸酱面,加油!