Featured image of post 深入解析 JavaScript 中的闭包、作用域和执行上下文

深入解析 JavaScript 中的闭包、作用域和执行上下文

​本文将会讲解闭包的含义及其带来的问题,并通过调试代码的形式帮助读者理解作用域、执行上下文和闭包的概念以及它们在JS中的实现。文章中还会涉及暂时性死区、this 指向以及垃圾回收算法的相关知识。

闭包的含义

在讲述闭包的概念时,一般有两种说法:

① 一个函数可以访问并操作位于其外部的变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function closureExample() {
  let num = 1;
  function printNum() {
    num++; // 改变位于函数外部的变量num
    console.log(num); // 访问位于函数外部的变量num
  }
  printNum();
}
closureExample();
// output: 2

② 一个函数可以访问其外层作用域中的变量,即便其外层作用域已经不存在于执行上下文中。

1
2
3
4
5
6
7
8
function addX(x) {
  return function (num) {
    return num + x; // 访问了函数外层的变量x
  };
}

const plusOne = addX(1); // addX执行完毕,执行上下文已不存在
console.log(plusOne(3)); // 内层函数仍然正常运行,说明其仍可以访问外层变量x

第二种说法其实是第一种说法的一个特殊情况。在这两种说法中,我认为更重要的是第二种,因为这种情况更能体现出闭包的特别之处。

为什么会有闭包

闭包产生的原因主要有两点,第一是在 JS 中函数是一等公民,第二是 JS 的静态词法作用域机制,两者的结合决定了闭包是 JS 必备的语言特性。

JS 中函数是一等公民

一等公民的意思是想干啥就干啥,能够做到所有普通变量能做的事情(如作为函数参数、返回值)。具体来讲,JS 中的函数可以做到下面这些:

  1. 以字面量的形式声明

这种形式是最常见的函数声明形式。函数声明会被提升,也就是会在所有代码执行之前被首先执行,不管声明被写在代码中的哪个位置,所以一个函数可以在其声明语句之前被调用

1
2
3
4
foo()  // 因为存在函数提升现象,这行代码可以正常运行
function foo() {
  console.log('以字面量的形式创建函数)
}
  1. 被赋值给变量、作为数组元素、作为对象属性
1
2
3
const func = function () {};
array.push(func);
obj.fn = func;
  1. 作为其他函数的参数
1
2
3
4
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
  console.log("button clicked");
});
  1. 作为函数返回值
1
2
3
4
5
function addX(X) {
  return function (num) {
    return num + X;
  };
}
  1. 创建自定义属性

函数和普通的对象一样,都是可以有自己的自定义属性的,可以通过点操作符对属性进行添加、修改和获取。

1
2
3
const foo = () => {};
foo.nickName = "fool function";
console.log(foo.nickName); // fool function

作用域、执行上下文和闭包

词法作用域的概念

JS 的变量作用域在编译阶段就确定了,是静态的。JS 有 REPL 这个事实会让我们倾向于认为它是一门解释型语言,但其实不然,JS 引擎在解析 JS 时是有一个编译阶段的。

作用域分为全局作用域、函数作用域和块级作用域。作用域层层嵌套,每个内层作用域都可以访问和操作其外层作用域内的变量。

上面这张图中共有三个作用域:

绿色的是全局作用域,其中的内容包括变量foo

橙色的是函数 foo 的作用域,其中的内容包括参数a变量b函数bar

蓝色的是函数 bar 的作用域,其中的内容包括参数c

当我们在函数 bar 中尝试访问变量a时,JS 首先会在其本地作用域中寻找,如果找不到,就顺着作用域链层层往外寻找,如果到了全局作用域还是找不到,就会报错,在这个例子中函数会使用外层作用域 foo 中的变量a。变量b和变量c的访问过程同理。

执行上下文的概念

执行上下文是在代码运行过程中动态产生的,分为全局上下文函数上下文两种类型。

执行上下文是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中任何的代码都是在执行上下文中运行的。上下文中的内容主要包括当前上下文中的本地变量scopethis,这几个概念的具体内容会在后文中提到。

JS 以执行栈的形式管理执行上下文。当代码开始运行时,全局上下文被推入执行栈,上下文中保存全局上下文中的变量。每执行一个新的函数,就会往执行栈中推入一个新的上下文,中断上一个上下文的执行。函数执行完毕后,该函数的上下文会从执行栈中被推出,然后继续执行被中断的上一个上下文。

同样以这段代码为例,其执行过程中执行栈的情况如图所示:

当一个上下文执行完毕时,由于其中声明的变量对象已经不会再被用到,所以会被 JS 引擎自动垃圾回收,从而释放内存空间。

通过调试代码理解

直接说概念很抽象,我们结合具体的代码,通过开发者工具来看看作用域、执行上下文以及闭包到底是怎么一回事。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const calcSum = function (a, b) {
  return a + b;
};

function addOne(num) {
  const one = 1;
  return function inner() {
    return calcSum(num, one);
  };
}

const resultOfOnePlusTwo = addOne(2);

console.log(resultOfOnePlusTwo());

html文件中引入这段代码,在开发者工具的Sources窗口点击右侧的1,给第一行代码打上断点,刷新后就可以逐行运行这段代码了。

此时代码还未开始运行,我们查看窗口右侧,可以看到不少信息。这里我们主要关注ScopeCall StackCall Stack是函数调用栈,实际上也就是执行上下文的执行栈,其中有一个(anonymous),这个其实就是刚刚所说的全局上下文。

我们发现此时Scope中已经出现了我们将要声明的两个常量,这也证实了刚刚所说的 JS 中存在编译阶段这个事实。但虽然从一开始就知道有这两个变量了,但如果我们尝试在常量声明之前就访问它的话还是会产生报错,这是因为对于letconst声明的变量存在 “暂时性死区” 的限制。

这里我们点击上方被我框出来的那个按钮,就可以逐行运行代码了。

我们点击一下,可以看到Scope中的calcSum已经被赋值了,也就是说第 1~3 行的常量声明执行完毕。但此时代码直接跳到了第 12 行,也就是说第 5 行的addOne函数声明直接被跳过了,这其实就是因为刚刚提到的函数提升现象。那么这个函数为什么没有出现在Scope中呢?这是因为函数声明会被放在Window对象中,也就是Scope里面的那个Global。我们刷新一下网页,回到代码尚未开始运行的时刻,打开Global标签,可以看到addOne函数已经被添加在其中了。

ScriptGlobal的区别在于Script是单个 JS 文件的全局作用域,而Global整个 html 中所有 JS 文件共享的全局对象(在浏览器中即Window 。这也就意味着同个 html 中其他的 Script 文件也可以访问我们声明的addOne函数,只要它们是在这个文件之后运行的。

言归正传,我们继续运行代码,此时为了给resultOfOnePlusTwo赋值,我们需要运行addOne函数得到其结果。所以点击下一步时,我们会进入addOne函数内部,此时观察执行栈,会看到addOne函数被推入了执行栈,这也就意味着我们进入了一个新的执行上下文。刚刚我们讲到执行上下文中会有本地变量、Scopethis三个要素,这些都可以在黄色框框出来的Scope部分中查看:

Local就是上下文的本地变量,其中包括函数的参数num和函数中声明的常量one

this的值为Window,因为这个函数是在非严格模式下直接调用(严格模式下为 undefined) 的。而不是作为某个对象的方法,通过obj.method()的形式调用,这种情况下this将会指向调用函数的那个对象。

Scope标签下的内容就是这个执行上下文的Scope(作用域),在这个例子中addOne函数的Scope包括本地作用域Local, 全局作用域ScriptGlobal

我们继续运行代码,点击下一步之后可以看到one被赋值1

再点一下就到了函数的 return 语句,这个函数返回了一个内层函数innerinner引用了外层函数的numone变量,也就是说出现了闭包

我们再点一下退出addOne函数的运行,此时观察Call Stack会发现,addOne的上下文已经被推出了。

同时由于我们已经求出了addOne的运行结果,所以resultOfOnePlusTwo也被赋值了,其值为我们刚刚返回的inner函数。

下一步我们想要打印resultOfOnePlusTwo的结果,那么就需要运行这个函数,所以点击下一步会进入inner函数的内部,往执行栈中推入inner函数的上下文。此时观察 Scope,会发现除了 Local, Script 和 Global 之外,出现了一个名为addOne的 Closure,也就是闭包。

点击展开可以看到Closure中的内容是inner引用的外层函数addOne中的变量numone

我们回想刚刚展示的那张关于 JS 的作用域的图片,再观察这个 Scope 的结构,会发现它和刚才那张图展示的作用域是类似的结构,即本地作用域 - 外层函数作用域 - 全局作用域。但是刚刚讲执行上下文时,笔者提到了执行上下文运行结束后其中的变量会被回收,那这里numone这两个变量为什么又回来了呢?这是因为在inner函数被返回之后,JS 引擎为了确保它后续能够被正常地调用,会让它把自己引用的外层函数变量也携带在身上,具体来讲,inner身上有一个私有变量[[Scopes]],它所引用的外层变量会作为一个闭包被储存其中,就好像在自己身上携带了一个背包。当inner函数被运行时,JS 引擎会解开它的[[Scopes]]背包作为执行上下文的 Scope。我们可以展开inner函数查看它的[[Scopes]]。这其实就是闭包背后真正的原理。

这里还有一个值得注意的点,就是有时候我们会认为作用域的结构和执行栈应该是相同的,但其实两者没有任何关系,作用域是编译时就确定的,而执行栈和执行上下文是运行时的概念。从这个例子中我们也可以看到,虽然执行栈是inner - 全局这样的结构,但作用域却是inner - addOne - 全局的结构。这说明作用域和函数运行的位置以及执行栈的情况是无关的。

闭包带来的问题

闭包会带来内存泄漏的问题,要理解这一点,首先得了解 JS 的垃圾回收机制。

JS 的垃圾回收机制

① 标记清除法

使用标记清除法的垃圾回收程序在运行时会标记内存中的所有变量,然后将所有存在于执行上下文中,以及被执行上下文中变量所引用的变量的标记去掉。这一步可以通过从window对象开始,递归地遍历其属性,只要某个变量能够被遍历到就说明其存在于上下文中。随后将所有仍带标记的变量销毁并回收它们的内存。

② 引用计数法 (不常用)

引用计数法的核心在于跟踪记录每个对象被引用的次数。每当存在一个变量引用了某个对象,该对象的引用 +1。相反,如果每存在一个引用该对象的变量发生更改,从而不再引用该对象时,该对象的引用 -1。当对象的引用值为 0 时,说明该对象不被任何变量所指向,即没有变量可以访问它。因此,就可以将该对象所在内存空间释放回收。

1
2
3
4
5
6
7
8
9
function Example() {
  let ObjectA = new Object();
  let ObjectB = new Object();

  ObjectA.p = ObjectB;
  ObjectB.p = ObjectA;
}

Example();

该方法无法解决循环引用的问题。上述代码中的两个对象在函数结束执行后引用数都不为 0,因此不会被清理,但实际上这两个变量都无法被上下文中的其他变量访问,属于无用变量。如果函数该多次运行,产生了多个无用但不会被回收的ObjectAObjectB,则会造成内存浪费。

内存泄漏

内存泄漏意味着我们声明了大量不会被垃圾回收的变量。

闭包会导致外层函数即使运行完毕,但其上下文中的变量仍作为闭包被内层函数的[[Scopes]]引用,这些被引用的变量无法被垃圾回收。不合理地滥用闭包会导致大量的变量无法被垃圾回收,从而导致内存泄漏,影响性能。这就是闭包可能带来的问题。

参考链接:

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy