为了解决内存泄露,我把 vue 源码改了
前言
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
场景复现
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
复现 demo
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<template>
<div id="app">
<button @click="render = true">render</button>
<button @click="render = false">destroy</button>
<Test v-if="render"/>
</div>
</template>
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
</style>
- test.vue
<template>
<div class="test">
<div>{{ total }}</div>
<div
v-for="(item,index) in 1000"
:key="`${item}-${index}`"
class="item"
>
{{ item }}ipc-prod2.8
</div>
</div>
</template>
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
</script>
复现流程
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染test
组件,此时我们发现dom
节点的个数来到了2045
考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板
-
500ms
后(定时器执行完成后,如果没复现可以把500ms 调整为 1000ms, 1500ms
),我们点击destroy
按钮 - 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
如果你的浏览器是最新的 chrome
,还能够点击这里的 已分离的元素
(detached dom),再点击录制
我们会发现此时整个 test
节点已被分离
问题分析
那么问题到底出在哪里呢?
vue 常见泄露场景
笔者搜遍了全网,网上所说的不外乎以下几种场景
1.未清除的定时器
2.未及时解绑的全局事件
3.未及时清除的 dom 引用
4.未及时清除的 全局变量
好像第一种和笔者的场景还比较类似,但是仔细看看代码好像也加了
beforeDestroy () {
clearTimeout(this.timer)
}
这段代码啊,就算不加,timer
执行完后,事件循环也会把它回收掉吧
同事提供灵感
就这样笔者这段代码来回测试了半天也没发现猫腻所在
这时候同事提供了一个想法说"total 更新的时候是不是可以提供一个 key
"
改了代码后就变成了这样了
- test.vue
<template>
<div class="test">
<div :key="renderKey">{{ total }}</div>
<div
v-for="(item,index) in 1000"
:key="`${item}-${index}`"
class="item"
>
{{ item }}ipc-prod2.8
</div>
</div>
</template>
<script>
export default {
name: 'Test',
data () {
return {
renderKey: 0,
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
this.renderKey = Date.now()
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
</script>
神奇的事情就这样发生了,笔者还是按以上流程测试了一遍,直接看结果吧
我们看到这个 DOM
节点曲线,在 destroy
的时候能够正常回收了
问题复盘
最简单的 demo
问题算是解决了
但是应用到实际项目中还是有点困难
难道我们要把每个更新的节点都手动加一个 key
吗?
其实仔细想想,有点 vue
基础的彦祖应该了解这个 key
是做什么的?
不就是为了强制更新组件吗?
等等,强制更新组件?更新组件不就是 updated
吗?
updated
涉及的不就是八股文中我们老生常谈的 patch
函数吗?(看来八股文也能真有用的时候😺)
那么再深入一下, patch
函数内部不就是 patchVnode
其核心不就是 diff
算法吗?
首对首比较,首对尾比较,尾对首比较,尾对尾比较
这段八股文要是个 vuer
应该都不陌生吧?😺
动手解决
其实有了问题思路和想法
那么接下来我们就深入看看 vue
源码内部涉及的 updated
函数到底在哪里吧?
探索 vue 源码
我们找到 node_modules/vue/vue.runtime.esm.js
我们看到了 _update
函数真面目,其中有个 __patch__
函数,我们再重点查看一下
createPatchFunction
最后 return 了这个函数
我们最终来看这个 updateChildren
函数
其中多次出现了上文中所提到的八股文,每个都用 sameVnode
进行了对比
- function sameVnode
function sameVnode (a, b) {
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
果然这里我们看到了上文中 key
的作用
key
不一样就会认作不同的 vnode
那么就会强制更新节点
对应方案
既然找到了问题的根本
在判定条件中我们是不是直接加个 || a.text !== b.text
强制对比下文本节点不就可以了吗?
修改 sameVnode
看下我们修改后的 sameVnode
function sameVnode (a, b) {
if(a.text !== b.text) return false // 文本不相同 直接 return
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
方案效果
让我们用同样的代码来测试下
测试了几次发现非常的顺利,至此我们本地的修改算是完成了
如何上线?
以上的方案都是基于本地开发的,那么我们如何把代码应用到线上呢?
其他开发者下载的 vue
包依旧是 老的 sameVnode
啊
不慌,接着看
patch-package
对比了好几种方式,最终我们选择了这个神器
其实使用也非常简单
1.npm i patch-package
2.修改 node_modules/vue
源码
3.在根目录执行 npx patch-package vue
(此时如果报错,请匹配对应 node 版本的包)
我们会发现新增了一个这样的文件
4.我们需要在package.json
scripts
新增以下代码
- package.json
"scripts": {
+"postinstall":"patch-package"
}
至此上线后,其他开发者执行 npm i
后便能使变动的补丁生效了
优化点
其实我们的改造还有一定的进步空间,比如说在指定节点上新增一个 attribute
在函数内部判断这个 attribute
再 return false
这样就不用强制更新每个节点了
当然方式很多种,文章的意义在于解决问题的手段和耐心
写在最后
最后再次感谢同事 juejin.cn/user/313102… 的提供的灵感和协助
感谢彦祖们的阅读
个人能力有限
如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟