一文搞懂如何避免JavaScript内存泄漏

目录
  • 一、什么是内存泄漏
  • 二、常见的内存泄漏
    • 1、意外的全局变量
    • 2、 计时器
    • 3、 闭包
    • 4、 事件监听器
    • 5、缓存
    • 6、分离的DOM元素
  • 三、识别内存泄漏
    • 1、使用性能分析器可视化内存消耗
    • 2、 识别分离的 DOM 节点

大家好,我是CUGGZ。SPA(单页应用程序)的兴起,促使我们更加关注与内存相关的 JavaScript 编码实践。如果应用使用的内存越来越多,就会严重影响性能,甚至导致浏览器的崩溃。下面就来看看JavaScript中常见的内存泄漏以及如何避免内存泄漏。

一、什么是内存泄漏

JavaScript 就是所谓的垃圾回收语言之一,垃圾回收语言通过定期检查哪些先前分配的内存仍然可以从应用程序的其他部分“访问”来帮助开发人员管理内存。垃圾回收语言中泄漏的主要原因是不需要的引用。如果你的 JavaScript 应用程序经常发生崩溃、高延迟和性能差,那么一个潜在的原因可能是内存泄漏。

在 JavaScript 中,内存是有生命周期的:

  • 分配内存: 内存由操作系统分配,允许程序使用它。在 JavaScript 中,分配内存是自动完成的。
  • 使用内存: 这是程序实际使用先前分配的内存的空间。当在代码中使用分配的变量时,会发生读取和写入操作。
  • 释放内存: 释放不需要的内存,这样内存就会空闲并可以再次利用。在 JavaScript 中,释放内存是自动完成的。

在JavaScript中,对象会保存在堆内存中,可以根据引用链从根访问它们。垃圾收集器是 JavaScript 引擎中的一个后台进程,用于识别无法访问的对象、删除它们并回收内存。

下面是垃圾收集器根到对象的引用链示例:

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

那该如何判断代码正在泄漏内存呢?通常,内存泄漏是很难被发现的,并且浏览器在运行它时不会抛出任何错误。如果注意到页面的性能越来越差,浏览器的内置工具可以帮助我们确定是否存在内存泄漏以及导致内存泄漏的对象。

内存使用检查最快的方法就是查看浏览器的任务管理器。 它们提供了当前在浏览器中运行的所有选项卡和进程的概览。在任务管理器中查看每个选项卡的 JavaScript 内存占用情况。如果网站什么都不做,但是 JavaScript 内存使用量却在逐渐增加,那么很有可能发生了内存泄漏。

二、常见的内存泄漏

我们可以通过了解在 JavaScript 中如何创建不需要的引用来防止内存泄漏。以下情况就会导致不需要的引用。

1、意外的全局变量

全局变量始终可以从全局对象(在浏览器中,全局对象是window)中获得,并且永远不会被垃圾回收。在非严格模式下,以下行为会导致变量从局部范围泄露到全局范围:

(1)为未声明的变量赋值

这里我们给函数中一个未声明的变量bar赋值,这时就会使bar成为一个全局变量:

function foo(arg) {
    bar = "hello world";
}

这就等价于:

function foo(arg) {
    window.bar = "hello world";
}

这样就会创建一个多余的全局变量,当执行完foo函数之后,变量bar仍然会存在于全局对象中:

foo()
window.bar   // hello world

(2)使用指向全局对象的 this

使用以下方式也会创建一个以外的全局变量:

function foo() {
    this.bar = "hello world";
}

foo();

这里foo是在全局对象中调用的,所以其this是指向全局对象的(这里是window):

window.bar   // hello world

我们可以通过使用严格模式“use strict”来避免这一切。在JavaScript文件的开头,它将开启更严格的JavaScript解析模式,从而防止意外的创建全局变量。

需要特别注意那些用于临时存储和处理大量信息的全局变量。如果必须使用全局变量存储数据,就使用全局变量存储数据,但在不再使用时,就手动将其设置为 null,或者在处理完后重新分配。否则的话,请尽可能的使用局部变量。

2、 计时器

使用 setTimeout 或 setInterval 引用回调中的某个对象是防止对象被垃圾收集的最常见方法。如果我们在代码中设置了循环计时器,只要回调是可调用的,计时器回调中对对象的引用就会保持活动状态。

在下面的示例中,只有在清除计时器后,才能对数据对象进行垃圾收集。由于我们没有对setInterval的引用,所以它永远无法被清除和删除数据。hugeString会一直保存在内存中,直到应用程序停止,尽管从未使用过。

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

  return function cb() {
    data.counter++;   // data对象是回调范围的一部分
    console.log(data.counter);
  }
}

setInterval(setCallback(), 1000);

当执行这段代码时,就会每秒输出一个数字:

那我们如何去阻止他呢?尤其是在回调的寿命未定义或不确定的情况下:

  • 修改计时器回调中引用的对象;
  • 必要时使用从计时器返回的句柄(定时器的标识符)取消它。
function setCallback() {
  // 将数据对象解包
  let counter = 0;
  const hugeString = new Array(100000).join('x'); // 在setCallback返回时被删除

  return function cb() {
    counter++; // 只有计数器counter是回调范围的一部分
    console.log(counter);
  }
}

const timerId = setInterval(setCallback(), 1000); // 保存定时器的ID

// 合适的时机清除定时器
clearInterval(timerId);

3、 闭包

我们知道,函数范围内的变量在函数退出调用堆栈后,如果函数外部没有任何指向它们的引用,则会被清除。尽管函数已经完成执行,其执行上下文和变量环境早已消失,但闭包将保持变量的引用和活动状态。

function outer() {
  const potentiallyHugeArray = [];

  return function inner() {
    potentiallyHugeArray.push('Hello');
    console.log('Hello');
  };
};

const sayHello = outer();

function repeat(fn, num) {
  for (let i = 0; i < num; i++){
    fn();
  }
}
repeat(sayHello, 10);

显而易见,这里就形成了一个闭包。其输出结果如下:

这里,potentiallyHugeArray 永远不会从任何函数返回,也无法访问,但它的大小可能会无限增长,这取决于调用函数 inner() 的次数。

那该如何防止这个问题呢?闭包是不可避免的,也是JavaScript不可或缺的一部分,因此重要的是:

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

4、 事件监听器

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

  • 使用 removeEventListener() 显式删除。
  • 关联的 DOM 元素被移除。

对于某些类型的事件,它会一直保留到用户离开页面,就像应该多次单击的按钮一样。但是,有时我们希望事件侦听器执行一定次数。

const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // 匿名内联函数,无法删除它
  doSomething(hugeString); // hugeString 将永远保留在回调的范围内
});

在上面的示例中,匿名内联函数用作事件侦听器,这意味着不能使用 removeEventListener() 删除它。同样,document 不能被删除,因此只能使用 listener 函数以及它在其范围内保留的内容,即使只需要启动一次。

那该如何防止这个问题呢?一旦不再需要,我们应该通过创建指向事件侦听器的引用并将其传递给 removeEventListener() 来注销事件侦听器。

function listener() {
  doSomething(hugeString);
}
document.addEventListener('keyup', listener);
document.removeEventListener('keyup', listener);

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

document.addEventListener('keyup', function listener() {
  doSomething(hugeString);
}, {once: true});

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;
console.log(mapCache); // {{…} => 'Peter has an id of 12345', {…} => 'Mark has an id of 54321'}

在上面的示例中,缓存仍然保留 user_1 对象。因此,我们需要将那些永远不会被重用的变量从缓存中清除。

可以使用 WeakMap 来解决此问题。它是一种具有弱键引用的数据结构,仅接受对象作为键。如果我们使用一个对象作为键,并且它是对该对象的唯一引用——相关变量将从缓存中删除并被垃圾收集。在以下示例中,将 user_1 对象清空后,相关变量会在下一次垃圾回收后自动从 WeakMap 中删除。

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const weakMapCache = new WeakMap();
function cache(obj){
  // ...
  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;
console.log(weakMapCache); // {(…) => "Mark has an id of 54321"}

6、分离的DOM元素

如果DOM节点具有来自 JavaScript 的直接引用,它将防止对其进行垃圾收集,即使在从DOM树中删除该节点之后也是如此。

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

function createElement() {
  const div = document.createElement('div');
  div.id = 'detached';
  return div;
}
// 即使在调用deleteElement() 之后,它仍将继续引用DOM元素
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}
deleteElement();

要解决此问题,可以将DOM引用移动到本地范围。在下面的示例中,在函数appendElement() 完成后,将删除指向DOM元素的变量。

function createElement() {...}
// DOM引用在函数范围内
function appendElement() {
  const detachedDiv = createElement();
  document.body.appendChild(detachedDiv);
}
appendElement();
function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}
deleteElement();

三、识别内存泄漏

调试内存问题是一项复杂的工作,我们可以使用 Chrome DevTools 来识别内存图和一些内存泄漏,我们需要关注以下两个方面:

  • 使用性能分析器可视化内存消耗。
  • 识别分离的 DOM 节点。

1、使用性能分析器可视化内存消耗

以下面的代码为例,有两个按钮:打印和清除。点击“打印”按钮,通过创建 paragraph 节点并将大字符串设置到全局,将1到10000的数字追加到DOM中。

“清除”按钮会清除全局变量并覆盖 body 的正文,但不会删除单击“打印”时创建的节点:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Memory leaks</title>
  </head>
  <body>
    <button id="print">打印</button>
    <button id="clear">清除</button>
  </body>
</html>
<script>
  var longArray = [];

  function print() {
    for (var i = 0; i < 10000; i++) {
      let paragraph = document.createElement("p");
      paragraph.innerHTML = i;
      document.body.appendChild(paragraph);
    }
    longArray.push(new Array(1000000).join("y"));
  }

  document.getElementById("print").addEventListener("click", print);
  document.getElementById("clear").addEventListener("click", () => {
    window.longArray = null;
    document.body.innerHTML = "Cleared";
  });
</script>

当每次点击打印按钮时,JavaScript Heap都会出现蓝色的峰值,并逐渐增加,这是因为JavaScript正在创建DOM节点并字符串添加到全局数组。当点击清除按钮时,JavaScript Heap就变得正常了。除此之外,可以看到节点的数量(绿色的线)一直在增加,因为我们并没有删除这些节点。

在实际的场景中,如果观察到内存持续出现峰值,并且内存消耗一直没有减少,那可能存在内存泄露。

2、 识别分离的 DOM 节点

当一个节点从 DOM 树中移除时,它被称为分离,但一些 JavaScript 代码仍然在引用它。让我们使用下面的代码片段检查分离的 DOM 节点。通过单击按钮,可以将列表元素添加到其父级中并将父级分配给全局变量。简单来说,全局变量保存着 DOM 引用:

var detachedElement;
function createList(){
  let ul = document.createElement("ul");
  for(let i = 0; i < 5; i++){
    ul.appendChild(document.createElement("li"));
  }
  detachedElement = ul;
}
document.getElementById("createList").addEventListener("click", createList);

我们可以使用 heap snapshot 来检查分离的DOM节点,可以在Chrome DevTools 的Memory面板中打开Heap snapshots选项:

点击页面的按钮后,点击下面蓝色的Take snapshot按钮,我们可以在中间的搜索栏目输入Detached来过滤结果以找到分离的DOM节点,如下所示:

当然也可以尝试使用此方法来识别其他内存泄漏。

到此这篇关于一文搞懂如何避免JavaScript内存泄漏的文章就介绍到这了,更多相关JavaScript内存泄漏内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • JavaScript内存泄漏的处理方式

    下面就是小编整理的关于JS遇到内存泄漏问题时应该采取的处理方式. 随着现在的编程语言功能越来越成熟.复杂,内存管理也容易被大家忽略.本文将会讨论JavaScript中的内存泄漏以及如何处理,方便大家在使用JavaScript编码时,更好的应对内存泄漏带来的问题. 概述 像C语言这样的编程语言,具有简单的内存管理功能函数,例如malloc( )和free( ).开发人员可以使用这些功能函数来显式地分配和释放系统的内存. 当创建对象和字符串等时,JavaScript就会分配内存,并在不再使用时自动释

  • JS造成内存泄漏的几种情况实例分析

    本文实例讲述了JS造成内存泄漏的几种情况.分享给大家供大家参考,具体如下: 介绍: js中的内存垃圾回收机制:垃圾回收器会定期扫描内存,当某个内存中的值被引用为零时就会将其回收.当前变量已经使用完毕但依然被引用,导致垃圾回收器无法回收这就造成了内存泄漏.传统页面每次跳转都会释放内存,所以并不是特别明显. Vue单页面应用中:Web App 与 传统Web的区别,因为Web App是单页面应用页面通过路由跳转不会刷新页面,导致内存泄漏不断堆积,导致页面卡顿. 泄漏点: 1.DOM/BOM 对象泄漏

  • JS常见内存泄漏及解决方案解析

    内存泄漏? 官方解释:内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果. 通俗点就是指由于疏忽或者错误造成程序未能释放已经不再使用的内存,不再用到的内存却没有及时释放,从而造成内存上的浪费. 避免内存泄漏? 在局部作用域中,等函数执行完毕,变量就没有存在的必要了,垃圾回收机制很亏地做出判断并且回收,但是对于全局变量,很难判断什么时候不用这些变量,无法正常回收:所以,尽量少使用全局变量.在

  • 深入理解JavaScript程序中内存泄漏

    垃圾回收解放了我们,它让我们可将精力集中在应用程序逻辑(而不是内存管理)上.但是,垃圾收集并不神奇.了解它的工作原理,以及如何使它保留本应在很久以前释放的内存,就可以实现更快更可靠的应用程序.在本文中,学习一种定位 JavaScript 应用程序中内存泄漏的系统方法.几种常见的泄漏模式,以及解决这些泄漏的适当方法. 一.简介 当处理 JavaScript 这样的脚本语言时,很容易忘记每个对象.类.字符串.数字和方法都需要分配和保留内存.语言和运行时的垃圾回收器隐藏了内存分配和释放的具体细节. 许

  • 一篇文章弄懂javascript内存泄漏

    1.什么是内存泄漏 在了解什么是内存泄漏之前, 我们应该要对内存是什么有个概念, 随机存取存储器(英语:Random Access Memory,缩写:RAM)是与 CPU 直接交换数据的内部存储器.它可以随时读写, 而且速度很快,通常作为操作系统或其他正在运行中的程序的临时资料存储介质. 什么是内存泄漏? : 程序不再需要使用的内存, 但是又没有及时释放, 就叫做内存泄漏! 然后在理解泄漏之前, 我们的了解下内存的管理, 在一些底层语言中, 如C语言, 内存是需要开发者自己分配和释放的, 通过

  • 详谈JavaScript内存泄漏

    1.什么是闭包.以及闭包所涉及的作用域链这里就不说了. 2.JavaScript垃圾回收机制 JavaScript不需要手动地释放内存,它使用一种自动垃圾回收机制(garbage collection).当一个对象无用的时候,即程序中无变量引用这个对象时,就会从内存中释放掉这个变量. 复制代码 代码如下: var s = [ 1, 2 ,3];     var s = null;     //这样原始的数组[1 ,2 ,3]就会被释放掉了. 3.循环引用 三个对象 A .B .C AàBàC :

  • 一文搞懂如何避免JavaScript内存泄漏

    目录 一.什么是内存泄漏 二.常见的内存泄漏 1.意外的全局变量 2. 计时器 3. 闭包 4. 事件监听器 5.缓存 6.分离的DOM元素 三.识别内存泄漏 1.使用性能分析器可视化内存消耗 2. 识别分离的 DOM 节点 大家好,我是CUGGZ.SPA(单页应用程序)的兴起,促使我们更加关注与内存相关的 JavaScript 编码实践.如果应用使用的内存越来越多,就会严重影响性能,甚至导致浏览器的崩溃.下面就来看看JavaScript中常见的内存泄漏以及如何避免内存泄漏. 一.什么是内存泄漏

  • 一文搞懂Golang中的内存逃逸

    目录 前言 什么是内存逃逸 查看对象是否发生逃逸 内存逃逸分析的意义 怎么避免内存逃逸 小结 前言 我们都知道go语言中内存管理工作都是由Go在底层完成的,这样我们可以不用过多的关注底层的内存问题,有更多的精力去关注业务逻辑, 但掌握内存的管理,理解内存分配机制,可以让你写出更高效的代码,本文主要总结一下 Golang内存逃逸分析,需要的朋友可以参考以下内容,希望对大家有帮助. 什么是内存逃逸 在了解什么是内存逃逸之前,我们先来了解两个概念,栈内存和堆内存. 堆内存(Heap):一般来讲是人为手

  • 一文搞懂JavaScript中的内存泄露

    目录 什么是内存泄漏 怎么检测内存泄漏 Performance Memory 内存泄漏的场景 垃圾回收算法 引用计数 循环引用 标记清除 闭包是内存泄漏吗 总结 以前我们说的内存泄漏,通常发生在后端,但是不代表前端就不会有内存泄漏.特别是当前端项目变得越来越复杂后,前端也逐渐称为内存泄漏的高发区.本文就带你认识一下Javascript的内存泄漏. 什么是内存泄漏 什么是内存?内存其实就是程序在运行时,系统为其分配的一块存储空间.每一块内存都有对应的生命周期: 内存分配:在声明变量.函数时,系统分

  • 一文搞懂JavaScript中原型与原型链

    目录 1.构造函数原型prototype 2.对象原型__proto__ 3.constructor构造函数 4.原型链 5.原型对象中的this指向 6.扩展内置对象(原型对象的应用) 在ES6之前,我们面向对象是通过构造函数实现的.我们把对象的公共属性和方法放在构造函数里 像这样: function student(uname,age) { this.uname = uname; this.age = age; this.school = function() { console.log('

  • 一文搞懂C++ 动态内存

    了解动态内存在 C++ 中是如何工作的是成为一名合格的 C++ 程序员必不可少的.C++ 程序中的内存分为两个部分: 栈:在函数内部声明的所有变量都将占用栈内存. 堆:这是程序中未使用的内存,在程序运行时可用于动态分配内存. 很多时候,您无法提前预知需要多少内存来存储某个定义变量中的特定信息,所需内存的大小需要在运行时才能确定. 在 C++ 中,您可以使用特殊的运算符为给定类型的变量在运行时分配堆内的内存,这会返回所分配的空间地址.这种运算符即new 运算符. 如果您不再需要动态分配的内存空间,

  • 一文搞懂JSON(JavaScript Object Notation)

    目录 JSON出现 Json结构 Json对象 Json对象与JavaScript对象 JSON 和 JavaScript 对象互转 Json数组 复杂数组类型 复杂对象数组组合 对象包含数组 数组包含对象 JSON 语法规则 Json键值对 Json值的数据类型 JSON(JavaScript Object Notation, JS 对象标记)是一种轻量级的数据交换格式,通常用于服务端向网页传递数据 .与 XML 一样,JSON 也是基于纯文本的数据格式. Json 文件的文件后缀是 .Jso

  • 一文搞懂JavaScript如何实现图片懒加载

    目录 实现思路 准备知识 data-* getBoundingClientRect() throttle window.innerHeight 完整代码 js部分 CSS部分 运行结果 总结 图片懒加载,往往作为减少首页白屏时间的一个解决方案而出现.直观的来说,就是不要直接加载所有图片,而是满足一定条件后才加载,也就是”惰性加载“.实现图片懒加载的方式有很多,如果要简单点那就直接使用第三方插件:vue-lazyload,如果想探究一下别人的插件是怎么实现图片懒加载的,那么可以看看本文是如何实现的

  • 一文搞懂JavaScript中bind,apply,call的实现

    目录 bind.call和apply的用法 bind call&apply 实现bind 实现call和apply 总结 bind.call和apply都是Function原型链上面的方法,因此不管是使用function声明的函数,还是箭头函数都可以直接调用.这三个函数在使用时都可以改变this指向,本文就带你看看如何实现bind.call和apply. bind.call和apply的用法 bind bind()方法可以被函数对象调用,并返回一个新创建的函数. 语法: function.bin

  • 一文带你搞懂C语言动态内存管理

    目录 一.malloc函数和free函数 二.calloc函数与malloc函数的异同 三.柔性数组 一.malloc函数和free函数 (1) 这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针. 如果开辟成功,则返回一个指向开辟好空间的指针. 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查. 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定. 如果参数 size为0,malloc的行为是标准是未

  • 一文搞懂C# 数据类型

    在 C# 中,变量分为以下几种类型: 值类型(Value types) 引用类型(Reference types) 指针类型(Pointer types) 值类型(Value types) 值类型变量可以直接分配给一个值.它们是从类 System.ValueType 中派生的. 值类型直接包含数据.比如 int.char.float,它们分别存储数字.字符.浮点数.当您声明一个 int 类型时,系统分配内存来存储值. 下表列出了 C# 2010 中可用的值类型: 类型 描述 范围 默认值 boo

随机推荐