前言
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个
bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有
1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
复现 demo
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
<template>
<div id
="app">
<button @click="render = true">renderbutton>
<button @click="render = false">destroybutton>
<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>
<template>
<div class="test">
<div>{{ total }}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>
复现 demo
以下流程建议彦祖们在
chrome 无痕模式
下执行
- 我们点击
render
按钮渲染 test
组件,此时我们发现 dom
节点的个数来到了 2045

考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板
500ms
后(定时器执行完成后,如果没复现可以把 500ms 调整为 1000ms, 1500ms
),我们点击 destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
如果你的浏览器是最新的 chrome
,还能够点击这里的 已分离的元素
(detached dom),再点击录制
我们会发现此时整个 test
节点已被分离
问题分析
那么问题到底出在哪里呢?
vue 常见泄露场景
笔者搜遍了全网,网上所说的不外乎以下几种场景
1.「未清除的定时器」
2.「未及时解绑的全局事件」
3.「未及时清除的 dom 引用」
4.「未及时清除的 全局变量」
5.「console 对引用类型变量的劫持」
好像第一种和笔者的场景还比较类似,但是仔细看看代码好像也加了
beforeDestroy () {
clearTimeout(this.timer)
}
这段代码啊,就算不加,timer
执行完后,事件循环也会把它回收掉吧
同事提供灵感
就这样笔者这段代码来回测试了半天也没发现猫腻所在
这时候同事提供了一个想法说"total 更新的时候是不是可以提供一个 key
"
改了代码后就变成了这样了
<template>
<div class="test">
<div :key="renderKey">{{ total }}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 (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) {