实现一个业务定制的级联选择器

What

常见的级联选择器,比如省市区选择器,实现起来比较简单,而且父子之间没有过多的依赖关系。然而由于我们业务的特殊性,这些选择器根本无法满足需求,不得已只能从0到1实现。先梳理一下要实现的功能

  • 支持单选、多选

    • 单选即只能选择一个子项,又分为两种情况
      • case 1, 只能选择叶子节点
      • case 2, 可以选到任一个层级
    • 多选 case 3
      • 可以选到任一个层级,可以选择多个项
      • 选择了通用节点,所有其他节点都不能被选择,即互斥关系
      • 选择了父节点,其所有孩子节点都不能被选择,但是依然可以访问其路径
  • 支持筛选

    • 只支持叶子节点筛选,适用于 case 1 (单选且只能选择到叶子节点)
    • 所有层级都支持筛选,使用 case2 & case 3

效果如下,

单选

多选

实现

数据格式

显然,这是个树结构

[
  {
    id: "L1",
    label: "level 1",
    children: [
      {
        id: "L2",
        label: "level 1-2"
      }
    ]
  }
]

从 UI 来看,切换节点,需要在下一级菜单显示对应的 children。如果每次切换,都要遍历一下树,太慢了效率不高,比较好的方式是在一开始就计算好对应的数据,切换的时候从 map 里取出来就好。

这里借鉴了文件目录的思想

  • 根目录是 ROOT

  • 查看包含的子文件列表 $ ls ROOT

  • 文件所在的目录 $ cwd

采用这个思想,那么每个 node 的数据结构如下

type INode = {
  id: // the key
  childrenCount: // the number of children
  cwd: // the current working directory
  parent: // 
  depth: // the depth of this node
}

接下来,全局维护一个 map 对象,其 key 即是 cwd,

{
  'ROOT': INode[],
  'ROOT/L1': INode[],
  'ROOT/L1/L1-2': INode[],
}

最后是深度遍历

treeData = flattenTree(tree, 'ROOT', {}, null)

function flattenTree(list, footpath, treeMap, parent) {
  if (!treeMap[footpath]) {
    treeMap[footpath] = []
  }

  list.forEach(t => {
    t.id = t.id
    t.childrenCount = t.children?.length
    t.depth = splitFootPath(footpath).length
    t.parent = parent
    t.cwd = concatFootPath(footpath, t.id)

    treeMap[footpath].push(omit(t, ['children']))
    
    flattenTree(t.children, t.cwd, treeMap, t)
  })
}

层级联动

接下来就好办了,监听节点的 click 事件,这里要注意

  • 可以选择中间层的,点击了 radio/checkbox 才会被选中;点击文本,只是展示下一层几

是否只能选择叶子节点,我们借鉴 checkbox 的设计,新增属性 intermediate

  • 如果是叶子节点,直接选择完成

  • 这里通过 classList 判断是否点击了 radio/checkbox,if so, 直接选择完成

  • switchLevel(record, true) 通过添加标识(第2个参数 true) 标记 选择完成

function rowClick(record, e) {
  if (props.intermediate) {
    // 如果是叶子节点,点自己也是可以的
    // 选择了 radio/checkbox,新增 `true` 表示选择结束
    if (
      !record.childrenCount ||
      (e.target && e.target.classList.contains('wxp-cascader-list-option-radio'))
    ) {
      e.stopPropagation()
      radioCheckedNode = record
      switchLevel(record, true)
    }

    // 点击的是文本,展开下一层
    else if (record.childrenCount) {
      radioCheckedNode = null
      switchLevel(record)
    }
  } else {
    switchLevel(record)
  }

  emit('click', record)
}

层级的切换,有几个情况

  • 向更深的层级展开

  • 在本层级不同兄弟节点之间

  • 回到父层级

这也很好办,结合当前路径 currentPath ,所选节点的信息比如 cwd, depth,就可以知道是是哪个情况

function switchLevel() {
  // 向更深的层级展开
  if (currentPath.value.length == record.depth) {
    currentPath.value = currentPath.value.concat(record.cwd)
  } else { // 切换到上一级,或者本层级
    const temp = currentPath.value.slice(0, record.depth)
    currentPath.value = temp.concat(record.cwd)
  }
}

最后就是渲染数据了,监听 currentPath 重新计算 currentList 即可

// currentPath: ['ROOT', 'L1', 'L1-2']
nextList = currentPath?.map((fp, idx) => {
  return treeMap[fp]?.map(ot => {
      const t = { ...ot } // copy
      
      // UI样式相关的逻辑
      const selecting = t.cwd == currentPath.value[idx+1]
      const selectingEnded = soleEnded.value && selecting
      const selectingEndedLeaf = props.intermediate ? t.cwd == radioCheckedNode?.cwd : !t.childrenCount && selectingEnded

      return {
        ...t,
        selecting,
        selectingEnded,
        selectingEndedLeaf,
      }
    })
})

if (props.intermediate && radioCheckedNode) {
  currentList.value = nextList.slice(0, radioCheckedNode.depth)
} else {
  currentList.value = nextList
}

节点互斥

前面提到,节点之间存在互斥行为

  • 通用节点,权限最高,与所有其他节点互斥

  • 选择了父节点,其孩子节点都不可选

不可选,那就是 disabled, 在上面的 nextList = ... 中新增 disabled 的判断即可

针对通用节点(该节点只有一个),我们新增属性 majestyId (your majesty 女王陛下),由业务指定节点 id

disabled = (majestySelected.value && (item.id !== props.majestyId))

针对第二个情况,遍历每一层的节点 item,如果

  • 当前层级深度比选择的节点所在的层级深,说明当前渲染的是孩子节点

  • 然后判断 item 是否依赖选择的节点 t,即是否有 “血缘关系”

disabled = !!(nextTagList?.find((t) => t.depth < item.depth && hasParentDependency(item, t)))

搜索筛选

最后是搜索的实现(前端搜索,因为业务数据量不大),也分两种情况

  • 只在叶子节点搜

  • 对整棵树进行搜索,且搜索结果需要按深度层级显示(换句话说,也是深度遍历)

    • Node_L1_1 匹配,继续匹配孩子节点

      • Node_L1_1_L2_2 匹配,继续匹配孩子节点

        • Node_L1_1_L2_2_L3_3匹配 ,继续匹配孩子节点
      • 如果 Node_L1_1_L2_6 匹配,继续匹配孩子节点

    • Node_L1_2 匹配,继续匹配孩子节点

叶子搜索

叶子节点搜索比较简单,首先要搜集所有的叶子节点,在 flattenTree() 中即可以实现

// 缓存所有叶子节点
if (!t.childrenCount) {
  leafNodeList.push({ ...(omit(t, ['children'])) })
}

然后根据关键字过滤一下即可

queryList = leafNodeList.filter(t => t.label.indexOf(q) > -1)

路径搜索

既然知道了叶子节点( flattenTree() 的深度遍历和这里层级搜索是一一对应的,所以按顺序遍历叶子节点就是我们所需要的路径顺序),那么我们可以通过 parent 回溯对应的链路

思考下面的路径

[A, B, C, D1]
[A, B, C, D2]

最终所有的路径顺序

[A]
[A, B]
[A, B, C]
[A, B, C, D1]
[A, B, C, D2]

这一步也简单,不断找 parent 就好了

function reachMe(node) {
  let tempList = [ node ]
  let o = node.parent
  while (o) {
    tempList.unshift(o)
    o = o.parent
  }
  return tempList
}

当给定一个节点,比如说 D1 ,这个函数就会返回 [A, B, C, D1],所以要得所有路径,还需要在遍历 D1.parent 直到 null

这里要注意一个问题,当我们继续遍历 D2 的时候,[A], [A, B], [A, B, C] 已经存在了,所以要去重。去重也很简单,只需要看一下最后一个节点是不是当前节点就行,

  • 比如 D2,这是肯定没有的,所以需要计算;

  • 但是到了D2.parentC 的时候,memo 里已经存在 [A, B, C] 了,所以不用再算了

oneLevelList = leafNodeList.reduce((memo, node) => {
  let resultList = []
  
  let o = node
  while (o) {
    if (!memo.length || !memo.find(nl => nl[nl.length - 1]['id'] == o.id)) { 
      resultList.unshift(reachMe(o))
    }
    o = o.parent
  }

  memo = memo.concat(resultList)

  return memo
}, [])

最后就是搜索匹配,类似的,只需要匹配每条路径的最后一个节点即可

if (props.intermediate) {
  // [[p1], [p1, p2], [p1, p2, leaf]]
  nextList = oneLevelList.filter((res) => {
    const lastNode = res[res.length - 1]
    return lastNode.label.indexOf(q) > -1
  })
}