场景:坐席监控大屏的侧边栏需要渲染一棵「部门 → 台 → 班组 → 坐席」的组织树,数据通过 SSE 持续推流更新,节点数随业务增长。原生
el-tree全量渲染 DOM,节点一多侧边栏明显卡顿,且与搜索、状态过滤、勾选联动深度耦合,不能一换了之。本文记录用「虚拟滚动 + 树」重构这棵树的完整过程:方案设计、三个 Vue 2 响应式大坑、展开动画的取舍,以及如何做到对上层组件零改动地替换 el-tree。
一、整体思路:把树拍平,让滚动只渲染可见切片
虚拟滚动的本质是用数组索引换 DOM 数量:只渲染视口内的行,上下用撑高的占位元素模拟完整滚动条。定高虚拟列表(每行固定 26px)的换算极其便宜:
ini
startIndex = floor(scrollTop / itemHeight)
visible = flatData.slice(startIndex, startIndex + viewportCount + buffer)
但虚拟列表只吃一维数组,而树是嵌套结构。所以核心转化是:
- 先序遍历(DFS)拍平整棵树为一维数组;
- 由「展开状态」动态过滤出当前可见节点;
- 虚拟列表只渲染可见节点的视口切片。
先序遍历有一个对后面所有逻辑都至关重要的性质:
数组中第 level 层节点的父节点,就是它前面最近出现过的第 level-1 层节点。
这个性质让我们不存任何指针就能做父子推导,一切过滤、级联都是线性扫描。
拍平时给每个节点附加四个元字段:
js
// 深度优先打平:flatIndex 是数组下标,parentIndex 指向父节点在数组中的位置
cloneTree(nodes, flat, level = 1, parentIndex = null) {
return nodes.map(item => {
const flatItem = {
...item,
flatIndex: flat.length,
level,
parentIndex, // 父节点下标,向上冒泡靠它
expanded: false, // 展开
checked: false, // 勾选
indeterminate: false // 半选
}
flat.push(flatItem)
if (item.children && item.children.length) {
flatItem.children = this.cloneTree(item.children, flat, level + 1, flatItem.flatIndex)
}
return flatItem
})
}
二、可见性过滤:一次 O(n) 的 chainExpanded 扫描
节点可见的条件是「祖先链上每一层都展开」。朴素做法是对每个节点递归查祖先,O(n·depth);利用先序遍历性质可以一次线性扫描搞定:
js
computed: {
renderData() {
const result = []
// chainExpanded[i]:DFS 路径上最近一个第 i 层节点是否可见且展开
const chainExpanded = []
this.flatData.forEach(item => {
const visible = item.level === 1 || chainExpanded[item.level - 1]
if (!visible) {
chainExpanded[item.level] = false
return
}
result.push(item)
chainExpanded[item.level] = !!(this.hasChildren(item) && item.expanded)
})
return result
}
}
原理:扫描到任意节点时,chainExpanded[level-1] 里存着的正是它父节点的展开状态(因为父节点是最近遍历过的上一层节点)。父不可见或未展开,整棵子树直接跳过------这同时天然实现了「收起时子孙全部隐藏」。
expanded 是响应式属性,点击箭头翻转它,renderData 自动重算,虚拟列表自动重新切片。没有手动维护可见列表的地方,这是把展开状态做成响应式 computed 依赖的最大红利。
三、Vue 2 响应式:本系列最大的坑(出现三次)
坑一:先 observe,后加属性 → 界面纹丝不动
最初的写法:
js
created() {
this.treeData = datajson.data.userMonitoringList // ① 赋值,递归 observe 整棵树
this.walk(this.treeData) // ② walk 里给节点加 expanded/checked
}
① 完成时 Vue 已把整棵树走 defineReactive 转换,只转换赋值那一刻已存在的属性 。② 再加的 expanded 就是普通属性:赋值成功、不触发 setter、renderData 永远不重算。症状极具迷惑性------事件处理函数都执行了,console.log 值都对,就是界面不更新。
修复:调换顺序,先加工,后赋值。
js
created() {
const list = datajson.data.userMonitoringList // 未被 observe 的原始数据
const flat = []
this.cloneTree(list, flat) // 纯 JS 操作,所有字段先就位
this.flatData = flat // 一次性赋值 → observe 时看到完整属性集
this.treeData = list
}
坑二:上游传来的数据已经被 observe 过
组件化后,树数据来自父组件的 prop(且被 deep watch 着)。此时无论先赋值还是后赋值,节点对象早就带着 __ob__,直接在原对象上加字段必然复现坑一;而且改动原对象还会触发父组件的 deep watcher,形成更新回环。
修复:克隆树。 { ...item } 展开运算符重建全新对象(不可枚举的 __ob__ 不会被复制),子数组也一并重建,整棵新树在赋值给 flatData 时才首次被 observe,所有字段天然响应式,且完全不触碰上游数据。
三、勾选级联:向下传递,向上冒泡
树勾选有两半:
向下级联------勾父全选子孙,直接递归:
js
cascadeDown(nodes, val) {
nodes.forEach(child => {
child.checked = val
child.indeterminate = false
if (this.hasChildren(child)) this.cascadeDown(child.children, val)
})
}
向上冒泡 ------勾叶子后父节点要变成全选或半选。靠拍平时记录的 parentIndex 逐层上溯:
js
updateParentState(item) {
let idx = item.parentIndex
while (idx !== null && idx !== undefined) {
const parent = this.flatData[idx]
const children = parent.children || []
const allChecked = children.every(c => c.checked)
const someChecked = children.some(c => c.checked || c.indeterminate)
parent.checked = allChecked
parent.indeterminate = someChecked && !allChecked
idx = parent.parentIndex
}
}
还有个批量场景:setCheckedKeys 一次性设置全量勾选时,逐个冒泡太浪费。利用「先序遍历中子必在父之前」的性质,逆序扫一遍数组即可保证每个父节点被计算时其子孙已就绪:
js
recalcUpAll() {
for (let i = this.flatData.length - 1; i >= 0; i--) {
const node = this.flatData[i]
if (this.hasChildren(node)) { /* 同上的 all/some 计算 */ }
}
}
el-checkbox 的 :indeterminate 直接绑定即可呈现半选态,与 el-tree 视觉行为完全一致。
四、无缝替换 el-tree:API 对齐是关键设计
组件化(VirtualTree)时定了硬指标:父组件里所有 this.$refs.tree.xxx 调用一行不改。也就是说组件实例方法必须与 el-tree 同名同语义:
| el-tree API | VirtualTree 对齐实现 | 备注 |
|---|---|---|
setCheckedKeys(keys) |
全量重置 + 父选级联 + recalcUpAll |
静默,不触发 check 事件 |
getCheckedKeys() |
flatData.filter(n => n.checked) |
--- |
getHalfCheckedKeys() |
filter(n => n.indeterminate) |
--- |
getCheckedNodes(leafOnly, includeHalf) |
同名参数语义 | emitCheckedChange 原样工作 |
@check 事件 |
仅用户点击 checkbox 时 emit | 编程式设置静默,与 el-tree 一致 |
auto-expand-parent |
expandNodes(keys) 内沿 parentIndex 展开全部祖先 |
--- |
父组件(Sidebar)里 ref="tree" 名字都不用换,applyStatusCheck(在线/离线/现场/居家状态过滤勾选)、emitCheckedChange、checkedData 回写 watcher 全部原样运行。
反例教训 :替换后搜索选中时炸了------handleSelect 链路里 Sidebar 自己封装的 collapseAllNodes/expandAllNodes 用的是 this.$refs.tree.store._getAllNodes()、getNode(key),这些是 el-tree 的私有 API ,VirtualTree 上不存在,直接 Cannot read properties of undefined。修复方式是 Sidebar 的这三个封装方法改为委托 VirtualTree 的同名实例方法,顺带删掉了 $forceUpdate()(响应式属性不需要强制刷新)。
经验:替换一个组件,公开 API 对齐只完成一半,上层代码摸过的私有 API 同样是契约。
五、SSE 推流与搜索定位:两个增量场景
推流不丢状态 。options deep watch 触发重建时,按 nodeKey 快照旧树各节点的 expanded/checked,克隆新树后回填:
js
initData(list) {
const prevState = {}
this.flatData.forEach(n => {
prevState[this.keyOf(n)] = { expanded: n.expanded, checked: n.checked }
})
const flat = []
this.cloneTree(list || [], flat, 1, null)
flat.forEach(n => {
const prev = prevState[this.keyOf(n)]
if (prev) { n.expanded = prev.expanded; n.checked = prev.checked }
})
this.flatData = flat // 一次性赋值,全新对象首次 observe
this.recalcUpAll()
}
搜索定位是虚拟滚动的隐藏红利 。el-tree 时代搜索选中后,目标节点可能藏在滚动区外,用户得自己找。现在 scrollToKey 在展开祖先链之后直接把目标滚到视口中部:
js
scrollToKey(key) {
this.$nextTick(() => {
const idx = this.renderData.findIndex(n => this.keyOf(n) === key)
if (idx < 0) return
const container = this.$refs.vlist.$el
container.scrollTop = Math.max(0, idx * this.itemHeight - container.clientHeight / 2)
})
}
注意必须在 $nextTick 里做:expandNodes 之后 renderData 要等下一次重渲染,目标节点的索引才有效。
六、写在最后
这棵虚拟滚动树的全部复杂度集中在三件事:拍平后的先序遍历性质 (一次线性扫描完成可见性过滤、半选回算、状态快照)、Vue 2 响应式的时机纪律 (所有字段 observe 前就位)、与 el-tree 的契约对齐(公开 API + 被摸过的私有 API)。逻辑骨架验证过之后,最终组件只有四百行上下,却换来了侧边栏从「节点多了就卡」到「渲染数量恒定于视口行数」的质变。
如果你的场景行高不定、需要拖拽、或数据量到了十万级,方案要重新评估------不定高要做测量缓存,拖拽要重算 flatIndex,十万级要考虑分片加载。但「拍平 + 可见性过滤 + API 对齐」这三层思维,是所有树形虚拟化方案的公共底座。