Skip to Content

面试导航 - 程序员面试题库大全 | 前端后端面试真题 | 面试

什么是闭包

闭包是一个函数及其周围词法环境(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 中的作用域链和闭包非常重要,因为这决定了变量是如何在作用域中查找和绑定的。

闭包是如何产生的

闭包的形成通常有以下几个条件:

  1. 有一个函数被定义在另一个函数内部(嵌套函数)。

  2. 内部函数引用了外部函数的变量。

  3. 外部函数返回了内部函数。

通过这种方式,内部函数可以访问到外部函数的变量,即使外部函数已经执行完毕并返回。

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 的局部变量,这引发了闭包的概念和一些内存管理问题。

接下来我们来看看一下代码的执行流程:

  1. 调用 foo 函数:

    • 当调用 foo 时,foo 函数执行并创建了两个局部变量:momenttest

    • 然后,foo 函数返回了内部函数 bar,此时 bar 形成了闭包,它引用了 foo 中的变量 moment

  2. 执行上下文的销毁:

    • foo 执行结束时,foo 函数的执行上下文从调用栈中弹出,通常会销毁 momenttest 变量。

    • 但由于 bar 函数还持有对 foomoment 变量的引用,因此 moment 不能被销毁。

目前主要以下这些问题点:

  1. 变量 moment 是否该被销毁?

    • 正常情况下,foo 函数执行完毕后,moment 变量应该被销毁。然而,由于 bar 函数还引用着 moment,它无法被销毁。闭包的核心就是确保即使外部函数已经结束,内部函数依然可以访问外部变量。
  2. V8 引擎的惰性解析:

    • V8 引擎采用惰性解析,即在执行 foo 函数时,它只会解析 foo 函数,而不会立即解析 bar 函数。V8 只会标记 bar 函数,但不会知道 bar 是否引用了 foo 函数的变量。

    • 但是,为了确保闭包的正确性,V8 引擎会在函数创建时进行预解析(pre-parsing),检查函数是否引用了外部变量,并确保这些变量能够正确地存活在内存中。

再来讲解一下预解析器的作用:

  1. 语法检查:预解析器首先会检查函数是否存在语法错误,确保代码的正确性。

  2. 闭包引用判断:

    • 在创建 foo 函数时,V8 会快速检查 bar 函数是否引用了外部变量(如 moment)。如果 bar 引用了这些外部变量,V8 会将这些变量复制到堆内存中,而不仅仅保存在栈中。

    • 这样,即使 foo 执行完毕并销毁其执行上下文,bar 依然能从堆内存中访问到这些外部变量。

  3. 复制闭包:具体来说,V8 会为闭包函数创建一个副本,这个副本包含了 bar 函数所引用的外部变量。在这个例子中,moment 会被复制到堆内存中,而 test 变量不会被复制,因为 bar 并没有引用它。

在 JavaScript 中,内存管理通过堆和栈进行。当执行全局代码时,V8 会将全局执行上下文压入栈中,并开始执行 foo 函数。foo 执行时,会为其创建执行上下文(包括 momenttest 变量),并将其压入栈中;当 foo 执行结束,foo 的上下文从栈中弹出,通常 momenttest 会被销毁。但由于 bar 引用了 moment,它的副本被保存在堆内存中,确保 moment 在闭包中存活,直到 bar 不再引用它。

如下图所示,复制一个变量,实际上是复制了一个闭包函数(Closure (foo)),但是此函数只有被 bar 函数引用的值,foo 函数中的 test 变量并没有被复制过去,

闭包的核心在于,即使外部函数执行结束并销毁了自己的执行上下文,内部函数(如 bar)仍然能够访问外部函数的局部变量。V8 引擎通过预解析来检查函数的引用关系,确保闭包能够正确存储和访问外部变量。由于闭包函数(bar)引用了 foo 中的变量 momentmoment 被复制到堆内存中,而不是被销毁,直到闭包函数不再引用它。

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 变量直接进行读写,这就实现了私有变量了。

总结

闭包是指函数能够记住并访问它定义时的外部作用域,即使外部函数已经执行完毕。它使得内部函数能够访问外部函数的局部变量,即使外部函数已经返回。闭包常用于数据封装、保持状态和异步回调等场景。

闭包的优点主要有以下几个方面:

  1. 数据封装和私有化:可以通过闭包隐藏变量,避免外部访问,提升代码的安全性。

  2. 保持函数状态:闭包能够“记住”外部函数的变量,适合用于实现计数器、缓存等。

  3. 回调和异步处理:闭包可以在异步操作中捕获变量,实现延迟执行。

  4. 动态函数生成:闭包能够生成灵活的高阶函数,适合构造可复用的逻辑。

闭包的缺点主要有以下几个方面:

  1. 内存泄漏:闭包会持续引用外部变量,可能导致内存无法释放。

  2. 调试困难:闭包的作用域链较为复杂,调试时可能会增加追踪难度。

  3. 变量不必要地存活:闭包可能让一些不再使用的变量占用内存,导致性能问题。

  4. 性能开销:访问闭包变量时需要查找作用域链,可能影响执行效率。

最后更新于:
Copyright © 2025Moment版权所有粤ICP备2025376666