preface

The last article talked about how to realize the virtual scrolling of the list structure. If you haven’t seen it, you can learn about it first. The principle is very simple, and what this article does is based on the principle of the last article.

Then the recent project needs to optimize the performance of the tree structure, and then studied how to realize the virtual scrolling of the tree structure, which is the result of this article.

Process and Principle

For virtual scrolling, the tree structure will not work, so we need to flatten the tree structure into a list structure, and then use list to simulate the tree structure, the detailed process is as follows:

  1. Flat tree structure, convert to list;
  2. The level level is recorded during the flatting process, and the offset is calculated according to the current level during the rendering of the list to simulate the effect of the tree;
  3. The isExpand field is added during the patting process to determine whether the current node is expanded.
  4. Add isShow field in the process of beating flat to judge whether the current node is in the closed state so as not to display;
// Flatten the tree structure and add corresponding fields
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)) // Set responsiveness
Copy the code
  1. Calculates the list of renders to display in the current window (to filter out nodes whose isShow is false);
// Calculate the data normally displayed in the non-shrinking state
const showFlattedTreeList = computed(() = > {
    return flattedTreeList.filter((item) = > item.isShow)
})
// Calculates the actual content to render in the current window
const activeList = computed(() = > {
    let showList = showFlattedTreeList.value
    const start = startNum.value
    return showList.slice(start, start + showNumber)
})
Copy the code
  1. Render and calculate the filtered list. Set the node offset to the right according to level, determine whether to display the drop-down arrow according to whether there are child nodes, and determine the direction of the drop-down arrow according to 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>
Copy the code
  1. Process the node’s unfolding and folding event (the key point);
// Use the property of reference data types to control expansion and close
const toggleExpand = (item) = > {
    letisExpand = item.isExpand item.isExpand = ! isExpandif(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) {
          // Expand only the subset whose isExpand is true
          // All of them
          if((status && child.isExpand) || ! status) travel(child.children) } }) } travel(children) }Copy the code

For the rest, just follow the virtual scrolling of the list from the previous article

The advantages and disadvantages

Advantages: easy to implement and understand Disadvantages: The data flattedTreeList after being shot is larger in the form of square index compared with the original data because each node keeps complete children information. When the original data itself is a huge amount of data, the page burden will be increased

Alternative plan (not guaranteed to be better, judge for yourself)

First of all, children information is not retained when shooting flat, so the modified flatTreeToList method is as follows:

// Flat tree structure
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.// Record whether there are children used to control whether arrows are displayed
        })
        if (item.children && item.children.length)
          travelTree(item.children, level + 1)
      })
    }
    travelTree(data, 0)
    return resList
}
const flattedTreeList = reactive(flatTreeToList(staticTree)) // Set responsiveness
Copy the code

Since the children information is not recorded, it is not possible to directly manipulate the state change when the expansion is closed by referring to the properties of the data type

Then change the way of thinking points to discuss:

Click node to close its child node: Assume that the level of the current node is 2, then proceed from the position of this node in the flattedTreeList. All nodes with a level greater than 2 represent their children or children of their children, until the level of the node is also 2. Set isShow to false.

Click the node to operate its child node expansion: this case can be divided into two cases

  • IsShow must be set to true if the level of the current node traversed is 1 greater than the level of the node expanded
  • If the level of the current node traversed is more than 1 greater than the level of the expanded node, the node can be set to true only if the isExpand and isShow of the previous node are both required to be true

Now one of you might be wondering why isn’t expansion just like close all the logic is true? Why is it such a judgment condition when the level difference is greater than 1? That is because the child node of the operation expanded node may exist itself is closed, when it is closed and then expanded because of the parent, its own must remain closed state; We assume that the node with subscript n is the child node of the node with subscript M. When the m node is closed and expanded, when we traverse to the N node, the isShow of the n node should be set to true first, and then continue to traverse to the node with n+1. If the isShow of the n+1 node is to be true, then the level of n+1 is either the same as that of N. That is, the difference with m is 1, or the n+1 node is a child node of the N node, and the isExpand of the N node is true; If the isShow of n+2 is true, then the level of n+2 is either the same as that of n, that is, the difference between n and m is 1, or the n+2 node is a child of n, or the N +2 node is a child of n+1. In this case, n+1 has no child nodes. N +1 and n+2 are brothers of each other, so isExpand of n+1 must always be true. At the same time, if isShow of n+1 is also true, it indicates that isExpand of the parent node of N +1 is also true, then the isShow state of N +2 can be obtained. Finally, the judgment conditions of the second and third cases are actually the same.

The revised operation folding and expanding method is as follows:

const toggleExpand = item= > {
    let trueIndex = flattedTreeList.findIndex(data= > item.value === data.value)
    letisExpand = item.isExpand flattedTreeList[trueIndex].isExpand = ! isExpandif (isExpand) {
    // Perform the unpack
        for (let i = trueIndex + 1; i < flattedTreeList.length; i++) {
            if (flattedTreeList[i].level > item.level) flattedTreeList[i].isShow = false
            else break}}else {
    // Perform expansion
        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 must be set to true if it is 1 greater than the level expanded
                flattedTreeList[i].isShow = true
            else {
                if (flattedTreeList[i - 1].isExpand && flattedTreeList[i - 1].isShow) flattedTreeList[i].isShow = true}}}}Copy the code

Demo

Plan a demo

<! DOCTYPEhtml>
<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>
      // Handle close and expand by referring to data type properties
      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) // Outer box
          const showNumber = 8 // The number of entries in the current window
          const itemHeight = 20 // The height of each item
          let startNum = ref(0) // Subscript the first element in the current window range
          let positionTop = ref(0) // The offset of the first element in the current window range

          // Flat tree structure
          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))

          // Calculate the data in the non-shrinking state
          const showFlattedTreeList = computed(() = > {
            return flattedTreeList.filter((item) = > item.isShow)
          })
          // Calculates the actual content to render in the current window
          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
          })
          // Calculates the index of the first element in the current window range while scrolling
          const scrollEvent = (event) = > {
            const { scrollTop } = event.target
            startNum.value = parseInt(scrollTop / itemHeight)
            positionTop.value = scrollTop
          }

          const toggleExpand = (item) = > {
            letisExpand = item.isExpand item.isExpand = ! isExpandif(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) {
                  // Expand only the subset whose isExpand is true
                  // All of them
                  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>
Copy the code

Scheme 2 demo

<! DOCTYPEhtml>
<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>
      // Wrap and unfold through hierarchy
      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) // Outer box
          const showNumber = 8 // The number of entries in the current window
          const itemHeight = 20 // The height of each item
          let startNum = ref(0) // Subscript the first element in the current window range
          let positionTop = ref(0) // The offset of the first element in the current window range

          // Flat tree structure
          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))

          // Calculate the data in the non-shrinking state
          const showFlattedTreeList = computed(() = > {
            return flattedTreeList.filter((item) = > item.isShow)
          })
          // Calculates the actual content to render in the current window
          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
          })
          // Calculates the index of the first element in the current window range while scrolling
          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
            )
            letisExpand = item.isExpand flattedTreeList[trueIndex].isExpand = ! isExpandif (isExpand) {
              // Perform the unpack
              for (let i = trueIndex + 1; i < flattedTreeList.length; i++) {
                if (flattedTreeList[i].level > item.level)
                  flattedTreeList[i].isShow = false
                else break}}else {
              // Perform expansion
              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 must be set to true if it is 1 greater than the level expanded
                  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>
Copy the code