设计一个百万级高性能树组件
最近在整理开发项目,之前做过一个百万级高性能树组件,里面涉及到的一些设计方案挺有意思的,写篇博客分享一下主要的设计方案和技术难点
需求分析
- 设计一个树组件,要求可承载10w数据以上
- 要极致性性能,滚动和拖动滚动条不卡顿、不延迟
- 支持多选,父节点折叠、父子勾选联动
- 数据量多的时候滚动条不能小到不能拾取
- 高可用,可以移植到不同框架
方案设计
1. 数据结构
要重载大量数据和随意拖动不卡顿不延迟,传统的分页加载是是肯定不行的,所以这里借鉴虚拟列表的思路,我们可以把一个树形结构的数据扁平化,通过节点的相对位置position串来表示数据的坐标,如0-1-0表示的位置是第一个节点下的第二个子节点的第一个子节点,通过level来表示数据的层级,通过css为每个level设置相应的缩进来展示树形结构:
2. 虚拟滚动方案
实现虚拟滚动的方法有很多,这里为了实现最佳性能,设计了一个滑动窗口+视图缓存,主要优化了快速拖动的白屏问题和数据拼接感;大致原理图如下:
可以看到,这里将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个变化
- 活跃数据索引
activeIndexs需要变化,activeIndexs就是页面的总数据量,也就是排除掉被折叠的数据后的数据 - 渲染数据
rangeData变化 - 因为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)
})