壹.2.2 作用域、执行上下文、作用域链

JavaScript不同于其他大多数高级语言,比如Java语言有块级作用域,也就是由一个花括号对{……}的位置决定作用域,而在 ES6 之前,Javascript却不是这样的,它使用函数作用域全局作用域,直到ES6出现之后,才有了块级作用域

很多前端工程师写了3年的代码,其实并不一定能讲清楚什么是作用域、什么是作用域链,尤其对执行上下文的概念不太清楚。

如果对这3个基础概念不清晰,后面使用JavaScript做项目,就会失之毫厘,谬以千里。做得越多越糊涂,往往停留在只会写,或者调试了很多次积累了一些经验,但是不懂原因无法触类旁通,碰到一点变化就无法快速应对。

壹.2.2.1 作用域(Scope)

作用域即函数或变量的可见区域。通俗点说,函数或者变量不在这个区域内,就无法访问到。

函数作用域

用函数形式以function(){……}类似的代码包起来的(省略号……)区域,即函数作用域

与函数作用域相对应的概念是全局作用域,也就是定义在最外层的变量或者函数,可以在任何地方访问到它们。前端工程师们在入门阶段应该已经学习并熟悉过这个概念,故不再举例赘述,后面我们重点研究函数作用域。

var a = "coffe";//在全局作用域
function func(){
var b="coffe";//在函数作用域内
console.log(a);
}
console.log(a);//>> coffe
console.log(b);//>> Uncaught ReferenceError: b is not defined
func();//>> coffe

如上,a定义在全局作用域内,任何地方都可见,所以函数func内能访问到a;而b定义在函数func内,可见区域就是函数代码块,后面的打印命令console.log(b)在函数func之外执行的,访问不到函数func内的b,因此输出Uncaught ReferenceError: b is not defined

任意代码片段外面用函数包装起来,就好像加了一层防护罩似的,可以将内部的变量和函数隐蔽起来,外部无法访问到内部的内容。

上面这个例子,展示一种办法:可用函数将一些东西隐藏起来。这种办法在日常开发中很有用!

//全局作用域
function func(){//作用域A
var a = "coffe";
function func1(){//作用域B。定义一个函数,把不想公开的内容隐藏起来
var a = "1891";//这里的a把外层的a的值覆盖了
var b = "b";
//这里可以放有很多其他要对外隐藏的内容:变量或者函数
//……
//…
console.log(a);
}
console.log(a);//>> coffe
console.log(b);//>> Uncaught ReferenceError: b is not defined
func1();//>> 1891
}

上面示例了一个嵌套函数,等于有外层函数func的作用域A内嵌了函数func1的作用域B。在func1里面的打印命令console.log(a)访问变量a时,JS引擎会先从离自己最近的作用域A查找变量a,找到就不再继续查找,找不到就去上层作用域(此例中上层作用域是全局作用域)继续查找,此例中a已经找到且值为"coffe",所以打印输出coffe。依此类推,执行func1(),会执行func1函数内部的console.log(a),随即会在作用域B查找里面a,而作用B里面存在一个a的声明和赋值语句var a = "1891",所以最先找到a的值是1891,找到便不再继续查找,最终func1()输出1891而不是coffe

但是每次都要定义一个“不重名的函数名“放在上一级作用域里,显得有点浪费内存空间,而且要想不重复的名字有点头疼。所以最好还是匿名函数的形式包起来,然后立即执行,也即IIFE。如下示例:

//全局作用域
function func(){//作用域A
var a = "coffe";
(function(){//作用域B。一个IIFE形式的函数,把不想公开的内容隐藏起来
var a = "1891";
var b = "b";
//这里可以放有很多其他要对外隐藏的内容:变量或者函数
//……
//…
console.log(a);
})();//>> 1891
console.log(a);//>> coffe
console.log(b);//>> Uncaught ReferenceError: b is not defined
}

如上,用一个IIFE加匿名函数的写法,把变量b隐藏起来,函数外面就没法访问它,函数内部可以访问到它。本书推荐你在任何时候都尽量用匿名函数把要调试的代码片段包起来,然后用IIFE的形式立即执行,而且本书后面也会遵守这个约定。

让我们用图来解释

上面那块代码,有几个作用域呢?画图来说明会更加容易理解。

如图所示,有3个作用域,从最外层往内分别是全局作用域、func、IIFE。

ES6带来了块级作用域

ES6规定,在某个花括号对{ }的内部let关键字生声明的变量和函数拥有块级作用域,这些变量和函数它们只能被花括号对{ }的内部的语句使用,外部不可访问。在你写下代码的时候,变量和函数的块级作用域就已经确定下来。块级作用域和函数作用域也可以统称为局部作用域

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,在块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。但是这样的处理规则显然会对老代码产生很大的影响,出于向后(backward)兼容的考虑,在块级作用域中声明的函数依然可以在作用域外部引用。如果需要函数只在块级作用域中起作用,应该用let关键字写成函数表达式,而不是函数声明语句。为了证明该段论述,我们来看一段代码。

{
function func(){//函数声明
return 1;
}
}
console.log(func());//>> 1

上面这段代码,函数func明明是在花括号内部声明的,按 ES6 原本的规范,外部应该是不可访问的,但实际上可以,证明JS引擎为了向后兼容在实现ES6规范的时候做了变通处理。再来看一段代码。

{
var func = function (){//未使用let关键字的函数表达式
return 1;
}
}
console.log(func());//>> 1

上面这段代码与它之前那一段代码效果是一样。

{
let func = function (){
return 1;
}
}
console.log(func());//>> func is not defined

上面这段代码证明,在花括号{}内部由let关键字声明的函数,才是真正的处于块级作用域内部。

为什么要引进块级作用域?

有了全局作用域和函数作用域,以及var已经挺好用的了,为何还要引进块级作用域和关键字let呢?

首先,的确,ES6 之前函数作用域和var结合也很好用,但是终究没有{}let结合来的块级作用域来的简洁!

其次,var声明的变量有副作用:声明提前

(function() {
console.log(a); //>> undefined
console.log(b); //>> ReferenceError
var a = "coffe"; //声明提前
let b = "1891"; //由let关键字声明的变量,不存在提前的特性
})();

上面这段代码,其中var a = "coffe" 含两个操作,一个是变量a声明(也即var a),一个是赋值(也即a = "coffe")。声明提前的意思是,用var关键字声明的变量,其实可以看做是在函数体内最顶端声明的,所以console.log(a)输出undefined,代表该变量已经被声明过(但还未赋值)。声明提前这个特性,让很多程序员容易变得十分迷惑。按理说,变量(或函数)应是在声明之后才能读取(查找)的,但是var已经让这个常理变得近似诡异,let的出现能让这诡异回归常理。

再次,因为var声明变量有污染。

(function() {
for (var i = 0; i < 100; i++) {
//……很多行代码
}
function func() {
//……很多行代码
}
//……很多行代码
console.log(i); //>> 100
})();

循环里面的i在循环完毕后就没有用了,但并没有被回收掉,而是一直存在的“垃圾”变量,污染了当前的环境。而用let声明变量,事后这种垃圾变量会很快被回收掉。

(function() {
for (let i = 0; i < 100; i++) {
//……很多行代码
}
function func() {
//……很多行代码
}
//……很多行代码
console.log(i); //>> ReferenceError
})();

综上,你应该使用let,尽量的避免使用var,当然你想定义一个全局变量除外。

壹.2.2.2 执行上下文(Execution Context)

定义

执行上下文就是当前 JavaScript 代码被解析和执行时所在的环境,也叫作执行环境。

它是一个抽象概念,意味着我们在脑海中理解一下就好,方便后续真正掌握JavaScript,而不要钻牛角尖去寻找它的具体实现。JavaScript 中运行任何的代码都是在执行上下文中运行,在该执行上下文的创建阶段,变量对象(Variable Object,本文接下来会详述)、作用域链、this指向会分别被确定。

类型

执行上下文总共有三种类型:

  • 全局执行上下文:这是默认的、最基础的执行上下文。不在任何函数中的代码都位于全局执行上下文中。它做了两件事:1. 创建一个全局对象,在浏览器中这个全局对象就是 window 对象;2. 将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文。

  • 函数执行上下文:每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。一个程序中可以存在任意数量的函数执行上下文。每当一个新的执行上下文被创建,它都会按照特定的顺序执行一系列步骤,具体过程将在本文后面讨论。

  • eval执行上下文:运行在 eval 函数中的代码也获得了自己的执行上下文,ES6 之后不再推荐使用 eval 函数,所以本书出于面试实用考虑,不会深入讨论eval。

执行上下文的生命周期

执行上下文的生命周期包括三个阶段:创建阶段 → 执行阶段 → 回收阶段,本文重点介绍创建阶段。

a. 创建阶段

当函数被调用,但未执行任何其内部代码之前,会做以下三件事:

  • 创建变量对象:首先初始化函数的参数 arguments,提升函数声明和变量声明(变量的声明提前有赖于var关键字)。

  • 创建作用域链:在执行期上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。作用域链用于解析变量。当被要求解析变量时,JavaScript 始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量。

  • 确定 this 指向。

b. 执行阶段

创建完成之后,就会开始执行代码,在这个阶段,会完成变量赋值、函数引用、以及执行其他代码。

c. 回收阶段

函数调用完毕后,函数出栈,对应的执行上下文也出栈,等待垃圾回收器回收执行上下文。

执行上下文栈

var a = "coffe"; //1.进入全局执行上下文
function out() {
var b = "18";
function inner() {
var c = "91";
console.log(a+b+c);
}
inner(); //3.进入inner函数的执行上下文
}
out(); //2.进入out函数的执行上下文

在代码开始执行时,首先会产生一个全局执行上下文,调用函数时,会产生函数执行上下文,函数调用完成后,它的执行上下文以及其中的数据都会被销毁,重新回到全局执行环境,网页关闭后全局执行环境也会销毁。其实这是一个入栈出栈的过程,全局上下文永远在栈底,而当前正在函数执行上下文在栈顶。以上代码的执行会经历以下过程:

  1. 当代码开始执行时就创建全局执行上下文,全局执行上下文入栈

  2. 全局执行上下文入栈后,其中的代码开始执行,进行赋值、函数调用等操作,执行到out()时,激活函数out创建自己的执行上下文,out函数执行上下文入栈

  3. out函数执行上下文入栈后,其中的代码开始执行,进行赋值、函数调用等操作,执行到inner()时,激活函数inner创建自己的执行上下文,inner函数执行上下文入栈

  4. inner函数上下文入栈后,其中的代码开始执行,进行赋值、函数调用、打印等操作,由于里面没有可以生成其他执行上下文的需要,所有代码执行完毕后,inner函数上下文出栈

  5. inner函数执行上下文出栈,又回到了out函数执行上下文环境,接着执行out函数中后面剩下的代码,由于后面没有可以生成其他执行上下文的需要,所有代码执行完毕后,out函数执行上下文出栈

  6. out函数执行上下文出栈后,又回到了全局执行上下文环境,直到浏览器窗口关闭,全局执行上下文出栈

执行上下文入栈出栈的全过程

我们可以发现:

  1. 全局执行上下文在代码开始执行时就创建,有且只有一个,永远在执行上下文栈的栈底,浏览器窗口关闭时它才出栈。

  2. 函数被调用的时候创建函数的执行上下文环境,并且入栈。

  3. 只有栈顶的执行上下文才是处于活动状态的,也即只有栈顶的变量对象才会变成活动对象。

壹.2.2.3 变量对象(Variable Object,VO)

变量对象(VO)是一个类似于容器的对象,与作用域链、执行上下文息息相关。

变量对象的创建过程的三条规则:

  1. 建立arguments对象。检查当前执行上下文中的参数,建立该对象下的属性与属性值。

  2. 检查当前执行上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果该属性之前已经存在,那么该属性将会被新的引用所覆盖。

  3. 检查当前执行上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改

变量对象的创建过程

可以用以下伪代码来表示变量对象:

VO={
Arguments:{},//实参
Param_Variable:具体值,//形参
Function:<function reference>,//函数的引用
Variable:undefined//其他变量
}

当执行上下文进入执行阶段后,变量对象会变为活动对象(Active Object,AO)。此时原先声明的变量会被赋值。变量对象和活动对象都是指同一个对象,只是处于执行上下文的不同阶段

我们可以通过以下伪代码来表示活动对象:

AO={
Arguments:{},//实参
Param_Variable:具体值, //形参
Function:<function reference>,//函数的引用
Variable:具体值//注意,这里已经赋值了喔
}

未进入执行上下文的执行阶段之前,变量对象中的属性都不能访问。但是进入执行阶段之后,变量对象转变为了活动对象(被激活了),里面的属性可以被访问了,然后开始进行执行阶段的操作。

全局执行上下文的变量对象

全局执行上下文的变量对象是window对象,而这个特殊,在this指向上也同样适用,this也是指向window

除此之外,全局执行上下文的生命周期,与程序的生命周期一致,只要程序运行不结束(比如关掉浏览器窗口),全局执行上下文就会一直存在。其他所有的执行上下文,都能直接访问全局执行上下文里的内容。

再看一段代码,留意注释

function func() {
console.log('function func');
}
var func = "coffe";
console.log(func); //>> coffe
// 以上代码中,按三条规则,变量声明的 func 遇到函数声明的 func 应该会跳过,
// 可是为什么最后 func 的输出结果仍然是被覆盖了显示"coffe"呢?
// 那是因为三条规则仅仅适用于变量对象的创建阶段,也即执行上下文的创建阶段。
// 而 func="coffe" 是在执行上下文的执行阶段中运行的,输出结果自然会是"coffe"。

这种现象很容易让人费解,其实也是因为var声明的变量允许重名导致的,若使用关键字let来声明变量,就可以避免这种令人费解的情况发生。

壹.2.2.4 作用域链(Scope Chain)

定义

多个作用域对应的变量对象串联起来组成的链表就是作用域链,这个链表是以引用的形式保持对变量对象的访问作用域链保证了当前执行上下文对符合访问权限的变量和函数的有序访问。

作用域链的图示

作用域链的最顶端一定是当前作用域(local scope)对应的变量对象,最底端一定是全局作用域对应的变量对象(全局VO)。

作用域链可以形象地比如为一个蒸笼。

蒸笼

最底下的一屉,相当于是全局作用域,它里面的蒸汽(变量和函数的可见性)可以渗透到整个蒸笼,底层之上的其他屉相当于局部作用域,这些上面屉的蒸汽只能影响更上面的屉。

作用域链可以理解为下面这种伪代码格式:

{
Scope: [
{ //当前作用域对应的VO
实参,
形参,
变量,
函数
},
{ //第二个作用域对应的VO
实参,
形参,
变量,
函数
},
...
{ //全局作用域对应的VO
变量,
函数
}
]
}

变量/函数的查找机制

在本篇 “壹.2.2.1 作用域(Scope)” 中我们已经了解到,查找变量/函数时JS引擎是从里离它最近作用域开始的查找的,也即从离它最近的变量对象(VO)开始查找。

如果在当前的变量对象里面找不到目标变量/函数,就在上一级作用域的变量对象里面查找。若这时找到了目标变量/函数,则停止查找;若找不到,一直回溯到全局作用域的变量对象里查找,若仍找不到目标变量/函数,停止查找。