初步理解闭包

接触 JavaScript 的闭包概念前,我在阅读部分前端项目代码时,下意识带着阅读 C++ 代码时的思维惯性来分析 JavaScript 代码,从而造成对一些变量的值,做出错误判断。经过系统性学习后了解到,JavaScript 中作用域的机制与 C++ 有很大区别,其中相关联的一个重要机制便是闭包(Closure)

闭包又称词法闭包或函数闭包,是在支持头等函数(即函数可以像普通变量那样被传递和赋值)的语言中实现词法绑定的一种技术。

闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包含约束变量(该函数内部绑定的符号),也要包含自由变量(在函数外部定义但在函数内被引用)。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被锁定,这样即便脱离捕捉时的上下文,它也能照常运行。这种设计突破了 C++ 等 OOP 语言中对局部变量作用域的限制。捕捉时对于值的处理,可以是值拷贝,也可以是名称引用。

简单来说,闭包就像一个小背包,里面装着两样东西:一是一个特定的函数,二是这个函数需要用到的一些外部变量的集合。这些外部变量有时候是在函数外面定义的,但在函数里面被使用。因为有了这个“小背包”,即便是在函数原来的环境之外,它也能像在原环境一样正常运行。

举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function makeFunc() {
  var name = "Mozilla";
  function displayName() {
    alert(name);
  }
  return displayName;
}

var myFunc = makeFunc();
myFunc();

第一眼看上去,也许不能直观地看出这段代码能够正常运行。在一些编程语言中,一个函数中的局部变量仅存在于此函数的执行期间。一旦 makeFunc() 执行完毕,我们可能会认为 name 变量将不能再被访问。然而,代码仍按预期运行,这便是闭包的效果。

在 JavaScript 中,函数会形成闭包。在本例中,myFunc 是执行 makeFunc 时创建的 displayName 函数实例的引用。displayName 的实例维持了一个对它的词法环境(变量 name 存在于其中)的引用。因此,当 myFunc 被调用时,变量 name 仍然可用,其值 Mozilla 就被传递到alert中。

闭包和匿名函数经常被用作同义词。但严格来说,匿名函数就是字面意义上没有被赋予名称的函数,而闭包则实际上是一个函数的实例,也就是说它是存在于内存里的某个结构体。如果从实现上来看的话,匿名函数如果没有捕捉自由变量,那么它其实可以被实现为一个函数指针,或者直接内联到调用点,如果它捕捉了自由变量那么它将是一个闭包;而闭包则意味着同时包括函数指针和环境两个关键元素。在编译优化当中,没有捕捉自由变量的闭包可以被优化成普通函数,这样就无需分配闭包结构体。

闭包的实现

实现闭包的关键在于理解函数作用域和词法作用域。在 JavaScript 中,函数可以嵌套定义,内部函数可以访问外部函数的变量。当内部函数被返回并在外部函数的作用域之外调用时,即使外部函数的执行上下文已经结束,内部函数仍然可以访问外部函数的变量,这就形成了闭包。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function outerFunction() {
  let outerVariable = 'I am outside!';
  
  function innerFunction() {
    console.log(outerVariable);
  }
  
  return innerFunction; // 返回内部函数,创建闭包
}

const myInnerFunction = outerFunction(); // 调用外部函数,获取内部函数
myInnerFunction(); // 在外部调用内部函数,“I am outside!”被打印出来
  1. 定义一个外部函数

    在这个例子中,outerFunction是外部函数,它有一个局部变量outerVariable和一个内部函数innerFunctioninnerFunction可以访问outerVariable,即使是在outerFunction执行完成后。

  2. 返回内部函数

    外部函数返回内部函数,是创建闭包的关键步骤,因为它允许内部函数在外部函数执行完毕后仍然存在。

  3. 在外部调用内部函数

    调用外部函数,并将返回的内部函数赋值给一个变量。然后,你可以在外部函数的作用域之外调用这个内部函数。此时,闭包已经形成,内部函数依然可以访问外部函数的变量。

在 JavaScript 中,闭包是自然而然形成的,任何函数都能成为闭包。而在 C++ 中,并没有直接被称为“闭包”的概念,但可以通过 Lambda 表达式和函数对象实现类似闭包的功能。

C++11 引入的 Lambda 表达式可以捕获变量的副本(值捕获)或引用(引用捕获),从而在函数对象的生命周期内保持对这些变量的访问,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    int multiplier = 5;
    auto multiply = [multiplier](int n) { return n * multiplier; }; // 捕获multiplier的副本

    std::cout << multiply(10) << std::endl; // 输出 50

    std::vector<int> numbers = {1, 2, 3, 4, 5};
    std::transform(numbers.begin(), numbers.end(), numbers.begin(), [multiplier](int n) { return n * multiplier; });

    for(int n : numbers) {
        std::cout << n << " "; // 输出 5 10 15 20 25
    }

    return 0;
}

闭包的应用

1. 封装私有变量

在 JavaScript 中,闭包通常用于封装私有变量,提供一个公共的方法来访问和修改私有变量,从而实现封装和隐藏数据(这点类似于 OOP 语言中对于 private 成员的访问控制),例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function makePerson(name, age) {
  let _name = name;
  let _age = age;

  return {
    getName: function() {
      return _name;
    },
    getAge: function() {
      return _age;
    },
    setAge: function(newAge) {
      _age = newAge;
    }
  };
}

const person = makePerson('John', 30);
console.log(person.getName()); // John
console.log(person.getAge()); // 30
person.setAge(31);
console.log(person.getAge()); // 31

这对于创建具有私有状态的对象非常有用,可以减少外部对内部状态的依赖和意外修改。比如在一个 Web 应用中,可能需要创建一个用户对象,这个对象需要保存用户的信息,但是我们不希望这些信息可以被外部直接修改,以防止数据被意外篡改。

2. 创建模块

在 JavaScript 中,模块化可以帮助我们将代码划分为独立的功能块,每个模块有其私有的作用域,以便组织代码,避免全局命名空间污染,同时提高代码的可维护性和复用性。通过闭包,我们可以创建模块,其中的变量和函数默认是私有的,如果想要外部访问,需要显式地暴露接口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const myModule = (function() {
  let _privateVar = 'Hello World';
  
  function _privateMethod() {
    console.log(_privateVar);
  }

  return {
    publicMethod: function() {
      _privateMethod();
    }
  };
})();

myModule.publicMethod(); // 输出: Hello World

这里,myModule 是一个具有公共方法 publicMethod 的对象,而 _privateVar_privateMethod 都是只能在模块内部访问的私有成员。

3. 在循环中使用闭包解决 var 问题

在循环中使用 var 声明变量时,由于 var 的作用域是函数作用域,循环结束后变量的值为最后一次迭代的值。通过闭包,我们可以为每次迭代创建一个新的作用域。

1
2
3
4
5
6
7
8
9
for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, j * 1000);
  })(i);
}
// 依次输出: 1, 2, 3, 4, 5

这里,通过创建一个立即执行函数表达式(IIFE),为每次循环创建了一个新的作用域,从而确保循环内部的延时函数能够访问到各自迭代的正确值。在实际开发中,经常需要在循环中进行异步操作,如设置超时、发送网络请求等。使用闭包可以确保异步操作中引用的变量值是正确的,而不是循环结束时的最终值。

闭包的问题

闭包在为模块化、数据封装和异步编程等高级功能提供便利的同时,也存在一些需要格外注意的问题。

  1. 内存占用,如前文所记录的,“闭包实际上是一个函数的实例,是存在于内存中的一个结构体”,因此其绑定的外部变量会一直保存在内存中,直到闭包自身被销毁。这意味着如果闭包长时间不被销毁,它绑定的变量也无法被 GC 回收,可能导致内存泄漏。

    所以,如果一个闭包不再被使用,确保断开与外部变量的连接,比如将闭包赋值为 null,以便垃圾回收机制可以正确回收不再使用的内存。

  2. 性能问题,使用闭包可以使代码结构更清晰,但也可能带来性能开销。创建闭包比普通函数调用更复杂,需要更多的时间和内存。尤其是在高性能要求的场景下(如大量的DOM操作、游戏开发等),过度使用闭包可能会导致页面响应变慢或卡顿。

  3. 变量作用域问题,闭包内部能访问到其外部函数的变量,这在很多时候是方便的,但也可能导致不小心修改了这些外部变量的值。特别是在使用循环和异步编程时,如果不正确地理解闭包作用域,很容易写出逻辑错误的代码。

    建议避免在循环中创建闭包,尤其是对于绑定事件处理器或设置定时器时,避免在循环中直接创建闭包。可以考虑使用其他技术,比如事件委托,或者使用 let 关键字声明循环变量,因为 let 有块级作用域特性。

    如果闭包引用了 DOM 元素,并且 DOM 元素也引用了这个闭包(比如,通过事件监听器),确保在不需要它们时,移除事件监听器并断开引用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function attachListener(element) {
  let someVar = "I am a variable";
  
  element.addEventListener('click', function handleClick() {
    console.log(someVar);
    // 执行一些操作
  });

  // 提供一个方法来移除监听器和清理变量
  return function detach() {
    element.removeEventListener('click', handleClick);
    element = null; // 断开 DOM 元素的引用
  };
}

// 使用闭包绑定事件
let detach = attachListener(document.getElementById('myElement'));

// 当不再需要事件监听器时
detach(); // 移除监听器和断开引用

  1. https://zh.wikipedia.org/wiki/%E9%97%AD%E5%8C%85_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6)
  2. https://www.liaoxuefeng.com/wiki/1022910821149312/1023021250770016
  3. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures