【相關推薦:vuejs影片教學】
最近發現公司項目偶發性發生奔潰現象。
剛開始我以為是程式碼寫了一些死循環,檢查完並未發現。
後面透過performance 檢查發現記憶體飚到了1個多G, 可能是記憶體沒有正常的回收,而項目是從多頁整合到單頁後發生的,單頁使用的是keepalive 內頁簽實現。所以初步推斷可能是內存擠爆了。
定位原因
透過performance -> memory 看到目前記憶體使用情況,
透過瘋狂的打開內部頁籤關閉,發現內存已經達到驚人的2g,相關的操作已經開始無法響應,頁面卡頓甚至白屏
透過指令可以看到可以使用2g,已使用2g,封頂4g
這時候已經芭比Q了,控制台console.log回車後,都沒空間執行和輸出了
#2.定位問題1.還原場景
#由於內部系統程式碼複雜且有交叉邏輯和隱性的記憶體洩漏的程式碼。比較了公司其他內建多頁簽快取項目,也有類似問題。所以需要建構一個純淨的環境一步步從底層分析。 首先還原專案使用的版本環境。
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')
<template> <div> <div>keep-alive includeList:{{indexNameList}}</div> <button>新增(enter)</button> <button>删除(esc)</button> <button>强制垃圾回收(backspace)</button> <span>内存已使用<b></b></span> <div> <keep-alive> <router-view></router-view> </keep-alive> </div> <div> <div> <span>a{{index}}</span> <button>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> .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>
<template> <div>首页</div> </template> <script> export default { name:'index', } </script>
<template> <div>组件view<input> </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>
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
點選新增會創建一個80mb的元件,可以看到新增4個元件,keepalive佔用大概330mb左右,(即時監控和performance介面計算,記憶體診斷報告會有偏差)
點選刪除會預設移除最後一個元素,也可以透過元素上的x
來刪除,每次刪除都先跳回首頁, 確保刪除的view 不是正在顯示的view。 3.重現問題#############1.當建立4個元件後,刪除最後一個a4時候,同時立即回收內存,內存並沒有釋放。依然是328mb。 #########2.但當再刪除多一個a3的時候 居然又釋放的80,讓人更加疑惑。 #########3.這還不算,如果我新增4個,然後先刪除最前面的居然能實時的釋放#########好傢伙,vue官方api也這麼不靠譜嗎?對程式設計師來說,不確定問題比實實在在的錯誤還要困難得多。 ######趕緊上官網看了下,發現vue2 從2.6.12 到2.7.10 之間在2.6.13 修復了關於keepalive的問題,由於2.7.10使用ts重寫了,並且引入的vue3的compositionAPI,為了穩定,只升級到2.6的最新2.6.14。 #####################结果问题依然存在,于是又试了下2.7.10,结果还是一样的现象。
4.分析
4.1全局引用是否正常释放
在vue里,只有一个子节点App,再里面就是 keepalive 和 a1,a2,a3,a4 ,这5个是平级的关系
可以看到当删除a4的时候App里面的子节点只剩下keepalive 和 a1,a2,a3, 4个元素,所以这里没有内存问题。
4.2keepalive 的cache是否正常释放
可以看到cache集合里面已经移除a4的缓存信息
4.3挨个组件检查引用关系
通过诊断报告搜索vuecomponent,可以看到有7个vuecomponent的组件(keepalive 和 App.vue + index.vue + 自定义创建的4个动态a组件)
通过鼠标移动到对应的vueVomponent上会显示对应的实例,如下图的a4实例
现在我尝试删除a4,再生成报告2,在报告2中我们还是能看到a4,这时候内存就没有正常释放了
并且发引用关系已经变成11层,与其他的5层不一样。点击改a4后,下面Object页签会展开显示正在引用他的对象
鼠标移动到$vnode上看,发现居然是被a3组件引用了,这是为什么?
根据一层层关系最后发现
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组件 正常,没被引用
这里看到看出 a3组件.$vnode.parent 其实就是keepalive对象。
由于keepalive不参与渲染,但是每次组件渲染都会传入componentOptions,componentOptions里面包含了当前的keepalive的信息,keepalive又包裹了上一次第一个渲染的子节点。
5.结论
当加载组件a1,a1对应的keepalive的componentOptions的children[0]信息也是a1。
当加载组件a2,a2对应的keepalive的componentOptions的children[0]信息也是a2,但是这时候上面的a1对应的keepalive由于是同一个引用,导致a1对应的keepalive的componentOptions信息也是a2。
当加载组件a3,a3对应的keepalive的componentOptions的children[0]信息也是a3,导致a2对应的keepalive的componentOptions信息也是a3。
当加载组件a4,a4对应的keepalive的componentOptions的children[0]信息也是a4,导致a3对应的keepalive的componentOptions信息也是a4。
上面描述的各个组件的引用关系,a1-> a2 -> a3 -> a4 。 这也解释了为什么删除a1内存能够立即释放,同理继续删除a2 也是能正常释放。
但是如果先删除a4,由于a3引用着他所以不能释放a4。
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
通过live server启动服务
这样每次修改源码,都会实时发布到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
nbsp;html> <meta> <meta> <meta> <link>favicon.ico"> <title></title> <!-- 这里是本地的vue源码 --> <script></script> <noscript> </noscript> <div></div> <!-- built files will be auto injected -->
这里cdn改成生成自己生成的vue sourcemap 实时地址。
4.调试代码
在开发者工具里,crtl+p 打开源码搜索框,输入keepalive,找到对应的源码。
在render方法里打上断点,可以发现每当路由发送变化,keepalive的render方法都会重新渲染
打开源码
/* @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]) } }</function></string></string>
这里包含了整个keepalive的所有逻辑,
刚开始也以为是LRU的设置问题,测试后发现keepalive的数组都是能正常释放。
怀疑是max最大长度限制,解决也是正常。 确保keepalive内部能正常释放引用后,就要想如何修复这个bug,关键就是把children设置为空
组件.$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依然被保留着。NND点击后发现,是a4的dom已经没在显示,dom处于游离detach状态,看看是谁还引用着。好家伙,又是父节点keepalive的引用着,这次是elm。
于是在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 } } ..... }
再次怀着喜悦的心情运行,发现这次靠谱了。
nice~~
由于早期浏览器的架构都是一个页面html一个tab,所以很少会出现tab内存不够的情况。但是随着前端工程化的发展,单页面客户端渲染的应用也越来越普及。所以内存的问题也会日渐均增,对内存的优化与问题也会越来越多。
当遇到偶发的奔溃问题时候,chrome的内存工具是个很好的帮手,可以快速生成报告并告知你引用的嵌套关系。
分析问题还有一个好方法就是对比其他vue多页签项目是否存在内存泄露问题,结果发现一样存在。基于这个前提再去分析官方的代码。
官方源码其实也提供了好像的调试环境,配合sourcemap对于分析定位和调试源码问题非常关键。
当然改源码都是下策,最好的办法还是提issue。赶紧上githut 提个PR看看,从代码源头处理掉这个bug。
demo 源码地址github.com/mjsong07/vu…
issue地址github.com/vuejs/vue/i…
以上是聊聊vue中keepalive的記憶體問題的詳細內容。更多資訊請關注PHP中文網其他相關文章!