JavaScript 中内存泄漏的原因以及如何避免它们

当涉及到“老式”网页时,对内存管理的关注不足通常不会产生戏剧性的后果。当用户浏览链接并加载新页面时,每次加载都会从内存中删除页面信息。

SPA(单页应用程序)的兴起促使我们更加关注与内存相关的 JavaScript 编码实践。如果应用程序开始逐渐使用越来越多的内存,那会严重影响性能甚至导致浏览器的选项卡崩溃。

在本文中,我们将探讨导致 JavaScript 内存泄漏的编程模式,并解释如何改进内存管理。

什么是内存泄漏以及如何发现它?

浏览器将对象保存在堆内存中,同时可以通过引用链从根访问它们。垃圾收集器是 JavaScript 引擎中的一个后台进程,用于识别无法访问的对象,将其删除并回收底层内存。

引用链-垃圾收集器-对象
从垃圾收集器根到对象的引用链示例

当本应在垃圾回收周期中清理的内存中的对象通过另一个对象的无意引用从根访问时,就会发生内存泄漏。将冗余对象保留在内存中会导致应用程序内部过度使用内存,并可能导致性能下降和性能下降。

引用链 - 内存泄漏
对象 4 不可访问,将从内存中删除。对象 3 仍然可以通过对象 2 中被遗忘的引用访问,并且不会被垃圾收集。

如何确定我们的代码正在泄漏内存?好吧,内存泄漏是偷偷摸摸的,通常很难被发现和定位。泄漏的 JavaScript 代码无论如何都不会被视为无效,浏览器在运行时不会抛出任何错误。如果我们注意到页面的性能越来越差,浏览器的内置工具可以帮助我们确定是否存在内存泄漏以及是什么对象导致的。

检查内存使用情况的最快方法是查看浏览器任务管理器(不要与操作系统的任务管理器混淆)。它们为我们提供了当前在浏览器中运行的所有选项卡和进程的概览。Chrome 的任务管理器可以通过在 Linux 和 Windows 上按 Shift+Esc 来访问,而通过在地址栏中键入 about:performance 内置于 Firefox 中。除此之外,它们还允许我们查看每个选项卡的 JavaScript 内存占用量。如果我们的站点只是坐在那里什么都不做,但是 JavaScript 内存使用量却在逐渐增加,那么我们很可能会发生内存泄漏。

开发人员工具正在提供更高级的内存管理方法。通过在 Chrome 的 Performance 工具中进行记录,我们可以直观地分析页面运行时的性能。一些模式是内存泄漏的典型模式,例如下面显示的增加堆内存使用的模式。

Chrome 中的性能记录
Chrome 中的性能记录——堆内存消耗持续增长(蓝线)

除此之外,Chrome 和 Firefox Developer Tools 都有很好的可能性在内存工具的帮助下进一步探索内存使用情况。比较连续的堆快照向我们展示了两个快照之间分配的内存位置和数量,以及帮助我​​们识别代码中有问题的对象的其他详细信息。

JavaScript 代码中内存泄漏的常见来源

搜索内存泄漏原因实际上是搜索可以“欺骗”我们保留对对象的引用的编程模式,否则这些对象将有资格进行垃圾回收。以下是代码中更容易发生内存泄漏并且在管理内存时值得特别考虑的地方的有用列表。

1.意外的全局变量

全局变量始终可从根目录获得,并且永远不会被垃圾回收。在非严格模式下,一些错误会导致变量从局部作用域泄漏到全局作用域中:

  • 为未声明的变量赋值,
  • 使用指向全局对象的“this”。
function createGlobalVariables() {
  leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
  this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

如何预防:严格模式(“use strict”)将帮助您防止内存泄漏并触发上例中的控制台错误。

2.闭包

函数作用域的变量将在函数退出调用堆栈后被清除,并且如果在函数之外没有任何引用指向它们。尽管函数已经完成执行并且它的执行上下文和变量环境早已不复存在,但闭包将保持变量被引用和存活。

function outer() {
  const potentiallyHugeArray = [];

  return function inner() {
    potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
    console.log('Hello');
  };
};
const sayHello = outer(); // contains definition of the function inner

function repeat(fn, num) {
  for (let i = 0; i < num; i++){
    fn();
  }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray

// now imagine repeat(sayHello, 100000)

在这个例子中,potentiallyHugeArray永远不会从任何函数返回并且无法到达,但它的大小可以无限增长,具体取决于我们调用function inner().

如何预防:闭包是 JavaScript 不可避免的组成部分,因此重要的是:

  • 了解闭包何时创建以及它保留了哪些对象,
  • 了解闭包的预期寿命和用法(尤其是用作回调时)。

3.计时器

在回调中使用 asetTimeout或 asetInterval引用某个对象是防止对象被垃圾回收的最常见方法。如果我们在我们的代码中设置循环计时器(我们可以使其setTimeout表现得像setIntervalie,通过使其递归),只要回调可调用,来自计时器回调的对象的引用将保持活动状态。

在下面的示例中,data只有在清除计时器后才能对对象进行垃圾回收。由于我们没有引用setInterval,因此它永远不会被清除并data.hugeString保留在内存中直到应用程序停止,尽管从未使用过。

function setCallback() {
  const data = {
    counter: 0,
    hugeString: new Array(100000).join('x')
  };

  return function cb() {
    data.counter++; // data object is now part of the callback's scope
    console.log(data.counter);
  }
}

setInterval(setCallback(), 1000); // how do we stop it?

如何防止它:特别是如果回调的生命周期未定义或不确定:

  • 意识到从计时器的回调中引用的对象,
  • 使用从计时器返回的句柄在必要时取消它。
function setCallback() {
  // 'unpacking' the data object
  let counter = 0;
  const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns

  return function cb() {
    counter++; // only counter is part of the callback's scope
    console.log(counter);
  }
}

const timerId = setInterval(setCallback(), 1000); // saving the interval ID

// doing something ...

clearInterval(timerId); // stopping the timer i.e. if button pressed

4.事件监听器

活动事件侦听器将防止在其范围内捕获的所有变量被垃圾收集。添加后,事件侦听器将一直有效,直到:

  • 明确删除removeEventListener()
  • 关联的 DOM 元素被移除。

对于某些类型的事件,它应该一直存在直到用户离开页面——比如应该被多次点击的按钮。然而,有时我们希望事件监听器执行一定次数。

const hugeString = new Array(100000).join('x');

document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
  doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

在上面的例子中,一个匿名内联函数被用作事件监听器,这意味着它不能被删除removeEventListener()。同样,无法删除文档,因此我们只能使用监听器函数及其范围内的任何内容,即使我们只需要它触发一次。

如何防止:一旦不再需要,我们应该始终注销事件侦听器,方法是创建一个指向它的引用并将其传递给removeEventListener().

function listener() {
  doSomething(hugeString);
}

document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here

如果事件侦听器必须只执行一次,addEventListener()则可以采用第三个参数,这是一个提供附加选项的对象。鉴于它{once: true}作为第三个参数传递给addEventListener(),监听函数将在处理一次事件后自动删除。

document.addEventListener('keyup', function listener() {
  doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

5.缓存

如果我们继续向缓存中添加内存而不删除未使用的对象并且没有一些限制大小的逻辑,则缓存可以无限增长。

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const mapCache = new Map();

function cache(obj){
  if (!mapCache.has(obj)){
    const value = `${obj.name} has an id of ${obj.id}`;
    mapCache.set(obj, value);

    return [value, 'computed'];
  }

  return [mapCache.get(obj), 'cached'];
}

cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']

console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1 = null; // removing the inactive user

// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache

在上面的例子中,缓存仍然保留着user_1对象。因此,我们需要额外清除缓存中永远不会被重用的条目。

可能的解决方案:要解决此问题,我们可以使用WeakMap. 它是一种具有弱键引用的数据结构,只接受对象作为键。如果我们使用一个对象作为键,并且它是对该对象的唯一引用——关联的条目将从缓存中删除并被垃圾收集。在下面的示例中,在使user_1对象为 null 之后,关联的条目会在下一次垃圾回收后自动从 WeakMap 中删除。

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const weakMapCache = new WeakMap();

function cache(obj){
  // ...same as above, but with weakMapCache

  return [weakMapCache.get(obj), 'cached'];
}

cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(weakMapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321"}
user_1 = null; // removing the inactive user

// Garbage Collector

console.log(weakMapCache); // ((…) => "Mark has an id of 54321") - first entry gets garbage collected

6.分离的DOM元素

如果一个 DOM 节点有来自 JavaScript 的直接引用,它将阻止它被垃圾收集,即使在节点从 DOM 树中删除之后也是如此。

在下面的示例中,我们创建了一个div元素并将其附加到document.bodyremoveChild()没有按预期工作,堆快照将显示分离的 HTMLDivElement,因为有一个变量仍然指向div.

function createElement() {
  const div = document.createElement('div');
  div.id = 'detached';
  return div;
}

// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv = createElement();

document.body.appendChild(detachedDiv);

function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // Heap snapshot will show detached div#detached

如何预防?一种可能的解决方案是将 DOM 引用移动到本地范围。在下面的示例中,指向 DOM 元素的变量在函数appendElement()完成后被删除。

function createElement() {...} // same as above

// DOM references are inside the function scope

function appendElement() {
  const detachedDiv = createElement();
  document.body.appendChild(detachedDiv);
}

appendElement();

function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // no detached div#detached elements in the Heap Snapshot

JS 内存泄漏结论

在处理重要的应用程序时,识别和修复 JavaScript 内存问题和泄漏可能会变成一项极具挑战性的任务。出于这个原因,内存管理过程的组成部分是了解典型的内存泄漏源,以防止它们首先发生。最后,当谈到内存和性能时,用户体验才是最重要的,而这才是最重要的。

作者:叶卡捷琳娜·武亚西诺维奇

JavaScript 中内存泄漏的原因以及如何避免它们
标签: