阅读视图

发现新文章,点击刷新页面。

为了解决内存泄露,我把 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 无痕模式下执行

  1. 我们点击 render 按钮渲染 test 组件,此时我们发现 dom 节点的个数来到了 2045

image.png

考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板

image.png

  1. 500ms 后(定时器执行完成后,如果没复现可以把 500ms 调整为 1000ms, 1500ms),我们点击 destroy 按钮
  2. 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)

image.png

如果你的浏览器是最新的 chrome,还能够点击这里的 已分离的元素(detached dom),再点击录制

image.png

我们会发现此时整个 test 节点已被分离

image.png

问题分析

那么问题到底出在哪里呢?

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>

神奇的事情就这样发生了,笔者还是按以上流程测试了一遍,直接看结果吧

image.png

我们看到这个 DOM 节点曲线,在 destroy 的时候能够正常回收了

问题复盘

最简单的 demo 问题算是解决了

但是应用到实际项目中还是有点困难

难道我们要把每个更新的节点都手动加一个 key 吗?

其实仔细想想,有点 vue 基础的彦祖应该了解这个 key 是做什么的?

不就是为了强制更新组件吗?

等等,强制更新组件?更新组件不就是 updated 吗?

updated 涉及的不就是八股文中我们老生常谈的 patch 函数吗?(看来八股文也能真有用的时候😺)

那么再深入一下, patch 函数内部不就是 patchVnode 其核心不就是 diff 算法吗?

首对首比较,首对尾比较,尾对首比较,尾对尾比较 这段八股文要是个 vuer 应该都不陌生吧?😺

动手解决

其实有了问题思路和想法

那么接下来我们就深入看看 vue 源码内部涉及的 updated 函数到底在哪里吧?

探索 vue 源码

我们找到 node_modules/vue/vue.runtime.esm.js

image.png

我们看到了 _update 函数真面目,其中有个 __patch__ 函数,我们再重点查看一下

image.png

image.png

createPatchFunction 最后 return 了这个函数

image.png

我们最终来看这个 updateChildren 函数

image.png

其中多次出现了上文中所提到的八股文,每个都用 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))));
}

方案效果

让我们用同样的代码来测试下

image.png

测试了几次发现非常的顺利,至此我们本地的修改算是完成了

如何上线?

以上的方案都是基于本地开发的,那么我们如何把代码应用到线上呢?

其他开发者下载的 vue 包依旧是 老的 sameVnode

不慌,接着看

patch-package

对比了好几种方式,最终我们选择了这个神器

其实使用也非常简单

1.npm i patch-package

2.修改 node_modules/vue 源码

3.在根目录执行 npx patch-package vue(此时如果报错,请匹配对应 node 版本的包)

我们会发现新增了一个这样的文件

image.png

4.我们需要在package.json scripts 新增以下代码

  • package.json
"scripts": {
    +"postinstall":"patch-package"
}

至此上线后,其他开发者执行 npm i 后便能使变动的补丁生效了

优化点

其实我们的改造还有一定的进步空间,比如说在指定节点上新增一个 attribute

在函数内部判断这个 attributereturn false

这样就不用强制更新每个节点了

当然方式很多种,文章的意义在于解决问题的手段和耐心

写在最后

最后再次感谢同事 juejin.cn/user/313102… 的提供的灵感和协助

感谢彦祖们的阅读

个人能力有限

如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟

❌