Vue树组件性能优化

写树组件的思路

一听说要写树组件,像我这样的菜鸡肯定都是想到,就按照树形的结构,递归来组织组件。

这个思路本身是没问题的,我之前就按照这个思路写了一版,结局嘛…如果是 happy ending 的话就不会有这篇文了。

递归组件的缺陷

先说说递归写法的优点:

  1. 结构直白,数据什么样,组件就什么样,傻瓜式组织。
  2. 父子节点相关样式控制方便,展开折叠什么的,一个 v-show 就能搞定。

然后是缺陷:

  1. 结构太直白,导致 DOM 层级深,数据层级有多深, DOM 就有多深。
  2. 父子组件通信不便,需要用 event bus , vuex 或 provide 之类的方式进行深层的通信。
  3. 数据遍历困难,如果没有预先处理,需要经常一层层往下处理数据,导致代码跟💩一样。
  4. 最重要的就是性能问题,这种类似列表的数据,量一多,渲染方面肯定先撑不住, DOM 数量多、层级深,会导致浏览器卡顿,递归遍历数据也可能导致 UI 阻塞(虽然实际上并不会, V8 很强的。

需要优化的点

根据列出的缺陷,可以简单得出一些优化关键点:

  1. 优化深层级 DOM 结构
  2. 优化子组件结构,方便父子通信
  3. 优化数据结构,方便数据遍历与节点获取

优化思路

让我们一点一点来看。

  1. 首先是 DOM 结构。 DOM 是由 Vue 组件挂载出来的,因此考虑优化 Vue 组件。
  2. 子组件由于是递归组件,才生成了深层级的 DOM 结构,而 Vue 组件是根据数据来渲染的,因此我们要在根源,也就是数据上做手脚。
  3. 树数据,一般就是树形的一个结构,既然树形结构会导致一个递归的渲染,那我们可以把这个结构扁平化,这样,子组件就组成了一个普通的列表。

把子组件变成列表,还有一个问题,就是 DOM 数量多的问题。平时我们写列表,如果是表格,一般都有分页,如果是选择下拉之类的列表,一般数量不多,几十个列表项,甚至几百个,我们可能都不会主动去优化,对扁平化后的树来说也是一样的。

不过既然已经优化了,那就优化得彻底一点,毕竟内存占用也是一个重要的优化点,优化过后可以轻松应对几百上千条、甚至上万条数据,岂不美哉?

优化步骤

数据扁平化

组件使用者传进来的数据一般是一个树形结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const treeData = [
{
title: 'parent-1',
children: [
{
title: 'children-1',
children: [
{
title: 'grandchildren-1',
},
],
},
],
}
]

为了达到渲染一个列表的效果,需要把子节点提出来,与父节点同级,也就是把 children 字段往外提:

1
2
3
4
5
6
7
8
9
10
11
12
13
const flattenedData = [
{
title: 'parent-1',
children: [ /* ... */ ],
},
{
title: 'children-1',
children: [ /* ... */ ],
},
{
title: 'grandchildren-1',
},
]

为了保留住节点之间的层级关系,可以在拍平过程中给每个节点额外加一个 level 字段,比如根节点的 level 为 0 ,往深层依次递增。

递归组件转换为列表结构

使用递归组件一般会用 ul 标签或者对子节点整体设置一个缩进来表示节点之间的层级关系,在数据扁平化后,我们可以根据上面设置的 level 字段,去控制每个节点的缩进。

原来的递归组件结构:

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
<!-- Tree.vue -->
<template>
<div>
<TreeNode
v-for="node in treeData"
:key="node.key"
/>
</div>
</template>

<!-- TreeNode.vue -->
<template>
<div>
<div>{{ title }}</div>
<div
v-if="children"
style="margin-left: 20px;"
>
<TreeNode
v-for="node in children"
:key="node.key"
/>
</div>
</div>
</template>

改为普通的列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- Tree.vue -->
<template>
<div>
<TreeNode
v-for="node in treeData"
:key="node.key"
:style="{
marginLeft: `${node.level * 20}px`
}"
/>
</div>
</template>

<!-- TreeNode.vue -->
<template>
<div>
<div>{{ title }}</div>
</div>
</template>

子节点显隐

组件改为列表结构后,新的问题出现了,怎么控制子节点的显隐。

在递归结构中,只要在 TreeNode 组件中控制子组件的容器 div 显隐即可。

在列表中,每个节点都是同级的,处理起来会稍微麻烦一些。

我们可以在每个节点上添加一个 visible 属性,在执行展开折叠操作时,只要控制父节点下的所有子节点,以及子节点的子节点他们的 visible ,即可实现显隐控制。

思路1 递归

还记得扁平化数据时把子节点提出来的操作吗,那一步提出来的数据实际是一个引用,每个节点本身是不变的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const flattenedData = [
{
title: 'parent-1',
children: [
{ // 这个节点跟外层提出去的节点实际上是同一个对象
title: 'children-1',
children: [ /* ... */ ],
}
],
},
{
title: 'children-1',
children: [ /* ... */ ],
},
/* ... */
]

也就是说,我们只要访问 parentNode.children ,还是能得到这个父节点下的所有子节点。那么,需要控制显隐时,递归遍历父节点以下的所有子节点以及子节点的子节点,分别设置他们的 visible 属性即可。

思路2 对扁平化后的数据做手脚

扁平化后的数据是有个 level 字段去表示他们的层级关系的,那么根据这个字段,我们不需要递归其实也可以获取到某个节点的所有子节点,包括子节点的子节点。

扁平化后的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const flattenedData = [
{
title: 'parent-1',
level: 0,
children: [ /* ... */ ],
},
{
title: 'children-1',
level: 1,
children: [ /* ... */ ],
},
{
title: 'grandchildren-1',
level: 2,
},
{
title: 'parent-2',
level: 0,
},
]

假设我们需要获取 parent-1 节点的所有子孙节点, 那么,只要从这个节点开始,在 flattenedData 中往下遍历,直到 level 大于等于 parent-1 ,或者到了数组末尾这段数据,均为其子孙节点。

但是这个方法需要先获取节点在 flattenedData 中的位置。

在性能影响不大的前提下,我还是偏向递归的(真香

控制 DOM 数量

之前有跟做 Android 的小伙伴讨论过,手机上的长列表也是需要优化的。在这点上,优化的思路是一致的,都是只渲染视野内可见的几条列表项,然后在滚动的时候不断回收、创建视野以外的列表项,达成优化的效果。

不过与浏览器不同的是, Android 应用在渲染之前,系统就会计算出每个列表项的高度,而浏览器是在渲染完成之后,才有办法去获取一个元素的实际高度,这导致在浏览器上优化列表会稍微麻烦一些。

简单思路

这里采用最简单的思路来优化:

  1. 我们假设每个节点高度都是相同的
  2. 节点容器的高度就是 (节点高度 * 节点总个数)
  3. 确定可见 div 的高度,监听其 scroll 事件,根据 scrollTop 计算当前可见区域对应 flattenedData 哪一部分。

思路相对简单,但是缺点其实也比较明显,就是对于节点高度的限制是比较死的,如果节点高度都不相等,或者与传入的节点高度 Prop 相差过大,就可能出现滚动条拉不下去,最后几条数据无法渲染,或者末尾出现空白的情况,因为节点容器的高度:(节点高度 * 节点总个数) 是限死的。

其他办法

其他思路总是有的,比如 Akryum 大佬的 vue-virtual-scroller ,利用 translate 与创建视图组件池(?creates pools of views)的方式,即能不限制节点的高度,又可以不用重复销毁、创建 Vue 组件。

其他优化点

还有一个要提到的地方是, Vue 会把需要监听的数据都遍历一遍,然后加上 gettersetter ,这样在数据量大的情况下,会导致开销变大。

为了避免 Vue 去监听大量的数据,我们可以提供一个方法 setData ,而不是 Prop ,来接收树数据,在方法里去初始化树数据。