Vue树组件性能优化
写树组件的思路
一听说要写树组件,像我这样的菜鸡肯定都是想到,就按照树形的结构,递归来组织组件。
这个思路本身是没问题的,我之前就按照这个思路写了一版,结局嘛…如果是 happy ending 的话就不会有这篇文了。
递归组件的缺陷
先说说递归写法的优点:
- 结构直白,数据什么样,组件就什么样,傻瓜式组织。
- 父子节点相关样式控制方便,展开折叠什么的,一个 v-show 就能搞定。
然后是缺陷:
- 结构太直白,导致 DOM 层级深,数据层级有多深, DOM 就有多深。
- 父子组件通信不便,需要用 event bus , vuex 或 provide 之类的方式进行深层的通信。
- 数据遍历困难,如果没有预先处理,需要经常一层层往下处理数据,导致代码跟💩一样。
- 最重要的就是性能问题,这种类似列表的数据,量一多,渲染方面肯定先撑不住, DOM 数量多、层级深,会导致浏览器卡顿,递归遍历数据也可能导致 UI 阻塞(虽然实际上并不会, V8 很强的。
需要优化的点
根据列出的缺陷,可以简单得出一些优化关键点:
- 优化深层级 DOM 结构
- 优化子组件结构,方便父子通信
- 优化数据结构,方便数据遍历与节点获取
优化思路
让我们一点一点来看。
- 首先是 DOM 结构。 DOM 是由 Vue 组件挂载出来的,因此考虑优化 Vue 组件。
- 子组件由于是递归组件,才生成了深层级的 DOM 结构,而 Vue 组件是根据数据来渲染的,因此我们要在根源,也就是数据上做手脚。
- 树数据,一般就是树形的一个结构,既然树形结构会导致一个递归的渲染,那我们可以把这个结构扁平化,这样,子组件就组成了一个普通的列表。
把子组件变成列表,还有一个问题,就是 DOM 数量多的问题。平时我们写列表,如果是表格,一般都有分页,如果是选择下拉之类的列表,一般数量不多,几十个列表项,甚至几百个,我们可能都不会主动去优化,对扁平化后的树来说也是一样的。
不过既然已经优化了,那就优化得彻底一点,毕竟内存占用也是一个重要的优化点,优化过后可以轻松应对几百上千条、甚至上万条数据,岂不美哉?
优化步骤
数据扁平化
组件使用者传进来的数据一般是一个树形结构:
1 | const treeData = [ |
为了达到渲染一个列表的效果,需要把子节点提出来,与父节点同级,也就是把 children
字段往外提:
1 | const flattenedData = [ |
为了保留住节点之间的层级关系,可以在拍平过程中给每个节点额外加一个 level
字段,比如根节点的 level 为 0 ,往深层依次递增。
递归组件转换为列表结构
使用递归组件一般会用 ul
标签或者对子节点整体设置一个缩进来表示节点之间的层级关系,在数据扁平化后,我们可以根据上面设置的 level
字段,去控制每个节点的缩进。
原来的递归组件结构:
1 | <!-- Tree.vue --> |
改为普通的列表:
1 | <!-- Tree.vue --> |
子节点显隐
组件改为列表结构后,新的问题出现了,怎么控制子节点的显隐。
在递归结构中,只要在 TreeNode
组件中控制子组件的容器 div 显隐即可。
在列表中,每个节点都是同级的,处理起来会稍微麻烦一些。
我们可以在每个节点上添加一个 visible
属性,在执行展开折叠操作时,只要控制父节点下的所有子节点,以及子节点的子节点他们的 visible
,即可实现显隐控制。
思路1 递归
还记得扁平化数据时把子节点提出来的操作吗,那一步提出来的数据实际是一个引用,每个节点本身是不变的:
1 | const flattenedData = [ |
也就是说,我们只要访问 parentNode.children
,还是能得到这个父节点下的所有子节点。那么,需要控制显隐时,递归遍历父节点以下的所有子节点以及子节点的子节点,分别设置他们的 visible
属性即可。
思路2 对扁平化后的数据做手脚
扁平化后的数据是有个 level
字段去表示他们的层级关系的,那么根据这个字段,我们不需要递归其实也可以获取到某个节点的所有子节点,包括子节点的子节点。
扁平化后的数据:
1 | const flattenedData = [ |
假设我们需要获取 parent-1
节点的所有子孙节点, 那么,只要从这个节点开始,在 flattenedData
中往下遍历,直到 level
大于等于 parent-1
,或者到了数组末尾这段数据,均为其子孙节点。
但是这个方法需要先获取节点在 flattenedData
中的位置。
在性能影响不大的前提下,我还是偏向递归的(真香
控制 DOM 数量
之前有跟做 Android 的小伙伴讨论过,手机上的长列表也是需要优化的。在这点上,优化的思路是一致的,都是只渲染视野内可见的几条列表项,然后在滚动的时候不断回收、创建视野以外的列表项,达成优化的效果。
不过与浏览器不同的是, Android 应用在渲染之前,系统就会计算出每个列表项的高度,而浏览器是在渲染完成之后,才有办法去获取一个元素的实际高度,这导致在浏览器上优化列表会稍微麻烦一些。
简单思路
这里采用最简单的思路来优化:
- 我们假设每个节点高度都是相同的
- 节点容器的高度就是
(节点高度 * 节点总个数)
- 确定可见 div 的高度,监听其
scroll
事件,根据scrollTop
计算当前可见区域对应flattenedData
哪一部分。
思路相对简单,但是缺点其实也比较明显,就是对于节点高度的限制是比较死的,如果节点高度都不相等,或者与传入的节点高度 Prop 相差过大,就可能出现滚动条拉不下去,最后几条数据无法渲染,或者末尾出现空白的情况,因为节点容器的高度:(节点高度 * 节点总个数)
是限死的。
其他办法
其他思路总是有的,比如 Akryum 大佬的 vue-virtual-scroller ,利用 translate
与创建视图组件池(?creates pools of views)的方式,即能不限制节点的高度,又可以不用重复销毁、创建 Vue 组件。
其他优化点
还有一个要提到的地方是, Vue 会把需要监听的数据都遍历一遍,然后加上 getter
跟 setter
,这样在数据量大的情况下,会导致开销变大。
为了避免 Vue 去监听大量的数据,我们可以提供一个方法 setData
,而不是 Prop ,来接收树数据,在方法里去初始化树数据。