某Vue应用页面性能优化实录
前言
此文原为对公司内部某个 Vue2应用存在的严重性能问题,进行优化后笔者进行的内部总结文档。个人觉得其中一些分析思路和方法还是值得回味的,且暴露的一些引发性能问题的做法也值得吸取经验,故而现在进行对其中一些数据进行脱敏后进行发布。
某编辑页性能问题表现

- 当
tab菜单切换时会出现肉眼可见的卡顿 - 浏览器页面内存随着切换次数的增长而增长(经测试,最高可占用
3~4G的内存),直至页面崩溃
内存泄漏分析
很明显,这种页面内存增长方式透露出这些 tab页面存在着内存泄漏的情况,即一些内存没有被及时回收;通过 Chrome浏览器自带的 Performance monitor工具即可清楚的看到泄漏情况:

而且通过上述工具监测得到的数据可以判断内存泄漏极有可能是由 DOM泄漏造成,即一些 DOM节点虽然从页面中移除了,但是还是存在被引用的情况;
定位内存泄漏的地方
借助 Chrome浏览器自带的 Memory工具可以很方便地分析内存变化,自然也可以分析内存泄漏产生的原因;该工具主要提供了3种内存分析的模式,这里主要用到 Heap snapshot这种模式,即内存快照,将页面当前的内存使用情况记录起来;
分析页面内存泄漏的一般操作路径[1]是:
-
进入可能发生内存泄漏的页面;
-
然后跳转到另一个页面;
-
再回退至上一个页面;
-
手动进行一次垃圾回收;

-
然后记录页面的内存快照进行分析;
拿到内存快照后,可以在面板中搜索关键字“detached”就可以快速得到目前内存中保留的已分离的 DOM节点,即页面中渲染已经用不到但还保留在内存中的 DOM节点,这些节点就是 DOM泄漏;

点开其中某组节点的第一个元素(我通常的做法是将节点按 Retained Size降序排列,然后再选择),然后下面就会显示所有引用该节点的内存节点:

考虑到页面本身是基于 Vue构建的,所以查看 VueComponent构建函数节点会更加方便一点,因为 VueComponent实例不仅保留了 DOM节点信息,还有父级和子级节点信息,所以通过实例树可以方便的找到泄漏的最顶级 VueComponent;
在网上看到一些关于 Memory工具使用教程,其中可以看到之前该工具可以直接悬停节点上查看该节点的信息,不过现在这个功能已经没了(搞不懂这么方便的功能为啥取消了);好在还有一种不太优雅的方式可以达到同样的目的,在节点上右击选择“store as global variable”即可将节点信息存到一个临时变量上,然后就能在控制台查看了:


通过打印的 VueComponent实例信息就可以筛选出泄漏的最大范围组件是哪一个了,具体做法就是不断地查看其父级组件实例($parent属性)然后确认该实例的DOM节点($el属性)是否还存在于当前页面中(直接鼠标 hover该属性,如果 DOM节点存在于页面中,则会显示一个浅蓝色的框,代表选中),找到的第一个还存在于当前页面中的 DOM节点,其子节点就是泄漏的最大层级的 VueComponent实例了;

经查,这个泄漏的最大层级的 VueComponent实例恰好是当前的路由组件,也就是说整个路由组件实例都被泄漏出去了,因此总体泄漏的 DOM数量非常可观,占用内存自然就很高了;
光知道泄漏 Vue组件实例还不够,还需要定位到具体是哪个代码引用了 Vue组件实例(及其子组件实例),不过至少可以确定的是泄漏就发生在这个路由里面所用到的代码;

说实话,当我在 Vue官方文档中看到这么一句的时候,我还在想为啥官方可以这么肯定其自身不会发生内存泄漏呢?后面证实我还是太 naive了,官方确实有自信的资本,往这个方向找最后确实证实是某个第三方库使用导致了内存泄漏:

像这种使用 DOM节点来初始化实例的第三方库,如果在 Vue组件生命周期结束前不及时销毁其实例,确实很容易导致 DOM泄漏;
内存泄漏优化后
对造成 DOM泄漏的地方进行优化后,再次打开 Performance monitor工具进行监测可以得到:

可以很明显的看到不同切换页面时,DOM节点数和占用内存都很稳定,而不是之前的直线上升。
卡顿原因分析
原本以为页面切换卡顿也是由于内存泄漏造成的,但实际上解决了内存泄漏后卡顿现象还是存在;所以此时可以断定,js代码中必然存在着阻塞主线程的 long task,使用性能分析工具一查看,果不其然,切换页面的过程中掉帧现象明显,且存在着大量 long task,甚至出现了超过 3s的 long task;

通过对这些 lont task的调用树(call tree)分析可知,引起这些 long task主要来源有两类:
- 数据深拷贝;
Vue视图更新;
深拷贝

由某个 long task的 call tree可知,某个名为 currentLogs的 getter执行时长达 3.5s,引发了超长的主线程阻塞;联系到 Vue生态,getter一般存在于 Vuex或者 computed这类响应式计算数据中,然后再进一步查看代码,果然是 Vuex中的名为 currentLogs的 getter使用了深拷贝逻辑,对这个深拷贝逻辑进行计时可知:

由于这个深拷贝算法采用了递归算法,因此在遇到结构层级较深的数据结构时会有很高的调用执行栈开销,因而耗时较多;在对没有内置对象的数据结构进行深拷贝时,显然 JSON.parse(JSON.stringify(xxx))是不错的选择[2];在使用序列化和反序列化进行深拷贝后,该任务的耗时明显降低:

Vue 视图更新
在这些 long task中还有很多 1~2s的任务,经分析大致上都是页面请求 API后更新数据所导致的视图更新(从函数名即可看出是 Vue里面的异步更新队列中的任务);

然后进一步查看可知,时间主要消耗在组件更新的 patch过程:

这说明页面的 DOM数量过多或者结构嵌套较深,否则 patch过程不会花费那么久;通过进一步分析 patch内部函数的消耗时间组成可以发现,其耗时时间主要来自几大类逻辑:
-
vnode节点的创建、移除和对比;
-
element-ui的input组件所引发的高度计算;
-
element-ui的table组件所引发的滚动高度和宽度计算;
-
vue响应式数据数据劫持初始化;
vnode节点的时间消耗
说实话,当看到 vnode节点的操作会产生如此多的时间消耗时,确实有点难以理解,毕竟页面的数据量远没达到上百上千条那么多(事实上,每页就 10条,再考虑到数据的多维性,将其展开大致就相当于 20~30条的数量),怎么会产生那么多 vnode节点呢?
由于 Vue是基于数据驱动的,一般是数据变动产生视图变动,再加上其内部的异步更新队列机制,可以借助 $nextTick方法来得到数据更新的一瞬到视图更新完成时大致过了多久:


经测试,这个过程耗时大致在 400ms ~ 500ms之间;也就是说由该数据更新所导致的一系列视图更新花了这么多的时间,远远超出了相邻两个队列的正常时间差(16ms),这说明这里的视图更新确实产生了大量的计算;熟悉 Vue响应式原理应该都清楚,这本质上是由于数据源这个发布者所携带的订阅者(或者其订阅者的订阅者……,简言之就是依赖链路)数量过多,因而涉及的订阅通知执行时间就飙升了;所以顺着这个思路可以查看该数据源的订阅者,说不定就能发现一些端倪,打印该响应式数据则可以看到,其订阅者数量有 80个:

然后,打开其中第一个订阅者(Watcher对象),惊喜就出现了:

没错,这个组件竟然依赖了高达 1072个 Observer对象(数据源),顺便也解答了另一个疑惑:

为啥单个调用消耗最长(应该是累计?)时间的函数会是 addDep,因为这个函数就是(订阅者)专门用来添加其依赖的 Dep对象的;所以看到前面那个拥有 1072个 Dep对象的组件时就不足为奇了,也难怪不到百条的数据就可以造成如此多的视图更新消耗,因为这几乎等同于将页面所有的 Vue组件全部写在一个 Vue文件里面(可以思考为啥这样做会产生 Vue的性能问题);
潜在解决方案:
- 分批加载数据,分批渲染,类似于时间切片,将一个
long task切分成多个更小的task;(治标不治本) - 重新组织组件结构,避免让大量
Vue组件全部挂载到同一个父组件上;(重构)
input组件所引发的高度计算
根据性能工具分析结果可以看到一个名为 resizeTextarea的方法消耗了较多的执行时间,其源码位置正好是 element的 input组件,进一步打开源码可以发现,该方法主要是给 textarea类型的输入框进行高度计算的,而其中的核心逻辑在一个名为 calcTextareaHeight.js的文件里:

看到上述代码就不难理解为何这个高度计算的逻辑会如此耗时了,因为 window.getComputedStyle()方法会引发页面重排,重排本身就很耗时的,所以整个计算过程当然也就很耗时;其实如果只是页面中只有一两个 textarea类型的 input组件也许不会这么明显,但是要命的是每一行数据就会渲染 2个这样的组件,联系到上文可以得知页面中最多可以存在 20~30个这样的组件,所以叠加的性能问题也就自然很明显了;
潜在解决方案:
- 替换
el-input的textare组件:基于原生textare编写组件,避免重排计算; - 基于
contenteditable属性编写组件; - 去
element-ui提PR(不太可取,因为使用场景过于极端、偏少);
好了,问题来了,那么 element-ui为啥要不惜使用重排来计算 textarea的高度呢?为何不直接使用原生 textarea元素的高度表现呢?答案就在于其组件所支持的 autosize属性:

原生的 textarea元素只有一个 rows属性用于根据行数设置高度[3],然而并不支持 minRows和 maxRows这种看起来像是弹性高度的属性,所以要支持这种特性当然只能靠实时的计算给出 min-height、max-height和 height的对比关系,最终设置实际的高度;
table组件所引发的滚动高度和宽度计算
定位到 table组件的源码可知,触发宽度计算的时机是表格数据发生变动时,而计算的逻辑大致是根据每个列配置的(最小)宽度或者自由分配来计算最终展示时表格每列的实际宽度及表格总体的最小宽度等;由于这里面涉及到修改 DOM节点的 width等宽度属性,所以就不可避免地触发重排了;
为啥表格重排会引发这么多的时间呢?其实背后是由于表格某一列所渲染的内容实在是过于繁多,导致重排的开销直线上升:

既然本身就要超过一行显示省略号,何不对文本本身就进行截取以降低实际 DOM渲染消耗(主要是字符数量过多,场景很极端);猜测像 text-overflow/white-space这种样式属性本身还是会对实际文本数量比较敏感,字符过多会造成计算样式的时间增长【待验证】;


对显示字符做一定数量的截取后(不影响原有功能):

同样地,那么 element-ui为何要计算表格的列宽度呢?答案还是由于其组件要支持(表格列)弹性宽度表现,所以自然要计算最终每列实际的宽度表现:

vue响应式数据数据劫持初始化
vue自带的响应式数据机制确实使用很方便,以至于我们无需特别地对数据进行处理,仅需将数据放置到特定的位置即可(这一点 Vue3的组合式 API已经不赞成这样了,而是提供一系列的函数模块来手动初始化响应式数据);事实上任何方便的背后都有一定的代价,而默认的响应式数据支持则带来了一定程度的数据劫持及监听的消耗,由于 vue组件实例的 data数据及计算属性的值都是进行了深层次的数据劫持(即对对象属性及其子属性都进行劫持),因而在遇到结构层级较多且数量较多的数据时,这种劫持方式显然会带来很大的时间及内存开销;比如:

可以看出,响应式数据中,每个对象本身会挂载一个 Observer实例,而对于每个属性则会挂载 getter实例和 setter实例,且不说这样的内存消耗,试想一下初始化如此多的对象实例是不是也挺费时间的;
这背后实际上涉及到的问题就是:是不是所有的数据都得是响应式数据?答案当然是“不是”,比如:
- 数据仅做展示相关的用途,即完全不必要担心数据被
set;这种情况无非最多只是需要对最高层级的数据进行更新的时候进行响应式的视图更新,即这个数据被整体更新的时候,可以理解为“浅层”的响应式数据; - 数据完全就是常量,不会改变也不希望在运行时进行改变,那就自然无需挂载到任何的
Vue组件实例上了;
虽然 vue2.x本身没有提供只对数据进行浅层响应式处理的 API,但是其源码暴露了一个办法[4]:那就是利用 Object.freeze()方法将对象的属性给“冻结”,然后 vue就不会对这些属性进行数据劫持操作了;可以对页面中用到的一个应该是浅层响应式数据来做实验:



可以看到,对于同样的数据做了冻结处理之后,所消耗的视图更新时间大幅降低;
对症下药
| 问题 | (潜在)解决方法 |
|---|---|
vnode节点的时间消耗 |
1. 时间切片/任务切片;<br> 2. 重新组织组件结构; |
input组件所引发的高度计算 |
1. 替换 textarea组件,避免重排;<br> 2. 提 PR; |
table组件所引发的滚动高度和宽度计算 |
截取显示字符长度; |
vue响应式数据数据劫持初始化 |
数据冻结:Object.freeze(); |
相关文档
- Effective前端6:避免页面卡顿 - 知乎
- 一个Vue页面的内存泄露分析 - 知乎
- js 内存泄漏场景、如何监控以及分析
- 避免内存泄漏 — Vue.js:官方指南
- 深入了解 JavaScript 内存泄露 - SegmentFault 思否
- JavaScript 内存分析 - Chrome 开发工具指南 - 极客学院Wiki
- Shallow Size和Retained Size详解 - 简书
- Content Editable - Web 开发者指南 | MDN
- 时间切片(Time Slicing)
- CSS Triggers
- Vue原理解析之observer模块 - SegmentFault 思否