高仿头条-广告系统中的级联面板组件

3,690 阅读6分钟

公司最近的设计中用的很多的一个组件,大体是参考的头条-广告系统中的级联面板。在此简单记录一下组件的设计和开发心得。

头条的效果

需求分析

根据效果图,首先需要把省市区的数据按列展示在左侧区域,点击父级节点联动展示子级数据,每次点击展开节点的下一级所在的列。
勾选父级节点,子级节点全选,反之全选子级节点,父节点变为勾选状态。每次进行勾选之后,右侧面板展示勾选结果。
这里有一个细节,就是右侧面板展示的选择结果不是简单的展示每一个被勾选中的节点。而是依据当某一个节点下的子节点全部被选中,则只展示父节点的原则,进行展示。我把它简单称为级联数据的压缩原则。
经过上面的分析可以发现这个组件本质上是一个扁平化了的checkbox-tree组件。之前关于类似省市区这种带有级联关系的数据选择,传统的交互设计往往就是采用的checkbox-tree。

开发这个组件之前,已经有过重构老项目中的checkbox-tree的经验,参考的是element-ui的tree组件,学习了很多关于依赖于树形结构的组件构建技巧。组件借鉴了element-ui和iview的tree组件,在此由衷感觉这些开源项目。 有了上述的分析,我们来正式开撸代码。因为是使用vue作为技术栈。第一步,也是最关键的一步,就是定义好组件的props和data。

props和data

 props: {
    data: {//展示数据
      type: Array,
      required: true
    },
    props: {//数据中的key和label字段别名,
              因为外部数据(例如后端返回的树形结构)中标志label和key的字段往往不是固定的
      type: Object,
      default() {
        return {
          key: "id",
          label: "label"
        };
      }
    },
    settings: {
    /*配置,允许自定义每一级,eg:[
        {
          level: 1,//列的级别,因为组件内部有一个虚拟的根节点,所以level从1开始
          title: "一级分类",//列的标题
          hasAllCheck: true,//是否展示全选checkbox
          showCheckBox: true//是否带有checkbox
        },
        {
          level: 2,
          title: "二级分类",
          hasAllCheck: true,
          showCheckBox: true
        }
    ]*/
      type: Array,
      required: true
    },
    checkedLevel: {//数据展示的级别
      type: Number,
      required: true
    },
    zipLevel: {//数据压缩的级别
      type: Number,
      required: true
    },
    isSingle: {//是否是单选模式,如果为true,则降级为一个级联选择器
      type: Boolean,
      default: false
    }
  }
data() {
    return {
      rootNode: null,//组件内部使用的树形数据结构。采用Node类型对data进行包装得到的树的根节点
      flattenData: [],//扁平化后的数据,方便查找任意节点
      curShowList: [],//控制当前面板的展开收起状态
      checkedData: []//勾选中的数据
    };
  }
  

Node数据类型

组件内部使用了Node 类型的对象来包装用户传入的数据,用来保存目前节点的状态。关于Node类型的具体包装过程这里就不再赘述,需要的话可以看源码或者搜索相关数据结构的介绍。这里仅对比一下用户传入的data和组建内部的Node。
用户传入的:

[
      {
        id: 1,
        label: "一级 1",
        children: [
          {
            id: 4,
            label: "二级 1-1",
            children: [
              {
                id: 9,
                label: "三级 1-1-1"
              },
              {
                id: 10,
                label: "三级 1-1-2"
              }
            ]
          }
        ]
      },
      {
        id: 2,
        label: "一级 2",
        children: [
          {
            id: 5,
            label: "二级 2-1"
          },
          {
            id: 6,
            label: "二级 2-2"
          }
        ]
      },
      {
        id: 3,
        label: "一级 3",
        children: [
          {
            id: 7,
            label: "二级 3-1"
          },
          {
            id: 8,
            label: "二级 3-2",
            children: [
              {
                id: 11,
                label: "三级 3-2-1"
              },
              {
                id: 12,
                label: "三级 3-2-2"
              },
              {
                id: 13,
                label: "三级 3-2-3"
              }
            ]
          }
        ]
      }
    ]

组件内部包装过的rootNode

Node类型的数据添加了checked(是否勾选),disabled(是否禁用),id(组件内部自增的唯一标识),level(节点深度),selected(是否选中),text(节点的文本)。提前定义好这些属性才能够让我们的组件变成响应式的(这些属性用户可以在初始化的时候选择传入也可以不传)。
同时具有parent和childNodes来进行任意方向上的查找。

有了根节点之后,通过查找childNodes并且递归就能够构建出级联面板的template。

节点的联动

 handleCheck(isCheck, id, immediate = true/*是否立即进行数据压缩 */) {
   const checkedLevel = this.checkedLevel;
   //勾选当前级别及子级
   const selectNode = this.flattenData.find(item => item.id === id);
   if (!selectNode) {
     return;
   }
   //递归
   //由父到子
   function setCheck(node) {
     node.checked = isCheck;
     if (!node.childNodes.length && node.level < checkedLevel) {
       node.noChildChecked = isCheck;
     }
     if (!Array.isArray(node.childNodes) || !node.childNodes.length) {
       return;
     }
     node.childNodes.forEach(node => {
       setCheck(node);
     });
   }
   //由子到父
   function setParentCheck(parent) {
     if (!parent || !parent.parent) {
       return;
     }
     parent.checked = parent.childNodes.every(child => {
       return child.checked === true;
     });
     setParentCheck(parent.parent);
   }

   setCheck(selectNode);
   setParentCheck(selectNode.parent);
   if (immediate) {
     this.getCheckedData();
   }
 }

节点的联动首先通过扁平化数据查找到当前节点。再对该节点进行由父到子和由子到父两个方向的checked设置。

这里因为涉及到全选或者批量设置节点的勾选状态,所以有一个参数标志是否立即调用数据压缩的方法。

列的展开

列的展开通过节点的select来触发,包括勾选和点击事件。

 handleSelect(id) {
   //单选
   const selectNode = this.flattenData.find(item => item.id === id);
   selectNode.parent.childNodes.forEach(node => (node.selected = false));
   selectNode.selected = true;

   //下一级展示出来,更深的层级不渲染
   if (selectNode.level < this.maxLevel) {
     this.curShowList[selectNode.level] = !!selectNode.childNodes.length;
     for (let i = selectNode.level + 1; i < this.maxLevel; i++) {
       this.curShowList[i] = false;
     }
   }
   //单选模式,逻辑变为类似级联选择器,选择非最深层次的节点直接清空当前节点下所有的checked结果,视为重新选择
   if (this.isSingle && selectNode.level !== this.maxLevel) {
     this.flattenData.forEach(p => (p.checked = false));
     this.getCheckedData();
   }
 }

列的展开通过控制curShowList数组,数组的每一项的true or false对应每一列的展开或者收起。这里额外提供一个isSingle的props可以把组件降级为级联选择器,满足只能单选的情况。

节点选择后的数据压缩

 getCheckedData() {
      const result = [];
      const toZipData = this.flattenData.filter(p => p.level === this.zipLevel);
      function step(nodes) {
        if (!nodes || !nodes.length) {
          return;
        }
        const curSelectData = nodes.filter(p => p.checked || p.noChildChecked);
        const noSelectData = nodes.filter(
          p => !(p.checked || p.noChildChecked)
        );
        result.push(...curSelectData);
        noSelectData.forEach(p => step(p.childNodes));
      }
      step(toZipData);
      this.checkedData = result;
}

首先通过扁平化的数组过滤出目标压缩级别的数据,直接把其中选中的数据push到结果中,只把没有勾选的数据当作下一次递归过程的目标数据,递归出口是节点不存在或者没有子节点。

方法

  setCheckedNodes(keys) {//设置节点的选中,可用于搜索
      /* public API */
      keys.forEach(key => {
        this.setCheckedNode(key, false);
      });
      this.getCheckedData();
    },
    getCheckedNodes(isZip = true) {//获取选中的数据
      /* public API */
      if (isZip) {
        return this.checkedData.map(item => {
          return {
            id: item.id,
            text: item.text,
            data: item.data,
            level: item.level
          };
        });
      } else {
        return this.flattenData.filter(p => p.checked || p.noChildChecked);
      }
    }

扩展方向

  1. 组件需要支持懒加载,满足数据量大的情况。
  2. 组件没有提供插槽,例如右侧面板数据展示的定制化,左侧列的标题等。

结语

组件相对还只是提供了基础的功能,有待完善。如有错漏,欢迎指正!希望能让大家有点收获。下面是项目的github地址 github.com/juenanfeng/…