此名字暂不可见
No distraction,it's you and me

闭包,垃圾回收和内存泄露

2025-02-18

一 . 闭包

1.什么是闭包:

​ 什么是闭包?引用JS书中的定义,当一个函数能够记住并且访问其所在的作用域,即使这个函数是在作用域之外被执行的,这就是闭包。

1
2
3
4
5
6
7
8
9
function a() {
let a = 1 // a 是一个被 a 创建的局部变量
function b() { // b 是一个内部函数
console.log(a) // 使用了父函数中声明的变量
}
return b
}
let c = a()
c() //1 这就是闭包

​ 在a() 函数中声明了一个私有变量 a , b() 函数是 a() 函数内部的函数,此时 a 内部的所有局部变量,对 b 都是可见的,但是a()中的变量对于外部是不可见的,b 内部的局部变量,对 a 就是不可见的。这就是JS的”作用域链“。其中,a()在执行之后,将 b 返回,并赋值给了c ,通过c从外部访问了 a() 的内部变量a。

​ 闭包是一种保护私有变量的机制,它在函数执行时创建一个私有作用域,从而保护内部的私有变量不受外界干扰,同时允许外界访问内部的私有变量。

​ 直观地说,闭包就像是一个不会被销毁的栈环境。

2.闭包的优点:

​ 从上面的例子可以注意到,a是a()中的一个私有变量,但是我们却在全局作用域中访问了函数的私有变量,从中可以看出了闭包的几个优点:

  1. 保护私有变量
  2. 延长变量寿命
  3. 实现变量共享

为什么这么说呢?可以来看看接下来的几个例子。

1
2
3
4
5
6
7
8
9
10
11
12
	
function a() {
let a = 1
function b() {
console.log(a)
}
return b
}
let c = a()
const a = 2 // 在全局作用域中为a赋值为2
c() //最后结果仍然是1

​ 上述代码中,a是a()中的一个私有变量,因此外部无法直接访问它。即使在全局声明了一个同名变量a,也无法影响到函数内部,a变量的值只能在函数内部被修改和访问,这样就保护了闭包中的私有变量。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

function fn(){
let a = 1
function f1(){
let n = 0
console.log(n++)
console.log(a++)
}
return f1
}

const add = fn()
add() //1 2
add() //1 3 a还活着!


​ 一般情况下,在函数fn执行完后,就应该连同它里面的变量一同被垃圾回收,但是在这个例子中,f1()函数作为fn的返回值被赋值给了add,这时候相当于add=function f1(){ … },并且f1()内部引用着fn里的变量a,所以变量a无法被销毁,而变量n是每次被调用时新创建的,所以每次f1执行完后它就把属于自己的变量连同自己一起销毁,但是把a保留了下来,实现了延长变量寿命,但这又出现了一个新问题,后面再讲。


1
2
3
4
5
6
7
8
9
10
11
function a() {
let a = 1
function b() {
console.log(a)
}
return b
}
let c = a()
c() //1 我从外部访问了私有变量!


​ 还是上面的例子,即使a是**a()**中的私有变量,但仍然从外部访问了它,这就是变量共享。


3. 闭包的作用

3.1节流

节流是指限制函数的执行频率,即在一定时间间隔内最多只执行一次**,即使事件被多次触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function throttle(fn, delay) {
let lastTime = 0;
return function (...args) {
let now = Date.now();
if (now - lastTime >= delay) {
fn.apply(this, args);
lastTime = now;
}
};
}


const log = throttle(() => console.log("我滚了"), 1000);
window.addEventListener("scroll", log);

3.2 防抖

防抖是指在事件触发后,一定时间内不再触发,才执行函数。

​ 如果在等待时间内再次触发,则重新计时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}


const input = document.querySelector("input");
const handleInput = debounce((event) => {
console.log("搜索:" + event.target.value);
}, 500);

input.addEventListener("input", handleInput);

3.3 函数柯里化

柯里化 是指将一个多参数函数转换成多个单参数函数的调用链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function (...nextArgs) {
return curried.apply(this, args.concat(nextArgs));
};
}
};
}


function add(a, b, c) {
return a + b + c;
}


const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

3.4 模块化

模块化是指将代码拆分成独立的模块,每个模块负责不同的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const CounterModule = (function () {
let count = 0;
return {
increment: function () {
count++;
console.log("Count:", count);
},
decrement: function (){
count--;
console.log("Count:", count);
}
reset: function () {
count = 0;
console.log("Reset to 0");
},
};
})();


CounterModule.increment(); // Count: 1
CounterModule.increment(); // Count: 2
CounterModule.decrement(); // Count: 1
CounterModule.reset(); // Reset to 0

​ 现在,再返回前面的问题,为什么fn()中的a被保留下来,而f1()中的n被销毁了呢?这就引出了JS的垃圾回收机制。

二 . 垃圾回收

1. 什么是垃圾:

​ 什么是垃圾呢?垃圾是如何定义的呢?

​ 很简单,JS中的函数,变量,对象等都需要占用一定的内存,当这些东西不再被使用的时候,就变成了垃圾。

垃圾可以分为以下几类:

  1. 已经调用完毕的函数作用域及其内部的值
  2. 无法被访问到的值

​ 正如之前所说,JS中的所有的变量都会占用内存,当这些变量变成垃圾的时候,如果不进行回收,内存就会被一直占用,随着程序的运行,垃圾也会越来越多,总有一刻,内存会被占满,程序也就无法运行了。


2. 垃圾回收机制:

​ 首先,根据JS的内存分配机制,基本数据类型保存在固定的占空间中,可以直接通过值进行访问,但是复杂数据类型的大小不固定,所以其会通过引用地址保存在栈空间,而引用的指向保存的值保存在堆空间,通过引用访问,比如函数,对象等。

​ 栈内存中的基本数据类型,可以直接通过操作系统来进行处理,但是堆内存中的复杂数据类型的值的大小不确定,这就需要JS的引擎通过垃圾回收机制进行处理。

2.1 可达性:

​ 在JS中,内存管理的主要概念是可达性,”可达性“值就是那些以某种方式可访问的值,他们被保证存储在内存中。

1
2
3
const me = {
name:"奶龙"
}

​ 在这里,创建了一个全局变量me,它指向了一个对象{name:“奶龙”},因此这个这个对象是可达的。

1
2
3
4
5
 const me = {
name:"奶龙"
}

me = null

​ 此时,对象{name:“奶龙”}处于不可达的状态,由于无法访问它,垃圾回收器将回收这个数据并释放内存。

2.2 引用计数和标记清除:

​ 在浏览器的历史上,有两种垃圾回收的方法:

  • 标记清除
  • 引用计数

引用计数

​ 引用计数是一种几乎被淘汰的垃圾回收机制,它通过对每个复杂数据类型的变量进行计数,比如:

  • 当变量声明并赋值之后,值得引用数为1
  • 当同一个值被赋值给另一个变量时,引用数加1
  • 当保存该值引用的变量被其它值覆盖时,引用数减1
  • 当一个值的引用数变为0时,就表示无法再访问该值,此时垃圾回收机制就会将其清除并回收内存

​ 看起来很完美,但只是看起来而已,引用计数存在一个严重的缺陷:

1
2
3
4
5
6
7
8
9
10
11
function problem(){
const objA = {}
const objB = {}
objA.a = objB
objB.b = objA

objA = null
objB = null
}


​ 可以看到,虽然没有其他引用指向objA和objB之前分别指向的两个对象,但它们相互引用,导致它们的引用数永远不会为零,也就永远不会被回收了。因此,现代浏览器采用了另一种新的回收机制。

标记清除

​ 相对于引用计数,标记清除就简单的多了,无非就是打不打标记的问题,就跟二进制的0和1十分相似,具体步骤如下:

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
  • 然后从各个根对象开始遍历,把不是垃圾的节点改成1
  • 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  • 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收

​ 但是,标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题,这里不过多介绍。

回过头来,再来解释上面的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fn(){
let a = 1
function f1(){
let n = 0
console.log(n++)
console.log(a++)
}
return f1
}

const add = fn()
add() //1 2
add() //1 3 我的a还活着!

fn 执行

  • 当调用 fn() 时,局部变量 a 被创建并赋值为 1
  • 然后返回的是内部函数 f1,并且这个函数内部会访问外部的 a 变量。

闭包形成

  • 函数 f1 是一个 闭包,它访问了 fn 中的 a 变量。因此,a 被保存在 f1 的作用域中。
  • 这意味着即使 fn 执行完毕,fn 中的 a 变量依然存在于 f1闭包环境中,并不会被销毁。

**调用 add()**:

  • addfn() 的返回值,即 f1 函数。
  • 每次调用 add() 时,f1 执行并访问了闭包中的 a 变量。
  • 尽管 fn 执行完毕,a 变量的作用域并没有被销毁,因为 f1(闭包)仍然在访问它。闭包让 a 保持在内存中,直到闭包本身不再被引用。

省流:

根对象:

  • window
  • add(全局变量,指向 f1

可达对象:

  • add 指向 f1
  • f1 通过闭包作用域 引用了 a
  • a 仍然可达

三. 内存泄漏

1.什么是内存泄露

​ 在介绍闭包的时候,我们发现了一个问题,现在再来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fn(){
let a = 1
function f1(){
let n = 0
console.log(n++)
console.log(a++)
}
return f1
}

const add = fn()
add() //1 2
add() //1 3 我的a还活着!

​ 由于我们使用了闭包,因此延长了局部变量a的寿命,使得我们能够在函数执行完毕时访问它,但这样也导致了a会一直存活,无法被回收,这就是内存泄露。

内存泄漏(Memory Leak)是指程序在运行时无法释放不再使用的内存,导致内存逐渐耗尽,甚至造成程序崩溃或性能下降。

2. 怎样会引起内存泄露

2.1 闭包

​ 略

2.2 意外的全局变量

​ 全局变量具有非常长的生命周期,一直到页面被关闭,它都会存活着,所以全局变量上的内存一直都不会被回收。

2.3 角落的定时器

​ 定时器是我们经常使用的一个功能,如果我们在某个页面使用了定时器,但在关闭定时器时没有清除定时器的话,定时器还是活着的。换句话说,定时器的生命周期并不挂靠在页面上,如果在当前页面的JS通过定时器注册了某个回调函数,而该回调函数又持有当前页面的某个变量或某个DOM元素,就会导致即使页面销毁,由于定时器持有该页面部分引用而造成页面无法正常回收,导致内存泄漏。

1
2
3
4
5
6
7
8
9
function clearTimeoutExample() {
let obj = new Array(1000000).fill("Large Object");

let timeoutId = setTimeout(function() {
console.log("This will be cleared after 1 second.");
}, 1000);

}
clearTimeoutExample();

2.4 被遗忘的DOM元素

​ DOM元素的生命周期是取决于是否可以挂载在DOM树上,当从DOM树上移除时,也就可以被销毁移除了。但是,如果某个DOM元素在JS中也持有它的引用时,那么它的生命周期就由JS和是否在DOM树上二者决定了,只有将两个地方都去清理才能正常回收它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="myElement">Hello, World!</div>
<button id="removeButton">Remove Element</button>

<script>

let element = document.getElementById("myElement");


document.getElementById("removeButton").addEventListener("click", function() {
document.body.removeChild(element);


});
</script>

​ 上述代码中,即使我们从DOM树中移除了#myElement 元素,但element 变量仍然持有对 #myElement 的引用,导致内存泄露。

3.如何解决内存泄露

1
2
3
4
5
6
7
8
9
10
11
function big() {
let bigData = new Array(1000000).fill("big");

return function() {
console.log(bigData.length);
};
}

let createData = big();
// 在不再需要时,手动解除引用
createData = null; // 解除闭包对 largeData 的引用,允许垃圾回收

1
2
3
4
5
6
7
8
9
function a() {
let a = 1
function b() {
console.log(a)
}
return b
}
let c = a()
c() //尽量使用局部变量而非全局变量

1
2
3
4
5
6
7
8
9
10
11
function clearTimeoutExample() {
let obj = new Array(1000000).fill("Large Object");

let timeoutId = setTimeout(function() {
console.log("This will be cleared after 1 second.");
}, 1000);

// 在不再需要时清除定时器
clearTimeout(timeoutId);
}
clearTimeoutExample();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="myElement">Hello, World!</div>
<button id="removeButton">Remove Element</button>

<script>
// 在JS中持有对DOM元素的引用
let element = document.getElementById("myElement");

// 给按钮绑定事件监听器
document.getElementById("removeButton").addEventListener("click", function() {
// 从DOM树中移除元素
document.body.removeChild(element);

// 清除对元素的引用,允许垃圾回收
element = null;
});

< PreviousPost
http与WebSocket
NextPost >
作用域链与执行上下文
CATALOG
  1. 1. 一 . 闭包
    1. 1.1. 1.什么是闭包:
    2. 1.2. 2.闭包的优点:
    3. 1.3. 3. 闭包的作用
      1. 1.3.0.1. 3.1节流
      2. 1.3.0.2. 3.2 防抖
      3. 1.3.0.3. 3.3 函数柯里化
      4. 1.3.0.4. 3.4 模块化
  • 2. 二 . 垃圾回收
    1. 2.1. 1. 什么是垃圾:
    2. 2.2. 2. 垃圾回收机制:
      1. 2.2.1. 2.1 可达性:
      2. 2.2.2. 2.2 引用计数和标记清除:
        1. 2.2.2.1. 引用计数
        2. 2.2.2.2. 标记清除
  • 3. 三. 内存泄漏
    1. 3.1. 1.什么是内存泄露
    2. 3.2. 2. 怎样会引起内存泄露
      1. 3.2.1. 2.1 闭包
      2. 3.2.2. 2.2 意外的全局变量
      3. 3.2.3. 2.3 角落的定时器
      4. 3.2.4. 2.4 被遗忘的DOM元素
    3. 3.3. 3.如何解决内存泄露