谈谈JS中的闭包

260 阅读12分钟

偶尔读到自己去年写的文章,觉得很有意思。听说掘金有很多前端大牛,于是把它分享到这里。这是我自己对闭包的理解,如果有不对的地方,请不吝赐教。此文转自我的公众号:mp.weixin.qq.com/s/pyOJWzvbx…

闭包和作用域据说是前端程序员必须理解和掌握的两样东西,不过我并不这么认为。理解作用域倒还有一些作用,但闭包是一个使用起来非常简单自然的东西,以至于我们可能都感受不到它的存在。而理不理解闭包的原理对实际开发似乎并没有多大影响,刻意去发现并理解它好像也并没有太大帮助。毕竟我们不用写JS引擎。换句话说,阅读这篇文章恐怕并不能提升你的编程水平,不过,却可能改变你对闭包的认识,虽然这似乎也没有什么用。

闭包和作用域似乎没有什么标准的定义,都可以从多种角度去理解。本文从作用域的角度去理解闭包,目的不是告诉你什么是闭包,而是想让你从一些比较特别的角度去理解闭包。

何为作用域

因为是从作用域的角度理解闭包,所以首先要知道什么是作用域。作用域可以理解得很深入,但这里只需要从一个角度来理解它,那就是,作用域就是变量表,代码中声明的所有变量都保存在某个作用域中。

例如以下函数:

function area (r) {
  const PI = Math.PI;
  return PI * r * r;
}

这个函数就对应一个函数作用域,作用域中含有r和PI两个变量。可以注意到,函数参数也在函数作用域的变量表中,因为参数也相当于声明的变量。另外,letconst 声明的变量也可以是属于函数作用域,只是它们没有声明提前罢了,这实际上是因为函数也是一种代码块。

当要使用某个变量的时候,就会在最内层的作用域变量表中开始查找。比如上面函数中的return语句中使用了PI和r两个变量,执行时就会临时从area函数对应的函数变量表中查找这两个变量,并取出它们的值。当然如果在内层的变量表中找不到的话,就会从更外层的变量表中查找。

需要注意的是,只有声明的变量才能保存到作用域中,对象的属性是不能保存在作用域中的。请看以下这两个全局变量:

var x = 0;
window.y = 1;

也许从实现上来说,它们都保存在全局作用域中,因为实际上作用域可以从另一个角度理解为一个对象。但按照作用域是变量表的角度来理解, x 是声明的变量,所以保存在全局作用域中,而 y 是全局对象上的一个属性,不属于全局作用域。如何证明呢?我们知道声明的变量是不能删除的,因为变量是保存在作用域中的,而对象的属性却可以删除。这里的 x 就是不能用 delete 删除的,而 y 却可以被删除。

作用域主要有两种,一种是函数级作用域,另一种是块级作用域。这指的是作用域变量表只能在一定代码范围内使用。函数作用域对应的是一个函数,其中的变量只能在函数内部使用。而块级作用域指的是一个代码块,只能在代码块内部使用。

代码块大概就是代码中的一个花括号。像函数和循环等结构都有花括号,所以也都是属于代码块。for 循环本身也是一个代码块。比如下面这个循环:

let i = i;
for (let i = 0; i < 10; i++) {
  let i = 2;
}

我们知道不可以在一个作用域中重复使用 let 声明同一个变量,但这段代码并不会报错,for 循环中的代码会执行10次。因为 for 循环本身相当于一个代码块,只是这种语法没有体现出花括号罢了。这个 for 循环实际含有两个块级作用域,你可以想象 for 循环外面有一层隐藏的花括号,实际上它和下面的代码是等效的:

let i = 1;
{
  let i = 0;
  while (i < 10) {
    {
      let i = 2;
    }
    i++;
  }
}

如何生成块级作用域

这是一个题外话。一般来说,只要一个花括号构成的代码块,再在里面用 letconst 声明变量,就可以形成一个块级作用域。但有没有其他的方法呢?

利用 for 循环生成代码块。前文说道,for 循环可以生成一个隐藏的代码块,只要在 for 循环的圆括号中用 constlet 声明变量,就能生成一个块级作用域。

利用 try/catch 生成块级作用域。try/catch 语法中的 catch 块对应一个块级作用域,其中包含一个代表错误信息的变量,只能在 catch 块中使用。例如:

try {
  throw 2;
} catch (err) {
  console.log(err); // 2
}
console.log(err); // ReferenceError

catch 块中有一个错误变量 err,它的值是 2,这个变量只能在 catch 块中使用。

何为闭包

在百度上搜索“什么是闭包”,就会得到下面这段定义:

闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数”。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

这段定义是从函数的角度来理解闭包,但它其实并不十分准确。实际上,闭包函数读取的并不一定是外部函数中的变量,而是外层作用域中的变量。比如,可以读取全局作用域中的变量,也可以读取外层块级作用域中的变量。下面这段代码就是内层函数从一个块级作用域中读取变量的例子:

let sum;
{
  let a = 1;
  let b = 2;
  sum = () => a + b;
}
sum();

代码块结束后,变量a和b本该被销毁,但实际上执行sum函数时还能被引用,因此这里形成了一个闭包。

不过,这个定义倒是给了我们一些启示,JS函数中寻找变量的时候,会先查找最内层的作用域。如果找不到,再查找外面一层的作用域。这样一层一层的查找,直到全局作用域。这样就形成了一个作用域链。

如果你认为这是JS中寻找变量的方式,其实也没错,但有一个小问题。函数执行时,外层作用域可能已经被销毁了。比如上面例子中,sum函数执行的时候,包含a和b的块级作用域已经销毁,因为那个代码块已经执行完了。而解决这个问题的方法就是闭包。

闭包是作用域中被内部函数使用的变量组成的子作用域,也就是一个作用域变量表的子集,是一个子变量表。闭包作用域在对应的函数作用域或块级作用域销毁后仍然存在,因此可以达到在作用域销毁之后仍能访问其中的变量的效果。

实际上,查找变量时遍历的作用域链上的那些作用域并不是原本的函数作用域或块级作用域,这是一条由闭包作用域形成的链。这条链的一端是最内层的块级作用域和函数级作用域,另一端是全局作用域,而中间的节点都是闭包作用域。

那我们来看看,以下代码是否形成了闭包:

function F () {
  var a = 1;
  var b = 0;
  function sum () {
    console.log(a + b);
  }
  sum();
}
F();

我们一般认为,只有外层作用域销毁之后,内部函数没有被销毁,这样内部函数使用的外部变量才会形成闭包。这里的 sum 函数引用了函数F中声明的变量 ab,但是 sum 函数执行时,函数 F 还没执行完,所以直接从 F 函数的函数作用域中就能读取到,理论上并没有形成闭包。不过在实际实现时,却有可能形成了闭包,执行 sum 函数时,是从闭包作用域中读取了 ab

为了理解这一点,我们不妨看看在其他语言中,这段代码长什么样。没错,其他编程语言中也有闭包,以下是用 PHP 语言重写这段代码:

function F () {
  $a = 1;
  $b = 0;
  $sum = function () use ($a, $b) {
    echo $a + $b;
  };
  $sum();
}
F();

可以看到,PHP 使用 use 关键字来指定函数中使用到的外部变量。所以,函数可以使用外部的变量,这并不是一件自然而然的事情。或者说,正常情况下函数是不能直接使用外部变量的。PHP 需要显示指定用到了哪些外部变量,从而正确生成闭包,而 JS 引擎则是自动做了这件事。

闭包是否一定要有函数

为了更深刻地理解闭包,我们来思考一个问题:是否一定要有函数才能形成闭包?

我们知道闭包的作用就是在作用域销毁之后仍能读写其中的变量。那我们除了使用内部函数来读写,还有没有其他方式可以读写到变量呢?比如,是否可以像下面这样用一个对象来引用变量呢?

function F () {
  var a = 1;
  var b = {};
  return {
    a: a,
    b: b
  };
}
var f = F();

当函数 F 执行完成之后,会返回一个对象,通过这个对象可以读取到变量 a 和变量 b 的值,变量 b 中的对象并没有随着函数作用域销毁。那这算是一个闭包吗?其实不是。

在构造返回的对象时,就已经把变量 a 和变量 b 的值赋给对象的属性了。所以,通过对象读取属性时,只能读取到构造返回对象时变量的值,而并没有读取变量。

那怎么样才算读取变量呢?关键就在于有没有去查询变量表。当我们在代码中碰到一个变量时,实际上我们知道的只是一个变量名,我们需要去作用域链中查找这个变量。而上面的例子并没有查询变量的过程,只有查询属性的过程。

只有作用域中的代码才能引用到作用域中的变量,而作用域销毁之后还想执行作用域中的代码的话,就只能通过函数了。

共享闭包作用域

需要注意到的是,闭包作用域是和函数作用域或块级作用域对应的,而不是和内部函数对应的。比如说一个作用域中有两个内部函数,分别引用了作用域中不同的变量,引擎并不会为每个内部函数分别生成一个闭包作用域,而是生成一个共用的闭包作用域,其中包含了所有内部函数中使用到的变量。

这是因为每个作用域中包含一张变量表,如果为每个内部函数都生成一个闭包作用域的话,它们就会使用不同的闭包变量表,就没办法读写同一个外部变量了。

另一个有趣的事情是,JS 引擎在生成闭包作用域时,会把所有内部函数使用到的变量都包括在闭包作用域中,而不管内部函数是否在作用域销毁后仍然存留,甚至不管内部函数是否会执行。比如下面这段代码:

function F () {
  var a = 1;
  var b = 0;
  function unuse () {
    a;
  }
  return function () {};
}
var f = F();

这段代码中的 unuse 函数从来没有机会执行,并且它的内容也没有什么意义,但它引用了变量 a。而返回的内部函数是个空函数。这种情况下,理想的处理方式是不生成闭包,以免占用不必要的内存。但实际上 JS 引擎会生成一个闭包,里面包含变量 a。也就是说,只需要满足以下两个条件,实现上就会生成一个闭包:

  • 某个内部函数引用了作用域中的变量
  • 某个内部函数在作用域销毁后没有被销毁

以下是一段会造成内存泄漏的代码,你可能会很疑惑它为什么会导致内存泄漏:

var obj = null;
function replace () {
  var o = obj;
  function fn () {
    if (false) {
      o;
    }
  }
  obj = {
    bigStr: '*'.repeat(100000000),
    useless: function () {}
  }
}
setInterval(replace, 1000);

这段代码的效果是每隔一秒执行一次 replace 函数,而 replace 函数每次执行会构建一个对象赋值给全局变量 objreplace 函数内部的变量 o 和函数 fn 都没有用到。按理每次执行完 replace 后,先前的 obj 对象就会被释放掉,而一个新的对象会赋值给 obj。但事实上是,先前的 obj 对象占用的内存并不会被释放,尽管这个内存不可能再被用到。

我们利用开发者工具的 memory 页签抓取快照,可以发现闭包占用的内存每秒都在增加。

如果你已经明白了原因,就不用继续往下看了。如果还没反应过来,那我就来解释一下。

replace 函数中的内部函数 fn 引用了 replace 函数中声明的变量 o,因此满足了闭包的第一个条件(内部函数引用了作用域中的变量)。每次执行 replace 函数时,会给 obj 重新赋值一个对象,这个对象的 useless 属性是 replace 函数的一个内部函数,因此满足了形成闭包的第二个条件(内部函数 uselessreplace 函数执行结束后没有被销毁)。

于是,每次执行完 replace 函数后,虽然函数作用域销毁,但会形成一个对应的闭包作用域,其中包含变量 o。而变量 o 指向了之前的 obj 对象,导致之前 obj 对象的内存无法释放,于是就就形成了一条链:

新的obj对象 → useless函数 → 闭包作用域 → 变量o → 旧的obj对象

这就是为什么之前的obj对象明明被替换,其占用的内存却不会被释放的原因了。