JS - 执行上下文/作用域链/闭包

对于JavaScript执行上下文作用域闭包等概念的一些思考。

执行上下文

执行上下文(Execution Context)用一句话说就是JS代码执行时的抽象环境

例如在函数被 调用 时会生成一个函数执行上下文,就是这个函数的环境,这里强调 调用 ,就是说是运行时的动态过程。

举个栗子,一个国家就好比是一个执行上下文,公民就好比是变量,许多公民(不出境的那种)在国家这个范围内活动;再往下执行上下文有嵌套,那中国就可以比作全局执行上下文,浙江省就是第一层函数执行上下文,杭州市就是第二层函数执行上下文

三种类型

JS中一共有三种执行上下文类型:

  • 全局执行上下文: 默认的最底层环境,任何不在函数eval内部的代码都属于全局执行上下文,一段程序中只有一个全局执行上下文。

  • 函数执行上下文: 当一个函数被 调用 时,就会创建一个函数执行上下文,并进行一些准备工作。

  • eval执行上下文: 执行eval函数时会创建一个eval执行上下文,类似于函数执行上下文。因为实际中用到eval的情况比较少,下文不再讨论。

以下代码创建的执行上下文如图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var a = 1;
var b = 2;

function aa() {
var c = 3;
bb();
}

function bb() {
var d = 4;
}

function cc() {
var e = 5;
}

aa();
cc();
三种类型的执行上下文
执行栈

在JS中,是以(后进先出)这种数据结构存储执行上下文的,也就是我们所说的 执行栈

执行JS代码时,JS引擎首先会创建一个全局执行上下文,并压入栈底;之后每当 调用 一个函数时,都会为该函数创建一个函数执行上下文,并压入栈。

之后引擎会执行栈顶的函数,函数执行结束后,该函数执行上下文从栈中弹出;再执行下一个函数,直到全部出栈。

还是这段代码,执行栈如图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var a = 1;
var b = 2;

function aa() {
var c = 3;
bb();
}

function bb() {
var d = 4;
}

function cc() {
var e = 5;
}

aa();
cc();
执行栈
执行过程

执行上下文的执行过程分为两个阶段:创建阶段执行阶段,这部分内容后面会解释到。

  • 创建阶段: 例如调用函数时,在执行代码前,将创建执行上下文,会处理三件事: 绑定this创建词法环境创建变量环境

  • 执行阶段: 对变量进行赋值,执行代码。

作用域链

静态作用域

作用域是指程序代码中定义变量的区域,JS中采用的是静态作用域(词法作用域)。作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

还是上面的栗子,执行上下文是国家这个概念上的环境,作用域就是这个国家的实际作用范围;比如中国就是雄鸡这块版图,人民群众在没办护照的情况下是不能跑到国外去的。

静态作用域 vs 动态作用域

  • 静态作用域: 作用域在函数定义时决定

  • 动态作用域: 作用域在函数调用时决定

因为JS是静态作用域,所以直接看代码中变量的定义位置即可,而不是看调用过程。

在以下代码中,运行会输出 1

1
2
3
4
5
6
7
8
9
10
11
12
var a = 1;

function aa() {
console.log(a); // 输出 1
}

function bb() {
var a = 2;
aa();
}

bb();
作用域链

在代码中,我们经常会进行函数嵌套,作用域会随着嵌套结构向顶层链接,形成作用域链。这个概念和原型链类似。

还是看代码:

1
2
3
4
5
6
7
8
9
10
11
var a = 1;

function aa() {
var b = 2;
function bb() {
console.log(a + b); // 输出 1和2
}
bb();
}

aa();

在函数bb()作用域中并没有定义变量ab,所以会通过作用域链往上层查找,在父函数aa()作用域中找到a,在全局作用域中找到b,最后输出。

闭包

上文我们介绍了执行上下文作用域链,终于到了闭包

ES5中变量只有两种作用域:函数外部,全局变量;函数内部,局部变量。且变量提升机制还会将变量声明提升到它所在作用域的顶端去执行。

函数内部的变量都是局部变量,在函数外部是无法访问的。

闭包其实就是充当一个桥梁的作用,从外部可以间接地访问到内部的变量。简单来讲,就是在一个函数内部返回另外函数,通过返回的函数去获取主函数内部的局部变量。

先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
function outer() {
var private = 1; // 局部变量
return function () {
return private;
}
}

console.log(private); // private is not defined

var inner = outer(); // 将外层 outer 函数返回的匿名函数赋值给 inner
console.log(inner()); // 1

outer函数中定义的private局部变量,在函数外部直接访问是无法访问到的。但是我们在函数中能够访问这个局部变量,并通过一个匿名函数返回,在外边调用outer()接收这个匿名函数,并且赋值给外部的inner变量,再调用inner()即可获取到内部的private局部变量了。

打个比方,函数就好比是一个集装箱,外面的人拿不到里面的物资。这时候甩出来一个小背包,装着里面的部分物资,外面的人就可以在小背包中拿到了。

闭包可以隐藏函数内部细节,界定公共变量 (public)私有变量 (private)。配合匿名函数立即执行函数,常用来实现ES5中的模块化

原理解析

正常函数执行完毕后,里面声明的变量被垃圾回收处理掉,但是闭包可以让作用域里的变量,在函数执行完之后依旧保持没有被垃圾回收处理掉,从而被外部访问。因此闭包也可以将临时变量“缓存”在内存中。

内存泄漏

前面提到闭包会暂存变量,不被垃圾回收,很直接的就会想到,闭包可能会造成内存泄漏。(很多文章都会这么说,包括我在面试中也讲到过。)

其实这个说法是不确切的,内存泄漏是指不再使用的变量没有被释放。而闭包函数中的局部变量,在之后是有可能使用的,并不是“垃圾变量”,所以称不上内存泄漏。

可以这么说,这一特性导致了闭包将会消耗额外的内存,因此谨慎使用。

个人体会

文中其实我还隐藏了很多细节。比如:执行上下文的概念对象ExecutionContext、作用域链中的[[scope]]等。有兴趣的话应该逐一地去深入了解。(详见文末链接)

执行上下文作用域链闭包this等概念。之前我是通过看面经的方式了解了点皮毛,所以面试过程中总是讲不清楚,一问一答就结束了。(我看你是完全不懂嘛)

后来我看了它们的实现原理,将它们理了一遍,感觉茅塞顿开,面试的时候就可以滔滔不绝了。其实这些概念都是一环扣一环的,串起来理解就会很直观。

参考文献

[译] 理解 JavaScript 中的执行上下文和执行栈
JavaScript深入之词法作用域和动态作用域
JavaScript深入之作用域链
我从来不理解JavaScript闭包,直到有人这样向我解释它…

查看评论