一、什么是内存泄漏

内存泄露:应用程序不再需要占用内存的时候,由于某些原因,内存没有被操作系统或可用内存池回收。

编程语言管理内存的方式各不相同。只有开发者最清楚哪些内存不需要了,操作系统可以回收。

二、内存泄漏的场景

1. 意外的全局变量

由于 JS 对未声明变量的处理方式是在全局对象上创建该变量的引用。
如果在浏览器中,全局对象就是 window 对象。
变量在窗口关闭或重新刷新页面之前都不会被释放,如果未声明的变量缓存大量的数据,就会导致内存泄露。

  1. function foo() {
  2. bar1 = 'some text'; // 没有声明变量 实际上是全局变量 => window.bar1
  3. this.bar2 = 'some text' // 全局变量 => window.bar2
  4. }
  5. foo();

2. 被遗忘的定时器和回调函数

在很多库中,如果使用了观察者模式,都会提供回调方法,来调用一些回调函数。要记得回收这些回调函数。
举一个setInterval的例子:

  1. var serverData = loadData();
  2. setInterval(function() {
  3. var renderer = document.getElementById('renderer');
  4. if(renderer) {
  5. renderer.innerHTML = JSON.stringify(serverData);
  6. }
  7. }, 5000); // 每 5 秒调用一次

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。
但如果你没有回收定时器,整个定时器依然有效,不但定时器无法被内存回收,定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

3. 闭包

在 JS 开发中,我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。
下面这种情况下,闭包也会造成内存泄露:

  1. var theThing = null;
  2. var replaceThing = function () {
  3. var originalThing = theThing;
  4. var unused = function () {
  5. if (originalThing) // 对于 'originalThing'的引用
  6. console.log("hi");
  7. };
  8. theThing = {
  9. longStr: new Array(1000000).join('*'),
  10. someMethod: function () {
  11. console.log("message");
  12. }
  13. };
  14. };
  15. setInterval(replaceThing, 1000);

这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。 同时 unused 是一个引用了 originalThing 的闭包。

这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。 当这段代码被反复执行时,内存会持续增长。

4. DOM 引用

很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中。

  1. var elements = {
  2. image: document.getElementById('image')
  3. };
  4. function doStuff() {
  5. elements.image.src = 'http://example.com/image_name.png';
  6. }
  7. function removeImage() {
  8. document.body.removeChild(document.getElementById('image'));
  9. // 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
  10. }

上述案例中,即使我们对于 image 元素进行了移除,但因为 doStuff 随时可能引用它,依然无法对该元素进行内存回收。

三、如何避免内存泄漏

查找内存泄漏是否存在

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。
这就要求实时查看内存的占用情况,这个通过查看 dev Tools 的 Performance,勾选 memory,查看折线图

下面是曾经做的档案系统,从 JS Heap 中可以看出,在一段时间后,下降得跟一开始差不多,没有内存泄漏。

观察蓝色折线

image.png
下面这个是测试掘金,任意一篇文章的页面。蓝色的折线,JS Heap 明显上升,并且一段时间后,没有释放。
这存在内存泄漏。
image.png
可以利用下列的代码,自己测试,感受一下,内存泄漏的情况。

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8" />
  5. </head>
  6. <body>
  7. <div id="app">
  8. <button id="run">运行</button>
  9. <button id="stop">停止</button>
  10. </div>
  11. <script>
  12. const arr = []
  13. for (let i = 0; i < 200000; i++) {
  14. arr.push(i)
  15. }
  16. let newArr = []
  17. function run() {
  18. newArr = newArr.concat(arr)
  19. }
  20. let clearRun
  21. document.querySelector('#run').onclick = function() {
  22. clearRun = setInterval(() => {
  23. run()
  24. }, 1000)
  25. }
  26. document.querySelector('#stop').onclick = function() {
  27. clearInterval(clearRun)
  28. }
  29. </script>
  30. </body>
  31. </html>

查找内存泄漏的位置

通过查看 dev tools 的 Memory,多次快照,查找哪个堆占用内存更高,从这个地方,开始排查。

下面我们用上面的 HTML 代码做测试。
从侧边栏的快照内存,就可以看出内存一直增加,没有减少,最后两个相同,是因为已经点击了停止。
image.png
我们选一个快照,把浅层内存shallow size按顺序排,可以很快找到 array 构造函数最大。
点开后,可以很快看到shallow size最大的,选中后,可以在下面看详细信息。
newArr就是引起我们内存泄漏的罪魁祸首,就是这个变量。然后我们点击下面的 index.html:18,就是跳转到它的代码位置。
然后去代码里,修改它的代码逻辑就好了。
image.png

参考资料

《JavaScript内存泄露的4种方式及如何避免》
《「前端进阶」JS中的内存管理》
《深入了解 JavaScript 内存泄露》