什么是闭包
闭包是一个函数及其周围词法环境(lexical environment)的组合,词法环境指的是该函数创建时所处的作用域环境,包括该作用域中的所有局部变量。闭包允许函数在外部作用域中访问局部变量,即使外部函数已经执行完毕并返回。
当一个函数被创建时,JavaScript 会为它创建一个闭包,其中包含了该函数的代码和它的词法环境。这意味着即使外部函数已经执行结束,内部函数依然可以访问到外部函数中的变量。这是闭包的核心特性,也让它在创建私有变量、实现模块化等方面有广泛应用。
举个例子,在 JavaScript 中,当一个函数内部返回另一个函数时,内部函数形成了闭包,它会“记住”外部函数的变量,即便外部函数已经执行完成。由于闭包会持续引用这些外部变量,因此这些变量不会被销毁,可以在后续的调用中继续使用。
这种特性使得闭包在实现数据封装、延迟执行、回调函数等场景中非常有用。但也正因如此,闭包有时会导致内存管理问题,比如长期引用外部变量,导致无法及时释放内存(内存泄漏)。
LHS 查询和 RHS 查询
LHS(Left Hand Side)查询和 RHS(Right Hand Side)查询是 JavaScript 中的两个关键概念,常用于解释如何在作用域链中查找变量。
1. LHS 查询(左值查询)
LHS 查询发生在对变量进行赋值操作时,即当我们尝试将一个值赋给某个变量时。此时 JavaScript 会按照作用域链从当前作用域开始查找这个变量。如果在当前作用域找不到,就会沿着作用域链逐层向上查找。如果到达全局作用域(或作用域链的顶端)仍然找不到这个变量,JavaScript 会在最顶层创建这个变量。
- 赋值操作时的行为:LHS 查询主要用在赋值操作中(例如
a = 10
),此时它查找并在需要时创建变量。
2. RHS 查询(右值查询)
RHS 查询发生在对变量进行读取操作时,即当我们想要获取一个变量的值时。此时 JavaScript 会按照作用域链从当前作用域开始查找该变量。如果在当前作用域找不到,它会沿着作用域链逐层向上查找。如果到达作用域链顶端仍然找不到该变量,JavaScript 会抛出错误(通常是 ReferenceError
)。
- 读取操作时的行为:RHS 查询通常发生在读取变量时(例如
console.log(a)
),此时它查找并返回变量的值。
示例
let a = 5;
function example() {
let b = 10;
a = 20; // LHS 查询,尝试修改 a 的值
console.log(a); // RHS 查询,读取 a 的值
console.log(b); // RHS 查询,读取 b 的值
}
example();
在上面的代码中:
-
a = 20
是 LHS 查询,因为它是在尝试给a
赋值。 -
console.log(a)
是 RHS 查询,因为它是在读取a
的值。 -
console.log(b)
也是 RHS 查询,读取b
的值。
两者的区别总结如下:
-
LHS 查询:用于赋值操作,查找变量并可能创建变量。
-
RHS 查询:用于读取操作,查找变量的值并返回,若找不到则抛出错误。
理解 LHS 和 RHS 查询对理解 JavaScript 中的作用域链和闭包非常重要,因为这决定了变量是如何在作用域中查找和绑定的。
闭包是如何产生的
闭包的形成通常有以下几个条件:
-
有一个函数被定义在另一个函数内部(嵌套函数)。
-
内部函数引用了外部函数的变量。
-
外部函数返回了内部函数。
通过这种方式,内部函数可以访问到外部函数的变量,即使外部函数已经执行完毕并返回。
function foo() {
var moment = 18;
var test = 111;
function bar() {
const may = moment + 777;
return may;
}
console.log(test);
return bar;
}
var baz = foo();
baz();
在上述代码中,foo
函数返回了它的内部函数 bar
,而 bar
函数引用了 foo
中的变量 moment
。尽管 foo
函数在执行后应该销毁其作用域,但由于 bar
函数仍然引用着 foo
的局部变量,这引发了闭包的概念和一些内存管理问题。
接下来我们来看看一下代码的执行流程:
-
调用
foo
函数:-
当调用
foo
时,foo
函数执行并创建了两个局部变量:moment
和test
。 -
然后,
foo
函数返回了内部函数bar
,此时bar
形成了闭包,它引用了foo
中的变量moment
。
-
-
执行上下文的销毁:
-
当
foo
执行结束时,foo
函数的执行上下文从调用栈中弹出,通常会销毁moment
和test
变量。 -
但由于
bar
函数还持有对foo
中moment
变量的引用,因此moment
不能被销毁。
-
目前主要以下这些问题点:
-
变量
moment
是否该被销毁?- 正常情况下,
foo
函数执行完毕后,moment
变量应该被销毁。然而,由于bar
函数还引用着moment
,它无法被销毁。闭包的核心就是确保即使外部函数已经结束,内部函数依然可以访问外部变量。
- 正常情况下,
-
V8 引擎的惰性解析:
-
V8
引擎采用惰性解析,即在执行foo
函数时,它只会解析foo
函数,而不会立即解析bar
函数。V8
只会标记bar
函数,但不会知道bar
是否引用了foo
函数的变量。 -
但是,为了确保闭包的正确性,
V8
引擎会在函数创建时进行预解析(pre-parsing),检查函数是否引用了外部变量,并确保这些变量能够正确地存活在内存中。
-
再来讲解一下预解析器的作用:
-
语法检查:预解析器首先会检查函数是否存在语法错误,确保代码的正确性。
-
闭包引用判断:
-
在创建
foo
函数时,V8
会快速检查bar
函数是否引用了外部变量(如moment
)。如果bar
引用了这些外部变量,V8
会将这些变量复制到堆内存中,而不仅仅保存在栈中。 -
这样,即使
foo
执行完毕并销毁其执行上下文,bar
依然能从堆内存中访问到这些外部变量。
-
-
复制闭包:具体来说,
V8
会为闭包函数创建一个副本,这个副本包含了bar
函数所引用的外部变量。在这个例子中,moment
会被复制到堆内存中,而test
变量不会被复制,因为bar
并没有引用它。
在 JavaScript 中,内存管理通过堆和栈进行。当执行全局代码时,V8
会将全局执行上下文压入栈中,并开始执行 foo
函数。foo
执行时,会为其创建执行上下文(包括 moment
和 test
变量),并将其压入栈中;当 foo
执行结束,foo
的上下文从栈中弹出,通常 moment
和 test
会被销毁。但由于 bar
引用了 moment
,它的副本被保存在堆内存中,确保 moment
在闭包中存活,直到 bar
不再引用它。
如下图所示,复制一个变量,实际上是复制了一个闭包函数(Closure (foo)),但是此函数只有被 bar 函数引用的值,foo 函数中的 test 变量并没有被复制过去,
闭包的核心在于,即使外部函数执行结束并销毁了自己的执行上下文,内部函数(如 bar
)仍然能够访问外部函数的局部变量。V8
引擎通过预解析来检查函数的引用关系,确保闭包能够正确存储和访问外部变量。由于闭包函数(bar
)引用了 foo
中的变量 moment
,moment
被复制到堆内存中,而不是被销毁,直到闭包函数不再引用它。
Other Example
function foo() {
var moment = 777;
function baz() {
console.log(moment);
}
bar(baz);
}
function bar(fn) {
fn(); // 这也是一个闭包
}
foo();
把内部函数 baz
传递给 bar
函数,当调用这个内部函数时(这个时候叫作 fn
),它涵盖的 foo
()内部作用域的闭包就可以观察到了,因为他能够访问。
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait('hello world');
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait('hello world'); // 这也是一个闭包
将一个内部函数 timer
传递给settimeout
(…)。timer 函数依然保存有wait
(…)作用域的闭包。
在引擎内部,内置的工具函数settimeout
(…)持有对一个参数的引用,这个参数也许叫作 fn 或者 func,又或者其他类型的名字。引擎会调用这个函数,在这个例子中就是内部的 timer 函数,而词法作用域在这个过程中保持完整。
经典永不过时
for (var i = 0; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 10000);
}
正常情况下,我们对这段代码行为的预期分别是输出 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次输出的频率输出五次 6。因为这个循环的终止条件是 i
不再<=5
,条件时 i
的值为 6,因此输出显示的是循环结束时 i
的最终值。
这是因为 setTimeout
是异步的,而 for 循环是同步的,延迟函数的回调会在循环结束时才执行,当循环结束时 i
已经是 6 了,所有的回调函数才会开始执行,因此会每次输出一个 6
来。
那么有什么办法可以让这个循环一次输出数字呢? 用 let
关键字代替 var
? 答案当然是可以的 , 你会看到 0 1 2 3 4 5
成功输出。
for (let i = 0; i <= 5; i++) {
setTimeout(() => {
console.log(i); // 0 1 2 3 4 5 成功输出
}, 1000);
}
如果不用 let
,用立即执行函数(IIFE
)呢?
for (var i = 0; i <= 5; i++) {
(function () {
setTimeout(() => {
console.log(i); // 输出 6 次 6
}, 1000);
})();
}
这样明显是不行的,为什么呢?虽然我们拥有了跟多的词法作用域了,每个延迟函数都会将 IIFE
在每次迭代中创建的作用域封闭起来。但是该错用域是空的,所以 IIFE
只是一个什么都没有的空作用域。
for (var i = 0; i <= 5; i++) {
(function (j) {
setTimeout(() => {
console.log(j); // 0 1 2 3 4 5 成功输出
}, 1000);
})(i);
}
在这里我们把 i
作为参数传递给 立即执行函数
,j
就是传进来的参数,这个时候 立即执行函数
就有自己的作用域变量 j
了,问题就迎刃而解了。这就是闭包的力量。
用闭包模拟私有方法
function Person() {
var friends = 0;
this.getFriends = function () {
return friends;
};
this.friend = function () {
friends++;
};
}
const student = new Person();
student.friend();
student.friend();
console.log(student.getFriends());
在上面的代码中,我们创建了一个构造函数 Person
,定义了一个变量 friends
用于保存状态。由于 JavaScript 的作用域规则的限制,因此只能在构造函数内部访问该变量。
我们可以通过方法读写私有变量,但是不能直接对 friends
变量直接进行读写,这就实现了私有变量了。
总结
闭包是指函数能够记住并访问它定义时的外部作用域,即使外部函数已经执行完毕。它使得内部函数能够访问外部函数的局部变量,即使外部函数已经返回。闭包常用于数据封装、保持状态和异步回调等场景。
闭包的优点主要有以下几个方面:
-
数据封装和私有化:可以通过闭包隐藏变量,避免外部访问,提升代码的安全性。
-
保持函数状态:闭包能够“记住”外部函数的变量,适合用于实现计数器、缓存等。
-
回调和异步处理:闭包可以在异步操作中捕获变量,实现延迟执行。
-
动态函数生成:闭包能够生成灵活的高阶函数,适合构造可复用的逻辑。
闭包的缺点主要有以下几个方面:
-
内存泄漏:闭包会持续引用外部变量,可能导致内存无法释放。
-
调试困难:闭包的作用域链较为复杂,调试时可能会增加追踪难度。
-
变量不必要地存活:闭包可能让一些不再使用的变量占用内存,导致性能问题。
-
性能开销:访问闭包变量时需要查找作用域链,可能影响执行效率。