Skip to content

Latest commit

 

History

History
294 lines (215 loc) · 10.8 KB

前端内存优化知多少?内存泄露只是冰山一角.md

File metadata and controls

294 lines (215 loc) · 10.8 KB

前端开发者往往不太关注页面所占用的内存,因为前端内存的分配与回收基本是依靠系统自动完成的,这个过程对于开发者是无感的

但内存优化的作用也同样重要,一个好的网站,内存的优化也是极致的,比如淘宝的首页,只有 10M 大小

特别是随着前端项目的逐渐复杂,内存的占用也逐渐攀升,曾经遇到过一个项目:页面的内存超 300M,同时打开几个窗口,就导致了页面崩溃,从此开始重视内存优化

淘宝.png

内存泄露只是冰山一角

之前有这种误解:认为只有内存泄露的时候,才需要去优化内存。然而,经过项目实践,发现很多场景下都有内存优化的必要,而内存泄露只是比较极端的场景之一

比如,页面中总有一些大数据渲染的场景,除了会造成页面渲染变慢外,所占用的内存也是巨大的

以下页面,左侧是一个 Tree 树形控件,该控件一次性加载了三千条数据。难以置信,该页面的内存竟然到了113M,而改为懒加载子节点后,该页面的内存直接降到了15M,内存的前后差异是惊人的

treeList.png

内存分析的流程

使用 Memory 工具,通过内存快照的方式,分析当前页面的内存使用情况(熟悉这块的同学可以直接跳到下面的优化建议)

Memory 使用流程

1)打开 chrome 浏览器控制台,选择 Memory 工具

2)点击左侧 start 按钮,刷新页面,开始录制 JS堆动态分配时间线,会生成页面加载过程内存变化的柱状统计图(蓝色表示未回收,灰色表示已回收)

memory.jpg

Memory 工具中的关键项

Constructor:对象的类名;
Distance:对象到根的引用层级;
Objects Count:对象的数量;
Shallow Size: 对象本身占用的内存,不包括引用的对象所占内存;
Retained Size: 对象所占总内存,包含引用的其他对象所占内存;
Retainers:对象的引用层级关系

通过以下测试代码来了解 Memory 各关键项的关系

// 测试代码
class Jane {}
class Tom {
  constructor() {
    this.jane = new Jane();
  }
}
let list = Array(1000000)
  .fill('')
  .map(() => new Tom());

Tom.png

shallow size 和 retained size 的区别,以用红框里的 TomJane 更直观的展示

Tom 的 shallow 占了 16M,retained 占用了 28M,这是因为 retained 包括了引用的指针对应的内存大小,即 tom.jane 所占用的内存

所以 Tom 的 retained - shallow = 12M,正好跟 Jane 占用的 12M 相同

retained size 可以理解为当回收掉该对象时可以释放的内存大小,在内存调优中具有重要参考意义

内存分析的关键点

通过 JS 堆动态分配时间线,找到内存最高的节点,分析这些时刻执行了哪些代码,发生了什么操作,尽可能去优化它们

1)从柱状图中找到最高的点,并选中它们

2)按照 Retainers size(总内存大小)排序,点击展开内存最高的哪一项,点击展开构造函数,可以看到所有构造函数相关的对象实例

3)选中构造函数,底部会显示对应源码文件,点击源码文件,可以跳转到具体的代码,这样我们就知道是哪里的代码造成内存过大

4)结合具体的代码逻辑,来判断这块内存变大的原因,重点是如何去优化它们,降低内存的使用大小

retainedSize.jpg

点击keyghost.js可以跳转到具体的源码

localkey.png

内存优化的建议

1、减少组件 DOM 渲染

大数据渲染始终是前端的一大难题,DOM 渲染会占用很大的内存,非常吃性能

根据笔者的以往案例,这往往是导致页面崩溃的首要原因,特别是对于电脑或手机配置低的用户

breakdown.png

可以通过:数据懒加载、组件懒加载、虚拟滚动、数据分页等方式,来减少组件的 DOM 渲染

2、 window 上的监听事件没有移除或移除错误

在 window 上添加的监听事件,在页面卸载时要主动移除,并注意移除的正确性

<template>
  <div id="about">这里是about页</div>
</template>

<script>
export default {
mounted () {
  window.addEventListener('resize', this.fn) // window对象引用了about页面的方法
  }
}
</script>

在页面销毁的时候,主动解绑,释放内存

mounted () {
  window.addEventListener('resize', this.fn)
},
beforeDestroy () {
  window.removeEventListener('resize', this.fn)
}

对于函数节流与防抖的场景,要特别注意:确保移除的是同一个事件,如果姿势不对,可能依旧会造成内存泄漏

// 版本一
mounted() {
    window.addEventListener('resize', debounce(this.fn, 100))
},
beforeDestroy() {
    window.removeEventListener('resize', debounce(this.fn, 100))
}

这段代码的写法是错误的,因为每次调用debounce(this.fn, 100)时, 其实都会返回一个新的函数,导致 addEventListener 和  removeEventListener 方法传入的回调函数已经不是同一个函数,监听器没有被正确移除

正确的写法:

// 版本二
data() {
    return {
        debounceFn: null
    }
},
mounted() {
    this.debounceFn = debounce(this.fn, 100)
    window.addEventListener('resize', this.debounceFn)
},
beforeDestroy() {
    window.removeEventListener('resize', this.debounceFn)
}

3、 console 导致的内存泄漏

我们总会在调试代码时,加上很多 console 打印。以下代码,因为 list 数组被 console 所引用,导致 list 内存不能被释放

function fn() {
  let list = new Array(10 * 1024 * 1024).fill(1); // 约占42M内存
  return function () {
    console.log(list);
  };
}
fn()();

console.png

经过验证,只有 devtools 打开时,console 打印才会引起内存泄漏的,如果不打开控制台,console 是不会引起内存变化的。稳妥起见,需要在生产环境时使用插件过滤掉 console 打印

// vue.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  configureWebpack: (config) => {
    if (process.env.NODE_ENV === 'production') {
      // 返回一个将会被合并的对象
      return {
        optimization: {
          minimizer: [
            new TerserPlugin({
              sourceMap: false,
              terserOptions: {
                compress: {
                  drop_console: true
                }
              }
            })
          ]
        }
      };
    }
  }
};

4、 闭包的错误使用

上一篇文章 闭包用多了会造成内存泄露?90%的人都理解错了 解释了项目中大量使用闭包,并不会造成内存泄漏,除非是错误的写法

错误的写法:闭包所引用的变量在函数外部。因为开发者需要非常小心,否则稍有不慎就会造成内存泄露,我们总是希望可以通过 gc 自动回收,避免人为干涉

正确的写法:闭包引用的变量定义在函数中。这样随着外部引用的销毁,该闭包就会被 gc 自动回收 (推荐),无需人工干涉

// 错误的写法: 闭包所引用的info变量在函数外部
let info = {
  arr: new Array(10 * 1024 * 1024).fill(1),
  timer: null
};
export const debounce = (fn, time) => {
  // 正确的写法: 闭包所引用的info变量在函数内部
  let info = {
    arr: new Array(10 * 1024 * 1024).fill(1),
    timer: null
  };
  return function (...args) {
    info.timer && clearTimeout(info.timer);
    info.timer = setTimeout(() => {
      fn.apply(this, args);
    }, time);
  };
};

5、绑在 EventBus 的事件没有解绑

举个例子

<template>
  <div id="home">这里是首页</div>
</template>

<script>
export default {
  mounted () {
   this.$EventBus.$on('homeTask', res => this.fn(res))
  }
}
</script>
复制代码

在页面卸载的时候可以考虑解除引用

mounted () {
 this.$EventBus.$on('homeTask', res => this.fn(res))
},
destroyed () {
 this.$EventBus.$off()
}

6、弱引用:weakset、weakmap

有些情况,需要手动清除引用。但有时候一疏忽就忘了,所以才有那么多内存泄漏

ES6 推出了两种新弱引用的数据结构: weaksetweakmap。它们对值的引用都是不计入垃圾回收机制的,如果其他对象都不再引用该对象,那么 gc 会自动回收该对象所占用的内存

const weak = new WeakMap();
const element = document.getElementById('#app');
weak.set(element, 'app');

注册监听事件的 listener 对象很适合用 WeakMap 来实现。

// 代码1
element.addEventListener('click', handler, false);

// 代码2
weak.set(element, handler);
element.addEventListener('click', weak.get(element), false);

代码 2 比起代码 1 的好处是:由于监听函数是放在 WeakMap 里面,一旦 element 对象的其他引用消失,与它绑定的监听函数 handler 所占的内存也会被自动释放

总结

内存优化虽然是前端性能优化中比较冷门的方向,但通过对页面的内存分析,并尝试优化它,可以帮助我们更好的了解自己的项目

当面试官问起:“你的页面内存有多少,有哪些方面去优化它?”时,可以结合自己的实践,深入的讲解我们关于内存优化的理解

参考文章:
万恶的前端内存泄漏及万善的解决方案
JavaScript 内存机制(前端同学进阶必备)
V8 内存浅析