invent.gif

问题

  1. 当数据过多时,渲染出现卡顿。因为 dom 数量过大。

实现方案

    1. 将树形数据和 dom 扁平化
    1. 虚拟长列表控制 dom 渲染数量

1.扁平化

扁平化tree的DOM结构图

扁平化tree的DOM结构图-1
由上图可以看出经过扁平化处理后的父子节点dom是同级的,接下来我们来实现将tree数据扁平化处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
	const treeData = [
  {
    id: 1,
    title: "课程1",
    children: [
      { id4title"课程1-1" },
      {
        id: 5,
        title: "课程1-2",
       children: [
          { id6title"课程1-2-1" },
          { id7title"课程1-2-2" },
        ],
      },
    ],
  },
  { id2title"课程2" },
  { id3title"课程3" },
];
const flatData = [
{ id: 1, title: "课程1" },
{ id: 4, title: "课程1-1" },
{ id: 5, title: "课程1-2" },
{ id: 6, title: "课程1-2-1" },
{ id: 7, title: "课程1-2-2" },
{ id: 2, title: "课程2" },
{ id: 3, title: "课程3" },
]

将 treeData 的数据格式转变成 faltData,将数据扁平化的过程

迭代和递归实现扁平化

我们先来讲讲递归迭代的异同。

  • 递归使用的是选择结构,能使程序的结构更清晰、更简洁、更容易让人理解,从而减少读懂代码的时间。但是大量的递归调用会建立函数的副本,会耗费大量的时间和内存。
  • 迭代使用的是循环结构,不需要反复调用函数和占用额外的内存。因此我们应该视不同 情况选择不同的代码实现方式。
    在算法中我们会遇到很多递归实现的案例,所有的递归都可以转换成非递归实现,其中转换的本质是:递归是解析器(引擎)来帮我们做了栈的存取,非递归是手动创建栈来模拟栈的存取过程
    下面我们通过来实现
1
2
3
4
5
6
7
8
9
10
11
12
function faltten(tree) {
let flatData = []
let stack = [...tree]
while (stack.length) {
let node = stack.shift()
if (node.children) {
stack.unshift(...node.children)
}
flatData.push({ id: node.id, title: node.title })
}
return flatData
}

下面我们通过递归来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
function faltten(tree) {
let flatData = []
for (var i = 0; i < data.length; i++) {
formatData.push({
id: data[i].id,
title: data[i].title,
})
if (data[i].children) {
formatData = formatData.concat(faltten(data[i].children))
}
}
return formatData
}

为了实现扁平化的树型组件功能,我们的数据字段需要 expandvisiblechildrenlevel 字段。大致结构:

{
  key:
  title:
  level: 通过 level 标识节点层级,通过 css 实现层级的区别
  visible: 节点的显示隐藏属性
  expand: 展开状态
  children: 存储子节点的引用,通过引用控制子节点的显示隐藏
}

通过上面的分析,我们来继续实现新增字段之后的扁平化处理

迭代实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
flatten () {
let flatData = []
let stack = [...tree]
let parentIndex = {} // 存储level的索引
while (stack.length) {
let node = stack.shift()
if (!node.level) {
node.level = 0
node.visible = true
}
if (node.children) {
parentIndex[node.level] = flatData.length // node的索引等于flatData的长度,因为接下来push的就是node
const children = node.children.map(item => { // 设置子类的level
return {...item, level: node.level + 1, visible: node.expand}}
})
stack.unshift(...children)
}
flatData.push({...node, children: []})
if (node.level !== 0) { // 添加子类引用(只要level不是第一层,node肯定有父节点)
flatData[parentIndex[node.level - 1]].children.push(flatData[flatData.length - 1]) // 往当前的node的父节点的children属性添加本身
}
}
return flatData
}

递归实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
flatten (data, arr = [], parent = null, level = 0, visible = true, children = [], insert = null) {
arr.push({...data, level, parent, visible, children})
if (insert !== null) {
arr[insert].children.push(arr[arr.length - 1]) // 给父节点的children字段添加子节点引用
}
if (data.children) {
insert = arr.length - 1 // 父节点插入子节点引用索引
data.children.forEach((item) => {
this.flatten(item, arr, arr[arr.length - 1], level + 1, data.expand, [], insert)
})
}
return arr
}

难点在于存储子节点的 data 引用,存储子节点的引用是因为子节点对象和 children 对象指向的是同一个内存地址,所以后续隐藏节点的时候,我们可以直接通过获取当前点击节点的 children 字段,进行 visible 属性的隐藏设置。

扁平化数据的树型渲染

1.树型节点的遍历

1
2
3
4
5
6
7
8
<div
:key="i"
class="tree-item"
v-show="item.visible"
@click="handleExpand(item)"
v-for="(item, i) in unHiddenList.slice(startIndex, endIndex)"
:style="`transform:translateX(${item.level * 20}px)`"
>

节点的层级区别通过level属性来控制

unHiddenList代表visible属性为false (即未展开) 的数据列表

2.节点的展开和隐藏实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
handleExpand (node) { // 点击节点操作
const status = !node.expand
node.expand = status
if (status && node.children.length > 0) {
node.children.forEach((item) => { // 将点击节点的子节点显示
item.visible = true
})
} else if (!status) {
this.handleClose(node.children) // 隐藏点击节点的子孙节点
}
},
handleClose (node) { // 隐藏节点
node.forEach((item) => {
item.visible = false
if (item.children.length > 0) {
item.expand = false
this.handleClose(item.children)
}
})
}

虚拟滚动

dom大致结构

1
2
3
4
5
6
<div class="wrap" ref="wrap" @scroll="handleScroll">
<div class="invent-space" :style="`height:${contentHeight}`"></div><!-- 虚拟滚动高度 -->
<div class="container" ref="list">
<!-- 节点遍历 -->
</div>
</div>

contentHeight代表整个滚动的内容高度
*contentHeight = unHiddenList.length * itemHeight(节点高度)*

监听滚动事件

1
2
3
4
5
6
7
8
9
updateVisibleData (scrollTop = 0) {
requestAnimationFrame(() => {
if (scrollTop < (this.unHiddenList.length - 20) * this.itemHeight) { // 小于可滚动距离(unHiddenList未隐藏的节点列表)
this.$refs.list.style.transform = 'translate3d(0,' + scrollTop + 'px,0)' // 同步滚动
this.startIndex = Math.floor(scrollTop / this.itemHeight) // 已滚动的节点数量
this.endIndex = this.startIndex + 20 // 已滚动的数量加可视区域的数量
}
})
}

其中的20代表的是可视item,25代表的是item的高度,数字可以根据需求自己设置。
其中使用transform来实现虚拟滚动的原因是由于 transform 是位于 Composite Layers 层,而 widthleftmargin 等则是位于 Layout 层,在 Layout 层发生的改变必定导致 Paint Setup and Paint -> Composite Layers ,所以相对而言使用 transform 实现的动画效果肯定比 left 这些更加流畅。