虚拟滚动之树形结构的极简实现(两种方案附完整demo)

前言

上一篇讲过list结构的虚拟滚动应该如何实现,没看过的可以先去了解一下,原理很简单,本文所做的也是基于上一篇文章的原理所进行的拓展。

然后近期项目有需求需要优化树形结构的性能,然后就又研究了一下如何实现树形结构的虚拟滚动,也就有了这篇文章。

流程与原理

要想虚拟滚动,树形结构肯定是不行的,所以需要将树形结构拍平成list结构,然后用list来模拟树形结构,详细流程如下:

  1. 拍平树形结构,转换成list;
  2. 在拍平的过程中记录层级level,然后在渲染list的时候根据当前level计算偏移量从而将list模拟出tree的效果;
  3. 在拍平的过程中添加isExpand字段用于判断当前节点是否处于展开状态;
  4. 在拍平的过程中添加isShow字段用于判断当前节点是否处于被收拢从而不显示的状态;
// 拍平树形结构并添加相应字段
const flatTreeToList = (data) => {
    let resList = []
    function travelTree(tree, level) {
        tree.forEach((item) => {
            item.level = level
            item.isExpand = true
            item.isShow = true
            resList.push(item)
            if (item.children && item.children.length) travelTree(item.children, level + 1)
        })
    }
    travelTree(data, 0)
    return resList
}
const flattedTreeList = reactive(flatTreeToList(staticTree)) // 设置响应式
复制代码
  1. 计算当前视窗要展示渲染的list(要过滤掉isShow为false的节点);
// 计算非收缩状态正常展示的数据
const showFlattedTreeList = computed(() => {
    return flattedTreeList.filter((item) => item.isShow)
})
// 计算当前视窗内实际要渲染的内容
const activeList = computed(() => {
    let showList = showFlattedTreeList.value
    const start = startNum.value
    return showList.slice(start, start + showNumber)
})
复制代码
  1. 渲染计算过滤后的list,根据level设置节点向右偏移量,根据是否有子节点判断是否展示下拉箭头,根据isExpand判断下拉箭头方向;
<template v-for="(item, index) in activeList" :key="item.value">
    <div class="scroll-item">
      <span :style="`padding-left: ${item.level * 15}px;`">
        <i
          class="arrow"
          :class="{ 'is-show': item.haveChildren, 'not-open': !item.isExpand }"
          @click="toggleExpand(item)"
        >
          >
        </i>
        {{item.label}}
      </span>
    </div>
</template>
复制代码
  1. 处理节点的展开收拢事件(要点所在);
// 借助引用数据类型的特性来控制展开收拢
const toggleExpand = (item) => {
    let isExpand = item.isExpand
    item.isExpand = !isExpand
    if (item.children && item.children.length) setTreeStatus(item.children, !isExpand)
}

const setTreeStatus = (children, status) => {
    const travel = (list) => {
      list.forEach((child) => {
        child.isShow = status
        if (child.children && child.children.length) {
          // 展开则只展开子集中的isExpand为true的
          // 收拢则全部收拢
          if ((status && child.isExpand) || !status) travel(child.children)
        }
      })
    }
    travel(children)
}
复制代码

其他的就直接按照上一篇文章list的虚拟滚动来做就行

优缺点

优点:实现简单,理解简单
缺点:拍平之后的数据flattedTreeList由于每一个节点都保留了完整的children信息,从而导致数据量相比于原数据量以平方指数的形式变多,当原数据本身就是一个很庞大的数据量的时候,会加大页面负担

另一种方案(并不保证更优,自行判断)

首先在拍平的时候不保留children信息,那么修改之后的flatTreeToList方法就如下:

// 拍平树形结构
const flatTreeToList = (data) => {
    let resList = []
    function travelTree(tree, level) {
      tree.forEach((item) => {
        resList.push({
          label: item.label,
          value: item.value,
          level,
          isExpand: true,
          isShow: true,
          haveChildren: item.children && item.children.length > 0, // 记录是否有子级用于控制箭头是否展示
        })
        if (item.children && item.children.length)
          travelTree(item.children, level + 1)
      })
    }
    travelTree(data, 0)
    return resList
}
const flattedTreeList = reactive(flatTreeToList(staticTree)) // 设置响应式
复制代码

由于没有记录children信息,也就无法通过引用数据类型的特性来直接操作展开收拢时的状态变化了

那么换个思路分情况讨论:

点击节点操作其子节点收拢:假设点击的当前节点level是2,那么从该节点在flattedTreeList中的位置开始往下遍历,所有level大于2的都代表这些节点是其子节点或者其子节点的子节点,直到节点的level也是2的为止,直接全部设置isShow为false即可。

点击节点操作其子节点展开:这种情况下又能分两种情况

  • 当前遍历到的节点level比点击展开的level大1则isShow必定置为true
  • 当前遍历到的节点level比点击展开的level大的不止1的时候,那么就要求它的上一个节点的isExpand和isShow都为true,该节点才能置为true

这里有人可能会疑惑展开为什么不是跟收拢一样逻辑全部置为true呢?当level差值大于1的时候为什么是这么个判断条件呢?
那是因为操作展开的节点的子节点可能存在本身是收拢,当它因为父级收拢再展开的时候,它的自己仍然要保持收拢的状态;
我们假设下标为n的节点是下标为m的节点的子节点,当m节点收拢之后再展开,我们遍历到n节点的时候,首先n节点的isShow要置为true,继续往下遍历到n+1的节点,n+1节点的isShow要想是true,那么n+1的level要么跟n一样,也就是说与m的差值是1,要么n+1节点是n节点的子节点,并且n节点的isExpand是true;
继续往下遍历n+2节点,n+2节点的isShow要想是true,那么n+2的level要么跟n一样,也就是说与m的差值是1,要么n+2节点也是n节点的子节点,要么n+2节点是n+1节点的子节点,不外乎这三种情况,第一种第三种情况就不用说了跟上面的一样,重点在于第二种情况,这种情况下表明n+1没有子节点,n+1和n+2互为兄弟节点,那么n+1的isExpand一定恒为true,同时如果n+1的isShow也为true的时候表明n+1的父节点的isExpand也是true,那么n+2的isShow状态也就可以的出来了,最终第二三种情况的判断条件其实是一致的。

修改后的操作收拢展开方法如下:

const toggleExpand = item => {
    let trueIndex = flattedTreeList.findIndex(data => item.value === data.value)
    let isExpand = item.isExpand
    flattedTreeList[trueIndex].isExpand = !isExpand
    if (isExpand) {
    // 执行收起
        for (let i = trueIndex + 1; i < flattedTreeList.length; i++) {
            if (flattedTreeList[i].level > item.level) flattedTreeList[i].isShow = false
            else break
        }
    } else {
    // 执行展开
        for (let i = trueIndex + 1; i < flattedTreeList.length; i++) {
            if (flattedTreeList[i].level === item.level) break
            else if (flattedTreeList[i].level === item.level + 1)
            // level比点击展开的level大1则必定置为true
                flattedTreeList[i].isShow = true
            else {
                if (flattedTreeList[i - 1].isExpand && flattedTreeList[i - 1].isShow) flattedTreeList[i].isShow = true
            }
        }
    }
}
复制代码

Demo

方案一demo

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
    <title>VirtualTree</title>
  </head>

  <body>
    <div id="app">
      <div
        ref="demo"
        class="scroll-box demo"
        :style="`height: ${showNumber * itemHeight}px;`"
      >
        <div
          class="scroll-blank"
          :style="`height: ${showFlattedTreeList.length * itemHeight}px;`"
        ></div>
        <div class="scroll-data" :style="`top: ${positionTop}px;`">
          <template v-for="(item, index) in activeList" :key="item.value">
            <div class="scroll-item">
              <span :style="`padding-left: ${item.level * 15}px;`">
                <i
                  class="arrow"
                  :class="{ 'is-show': item.children, 'not-open': !item.isExpand }"
                  @click="toggleExpand(item)"
                >
                  >
                </i>
                {{item.label}}
              </span>
            </div>
          </template>
        </div>
      </div>
    </div>
    <script>
      // 通过引用数据类型特性处理收拢和展开
      const { onMounted, onUnmounted, computed, ref, reactive } = Vue

      const staticTree = [
        {
          label: 'a',
          value: 1,
          children: [
            {
              label: 'a-a',
              value: 2,
            },
            {
              label: 'a-b',
              value: 3,
              children: [
                {
                  label: 'a-b-a',
                  value: 7,
                },
              ],
            },
            {
              label: 'a-c',
              value: 4,
              children: [
                {
                  label: 'a-c-a',
                  value: 8,
                  children: [
                    {
                      label: 'a-c-a-a',
                      value: 9,
                    },
                    {
                      label: 'a-c-a-b',
                      value: 10,
                    },
                  ],
                },
              ],
            },
            {
              label: 'a-d',
              value: 5,
            },
            {
              label: 'a-e',
              value: 6,
              children: [
                {
                  label: 'a-e-a',
                  value: 11,
                },
              ],
            },
          ],
        },
        {
          label: 'b',
          value: 12,
          children: [
            {
              label: 'b-a',
              value: 22,
            },
            {
              label: 'b-b',
              value: 23,
              children: [
                {
                  label: 'b-b-a',
                  value: 27,
                },
              ],
            },
            {
              label: 'b-c',
              value: 24,
              children: [
                {
                  label: 'b-c-a',
                  value: 28,
                  children: [
                    {
                      label: 'b-c-a-a',
                      value: 29,
                    },
                    {
                      label: 'b-c-a-b',
                      value: 30,
                    },
                  ],
                },
              ],
            },
            {
              label: 'b-d',
              value: 25,
            },
            {
              label: 'b-e',
              value: 26,
              children: [
                {
                  label: 'b-e-a',
                  value: 31,
                },
              ],
            },
          ],
        },
      ]
      const App = {
        setup() {
          const demo = ref(null) // 外框盒子
          const showNumber = 8 // 当前视窗展示条数
          const itemHeight = 20 // 每一条内容的高度
          let startNum = ref(0) // 当前视窗范围内第一个元素下标
          let positionTop = ref(0) // 当前视窗范围内第一个元素偏移量

          // 拍平树形结构
          const flatTreeToList = (data) => {
            let resList = []
            function travelTree(tree, level) {
              tree.forEach((item) => {
                item.level = level
                item.isExpand = true
                item.isShow = true
                resList.push(item)
                if (item.children && item.children.length)
                  travelTree(item.children, level + 1)
              })
            }
            travelTree(data, 0)
            return resList
          }
          const defaultTree = reactive(staticTree)
          const flattedTreeList = reactive(flatTreeToList(defaultTree))

          // 计算非收缩状态的数据
          const showFlattedTreeList = computed(() => {
            return flattedTreeList.filter((item) => item.isShow)
          })
          // 计算当前视窗内实际要渲染的内容
          const activeList = computed(() => {
            let showList = showFlattedTreeList.value
            const start = startNum.value
            return showList.slice(start, start + showNumber)
          })

          onMounted(() => {
            demo.value.addEventListener('scroll', scrollEvent)
          })
          onUnmounted(() => {
            if (!demo.value) return
            demo.value.removeEventListener('scroll', scrollEvent)
            demo.value = null
          })
          // 滚动的时候计算当前视窗范围内第一个元素下标
          const scrollEvent = (event) => {
            const { scrollTop } = event.target
            startNum.value = parseInt(scrollTop / itemHeight)
            positionTop.value = scrollTop
          }

          const toggleExpand = (item) => {
            let isExpand = item.isExpand
            item.isExpand = !isExpand
            if (item.children && item.children.length)
              setTreeStatus(item.children, !isExpand)
          }

          const setTreeStatus = (children, status) => {
            const travel = (list) => {
              list.forEach((child) => {
                child.isShow = status
                if (child.children && child.children.length) {
                  // 展开则只展开子集中的isExpand为true的
                  // 收拢则全部收拢
                  if ((status && child.isExpand) || !status)
                    travel(child.children)
                }
              })
            }
            travel(children)
          }

          return {
            showNumber,
            itemHeight,
            demo,
            positionTop,
            activeList,
            flattedTreeList,
            showFlattedTreeList,
            toggleExpand,
          }
        },
      }

      const app = Vue.createApp(App)
      app.mount('#app')
    </script>
    <style>
      .scroll-box {
        position: relative;
        overflow: auto;
        width: 400px;
        border: 1px solid rgb(0, 0, 0);
      }

      .scroll-data {
        position: absolute;
        width: 100%;
      }

      .scroll-item {
        height: 20px;
      }

      .scroll-item:hover {
        background: rgb(104, 111, 211);
        color: #fff;
      }

      .arrow {
        display: inline-block;
        width: 25px;
        text-align: center;
        opacity: 0;
        cursor: pointer;
        transform: rotate(90deg);
      }

      .is-show {
        opacity: 1;
      }

      .not-open {
        transform: rotate(0deg);
      }
    </style>
  </body>
</html>
复制代码

方案二demo

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
    <title>VirtualTree</title>
  </head>

  <body>
    <div id="app">
      <div
        ref="demo"
        class="scroll-box demo"
        :style="`height: ${showNumber * itemHeight}px;`"
      >
        <div
          class="scroll-blank"
          :style="`height: ${showFlattedTreeList.length * itemHeight}px;`"
        ></div>
        <div class="scroll-data" :style="`top: ${positionTop}px;`">
          <template v-for="(item, index) in activeList" :key="item.value">
            <div class="scroll-item">
              <span :style="`padding-left: ${item.level * 15}px;`">
                <i
                  class="arrow"
                  :class="{ 'is-show': item.haveChildren, 'not-open': !item.isExpand }"
                  @click="toggleExpand(item)"
                >
                  >
                </i>
                {{item.label}}
              </span>
            </div>
          </template>
        </div>
      </div>
    </div>
    <script>
      // 通过层级处理收拢和展开
      const { onMounted, onUnmounted, computed, ref, reactive } = Vue

      const staticTree = [
        {
          label: 'a',
          value: 1,
          children: [
            {
              label: 'a-a',
              value: 2,
            },
            {
              label: 'a-b',
              value: 3,
              children: [
                {
                  label: 'a-b-a',
                  value: 7,
                },
              ],
            },
            {
              label: 'a-c',
              value: 4,
              children: [
                {
                  label: 'a-c-a',
                  value: 8,
                  children: [
                    {
                      label: 'a-c-a-a',
                      value: 9,
                    },
                    {
                      label: 'a-c-a-b',
                      value: 10,
                    },
                  ],
                },
              ],
            },
            {
              label: 'a-d',
              value: 5,
            },
            {
              label: 'a-e',
              value: 6,
              children: [
                {
                  label: 'a-e-a',
                  value: 11,
                },
              ],
            },
          ],
        },
        {
          label: 'b',
          value: 12,
          children: [
            {
              label: 'b-a',
              value: 22,
            },
            {
              label: 'b-b',
              value: 23,
              children: [
                {
                  label: 'b-b-a',
                  value: 27,
                },
              ],
            },
            {
              label: 'b-c',
              value: 24,
              children: [
                {
                  label: 'b-c-a',
                  value: 28,
                  children: [
                    {
                      label: 'b-c-a-a',
                      value: 29,
                    },
                    {
                      label: 'b-c-a-b',
                      value: 30,
                    },
                  ],
                },
              ],
            },
            {
              label: 'b-d',
              value: 25,
            },
            {
              label: 'b-e',
              value: 26,
              children: [
                {
                  label: 'b-e-a',
                  value: 31,
                },
              ],
            },
          ],
        },
      ]
      const App = {
        setup() {
          const demo = ref(null) // 外框盒子
          const showNumber = 8 // 当前视窗展示条数
          const itemHeight = 20 // 每一条内容的高度
          let startNum = ref(0) // 当前视窗范围内第一个元素下标
          let positionTop = ref(0) // 当前视窗范围内第一个元素偏移量

          // 拍平树形结构
          const flatTreeToList = (data) => {
            let resList = []
            function travelTree(tree, level) {
              tree.forEach((item) => {
                resList.push({
                  label: item.label,
                  value: item.value,
                  level,
                  isExpand: true,
                  isShow: true,
                  haveChildren: item.children && item.children.length > 0,
                })
                if (item.children && item.children.length)
                  travelTree(item.children, level + 1)
              })
            }
            travelTree(data, 0)
            return resList
          }
          const flattedTreeList = reactive(flatTreeToList(staticTree))

          // 计算非收缩状态的数据
          const showFlattedTreeList = computed(() => {
            return flattedTreeList.filter((item) => item.isShow)
          })
          // 计算当前视窗内实际要渲染的内容
          const activeList = computed(() => {
            let showList = showFlattedTreeList.value
            const start = startNum.value
            return showList.slice(start, start + showNumber)
          })

          onMounted(() => {
            demo.value.addEventListener('scroll', scrollEvent)
          })
          onUnmounted(() => {
            if (!demo.value) return
            demo.value.removeEventListener('scroll', scrollEvent)
            demo.value = null
          })
          // 滚动的时候计算当前视窗范围内第一个元素下标
          const scrollEvent = (event) => {
            const { scrollTop } = event.target
            startNum.value = parseInt(scrollTop / itemHeight)
            positionTop.value = scrollTop
          }

          const toggleExpand = (item) => {
            let trueIndex = flattedTreeList.findIndex(
              (data) => item.value === data.value
            )
            let isExpand = item.isExpand
            flattedTreeList[trueIndex].isExpand = !isExpand
            if (isExpand) {
              // 执行收起
              for (let i = trueIndex + 1; i < flattedTreeList.length; i++) {
                if (flattedTreeList[i].level > item.level)
                  flattedTreeList[i].isShow = false
                else break
              }
            } else {
              // 执行展开
              for (let i = trueIndex + 1; i < flattedTreeList.length; i++) {
                if (flattedTreeList[i].level === item.level) break
                else if (flattedTreeList[i].level === item.level + 1)
                  // level比点击展开的level大1则必定置为true
                  flattedTreeList[i].isShow = true
                else {
                  if (
                    flattedTreeList[i - 1].isExpand &&
                    flattedTreeList[i - 1].isShow
                  )
                    flattedTreeList[i].isShow = true
                }
              }
            }
          }

          return {
            showNumber,
            itemHeight,
            demo,
            positionTop,
            activeList,
            flattedTreeList,
            showFlattedTreeList,
            toggleExpand,
          }
        },
      }

      const app = Vue.createApp(App)
      app.mount('#app')
    </script>
    <style>
      .scroll-box {
        position: relative;
        overflow: auto;
        width: 400px;
        border: 1px solid rgb(0, 0, 0);
      }

      .scroll-data {
        position: absolute;
        width: 100%;
      }

      .scroll-item {
        height: 20px;
      }

      .scroll-item:hover {
        background: rgb(104, 111, 211);
        color: #fff;
      }

      .arrow {
        display: inline-block;
        width: 25px;
        text-align: center;
        opacity: 0;
        cursor: pointer;
        transform: rotate(90deg);
      }

      .is-show {
        opacity: 1;
      }

      .not-open {
        transform: rotate(0deg);
      }
    </style>
  </body>
</html>
复制代码

猜你喜欢

转载自juejin.im/post/7062676726307880997