这里有一份简洁的前端知识体系等待你查收,看看吧,会有惊喜哦~如果觉得不错,恳求star哈~
概述
一段 JS 代码可能会包含函数调用的相关内容,你可能听说过很多概念,诸如闭包、作用域链、执行上下文、this值。
实际上,尽管它们是表示不同的意思的术语,所指向的几乎是同一部分知识,那就是函数执行过程相关的知识。我们可以简单看一下图。
我们先来讲讲这个有点复杂的概念:闭包。
闭包
在编程语言领域,闭包表示一种函数。
在上世纪60年代,主流的编程语言是基于lambda演算的函数式编程语言,最初的闭包定义,是“带有一系列信息的λ表达式”。对函数式语言而言,λ表达式其实就是函数。
所以,闭包其实只是一个绑定了执行环境的函数,闭包与普通函数的区别是,它携带了执行的环境。
我们来看下古典的闭包定义跟 JS 中的闭包定义,观察他们的区别。
古典的闭包定义中,闭包包含两个部分:
- 环境部分
- 环境
- 标识符列表
- 表达式部分
JS 中闭包组成部分:
- 环境部分
- 环境:函数的词法环境(执行上下文的一部分)
- 标识符列表:函数中用到的未声明的变量(也就是函数里不带var/let/const的变量)
- 表达式部分:函数体
有些人会把 JS 执行上下文,或者作用域(Scope,ES3中规定的执行上下文的一部分)这个概念当作闭包。实际上JS 中跟闭包对应的概念就是“函数”。
这里给闭包做个简单的定义:函数 A 内部有一个函数 B,函数 B 访问到函数 A 中的变量,那么函数 B 就是闭包。
我们可以这样理解:
- 首先,函数B绑定了函数A的语法环境,该闭包不管在何处声明,函数B绑定的环境都不会改变。
- 其次,函数B用到了未声明的变量,这些变量来自函数A。
执行上下文:执行的基础设施
相比普通函数,JS 闭包的主要复杂性来自于它携带的“环境部分”。当然,发展到今天的 JS ,它所定义的环境部分,已经比当初经典的定义复杂了很多。
JS 中与闭包“环境部分”相对应的术语是“词法环境”,但是 JS 函数比λ函数要复杂得多,我们还要处理this、变量声明、with等等一系列的复杂语法,λ函数中可没有这些东西,所以,在 JS 的设计中,词法环境只是 JS 执行上下文的一部分。
JS 标准把一段代码(包括函数),执行所需的所有信息定义为:“执行上下文”。
因为这部分术语经历了比较多的版本和社区的演绎,所以定义比较混乱,这里我们先来理一下 JS 中的概念。
ES3
执行上下文在ES3中,包含三个部分。
- scope:作用域,也常常被叫做作用域链。
- variable object:变量对象,用于存储变量的对象。
- this value:this值。
注意:网上流传甚广的,用global object,和active object 来解释闭包、作用域、执行上下文,这是ES3里的解释法,现在已经解释不了很多语法了。
ES5
在ES5中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。
- lexical environment:词法环境,当获取变量时使用。
- variable environment:变量环境,当声明变量时使用。
- this value:this值。
ES2018
在ES2018中,执行上下文又变成了这个样子,this值被归入lexical environment,但是增加了不少内容。
- lexical environment:词法环境,当获取变量或者this值时使用。
- variable environment:变量环境,当声明变量时使用
- code evaluation state:用于恢复代码执行位置。
- Function:执行的任务是函数时使用,表示正在被执行的函数。
- ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
- Realm:使用的基础库和内置对象实例。
- Generator:仅生成器上下文有这个属性,表示当前生成器。
我们在这里介绍执行上下文的各个版本定义,是考虑到你可能会从各种网上的文章中接触这些概念,如果不把它们理清楚,我们就很难分辨对错。如果是我们自己使用,建议统一使用最新的ES2018中规定的术语定义。
接下来,我们从代码实例出发,推导函数执行过程中需要哪些信息,它们又对应着执行上下文中的哪些部分。
比如,我们看以下的这段 JS 代码:
var b = {}
let c = 1
this.a = 2;
要想正确执行它,我们需要知道以下信息:
- var 把 b 声明到哪里;
- b 表示哪个变量;
- b 的原型是哪个对象;
- let 把 c 声明到哪里;
- 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使用的作用域:
- for;
- if;
- switch;
- 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 这两个函数生成的作用域里面分别定义了一个变量。
在规范中作用域更官方的叫法是词法环境,没错,就是上文提到的词法环境,包含在执行上下文中。
作用域其实由两部分组成:
- 记录作用域内变量信息(我们假设变量,常量,函数等统称为变量)和代码结构信息的东西,称之为 Environment Record。
- 一个引用 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的设计。
最后,我们对比了执行上下文跟作用域的关系。