如何實現一個高性能可渲染大數據的Tree組件
- 2019 年 12 月 17 日
- 筆記
作者:jayzou
https://segmentfault.com/a/1190000021228976
背景
項目中需要渲染一個5000+節點的樹組件,但是在引入element Tree組件之後發現性能非常差,無論是滾動、展開/收起節點還是點擊節點卡頓都非常明顯,利用performance跑一下性能數據發現到問題所在

從上圖可以看到,除去Idle之外,總共花費的時間為12s,其中Scripting花了10s

從上圖可以看出,Scripting期間除了 Observe 之外,大部分時間都在調用createChildren來創建vue實例
優化思路
從上面的分析可以看出引發的性能問題都是因為渲染的節點過多導致,那麼要解決這個問題就是盡量減少節點的渲染,然而在業界中與之相類似的解決方案就是虛擬列表 虛擬列表的核心概念就是 根據滾動來控制可視區域渲染的列表 這樣一來,就能大幅度減少節點的渲染,提升性能
具體的步驟如下:
- 將遞歸結構的tree數據「拍平」,但是保留parent以及child的引用(一方面是為了方便查找子級和父級節點的引用,另一方面是為了方便計算可視區域的list數據)
- 動態計算滾動區域的高度(很多虛擬長列表的組件都是固定高度的,但是因為這裡是tree,需要摺疊/展開節點,所以是動態計算高度)
- 根據可見的高度以及滾動的距離渲染相應的節點
程式碼實現
最簡程式碼實現
<template> <div class="b-tree" @scroll="handleScroll"> <div class="b-tree__phantom" :style="{ height: contentHeight }"></div> <div class="b-tree__content" :style="{ transform: `translateY(${offset}px)` }" > <div v-for="(item, index) in visibleData" :key="item.id" class="b-tree__list-view" :style="{ paddingLeft: 18 * (item.level - 1) + 'px' }" > <i :class="item.expand ? 'b-tree__expand' : 'b-tree__close' " v-if="item.children && item.children.length" /> <slot :item="item" :index="index"></slot> </div> </div> </div> </template> <style> .b-tree { position: relative; height: 500px; overflow-y: scroll; } .b-tree__phantom { position: absolute; left: 0; top: 0; right: 0; z-index: -1; } .b-tree__content { position: absolute; left: 0; right: 0; top: 0; min-height: 100px; } .b-tree__list-view{ display: flex; align-items: center; cursor: pointer; } .b-tree__content__item { padding: 5px; box-sizing: border-box; display: flex; justify-content: space-between; position: relative; align-items: center; cursor: pointer; } .b-tree__content__item:hover, .b-tree__content__item__selected { background-color: #d7d7d7; } .b-tree__content__item__icon { position: absolute; left: 0; color: #c0c4cc; z-index: 10; } .b-tree__close{ display:inline-block; width:0; height:0; overflow:hidden; font-size:0; margin-right: 5px; border-width:5px; border-color:transparent transparent transparent #C0C4CC; border-style:dashed dashed dashed solid } .b-tree__expand{ display:inline-block; width:0; height:0; overflow:hidden; font-size:0; margin-right: 5px; border-width:5px; border-color:#C0C4CC transparent transparent transparent; border-style:solid dashed dashed dashed } </style> <script> export default { name: "bigTree", props: { tree: { type: Array, required: true, default: [] }, defaultExpand: { type: Boolean, required: false, default: false }, option: { // 配置對象 type: Object, required: true, default: {} } }, data() { return { offset: 0, // translateY偏移量 visibleData: [] }; }, computed: { contentHeight() { return ( (this.flattenTree || []).filter(item => item.visible).length * this.option.itemHeight + "px" ); }, flattenTree() { const flatten = function( list, childKey = "children", level = 1, parent = null, defaultExpand = true ) { let arr = []; list.forEach(item => { item.level = level; if (item.expand === undefined) { item.expand = defaultExpand; } if (item.visible === undefined) { item.visible = true; } if (!parent.visible || !parent.expand) { item.visible = false; } item.parent = parent; arr.push(item); if (item[childKey]) { arr.push( ...flatten( item[childKey], childKey, level + 1, item, defaultExpand ) ); } }); return arr; }; return flatten(this.tree, "children", 1, { level: 0, visible: true, expand: true, children: this.tree }); } }, mounted() { this.updateVisibleData(); }, methods: { handleScroll(e) { const scrollTop = e.target.scrollTop this.updateVisibleData(scrollTop) }, updateVisibleData(scrollTop = 0) { const start = Math.floor(scrollTop / this.option.itemHeight); const end = start + this.option.visibleCount; const allVisibleData = (this.flattenTree || []).filter( item => item.visible ); this.visibleData = allVisibleData.slice(start, end); this.offset = start * this.option.itemHeight; } } }; </script>
細節如下:
- 整個容器使用相對定位是為了避免在滾動中引起頁面迴流
- phantom 容器為了撐開高度,讓滾動條出現
- flattenTree 為了拍平 遞歸結構的tree數據,同時添加level、expand、visibel屬性,分別代表節點層級、是否展開、是否可視
- contentHeight 動態計算容器的高度,隱藏(收起)節點不應該計算在總高度裡面
這樣一來渲染大數據的tree組件就有了基本的雛形,接下來看看節點展開/收起如何實現
節點展開收起
在flattenTree中保留了針對子級的引用,展開/收起的話,只需要對子級進行顯示/隱藏即可
{ methods: { //展開節點 expand(item) { item.expand = true; this.recursionVisible(item.children, true); }, //摺疊節點 collapse(item) { item.expand = false; this.recursionVisible(item.children, false); }, //遞歸節點 recursionVisible(children, status) { children.forEach(node => { node.visible = status; if (node.children) { this.recursionVisible(node.children, status); } }) } }
結論
對比下優化前和優化後的一些性能數據
element tree組件
初次渲染(全收起)

scripting: 11525ms rendering: 2041ms 註:全展開直接卡死

scripting: 84ms rendering: 683ms
優化後的tree組件
首次渲染(全展開)

scripting: 1671ms 對比優化前性能提升 6.8倍 rendering: 31ms 對比優化前性能提升 65倍
節點展開

scripting: 86ms 優化前性能一致 rendering: 6ms 對比優化前性能提升 113倍
big-tree組件
最終封裝成 vue-big-tree 組件供調用,歡迎star~~~