从零设计一个百万级高性能树组件

1,341 阅读4分钟

设计一个百万级高性能树组件

最近在整理开发项目,之前做过一个百万级高性能树组件,里面涉及到的一些设计方案挺有意思的,写篇博客分享一下主要的设计方案和技术难点

需求分析

  • 设计一个树组件,要求可承载10w数据以上
  • 要极致性性能,滚动和拖动滚动条不卡顿、不延迟
  • 支持多选,父节点折叠、父子勾选联动
  • 数据量多的时候滚动条不能小到不能拾取
  • 高可用,可以移植到不同框架

方案设计

1. 数据结构

要重载大量数据和随意拖动不卡顿不延迟,传统的分页加载是是肯定不行的,所以这里借鉴虚拟列表的思路,我们可以把一个树形结构的数据扁平化,通过节点的相对位置position串来表示数据的坐标,如0-1-0表示的位置是第一个节点下的第二个子节点的第一个子节点,通过level来表示数据的层级,通过css为每个level设置相应的缩进来展示树形结构:

image.png

2. 虚拟滚动方案

实现虚拟滚动的方法有很多,这里为了实现最佳性能,设计了一个滑动窗口+视图缓存,主要优化了快速拖动的白屏问题数据拼接感;大致原理图如下:

image.png 可以看到,这里将dom的数量限制在4屏,为什么是4屏而不是3屏,因为三段无法保证前后各有一段缓存区,渲染慢画面会有拼接感觉。缓存区是指在请求追加数据的时候还有一屏内容可用,比如,当P4即将进入可视区的时候,P5会拼接到P4后面,而白屏的位置一般发生在拼接处,有了一屏缓存可以保证数据在可视区外拼接;当P5拼接之后采用transform: translateY对P1进行填充

3.实现一个滚动条

为什么要自己实现滚动条呢,因为当数据非常大的时候,浏览器自带的滚动条会变得非常短,不方便鼠标拾取,这个就不累赘了,太简单了。

4.计算数据区间

这个主要就是个计算问题,这里采用双指针来截取渲染的数据,通过临界位置点来计算出滚动条的临界位置点,当达到临界位置把指针向前或者向后移动一屏即可

...
const forwardCriticalPoint = Math.ceil((this.start + this.end) / 2) // 指针前进临界点
    const backCriticalPoint = forwardCriticalPoint - this.sectionSize // 指针后退临界点
    if (e.target.scrollTop > forwardCriticalPoint * this.rowHeight) {
      this.setRange(this.start + this.sectionSize)
    } else if (e.target.scrollTop < backCriticalPoint * this.rowHeight && this.start > 0) {
      this.setRange(this.start - this.sectionSize)
    }
...
setRange (start) {
    this.start = start
    this.end = this.start + this.sectionSize * 4 - 1
    this.rangeChange()
  }
rangeChange () {
    this.emit('rangeChange', { start: this.start, end: this.end })
  }
  

视图层订阅区间变化、截取数据

 // 视图层订阅区间变化=>截取数据
 this.virtuaListEngine.on('rangeChange', ({ start, end }) => {
      console.log('数据区间改变', start, end)
      this.start = start
      this.end = end
    })
  watch: {
    start () {
      this.rangeData = activeIndexs.slice(this.start, this.end + 1).map(i => this.allData[i])
    }
  },

5.关于节点折叠

节点折叠之后,有3个变化

  1. 活跃数据索引 activeIndexs 需要变化,activeIndexs就是页面的总数据量,也就是排除掉被折叠的数据后的数据
  2. 渲染数据rangeData变化
  3. 因为activeIndexs变了,因此滚动条的长度也需要变

具体实现如下:

collapseChange (item, index) {
      const { level, collapsed } = item
      const activeIdx = index + this.start
      // 折叠行为
      if (!collapsed) {
        item.collapsed = true
        for (let idx = activeIdx + 1; idx < activeIndexs.length; idx++) {
          const ele = this.allData[activeIndexs[idx]]
          if (level >= ele.level) {
            activeIndexs = activeIndexs.slice(0, activeIdx + 1).concat(activeIndexs.slice(idx))
            this.setRangeData()
            return
          }
        }
        // case:尾部折叠
        activeIndexs = activeIndexs.slice(0, activeIdx + 1)
        this.setRangeData()
      } else {
        // 展开行为
        // 在总数据池中的位置
        item.collapsed = false
        const i = search(this.allData, item)
        let queue = [item]
        const res = []
        for (let idx = i + 1; idx < this.allData.length; idx++) {
          const ele = this.allData[idx]
          let lastQueueEle = queue[queue.length - 1]
          if (level >= ele.level) {
            activeIndexs = activeIndexs.slice(0, activeIdx + 1).concat(res, activeIndexs.slice(activeIdx + 1))
            this.setRangeData()
            return
          }
          if (lastQueueEle.level >= ele.level) {
            const idx = queue.findIndex(i => i.level === ele.level)
            queue = queue.slice(0, idx)
            lastQueueEle = queue[queue.length - 1]
          }
          if (!lastQueueEle.collapsed) {
            res.push(idx)
            if (lastQueueEle.level < ele.level) queue.push(ele)
          }
        }
        activeIndexs = activeIndexs.slice(0, activeIdx + 1).concat(res, activeIndexs.slice(activeIdx + 1))
        this.setRangeData()
      }
    },

处理滚动条长度

  watch: {
    dataSize (nv) {
      this.virtuaListEngine && this.virtuaListEngine.emit('dataSize', nv)
    }
  },

6. 关于复选、父子联动

这块是虚拟树和普通树节点比较不同的地方,首先来考虑以下问题

1.a节点下有10万个子节点,勾选a之后,该如何把数据给接口,只给a还是全给?

2.如果全给,数据量太大了,网络耗时太长,必然不合理

3.如果只给a,那如果勾选a之后,用户取消勾选了10个子节点,这个情况给什么数据呢?

对于这个问题的方案的只考虑用户的主动勾选行为,因为用户的实际场景肯定是基于非常有限的操作对大量数据的选取。通过选中的元素includeKeys和排除的元素excludeKeys 来共同确定用户的数据范围

getAbsolutelType (position) {
    if (this.excludeKeys.includes(position)) return this.checkType.UNCHECKED
    if (this.includeKeys.includes(position)) return this.checkType.CHECKED
    if (position.indexOf('-') > 0) {
      const parentPos = position.replace(/(.*)(-\d+){1}/, "$1")
      return this.getAbsolutelType(parentPos)
    }
    return this.checkType.INDETERMINATE
  }
  getItemChcekType (position, isLeaf = false) {
    const absolutelType = this.getAbsolutelType(position)
    if (isLeaf) return absolutelType === this.checkType.CHECKED ? this.checkType.CHECKED : this.checkType.UNCHECKED
    if (absolutelType === this.checkType.CHECKED) {
      if (this.excludeKeys.some(e => e.startsWith(position))) return this.checkType.INDETERMINATE
      return this.checkType.CHECKED
    }
    if (absolutelType === this.checkType.UNCHECKED) {
      if (this.includeKeys.some(e => e.startsWith(position))) return this.checkType.INDETERMINATE
      return this.checkType.UNCHECKED
    }
    if (absolutelType === this.checkType.INDETERMINATE) {
      if (this.includeKeys.some(e => e.startsWith(position))) return this.checkType.INDETERMINATE
      return this.checkType.UNCHECKED
    }
  }
  check (position) {
    // 父级勾选 子集勾选取消
    this.includeKeys.forEach((e, index) => {
      if (e.startsWith(position) && e !== position) {
        this.includeKeys[index] = null
      }
    })
    // 父级勾选 子集排除取消
    this.excludeKeys.forEach((e, index) => {
      if (e.startsWith(position) && e !== position) {
        this.excludeKeys[index] = null
      }
    })
    this.includeKeys = this.includeKeys.filter(e => e !== null)
    this.excludeKeys = this.excludeKeys.filter(e => e !== null)

    if (this.excludeKeys.includes(position)) {
      this.excludeKeys.splice(this.excludeKeys.indexOf(position), 1)
    } else if (!this.includeKeys.includes(position)) {
      this.includeKeys.push(position)
    }
  }
  uncheck (position) {
    if (this.includeKeys.includes(position)) {
      this.includeKeys.splice(this.includeKeys.indexOf(position), 1)
    } else {
      this.excludeKeys.push(position)
    }
  }

7. 关于可用性和移植性

整个组件其实涉及到的逻辑还是比较复杂的,为了可用性和移植性,这里将大部分非视图相关的逻辑抽离到一个类VirtualEngine里面,之后移动到不同的框架只需要实现UI界面就行了

 this.virtuaListEngine = new VirtualEngine({
      container: `#${this.uuid}`,
      rowHeight: this.rowHeight,
      sectionSize: this.sectionSize,
      isStatic: this.isStatic,
      dataSize: activeIndexs.length,
    })
    this.virtuaListEngine.on('pageChange', ({ pageNo, pageSize }) => {
      console.log('请求数据', pageNo, pageSize)
      // 分页数据
      const partialData = this.data(pageNo, pageSize)
      this.allData.push(...partialData)
      console.log(partialData);
    })
    this.virtuaListEngine.on('rangeChange', ({ start, end }) => {
      console.log('数据区间改变', start, end)
      this.start = start
      this.end = end
    })
    
    this.virtuaListEngine.run((ve) => {
      this.setRangeData()
      this.$refs.scrollBar.connect(ve)
    })

8.完整代码 github.com/caiwuu/virt…

image.png