关于闭包其余凑标题字数
发布于 1 年前 作者 hsiaosiyuan0 2616 次浏览 来自 分享

很多分享都会解释什么是闭包,不得不说大都管中窥豹。比如某 star 1k 的项目中描述

所有函数都是闭包

是不恰当的。但是在有 1k stars 的情况下,居然没有 issue 指出其中的错误。

首先必须说,闭包就取名和中文翻译而言,在你正在理解了它之后,会发现它恰到好处。如果你还觉得命名很奇怪,那么就说明你并不理解它。

就如同它的名字描述的一般,闭包表示的是一个封闭的内存空间。每个函数被创建的时候,都有一个与之关联的闭包。在了解闭包的使用场景之前,先看下面一个例子:

function f() {
  var i = 0;
  console.log(i);
}

f();

这段代码非常简单。我们知道一旦 f 执行完毕,那么它本次执行的栈上的数据将会被释放,所以每次调用结束后,栈上的 i 都会被及时的释放。

再来看另一个例子:

function f() {
  var i = 0;
  return function () { // f1
    console.log(i);
  }
}

var ff = f();
ff();

和第一个例子一样,这段代码同样会打印 0。但是这似乎打破了我们第一个例子的总结,按照第一个例子的说法,f 运行结束后,本次调用的栈上的 i 应该被释放掉了。但是我们随后调用返回的匿名函数,发现并没有报错,这就归功于闭包。

每个函数被创建的时候,都会有一个与之关联的闭包被同时创建。在新创建的函数内部,如果引用了外部作用域中的变量,那么这些变量都会被添加到该函数的闭包中。

注意上面代码的注释,为了方便描述,我们将匿名函数取名为 f1。当 f 被调用的时候,f1 被创建,同时与之关联的闭包也被创建。由于 f1 内部引用了位于其作用域之外的、f 作用域中的变量 i,因此 f 作用域中的 i 被拷贝到了 f1 的闭包中。这就解释了,为什么 f 执行完成之后,调用 f1 依然可以打印 0

现在来看一下第三个例子:

function f() {
  var i = 0;
  function f1() { 
    console.log(i);
  }
  i = 1;
  return f1;
}

var ff = f();
ff();

我们会发现打印 1。好像又与第二个例子的结论有些冲突,f 中的 i 不是被拷贝到了 f1 的闭包中吗?为什么不是打印 0 而是打印 1 呢?

这是因为,我们还没有介绍发生拷贝的时机。如果新创建的函数,引用了外部作用域的变量,并且该变量为活动的,那么并不急于将该变量的内容拷贝到闭包中,而是将该变量所指向的内存单元的地址保存于闭包中。比如我们这里,只是先将 i 所绑定到的内存地址保存于闭包中,等到 i 为非活动状态时,才会进行拷贝。也就是这里,当 f 即将运行结束时,i 的将变为非活动状态,那么需要将其内容拷贝到引用它的闭包中,也就是这里的 f1 的闭包中。一旦内容被拷贝到闭包中,除了与之关联的函数对象之外,再也没有其他方式可以访问到其中的内容。

顺便介绍一下,那么闭包中占用的内存何时才会被释放呢?答案就是当与它关联的函数对象被释放的时候。比如我们接着上面的例子运行:

var ff = null

我们将引用 f1 的变量 ff 赋值为 null,这样就没有任何变量引用 f1 了,所以 f1 成为了垃圾,会在未来的某个时间点(具体要看 GC 的实现以及运行情况),由垃圾回收器进行所占内存回收。

上面的例子,其实就是下面的例子的简化版:

function f() {
  var a = [];
  for(var i = 0; i < 2; i++) {
    var ff = function () {
      console.log(i)
    };
    a.push(ff);
  }
  return a;
}

const [f1, f2] = f();
f1();
f2();

这里新创建的两个函数都会打印 2,想必这个例子大家都很熟悉了,就不再赘述了。只是有一个问题需要注意,既然上面提到了说,新创建的函数引用的外部作用域上的变量内容、最终都会拷贝到该函数的闭包中,那么上面的例子中,i 是不是被拷贝了两次?

再来看一个例子:

function f() {
  var a = [];
  for(var i = 0; i < 2; i++) {
    var ff = function () {
      console.log(i)
    };
    a.push(ff);
  }
  a.push(function () {
    i++;
  });
  return a;
}

const [f1, f2, f3] = f();
f1();
f3();
f2();

这个例子会打印什么?答案是 23。这是因为闭包的另一个机制,同一个变量被引用它的多个闭包所共享。我们在 for 循环内部创建了两个函数,在循环外部创建了一个函数,这三个函数的都引用了 f 中的 i,因而 i 被这三个函数的闭包所共享,也就是说在 i 离开自己所属的作用域时(f 退出前),将只会发生一次拷贝,并将新创建的三个函数的闭包中的 i 的对应的指针设定为那一份拷贝的内存地址即可。对于这一个共享的拷贝地址,除了这三个闭包之外,没有其他方式可以访问到它。

必须再次强调的是,被引用的变量拷贝到闭包中的时机发生在、被引用的变量离开自己所属的作用域时,即状态为非活动时。考虑下面的例子:

function f() {
  const a = [];
  for(let i = 0; i < 2; i++) {
    var ff = function () {
      console.log(i)
    };
    a.push(ff);
  }
  return a;
}

const [f1, f2] = f();
f1();
f2();

我们知道 ES6 中引入了 let 关键字,由它声明的变量所属块级作用域。在上面的例子中,我们在 for 循环体的初始化部分使用了 let,这样一来 i 的作用域被设定为了该循环的块级作用域内。不过另一个细节是,循环体中的 i ,也就是 ff 中引用的 i,在每次迭代中都会进行重新绑定,换句话说循环体中的 i 的作用域是每一次的迭代。因此在循环体中,当每次迭代的 i 离开作用域时,它的状态变为非活动的,因此它的内容被拷贝到引用它的闭包中。

闭包常常会和 IIFE 一起使用,比如:

var a = [];
for(var i = 0; i < 2; i++) {
  a.push((function (i) { // f1, i1
    return function () { // f2
      console.log(i) // i2
    }
  })(i)); // i3
};

const [f1, f2] = a;
f1();
f2();

在上面的例子中,让人迷惑的除了闭包的部分之外,就是 i1i2i3 了。

  • i1f1 的形参
  • i2f2 中对外层作用域中的变量的引用
  • i3 是全局的变量 i,IIFE 执行时 i 对应的值将被作为实参来调用 f1
  • f1 被调用时,也就是 IIFE 执行阶段,它内部创建了一个新的函数 f2,同时也创建了 f2 对应的闭包
  • 由于 f2 中引用了外层作用域中的 i,即 f1 执行期间的 i,且 i 为活动内容,所以 f2 的闭包中添加一条 Key 为 i,Value 为指向 f1 中活动的 i 绑定到的内存单元的地址
  • 当 IIFE 执行完毕,即 f1 要退出的时候,其栈上活动对象 i 就会离开作用域,因此需要将 i 拷贝到引用它的闭包中。

到目前为止,我们看到的例子都引用的直接外层作用域中的变量,那么我们再来看一个例子:

function f(x) { // f1
  return function (y) { // f2
    return function (z) { // f3
      console.log(x + y + z)
    }
  }
}

const xy = f(1);
const xyz = xy(2);
xyz(3);

为了方便描述,我们分别标记了 f1f2f3。我们在 f3 内部,引用了 xy,并且 x 并不是 f3 的直接外部作用域。那么这个闭包的构建过程时怎样的?

在 JS 中,函数也是以对象的形式存在的,如果将与函数关联的闭包想象成函数对象的一个类型为 Map<string, Value> 的属性也不过份,比如:

const CLOSURE = Symbol('closure');
const FUN_BODY = Symbol('fun-body');
const FUN_PARAMS = Symbol('fun-params');

const funObj = {
  [FUN_PARAMS]: [/* parameters list */],
  [FUN_BODY]: [/* instructions */],
  [CLOSURE]: new Map<string, Value>(), // Value 可以被多个 closure 共享
}

即使在引擎的实现阶段,因为性能或者实现差异不采用这样的设计,但本质上与这个结构含义是一致的。为了能在运行阶段创建函数对象,在编译阶段就需要收集到必要的信息:

  • 形参列表
  • 函数体
  • 引用的外部变量

比如在编译 f3 的阶段,我们发现它内部引用了外部的 xy,由于 x 不是直接存在于父及作用域 f2 中的,为了使得未来使用 f2 创建 f3 的时候,仍能够找到 x 的绑定,我们需要将 x 加入到 f2 的闭包中。所以在编译阶段,我们会在 f2 的信息中标注它内部引用了外部变量 x。这样在创建 f2 的时候,x 就会被拷贝到它的闭包中了,等到使用它再创建 f3 的时候,f3 中的 x 也就有了着落。

最后来一个拓展练习:

function f(x) { 
  return [
    function () { x++ }, 
    function (y) { 
      return function (z) { 
        console.log(x + y + z)
      }
    }
  ]
}

const [f1, xy] = f(1);
const xyz = xy(2);
f1();
xyz(3);

如果想要了解跟多引擎层面实现闭包的细节,可以参考我的另外的项目,Naive - 使用 Rust 编写的 JS 引擎

回到顶部