# Vue2 树形虚拟滚动组件实战:从原型踩坑到无缝替换 el-tree

场景:坐席监控大屏的侧边栏需要渲染一棵「部门 → 台 → 班组 → 坐席」的组织树,数据通过 SSE 持续推流更新,节点数随业务增长。原生 el-tree 全量渲染 DOM,节点一多侧边栏明显卡顿,且与搜索、状态过滤、勾选联动深度耦合,不能一换了之。本文记录用「虚拟滚动 + 树」重构这棵树的完整过程:方案设计、三个 Vue 2 响应式大坑、展开动画的取舍,以及如何做到对上层组件零改动地替换 el-tree。

一、整体思路:把树拍平,让滚动只渲染可见切片

虚拟滚动的本质是用数组索引换 DOM 数量:只渲染视口内的行,上下用撑高的占位元素模拟完整滚动条。定高虚拟列表(每行固定 26px)的换算极其便宜:

ini 复制代码
startIndex = floor(scrollTop / itemHeight)
visible    = flatData.slice(startIndex, startIndex + viewportCount + buffer)

但虚拟列表只吃一维数组,而树是嵌套结构。所以核心转化是:

  1. 先序遍历(DFS)拍平整棵树为一维数组;
  2. 由「展开状态」动态过滤出当前可见节点;
  3. 虚拟列表只渲染可见节点的视口切片。

先序遍历有一个对后面所有逻辑都至关重要的性质:

数组中第 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 对齐」这三层思维,是所有树形虚拟化方案的公共底座。

相关推荐
vx_Biye_Design3 小时前
flask学生课程笔记共享系统29026-计算机课程设计、毕业设计
java·javascript·spring boot·后端·elasticsearch·flask·课程设计
OpsEye3 小时前
上线大模型只是第一步,用好 AI 离不开完整的成本管控
javascript·ai编程
Hilaku3 小时前
GraphQL 在国内为什么水土不服?
前端·javascript·程序员
夏幻灵4 小时前
前端八股:JavaScript 深拷贝详解:实现方式、递归原理与循环引用
开发语言·前端·javascript
suaizai_4 小时前
ComputerUse:让AI真正操控桌面系统
java·前端·javascript
FYKJ_20105 小时前
express绿叶横店短剧推荐与影评分享平台34219-计算机课程设计、毕业设计
java·javascript·vue.js·spring boot·后端·课程设计·express
weishuangyun15 小时前
小程序制作平台怎么选?三大平台对比!
大数据·开发语言·javascript
一颗小芹菜的日常5 小时前
vue-crack插件是干什么的
前端·javascript·vue.js
小比特-combat5 小时前
LVGL_1(使用示例讲解父子关系)
开发语言·前端·javascript