Vue计算属性在扁平化Tree组件中的应用

1,145 阅读7分钟

各位掘金的小伙伴,大家好!上一节我们初步实现了一个扁平化结构的Tree组件,本节咱们将很好的应用计算属性,实现节点层级信息的计算与缓存,并实现垂直参照线的展示。

前一节咱们留了一个需要改进的实现:获取一个父节点下所有子节点的长度。咱们的临时做法是投机取巧的取用generateFlatTree函数内部递归返回的childrenlength。这种弊端是:可以动态增删节点的情况下,该结果是一成不变的。为此,咱们需要实时去计算,这种“苦差事”交给vue计算属性再好不过了。

计算子节点长度

定义length计算属性

因为咱们将针对结构化的ITreeNode类型的子节点进行计算,所以在其类型定义中扩展一个字段:

// 定义基本的树节点类型
export interface ITreeNode {
  ...
  length?: ComputedRef<number> // 子孙节点的长度
}

注意这里的ts类型为计算属性的结果引用类型。 相应的,咱们在tree/utils.ts中增加一个单独的initParentTreeNode函数来对原始结构的父节点进行计算属性的绑定:

function initParentTreeNode(node: ITreeNode) {
  const nodeRef = ref(node)
  node.length = computed(() => {
    return nodeRef.value.children!.reduce((count, cur) => count + (cur.children ? 1 + cur.length! : 1), 0)
  })
}

该初始化函数中,我们先将node包装成响应式对象,绑定的计算属性中,咱们会获取到响应式对象中的children属性,注意这里的!.的访问形式,因为咱们一定是对父节点操作的,children一定不会为空嘛。然后,咱们巧用了数组的reduce方法,对子节点进行遍历执行count累加,如果子节点也是父节点,则count记为1 + cur.length!,显然这里我们采用了计算属性的递归访问,这种思想是在多层级结构计算中为我们所惯用的哦~还要注意这里cur.length!,因为其children属性不为空,说明是父节点,则一定会绑定length计算属性呢。总体来说,一行代码就巧妙的计算出了一个父节点下所有的后代节点的长度。 该函数在generateFlatTree中判断父节点的地方调用:

if (o.children) {
  initParentTreeNode(cur)
  ...
}

同时,我们将原先获取子节点长度的代码删掉,IFlatTreeNode类型上临时定义的length属性也移除掉,而采用现在ITreeNode上计算属性的方式

使用length计算属性

再看下tree组件tsx文件中的调整:

  1. toggleNode参数的接收类型改为(node: IFlatTreeNode)

    这样调整的意图是,不光要操作节点继承的expanded属性,后续为了触发某些计算属性,咱们还得同时操作节点originalNode,也就是原始节点的expanded属性,目前还用不上。

  2. 接着第一点,相应的展开/折叠的button点击事件中调用toggleNode参数类型的显式指定

    <button onClick={() => toggleNode(treeNode as IFlatTreeNode)} ...>
    
  3. expandedTree计算属性中需要跳过折叠节点内部所有子节点的代码调整为:

    i += item.originalNode.length!
    

为了方便看效果,在tree模板中输出下:

<div ... >
  ...
  {treeNode.label}
  <span class='ml-4 text-amber-600'>{treeNode.originalNode.length}</span>
</div>

效果:

image.png

计算属性重新计算的前提

  1. 由外部访问后首次执行过
  2. 所绑定的组件或vnode已渲染
  3. 外部操作的是从响应式对象访问的属性
  4. 计算属性内部操作的是从响应式对象访问的属性
  5. 检测到其所关心的对象属性或者计算属性发生变化

计算节点索引

对于扁平化的tree结构,我们希望在进行一些节点上下文操作时,不用每次都计算其在列表中所处的位置。聪明的小伙伴自然想到用计算属性呗!没错!相信聪明的你很快就有思路了:

思路点拨

拽着前一个人,从排头开始报数

IFlatTreeNode进行扩展:

export interface IFlatTreeNode extends ITreeNode {
  ...
  prev?: IFlatTreeNode // 前一个节点
  index: ComputedRef<number> // 在列表中的索引
}

调整tree/utils.ts

...
​
/**
 *
 * ...
 * @param parent 父节点
 */
export function generateFlatTree(..., parent: IFlatTreeNode | null = null)... {
  ...
  return tree.reduce((prev, cur) => {
    // 拷贝当前节点
    const o = ...
    // 绑定前驱节点
    if (prev.length > 0) {
      o.prev = prev[prev.length - 1]
    } else if (parent) {
      o.prev = parent
    }
    ...
    if (level > 1 && parent) {
      o.parentId = parent.id
    }
    // 初始扁平化节点
    initFlatTreeNode(o)
    if (o.children) {
      ...
      const children = generateFlatTree(o.children, level, o)
      ...
    } ...
  }, [] as IFlatTreeNode[])
}
function initFlatTreeNode(node: IFlatTreeNode) {
  // 绑定index计算属性
  const prevNode = ref(node.prev)
  node.index = computed(() => {
    return prevNode.value == null ? 0 : prevNode.value.index + 1
  })
}
...

为了便于追踪父节点作为前驱节点来绑定,这里调整了generateFlatTree最后一个参数为IFlatTreeNode类型,函数内部对parent获取和传参的地方都做了相应的调整,注意获取前驱节点的逻辑,如果reduce的回调函数第一个参数为非空数组则取最后一个元素作为前驱节点,否则取parent。在initFlatTreeNode函数中绑定计算属性所访问的前驱节点是响应式的,因为在插入和删除节点时,前驱绑定节点会发生改变。

当我们扩展了IFlatTreeNode定义后,在tree/index.tsx中发现ts飘红了:

image.png 咱们干脆使用any类型来声明入参类型,一了百了,toggleNode(treeNode as any),有时候ts语法太苛刻了,凡事考虑的太精细,也是操碎心了。不要忘了在.eslintrc.jsonrules中关闭对any的检查:

"rules": {
  "@typescript-eslint/no-explicit-any": ["off"]
}

最后咱们为节点内容输出index

<div
  ...
>
  ...
  {treeNode.label}
  <span class='ml-4 text-amber-600'>{treeNode.originalNode.length}</span>
  <span class='ml-4 text-green-600'>{treeNode.index}</span>
</div>

页面效果,看到不管是否折叠,index都是连续的:

8888.gif

计算参照线高度

接下来,咱们趁热打铁,以前面的思路计算属性的递归,为tree组件实现垂直的ztree风格的参照线。画线的思想:从一个父节点下子节点列表的第一个节点开始,一直往下,沿着可见的节点一直画到最后一个子一代节点的位置结束。

思路点拨

这里咱们要计算两种属性:一个父节点下可见的所有子孙节点的长度,我们记为visibleLength;一个父节点下参照线的长度,我们记为lineLengthlineLength的计算规则有两种:

  1. 累加除最后子一代节点的其他所有子一代节点的visibleLength,当然还要加上自身,也就是加1,如果子一代节点是叶节点,则可见长度为0
  2. 用父节点的visibleLength减去最后一个子一代节点的visibleLength

下面咱们来做实现,首先在ITreeNode中定义两个属性:

export interface ITreeNode {
  ...
  visibleLength?: ComputedRef<number> // 可见子节点的长度
  lineLength?: ComputedRef<number> // 参照线长度
}

tree/utils.tsinitParentTreeNode函数中增加ITreeNodevisibleLength计算属性,其实很简单,参照length计算属性的实现,判断如果子节点children有值时,在此基础上再判断是否展开,展开则递归调用子节点的visibleLength计算属性即可。

node.visibleLength = computed(() => {
  if (!nodeRef.value.expanded) return 0
  return nodeRef.value.children!.reduce((count, cur) => count + (cur.children ? (cur.expanded ? 1 + cur.visibleLength! : 1) : 1), 0)
})

再来看lineLength计算属性的实现:

node.lineLength = computed(() => {
  if (!nodeRef.value.expanded) return 0
  let length = 1
  const children = nodeRef.value.children
  // 排除最后一个子一代节点
  for (let i = 0; i < children!.length - 1; i++) {
    if (i < children!.length - 1) {
      const child = children![i]
      if (child.children) {
        length += 1 + child.visibleLength!
      } else {
        length += 1
      }
    }
  }
  return length
})

tree模板中输出下lineLength计算属性:

<div
  ...
>
  ...
  {treeNode.label}
  {/*<span class='ml-4 text-amber-600'>{treeNode.originalNode.length}</span>*/}
  {/*<span class='ml-4 text-green-600'>{treeNode.index}</span>*/}
  <span class='ml-4 text-blue-600'>{treeNode.originalNode.lineLength}</span>
</div>

最后画龙点睛的一笔,在toggleNode点击处理函数中,操作原始节点对象的expanded属性,触发之前定义的lengthvisibleLengthlineLength计算属性的连锁调用更新:

const toggleNode = (node: IFlatTreeNode) => {
  node.expanded = !node.expanded
  // 操作原始节点,对展开状态取反
  const originalNode = node.originalNode
  originalNode.expanded = !originalNode.expanded
}

页面效果,ok!随着节点的展开与折叠,参照线长度也会动态的变化:

8888.gif

之前我们说了lineLength计算属性还有一种简化实现方式,一起来看下代码实现:

node.lineLength = computed(() => {
  if (!nodeRef.value.expanded) return 0
  const children = nodeRef.value.children
  // 排除最后一个子一代节点
  const lastChild = children![children!.length - 1]
  return nodeRef.value.visibleLength! - (lastChild.visibleLength || 0)
})

锦上添花,加上参照线:

<div
  ...
  class='juan-tree-node relative'
  ...
>
  {!treeNode.isLeaf && treeNode.expanded && (
    <span
      class='tree-node-line absolute w-px border-l-[1px] border-dashed border-l-gray-400'
      style={{
        height: `${24 * (treeNode.originalNode.lineLength! - 1) + 14}px`,
        left: `${24 * (treeNode.level - 1) + 34}px`,
        top: `${24}px`
      }}
    ></span>
  )}
  ...
</div>

这里,咱们用<span>标签做绝对定位,设置了左边框作为参照线,静态样式直接指定tailwindcssclass值即可,非常方便,然后我们用style属性动态绑定了之前实现的原始节点的lineLength计算属性。最后再来看下效果,非常完美!

8888.gif

好了!学到这里,相信小伙伴们对于vue计算属性在实际项目中的类似实践,可以拍拍胸脯,信心满满的说,原来看似复杂的功能用计算属性来实现这么简单!后续对vue的相关知识点和特性,咱们将继续用这种思路点拨加深入实践的方式,结合有趣的例子,带小伙伴们一起来学习体验,大家加油!!