• 技术文章 >web前端 >Vue.js

    聊聊vue中keepalive的内存问题

    青灯夜游青灯夜游2022-10-14 20:21:54转载336

    大前端零基础入门到就业:进入学习

    【相关推荐:vuejs视频教程

    1.起因

    最近发现公司项目偶发性发生 奔溃现象。

    1.png

    刚开始以为是代码写了一些死循环,检查完并未发现。

    后面通过 performance 检查 发现内存飚到了1个多G, 可能是内存没有正常的回收,而项目是从多页面整合到单页面后发生的,单页面使用的是keepalive 内部页签实现。所以初步推断可能是内存挤爆了。

    定位原因

    通过performance -> memory 看到当前内存使用情况,

    2.png

    3.png

    4.png

    5.png

    2. 定位问题

    1.还原场景

    由于内部系统代码复杂并有交叉逻辑和隐性的内存泄露的代码。对比了公司其他内置多页签缓存项目,也存在类似问题。所以需要搭建一个纯净的环境一步步从底层分析。 首先还原项目使用的版本环境。

    2.写个demo

    先写个demo重现问题。使用vue-cli创建项目对应版本 vue2.6.12, vue-router3.6.4

    main.js

    import Vue from 'vue'
    import App from './App.vue'
    import router from './router'
    Vue.config.productionTip = false
    
    new Vue({
      render: h => h(App),
      router
    }).$mount('#app')

    App.vue

    <template>
        <div>
            <div>keep-alive includeList:{{indexNameList}}</div>
            <button @click="routerAdd()">新增(enter)</button> <button @click="routerDel()">删除(esc)</button> <button @click="gc()">强制垃圾回收(backspace)</button> <span  >内存已使用<b id="usedJSHeapSize"></b></span>
            <div class="keepaliveBox">
                <keep-alive :include="indexNameList">
                    <router-view />
                </keep-alive>
            </div>
            <div class="barBox">
                <div class="box" v-for="(index) in indexList" :key="index">
                    <span @click="routerClick(index)">a{{index}}</span>
                    <button @click="routerDel(index)" title="删除(esc)">x</button>
                </div>
            </div>
        </div>
    </template>
    
    <script>
    export default {
        name: "App",
        data() {
            return {
                indexList: [],
                usedJSHeapSize: ''
            }
        },
        mounted() {
            const usedJSHeapSize = document.getElementById("usedJSHeapSize")
            window.setInterval(() => {
                usedJSHeapSize.innerHTML = (performance.memory.usedJSHeapSize / 1000 / 1000).toFixed(2) + "mb"
            }, 1000)
            // 新增快捷键模拟用户实际 快速打开关闭场景
            document.onkeydown = (event) => {
                event = event || window.event;
                if (event.keyCode == 13) {//新增 
                    this.routerAdd()
                } else if (event.keyCode == 27) {  //删除  
                    this.routerDel() 
                } else if (event.keyCode == 8) {  //垃圾回收  
                    this.gc() 
                }
            };
        },
        computed: {
            indexNameList() {
                const res = ['index']//
                this.indexList.forEach(index => {
                    res.push(`a${index}`)
                }) 
                return res
            }
        },
        methods: {
            routerAdd() {
                let index = 0
                this.indexList.length > 0 && (index = Math.max(...this.indexList)) 
                index++ 
                this.indexList.push(index)
                this.$router.$append(index)
                this.$router.$push(index)
            },
            routerDel(index) { 
                if (this.indexList.length == 0) return
                if(!index) {
                    index = Math.max(...this.indexList)
                }  
                   //每次删除都先跳回到首页, 确保删除的view 不是正在显示的view
                if (this.$route.path !== '/index') { 
                    this.$router.push('/index') 
                }
                let delIndex = this.indexList.findIndex((item) => item == index)
                this.$delete(this.indexList, delIndex)
                //延迟执行,加到下一个宏任务
                // setTimeout(() => {
                //     this.gc() 
                // }, 100);
            },
            routerClick(index) {
                this.$router.$push(index)
            },
            gc(){
                //强制垃圾回收 需要在浏览器启动设置 --js-flags="--expose-gc",并且不打开控制台,没有效果
                window.gc && window.gc()
            }, 
        }
    };
    </script>
    
    <style scoped>
    .keepaliveBox {
        border: 1px solid red;
        padding: 3px;
    }
    
    .barBox {
        display: flex;
        flex-wrap: wrap;
    }
    
    .box {
        margin: 2px;
        min-width: 70px;
    }
    
    .box>span {
        padding: 0 2px;
        background: black;
        color: #fff;
    }
    </style>

    view/index.vue

    <template>
        <div>首页</div>
    </template>
    <script>
    export default {
        name:'index',
    }
    </script>

    view/a.vue

    <template>
        <div>组件view<input v-model="myname"/> </div>
    </template>
    <script>
    export default {
        name:'A',
        data(){
            return {
                a:new Array(20000000).fill(1),//大概80mb
                myname:""
            }
        },
        mounted(){  
            this.myname = this.$route.query.name
        }
    }
    </script>

    router/index.js

    import Vue from 'vue'
    import Router from 'vue-router'
    import a from '../view/a.vue'
    Vue.use(Router)
    
    const router = new Router({
        mode: 'hash', 
         routes: [
            {
                path: '/',
                redirect: '/index'
            },
            {
                path: '/index',
                component: () => import('../view/index.vue')
            }
        ]
    })
    
    //动态添加路由
    router.$append = (index) => { 
        router.addRoute(`a${index}`,{
            path: `/a${index}`,
            component:  {
                ...a,
                name: `a${index}`
            },
        })  
    }
    
    router.$push = (index) => { 
            router.push({
                path:`/a${index}`,
                query:{
                    name:`a${index}`
                }
            })
    } 
    export default  router

    demo效果

    6.png

    3.重现问题

    1.当创建4个组件后,删除最后一个a4时候,同时立即回收内存,内存并没有释放。依然是328mb。

    动画12345.gif2.但是当再删除多一个a3的时候 居然又释放的80,让人更加疑惑。动画12345.gif

    3.这还不算,如果我新增4个,然后先删除最前面的居然能实时的释放动画12345.gif

    好家伙,vue官方api也这么不靠谱吗?对于程序员来说,不确定问题比实实在在的错误都要难得多。

    赶紧上官网看了下,发现vue2 从2.6.12 到 2.7.10 之间 在 2.6.13 修复了 关于keepalive的问题,由于2.7.10使用ts重写了,并且引入的vue3的compositionAPI,为了稳定,只升级到 2.6的最新2.6.14。

    7.png

    8.png

    结果问题依然存在,于是又试了下2.7.10,结果还是一样的现象。

    4.分析

    4.1全局引用是否正常释放

    在vue里,只有一个子节点App,再里面就是 keepalive 和 a1,a2,a3,a4 ,这5个是平级的关系

    9.png

    10.png

    可以看到当删除a4的时候App里面的子节点只剩下keepalive 和 a1,a2,a3, 4个元素,所以这里没有内存问题。

    11.png

    4.2keepalive 的cache是否正常释放

    可以看到cache集合里面已经移除a4的缓存信息

    12.png

    4.3挨个组件检查引用关系

    13.png

    14.png

    15.png

    16.png

    17.png

    根据一层层关系最后发现

     a3组件.$vnode.parent.componentOptions.children[0] 引用着 a4

    导致a4 无法正常释放

    基于这个点,查询了前面a2,a3 也存在引用的关系,a1 正常无人引用它。

    a2组件.$vnode.parent.componentOptions.children[0] 引用着 a3
    a1组件.$vnode.parent.componentOptions.children[0] 引用着 a2
    a1组件 正常,没被引用

    5.结论

    18.png

    上面描述的各个组件的引用关系,a1-> a2 -> a3 -> a4 。 这也解释了为什么删除a1内存能够立即释放,同理继续删除a2 也是能正常释放。

    但是如果先删除a4,由于a3引用着他所以不能释放a4。

    3. 修复问题

    1.思路

    根据上面的关系我们指导,所有问题都是vue实例的时候关联的keepalive引用了别的组件,我们只需要把keepalive上面componentOptions的children[0] 引用的关系切断就ok了。这时候我们可以从vue的keepalive源码入手调整。

    2.构建可以定位具体源码的环境

    该项目使用的是vue 的cdn引入,所以只需要重新上传一份支持sourcemap的并且没有被混淆的vue库即可。 通过--sourcemap 命令参数 生产支持源码映射的代码,以相对路径的方式上传的对应的cdn地址。参考地址

    git clone --branch 2.6.14  https://github.com/vuejs/vue.git //拉取代码

    修改package.json,添加 --sourcemap

    "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:webfull-dev",

    本地运行

    npm run dev

    image.png

    通过live server启动服务image.png

    这样每次修改源码,都会实时发布到dist下的vue.js 我们就可以实时调试了访问地址: 访问地址:http://127.0.0.1:5500/dist/vue.js

    3.改造现有项目成cdn

    vue.config.js

    module.exports = {
        chainWebpack: config => { 
          config.externals({
            vue: "Vue", 
          }); 
        },
        configureWebpack: {
          devtool: "eval-source-map"
        },
        lintOnSave: false
      };

    public/index.html

    <!DOCTYPE html>
    <html lang="">
      <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1.0">
        <link rel="icon" href="<%= BASE_URL %>favicon.ico">
        <title><%= htmlWebpackPlugin.options.title %></title> 
         <!-- 这里是本地的vue源码 -->
        <script src="http://127.0.0.1:5500/dist/vue.js"></script>
      </head>
      <body>
        <noscript>
        </noscript>
        <div id="app"></div>
        <!-- built files will be auto injected -->
      </body>
    </html>

    这里cdn改成生成自己生成的vue sourcemap 实时地址。

    4.调试代码

    在开发者工具里,crtl+p 打开源码搜索框,输入keepalive,找到对应的源码。

    image.png

    在render方法里打上断点,可以发现每当路由发送变化,keepalive的render方法都会重新渲染image.png

    打开源码

    /* @flow */
    
    import { isRegExp, remove } from 'shared/util'
    import { getFirstComponentChild } from 'core/vdom/helpers/index'
    
    type CacheEntry = {
      name: ?string;
      tag: ?string;
      componentInstance: Component;
    };
    
    type CacheEntryMap = { [key: string]: ?CacheEntry };
    
    function getComponentName (opts: ?VNodeComponentOptions): ?string {
      return opts && (opts.Ctor.options.name || opts.tag)
    }
    
    function matches (pattern: string | RegExp | Array<string>, name: string): boolean {
      if (Array.isArray(pattern)) {
        return pattern.indexOf(name) > -1
      } else if (typeof pattern === 'string') {
        return pattern.split(',').indexOf(name) > -1
      } else if (isRegExp(pattern)) {
        return pattern.test(name)
      }
      /* istanbul ignore next */
      return false
    }
    
    function pruneCache (keepAliveInstance: any, filter: Function) {
      const { cache, keys, _vnode } = keepAliveInstance
      for (const key in cache) {
        const entry: ?CacheEntry = cache[key]
        if (entry) {
          const name: ?string = entry.name
          if (name && !filter(name)) {
            pruneCacheEntry(cache, key, keys, _vnode)
          }
        }
      }
    }
    
    function pruneCacheEntry (
      cache: CacheEntryMap,
      key: string,
      keys: Array<string>,
      current?: VNode
    ) {
      const entry: ?CacheEntry = cache[key]
      if (entry && (!current || entry.tag !== current.tag)) {
        entry.componentInstance.$destroy()
      }
      cache[key] = null
      remove(keys, key)
    }
    
    const patternTypes: Array<Function> = [String, RegExp, Array]
    
    export default {
      name: 'keep-alive',
      abstract: true,
    
      props: {
        include: patternTypes,
        exclude: patternTypes,
        max: [String, Number]
      },
    
      methods: {
        cacheVNode() {
          const { cache, keys, vnodeToCache, keyToCache } = this
          if (vnodeToCache) {
            const { tag, componentInstance, componentOptions } = vnodeToCache
            cache[keyToCache] = {
              name: getComponentName(componentOptions),
              tag,
              componentInstance,
            }
            keys.push(keyToCache)
            // prune oldest entry
            if (this.max && keys.length > parseInt(this.max)) {
              pruneCacheEntry(cache, keys[0], keys, this._vnode)
            }
            this.vnodeToCache = null
          }
        }
      },
    
      created () {
        this.cache = Object.create(null)
        this.keys = []
      },
    
      destroyed () {
        for (const key in this.cache) {
          pruneCacheEntry(this.cache, key, this.keys)
        }
      },
    
      mounted () {
        this.cacheVNode()
        this.$watch('include', val => {
          pruneCache(this, name => matches(val, name))
        })
        this.$watch('exclude', val => {
          pruneCache(this, name => !matches(val, name))
        })
      },
    
      updated () {
        this.cacheVNode()
      },
    
      render () {
        const slot = this.$slots.default
        const vnode: VNode = getFirstComponentChild(slot)
        const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
        if (componentOptions) {
          // check pattern
          const name: ?string = getComponentName(componentOptions)
          const { include, exclude } = this
          if (
            // not included
            (include && (!name || !matches(include, name))) ||
            // excluded
            (exclude && name && matches(exclude, name))
          ) {
            return vnode
          }
    
          const { cache, keys } = this
          const key: ?string = vnode.key == null
            // same constructor may get registered as different local components
            // so cid alone is not enough (#3269)
            ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
            : vnode.key
          if (cache[key]) {
            vnode.componentInstance = cache[key].componentInstance
            // make current key freshest
            remove(keys, key)
            keys.push(key)
          } else {
            // delay setting the cache until update
            this.vnodeToCache = vnode
            this.keyToCache = key
          }
    
          vnode.data.keepAlive = true
        }
        return vnode || (slot && slot[0])
      }
    }

    这里包含了整个keepalive的所有逻辑,

    组件.$vnode.parent.componentOptions.children = []

    最合适的位置就在每次render的时候都重置一下所有错误的引用即可

    代码如下,把错误引用的children设置为空

      render () {
        const slot = this.$slots.default
        const vnode: VNode = getFirstComponentChild(slot) 
        
        //修复缓存列表问题
        for (const key in this.cache) {
          const entry: ?CacheEntry = this.cache[key]
          if (entry && vnode && entry.tag && entry.tag !== vnode.tag ) { //如果当前的缓存对象不为空 并且 缓存与当前加载不一样
            entry.componentInstance.$vnode.parent.componentOptions.children = []
          }
        }
       .....
    }

    怀着喜悦的心情以为一切ok,运行后发现,a4依然被保留着。NNDimage.png点击后发现,是a4的dom已经没在显示,dom处于游离detach状态,看看是谁还引用着。好家伙,又是父节点keepalive的引用着,这次是elm。

    image.png于是在keepalive源码的render方法加入

    entry.componentInstance.$vnode.parent.elm = null

    整体代码如下

      render () {
        const slot = this.$slots.default
        const vnode: VNode = getFirstComponentChild(slot) 
        
        //修复缓存列表问题
        for (const key in this.cache) {
          const entry: ?CacheEntry = this.cache[key]
          if (entry && vnode && entry.tag && entry.tag !== vnode.tag ) { //如果当前的缓存对象不为空 并且 缓存与当前加载不一样
            entry.componentInstance.$vnode.parent.componentOptions.children = []
            entry.componentInstance.$vnode.parent.elm = null
          }
        }
       .....
    }

    再次怀着喜悦的心情运行,发现这次靠谱了。

    动画12345.gif

    nice~~

    总结

    demo 源码地址github.com/mjsong07/vu…

    issue地址github.com/vuejs/vue/i…

    (学习视频分享:web前端开发编程基础视频

    以上就是聊聊vue中keepalive的内存问题的详细内容,更多请关注php中文网其它相关文章!

    声明:本文转载于:掘金社区,如有侵犯,请联系admin@php.cn删除

    前端(VUE)零基础到就业课程:点击学习

    清晰的学习路线+老师随时辅导答疑

    快捷开发Web应用及小程序:点击使用

    支持亿级表,高并发,自动生成可视化后台。

    专题推荐:Vue vue3 vue.js
    上一篇:聊聊vue for循环中key的作用 下一篇:自己动手写 PHP MVC 框架(40节精讲/巨细/新人进阶必看)

    相关文章推荐

    • ❤️‍🔥共22门课程,总价3725元,会员免费学• ❤️‍🔥接口自动化测试不想写代码?• Vue如何实现拖拽穿梭框功能?四种方式分享(附代码)• 手把手教你使用webpack实现vue-cli• Vue计算属性与侦听器和过滤器超详细介绍• Vue中什么是修饰符?常见的修饰符总结• 手把手带你使用Vue实现一个图片水平瀑布流插件
    1/1

    PHP中文网