【관련 추천: vuejs 동영상 튜토리얼】
최근 회사 프로젝트가 가끔 충돌하는 것으로 나타났습니다.
처음에는 코드에 적힌 무한루프인줄 알았는데 확인해 보니 발견되지 않았습니다.
이후 성능 점검을 통해 메모리가 1G 이상에 도달한 것으로 확인되었습니다. 메모리가 정상적으로 재활용되지 않아 프로젝트가 멀티 페이지에서 싱글 페이지로 통합된 후 프로젝트가 발생한 것일 수도 있습니다. 페이지는 연결 유지 내부 탭 구현을 사용합니다. 따라서 초기 추론은 메모리가 과밀화될 수 있다는 것입니다.
원인 찾기
성능 -> 메모리를 통해 현재 메모리 사용량을 확인하고,
내부 탭을 필사적으로 열고 닫아보니 메모리가 무려 2g에 도달한 것을 발견하고 관련 작업이 시작되었습니다. 반응이 안되고 페이지가 멈췄거나 심지어 화면이 나오지도 않네요
명령어를 보면 2g 사용 가능, 2g 사용, 캡 4g
명령어를 통해 알 수 있습니다
마법은 2g 이후에도 잠시만 기다리시면 됩니다. 계속 작동하고 플레이할 수 있으며, 메모리는 4g에 도달했습니다.
이때 Barbie Q는 이미 자리잡고 있습니다. console.log에 들어가보니 실행해서 출력할 공간이 없네요
1. 내부 시스템이 복잡해서 복원합니다. 코드와 교차 논리 및 숨겨진 메모리 누수가 있는 코드. 회사의 다른 내장 멀티탭 캐시 프로젝트와 비교해도 비슷한 문제가 존재합니다. 따라서 순수 환경을 구축하고, 이를 단계별로 밑바닥부터 분석하는 것이 필요하다. 먼저 프로젝트에서 사용하는 버전 환경을 복원합니다.
2. 데모 작성
먼저 문제를 재현하기 위한 데모를 작성하세요. vue-cli를 사용하여 vue2.6.12 버전, vue-router3.6.4main.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>新增(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>
view/index.vue
<template> <div>首页</div> </template> <script> export default { name:'index', } </script>
view/a.vue
<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>
에 해당하는 프로젝트를 생성합니다. 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 effect
새로 만들기를 클릭하면 80mb의 구성 요소가 생성됩니다. 4개의 새로운 구성 요소가 추가된 것을 볼 수 있습니다. (실시간 모니터링 및 성능 인터페이스) 계산, 메모리 진단 보고서 편차 있음)
삭제를 클릭하면 기본적으로 마지막 요소가 제거됩니다. 요소에서
를 통해 삭제할 수도 있습니다. 삭제할 때마다 먼저 홈페이지로 돌아갑니다. 삭제된 보기가 현재 표시된 보기가 아닌지 확인하십시오.x
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의 구성 API가 도입되었기 때문에 안정적입니다. , 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…
(학습 영상 공유: 웹 프론트엔드 개발, 기본 프로그래밍 영상)
위 내용은 Vue에서 keepalive의 메모리 문제에 대해 이야기해 보겠습니다.의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!