JavaScript执行环境二,闭包和执行上下文到底是怎么回事

在上一节了解了 JavaScript 执行中最粗粒度的任务:传给引擎执行的代码段。并且,我们还根据“由 JavaScript 引擎发起” 还是“由宿主发起”,分成了宏观任务和微观任务,接下来继续学习更细的执行粒度。

  • 闭包
  • 作用域链
  • 执行上下文
  • this 值

实际上,尽管这里的几个词语表示了不同的术语,但他们所指向的几乎是同一部分知识,那就是函数执行过程相关的内容;看下图(来自winter老师 ):

这里除了要理解函数执行过程的知识,对理清这些知识的概念也非常重要;所以先来看看有点儿复杂的概念:闭包。

闭包

闭包翻译自英文单词 closure,这个词在计算机领域,它就有三个完全不同的意义:编译原理中,它是处理语法产生的一个步骤;计算几何中,它表示包裹平面点集的凸多边形;而在编程语言领域,它表示一种函数。

从简单角度理解,闭包其实就是一个绑定了执行环境的函数,这个函数并不是印在书里的一条简单的表达式,闭包与普通函数的区别是,它携带了执行的环境,就像人在外星中需要自带吸氧设备一样,这个函数也带有在程序中生存的环境。

这个古典的闭包定义中,闭包包含了两个部分:

  • 环境部分
    • 环境
    • 标识符列表
  • 表达式部分

当我们把视角放在 JavaScript 的标准中,我们发现,标准中并没有出现过 closure 这个术语,但是,我们却不难根据古典定义,在 JavaScript 中找到对应的闭包组成部分。

  • 环境部分
    • 环境:函数的词法环境(执行上下文的一部分)
    • 标识符列表:函数中用到的未声明的变量
  • 表达式部分:函数体

这样,在 JavaScript 中的函数完全符合闭包的定义,它的环境部分是函数词法环境部分组成,它的标识符列表是函数中用到的未声明变量,它的表达式部分就是函数体。
这里我们容易产生的一个常见的概念误区,经常会把 JavaScript 执行上下文,或者作用域(Scope,ES3 中规定的执行上下文的一部分)这个概念当做闭包;实际上,JavaScript 中跟闭包对应的概念就是“函数”(感谢 winter 老师的指导)。

执行上下文:执行的基础设施

相比普通函数,JavaScript 函数的主要复杂性来自于它携带的“环境部分”;当然,发展到今天的 JavaScript,它所定义的环境部分已经比最开始经典的定义复杂了很多;JavaScript 中与闭包“环境部分”相对应的术语是“词法环境”,它只是 JavaScript 执行上下文的一部分。
JavaScript 标准把一段代码(包括函数),执行所需的所有信息定义为:“执行上下文”;其概念学习一波:

执行上下文在 ES3 中,包含三个部分:

  • scope: 作用域,也常常被叫做作用域链
  • variable object: 变量对象,用于存储变量的对象
  • this value: this 值

在 ES5 中,改进了命名方式,把执行上下文最初的三个部分改成下面这样:

  • lexical environment: 词法环境,当获取变量时使用
  • variable environment: 变量环境,当声明变量时使用
  • this value: this 值

在 ES2018 中,执行上下文又变了,this 值被归入 lexical environment,但是增加了一些其他内容:

  • lexical environment: 词法环境,当获取变量或者 this 值使用
  • variable environment: 变量华景,当声明变量时使用
  • code evaluation state: 用于恢复代码执行位置
  • Function: 执行的任务是函数时使用,表示正在被执行的函数
  • ScriptOrModule: 执行的任务是脚本或者模块时使用,表示正在被执行的代码
  • Realm: 使用的基础库和内置对象实例
  • Generator: 仅生成器上下文有这个属性,表示当前生成器

这里介绍了执行上下文的各个版本定义,把网络上零散的只是进行了一个归总,如果是自己使用,尽量使用最新的 ES2018 中规定的术语定义。尽管这里介绍了这些定义,但是要容易理解这些执行过程直接从实例触发比较好。

比如下面这段代码:

1
2
3
var b = {}
let c = 1
this.a = 2

要想正确执行它,我们需要知道如下信息:

  1. var 把 b 声明到了哪里
  2. b 表示哪个变量
  3. b 的原型是哪个对象
  4. let 把 c 声明到哪里
  5. this 指向哪个对象

这些信息就需要执行上下文来给出了,这段代码出现在不同的位置,甚至在每次执行中会关联到不同的执行上下文,所以,同样的代码会产生不一样的行为。

var 声明与赋值

如下代码:

1
var b = 1

通常我们认为它声明了 b,并且为它赋值为1,var 声明作用域函数执行的作用域。也就是说 var 会穿透 for、if 等语句。
在只有 var,没有 let 的旧 JavaScript 时代,诞生了一个技巧:立即执行的函数表达式(IIFE),通过创建一个函数并且立即执行来构造一个新的域,从而控制 var 的范围。

由于语法规定了 function 关键字开头的是函数声明,所以要想让函数编程函数表达式,我们必须得加点儿东西,最常见的就是加括号。

1
2
3
4
5
6
7
8
9
(function() {
var a;
console.log('IIFE console');
})();

(function() {
var a;
console.log('IIFE console');
}());

但是,括号有个缺点,那就是如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,产生不符合预期且难以调试的奇怪 bug;所以在一些推荐不加分号的代码风格规范里,会要求在括号前面加上分号。

1
2
3
4
5
6
7
8
9
;(function() {
var a;
console.log('IIFE console');
})();

;(function() {
var a;
console.log('IIFE console');
}());

而一些大佬推荐的写法是使用 void 关键字,如下:

1
2
3
4
void function() {
var a;
console.log('IIFE console');
}();

这样就有效的避免了语法的问题,同时,语义上 void 运算表示忽略后面表达式的值,变成 undefined,况且我们确实不关心 IIFE 的返回值,所以这里语义也更为合理。
值得特别注意的是,有时候 var 的特性会导致声明的变量和被赋值的变量是两个 b,JavaScript 中有特例,那就是在使用 with 的时候:

1
2
3
4
5
6
7
8
9
10
11
var b;
void function() {
var env = {b:1};
b = 2;
console.log("In function b:", b);
with(env) {
var b = 3;
console.log('In with b:', b);
}
}();
console.log('Global b:', b);

在这个例子中,我们利用立即执行函数表达式(IIFE)构造了一个函数的执行环境,可以看到,在 Global function with 三个环境中,b 的值都不一样,而在 function 环境中,并没有出现 var b,这说明 with 内的 var b 作用到了 function 这个环境中。
var b = {} 这样的一句对两个域产生了作用,从语言的角度是个非常糟糕的设计,这也是一些人坚定的反对在任何场景下使用 with 的原因之一。

let

let 是 ES6 开始引入的新的变量声明模式,比起 var 的诸多问题,let 做了非常明确的梳理和规定。

为了实现 let,JavaScript 在运行时引入了块级作用域;也就是说,在 let 出现之前,JavaScript 的 if for 等语句皆不产生作用域;而使用 let 会产生作用域的有:

  • for
  • if
  • switch
  • try/catch/finally

总结

这里对闭包、执行上下文、作用域、this值等做了学习,并且在执行上下文中从 var、let、对象字面量等语法中了解了词法作用域、变量作用域。。。