zoukankan      html  css  js  c++  java
  • 记一次vue长列表的内存性能分析和优化

    好久没写东西,博客又长草了,这段时间身心放松了好久,都没什么主题可以写了

    上周接到一个需求,优化vue的一个长列表页面,忙活了很久也到尾声了,内存使用和卡顿都做了一点点优化,还算有点收获

    写的有点啰嗦,可以看一下我是怎么进行这个优化的,也许有点帮助呢

    这个长列表页面,其实是一个实时日志上报的页面,随着页面打开时间的增加,日志数量也会增多,常规的页面布局和渲染免不了会遇到性能问题。

    使用了vue框架,框架内部的虚拟DOM和组件缓存已经做了一些优化,比起原生实现是有了一些优化处理。

    但这个页面是用到element-ui的el-table组件,渲染出来的是表格数据列表,众所周知,表格在渲染的时候需要绘制整个表格区,所以,

    第一步就是将表格实现改为其他元素标签实现

    这一步操作之后,其实没什么大的变化的,几千条日志(每条日志还有很多信息)左右,滚动页面明显卡顿严重

    而需求又改不了,日志可以展开查看详情或收起,已经看过的日志在下次看的时候不需要加载,新的日志会实时添加进来

    以前在做大表格数据鼠标滑过行着色的时候,也有严重的卡顿,当时主要的优化手段是不对所有数据进行处理,仅处理视窗可见区域,也可以在这里试试,所以

    第二步就是仅渲染视窗可见的数据

    这种方案的原理是使用一个大容器作为滚动区域,里面有一个内容区域,JS通过数据数量和每条数据的高度计算出内容区的高度,内容区用padding或绝对定位撑开滚动区域,让容器可滚动,另外就是数据项了,滚动的时候,计算当前滚动位置scrollTop,再从数据项中找出各项的高度,从头到尾计算出此时容器中放什么数据

    哈哈哈 ... 这文字描述简直了,看不懂就不看了吧,可以去看下别人的解说

    知道原理之后,实现起来也不难,不过代码就写的比较凌乱了,还是使用现成的比较成熟的vue插件吧,比较方便

    复制粘贴一顿猛操作之后,页面重新展现出来,想着应该可以收工了吧

    然鹅,测试的时候发现,页面内存使用可以达到一两G,看来不仅要优化卡顿,还要优化内存使用

    还能遇到这种少见的页面崩溃,也算是开了眼了

    这个方案是把原先页面应该渲染的所有DOM拆分出来,动态地渲染该渲染的部分,

    所以就会有一个问题,动态计算需要时间,当滚动非常快的时候会有明显的卡顿现象,所以

    第三步就是进行函数节流,即控制scroll事件的处理,在规定的时间内仅触发一次

    // 函数节流,频繁操作中间隔 delay 的时间才处理一次
    function throttle(fn, delay) {
        delay = delay || 200;
        
        var timer = null;
        // 每次滚动初始的标识
        var timestamp = 0;
    
        return function() {
            var arg = arguments;
            var now = Date.now();
            
            // 设置开始时间
            if (timestamp === 0) {
                timestamp = now;
            }
            
            clearTimeout(timer);
            timer = null;
            
            // 已经到了delay的一段时间,进行处理
            if (now - timestamp >= delay) {
                fn.apply(this, arg);
                timestamp = now;
            }
            // 添加定时器,确保最后一次的操作也能处理
            else {
                timer = setTimeout(function() {
                    fn.apply(this, arg);
                    // 恢复标识
                    timestamp = 0;
                }, delay);
            }
        }
    };
    
    var count = 0;
    
    window.onscroll = throttle(function(e) {
        console.log(e.type, ++count); // scroll
    }, 500);
    代码参考

    虽然改善不是很大,但好歹也是一种方案

    接下来是针对这个磨人的内存占用了,也花了蛮多时间去分析去定位,头发又少了几根..

    现象是这样的:

    刚进入页面的时候,最初100条数据,仅渲染30条数据,内存就占用了100+M

    滚动的时候内存蹭蹭蹭往上涨,峰值能到几个G,一段时间后又下降一部分

    随着数据总量的增多,内存最初的占用和最后的占用也不同

    在常规滚动和快速滚动的时候,内存占用也不同

    最后发现在数据总量一定的时候,内存最大占用量是固定的(垃圾回收之后)

    嗯挺奇怪的,实际项目比较复杂,有其他组件干扰,不好排除法分析

    所以就从插件给的Demo 开刀,发现它的表现是一致的

    分析要有数据,实验和方案选取要有对比测试

    所以使用Chrome DevTool 自带的 Memory工具,另外为了避免Chrome插件的影响,在隐身窗口中进行调试

    上面有个强制垃圾回收的按钮,JS垃圾回收机制是什么这里就不说了,可以去搜一下

    目前垃圾回收方案主要都是标记清除法了,而实现主要是根据GC根往下一层层遍历,遍历不到的对象会被垃圾回收掉,当某些对象本应该被回收,但还是能从GC根访问的时候,就产生了内存泄漏,主要需要考虑两类内存泄漏:普通JS的对象,游离的DOM节点(本该被回收,却还有对象引用它)

    垃圾回收的时间点是不固定的,随机的,我们在代码中没法控制

    点击左边的第一个小圆圈就可以开始分析了,一般来说分析之前都会自动进行垃圾回收,不过为了更准确,可以再强制点按钮回收一次

    常用的主要就是两种分析方式:

    第一种是进行堆快照(JS的对象一般放在堆中),查看当前的内存分布情况

    第二种是进行内存时间线分析,查看一顿操作之后的内存增长情况,主要针对这个操作过程(这个时候可以结合Performance标签功能中来分析)

    上图中左侧是两个快照的结果,64.5M是进入页面之后的内存快照,149M是各种操作之后的内存快照

    <VirtualList :size="50" :remain="6" :bench="44" class="list" :start="startIndex" :debounce="10">
                <Item v-for="(udf, index) of items" :index="index" :key="index"></Item>
            </VirtualList>

    这个长列表总共10w条数据,仅仅渲染了50条(6 + 44)数据,每条数据仅仅是短短的字符串,不该占用这么多内存

    去看下内存具体占用情况

    内容有点多,因为用的是vue,所以我们只需要关注比较重要的虚拟DOM对象 VNode和渲染的组件就行了

    VNode基本就是所有的数据了,VueComponent是当前渲染的,所以,这里的VNode是不是有很多内存浪费了,与之关联的很多东西也占坑了

    看看字符串内容,每条仅仅占用了32字节,所以这里想到的一个点是要缩减Item项的数量

    然后,想想为什么所有虚拟DOM都留在了内存中呢,展开一个来看对象的引用关系,有一个$slot.default

    然后回去看看插件的实现,插件是将所有子项目都放到了子元素中,以slot的方式插入,然后在内部抽出进行再创建

     容器组件在重新渲染的时候,确实能触发了组件的销毁函数 destroy,而这个也将对象间的关系清的干干净净的了

    具体可以看vue中组件是怎么销毁的

    Vue.prototype.$destroy = function () {
          var vm = this;
          if (vm._isBeingDestroyed) {
            return
          }
          callHook(vm, 'beforeDestroy');
          vm._isBeingDestroyed = true;
          // remove self from parent
          var parent = vm.$parent;
          if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
            remove(parent.$children, vm);
          }
          // teardown watchers
          if (vm._watcher) {
            vm._watcher.teardown();
          }
          var i = vm._watchers.length;
          while (i--) {
            vm._watchers[i].teardown();
          }
          // remove reference from data ob
          // frozen object may not have observer.
          if (vm._data.__ob__) {
            vm._data.__ob__.vmCount--;
          }
          // call the last hook...
          vm._isDestroyed = true;
          // invoke destroy hooks on current rendered tree
          vm.__patch__(vm._vnode, null);
          // fire destroyed hook
          callHook(vm, 'destroyed');
          // turn off all instance listeners.
          vm.$off();
          // remove __vue__ reference
          if (vm.$el) {
            vm.$el.__vue__ = null;
          }
          // release circular reference (#6759)
          if (vm.$vnode) {
            vm.$vnode.parent = null;
          }
        };

    把$vnode的对象关系都切的差不多了,但slot方式的使用下是处理不了的,所以在垃圾回收之后,内存中的vnode对象非常多

    再来看看内存占用的最大值

    可以发现VNode增长了一部分,而最为瞩目的是VueComponent数量竟然有那么多,按道理应该只有渲染的几个组件的

    为了做对比,我们一般使用comparison对比两个快照,看看相差的地方

    相关使用可以去看文档

    有兴趣的也可以导入我这两个快照自行分析 default  maximum

    这段时间里创建的vue对象基本没能被清理掉,说明有很多不应该出现的对象引用关系,其中detached HTMLDivElement是指游离的DOM对象,一般用于分析DOM相关的内存泄漏,可以猜测出这里的主角应该是vue的组件

    挑一个组件来看看,可以发现它还是和slot有关的,所以滚动期间创建的组件,属于VNode节点的componentInstance属性,而VNode节点没法被回收,所以组件驻留在内存中

    接下来的问题是,既然一开始VNode是所有的数据了,为何在滚动期间,还会有那么多VNode会创建出来

    挑一个这期间增加的VNode来看看引用关系,可以发现VNode中有两种,增加的是不同的_vnode

    @后面带的是对象的id,另外我们也可以在调试的时候,console打印出它们是不同的对象

    经过上面各种分析,有两个问题需要去解决

    减少驻留的VNode和Vue组件

    减少操作期间增加的对象

    减少驻留,即不用slot的方式,那只能改插件了

    插件中vm.$slots.default 获取到的是vnode节点,然后再使用render函数传递vnode进行创建组件并渲染

    由此想来,我们也可以自己创建vnode节点,

    不直接写成子组件,而是将纯粹的数据项和组件单元传递给插件,让插件来创建vnode节点

    <VirtualList :size="50" :remain="6" :bench="44" class="list" :start="startIndex"
                :items="items" :item-component="itemComponent" :item-binding="itemBinding">
            </VirtualList>

    items 是数据项,itemComponent是 import 进来的一个组件单元,itemBinding是一个函数,返回类似渲染函数的data对象,用以传递属性

    itemBinding(item, idx) {
                    return {
                        key: item,
                        props: {
                            index: item
                        }
                    };
    
                    // return {
                    //     key: item.id,
                    //     props: {
                    //         index: item.num,
                    //     },
                    //     nativeOn: {
                    //         dblclick: (...args) => {
                    //             console.log(idx, 'dblclick');
                    //         }
                    //     }
                    // }
                }

    在插件内部,接收传递进来的items和itemComponent,构造出相应的vnodes,当然slots方式也可以支持

    for (var i = delta.start; i <= Math.ceil(delta.end); i++) {
                        targets.push(!this.itemComponent ? slots[i]
                            // create vnode, using custom attrs binder
                            : this.$createElement(this.itemComponent, this.itemBinding(this.items[i], i) || {})
                        )
                    }
    
                    return targets

    完整的代码实例可以看这里

    解决办法挺简单的,虽然这一步创建会耗费一些时间,不过测试发现,跟原先的做法差不多的,原先的也需要创建

    来看看优化之后的内存占用情况

    同样的数据,最初进入页面占用5M,各种操作之后也差不多,操作之中创建的vue对象基本被清理掉了,且对象数量还算符合预期

    在当前10万条简单数据下,内存使用初始减小成1/13,最大减小成1/26,而且随着总数量的增加,优化比率也更高

    在实际项目组件复杂的情况下使用,400条日志,内存使用大概由400M到80M,优化率达到了1/5,也挺可观

    接下来考虑一下如何减少操作期间增加的对象

    这就需要收集一些操作过程中的数据了

    分析过程,我比较喜欢用Performance面板,这里有非常详细的函数调用栈,

    另外还要使用调试大法,由最开始的onScroll事件入口开始,一步一步地理解组件创建更新销毁过程,看看哪些地方合不合理,能不能在上游在外部间接地改进

    点击左侧小圆圈开始记录,然后滚动一段时间,然后结束记录,查看收集的信息

    勾选了右上角的memory选项框知乎,这个面板也可以查看内存的使用,不过记得手动进行一次垃圾回收(那个按钮),因为它一般在记录之前不会自动调用

    可以发现还是比较规律的,挑这段略为明显的进行分析

    有兴趣的也可以自己导入我这份数据进行分析

    可以发现这里发生了组件的更新,$mount和$destroy的调用,是发生在插件重新渲染可视区域组件的时候

    找到关键的地方,调试分析发现每次都会创建新的VNode对象

    这样看来,操作期间创建的对象是避免不了的了,只能通过减少操作期间函数执行的次数了,即最初提到的函数节流

    而组件销毁的时候,会判断组件是否为keepAlive型,可以尝试一下给Item组件加上,这能解决操作期间组件创建和销毁带来的内存开销,不过会导致所有组件都会驻留在内存中,综合考虑下,这种方案不可取

    最后想想,再挤出一点优化方案,既然操作过程中会创建组件,而组件里可能还有子组件,所以,还可以优化子组件

    即Item组件内部,能不用组件的可以不用组件,改为普通HTMl标签代替,经过测试,确实能改善那么一丢丢

    一个性能问题的排查分析和解决,文章略长略啰嗦,到这里就结束了

    总结一下,主要的五个优化

    1. 将表格实现改为其他元素标签实现

    2. 仅渲染视窗可见的数据

    3. 进行函数节流

    4. 减少驻留的VNode和Vue组件,不使用显示的子组件slot方式,改为手动创建虚拟DOM来切断对象引用

    5. 减少操作期间增加的对象,操作时组件必然会更新创建,可以减少组件中子组件的数量

  • 相关阅读:
    linux 短信收发
    sama5d3 环境检测 adc测试
    【Codeforces 723C】Polycarp at the Radio 贪心
    【Codeforces 723B】Text Document Analysis 模拟
    【USACO 2.2】Preface Numbering (找规律)
    【Codeforces 722C】Destroying Array (数据结构、set)
    【USACO 2.1】Hamming Codes
    【USACO 2.1】Healthy Holsteins
    【USACO 2.1】Sorting A Three-Valued Sequence
    【USACO 2.1】Ordered Fractions
  • 原文地址:https://www.cnblogs.com/imwtr/p/10428819.html
Copyright © 2011-2022 走看看