闭包和执行上下文

872 阅读10分钟

这里有一份简洁的前端知识体系等待你查收,看看吧,会有惊喜哦~如果觉得不错,恳求star哈~


概述

一段 JS 代码可能会包含函数调用的相关内容,你可能听说过很多概念,诸如闭包、作用域链、执行上下文、this值。

实际上,尽管它们是表示不同的意思的术语,所指向的几乎是同一部分知识,那就是函数执行过程相关的知识。我们可以简单看一下图。



我们先来讲讲这个有点复杂的概念:闭包。


闭包

在编程语言领域,闭包表示一种函数。

在上世纪60年代,主流的编程语言是基于lambda演算的函数式编程语言,最初的闭包定义,是“带有一系列信息的λ表达式”。对函数式语言而言,λ表达式其实就是函数。

所以,闭包其实只是一个绑定了执行环境的函数,闭包与普通函数的区别是,它携带了执行的环境。

我们来看下古典的闭包定义跟 JS 中的闭包定义,观察他们的区别。

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

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

JS 中闭包组成部分:

  1. 环境部分
    • 环境:函数的词法环境(执行上下文的一部分)
    • 标识符列表:函数中用到的未声明的变量(也就是函数里不带var/let/const的变量)
  2. 表达式部分:函数体

有些人会把 JS 执行上下文,或者作用域(Scope,ES3中规定的执行上下文的一部分)这个概念当作闭包。实际上JS 中跟闭包对应的概念就是“函数”。

这里给闭包做个简单的定义:函数 A 内部有一个函数 B,函数 B 访问到函数 A 中的变量,那么函数 B 就是闭包。

我们可以这样理解:

  1. 首先,函数B绑定了函数A的语法环境,该闭包不管在何处声明,函数B绑定的环境都不会改变。
  2. 其次,函数B用到了未声明的变量,这些变量来自函数A。

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

相比普通函数,JS 闭包的主要复杂性来自于它携带的“环境部分”。当然,发展到今天的 JS ,它所定义的环境部分,已经比当初经典的定义复杂了很多。

JS 中与闭包“环境部分”相对应的术语是“词法环境”,但是 JS 函数比λ函数要复杂得多,我们还要处理this、变量声明、with等等一系列的复杂语法,λ函数中可没有这些东西,所以,在 JS 的设计中,词法环境只是 JS 执行上下文的一部分。

JS 标准把一段代码(包括函数),执行所需的所有信息定义为:“执行上下文”。

因为这部分术语经历了比较多的版本和社区的演绎,所以定义比较混乱,这里我们先来理一下 JS 中的概念。


ES3

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

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

注意:网上流传甚广的,用global object,和active object 来解释闭包、作用域、执行上下文,这是ES3里的解释法,现在已经解释不了很多语法了。


ES5

在ES5中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

  1. lexical environment:词法环境,当获取变量时使用。
  2. variable environment:变量环境,当声明变量时使用。
  3. this value:this值。

ES2018

在ES2018中,执行上下文又变成了这个样子,this值被归入lexical environment,但是增加了不少内容。

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

我们在这里介绍执行上下文的各个版本定义,是考虑到你可能会从各种网上的文章中接触这些概念,如果不把它们理清楚,我们就很难分辨对错。如果是我们自己使用,建议统一使用最新的ES2018中规定的术语定义。

接下来,我们从代码实例出发,推导函数执行过程中需要哪些信息,它们又对应着执行上下文中的哪些部分。

比如,我们看以下的这段 JS 代码:

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

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

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

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

这里我们先讲var声明与赋值,let,realm三个特性来分析执行上下文中提供的信息。


var

我们来分析一段代码:var b = 1;

通常我们认为它声明了b,并且为它赋值为1,var声明作用域是函数执行的作用域。也就是说,var会穿透for 、if等语句。

在只有var,没有let的旧 JS 时代,诞生了一个技巧,叫做:立即执行的函数表达式(IIFE),通过创建一个函数,并且立即执行,来构造一个新的域,从而控制var的范围。

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

(function(){
  var a;
  //code
}());
(function(){
  var a;
  //code
})();

值得特别注意的是,有时候var的特性会导致声明的变量和被赋值的变量是两个b,JS 中有特例,那就是使用with的时候:

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,JS 在运行时引入了块级作用域。也就是说,在let出现之前,JS 的 if 、for 等语句皆不产生作用域。

简单统计了下,以下语句会产生let使用的作用域:

  1. for;
  2. if;
  3. switch;
  4. try/catch/finally。

Realm

在最新的标准(9.0)中,JS 引入了一个新概念Realm,它的中文意思是“国度”“领域”“范围”。这个英文的用法就有点比喻的意思,几个翻译都不太适合 JS 语境,所以这里就不翻译啦。

我们继续来看这段代码:var b = {}

在 ES2016 之前的版本中,标准中甚少提及{}的原型问题。但在实际的前端开发中,通过iframe等方式创建多window环境并非罕见的操作,所以,这才促成了新概念Realm的引入。

Realm中包含一组完整的内置对象,而且是复制关系。

对不同Realm中的对象操作,会有一些需要格外注意的问题,比如 instanceOf 几乎是失效的。

以下代码展示了在浏览器环境中获取来自两个Realm的对象,它们跟本土的Object做instanceOf时会产生差异:

var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"
var b1 = iframe.contentWindow.b;
var b2 = {};
console.log(typeof b1, typeof b2); //object object
console.log(b1 instanceof Object, b2 instanceof Object); //false true

可以看到,由于b1、 b2由同样的代码“ {} ”在不同的Realm中执行,所以表现出了不同的行为。


作用域

我们再来说下作用域,简单来说作用域就是一个区域,包含了其中变量,常量,函数等等定义信息和赋值信息,以及这个区域内代码书写的结构信息。作用域可以嵌套。我们通常知道 js 中函数的定义可以产生作用域,下面我们用具体代码来示例下:



全局作用域(global scope)里面定义了两个变量,一个函数。walk 函数生成的作用域里面定义了一个变量,两个函数。innerFunc 和 anotherInnerFunc 这两个函数生成的作用域里面分别定义了一个变量。

在规范中作用域更官方的叫法是词法环境,没错,就是上文提到的词法环境,包含在执行上下文中。

作用域其实由两部分组成:

  1. 记录作用域内变量信息(我们假设变量,常量,函数等统称为变量)和代码结构信息的东西,称之为 Environment Record。
  2. 一个引用 outer,这个引用指向当前作用域的父作用域。拿上面代码为例。innerFunc 的函数作用域有一个引用指向 walk 函数作用域,walk 函数作用域有一个引用指向全局作用域。全局作用域的 outer 为 null。

规范中定义了查找一个变量的过程:先查看当前作用域里面的 Environment Record 是否有此变量的信息,如果找到了,则返回当前作用域内的这个变量。如果没有查找到,则顺着 outer 到父作用域里面的 Environment Record 查找,以此递归。

所以我们通常所说的函数内同名变量遮蔽全局变量就是这么回事。不过如果你在变量查找的时候指定某个作用域中的 Environment Record,那么也是可以的,譬如:window.name 【其实 window 对象就是全局作用域的 Environment Record 对象,但是普通函数作用域的 Environment Record 对象是获取不到的】。


作用域和执行上下文的关系

执行上下文是用于跟踪代码的运行情况,而作用域用于获取变量或者this值。从职责上看,他们几乎是没有啥交集的。那么为啥通常两者会被同时提到呢?因为在一个函数被执行时,创建的执行上下文对象除了保存了些代码执行的信息,还会把当前的作用域保存在执行上下文中。所以它们的关系只是存储关系。

结合作用域和执行上下文,我们再来看下变量查找的过程。其实第一步不是到作用域里面找 Environment Record,而是先从当前的执行上下文中找保存的作用域(对象),然后再是通过作用域链向上查找变量。


结语

在这篇文章中,我们梳理了一些概念:有编程语言的概念闭包,也有各个版本中的 JS 标准中的概念:执行上下文、作用域、this值等等。

之后我们又从代码的角度,分析了一些执行上下文中所需要的信息,并从var、let、对象字面量等语法中,推导出了词法作用域、变量作用域、Realm的设计。

最后,我们对比了执行上下文跟作用域的关系。