The hard ways

闭包是什么

很多分享都会解释什么是闭包,不得不说大都管中窥豹比如某 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 应该被释放掉了但是我们随后调用返回的匿名函数,发现并没有报错,这就归功于闭包

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

注意上面代码的注释,为了方便描述,我们将匿名函数取名为 f1f 被调用的时候,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 引擎

    Made with gadget