深入理解执行上下文、作用域链、闭包、this

执行上下文被创建时,发生了什么?

以上就是一个执行上下文的生命周期,相信你对其中的一些点也不陌生,下面就来深入的学习它们。

变量对象

在创建执行上下文时,会创建一个叫做变量对象的特殊对象,JavaScript的基础数据类型的值往往都会保存在变量对象中。而引用类型因为大小不固定,所以引用数据类型的值是保存在堆内存中的对象。因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量对象中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。

1
2
3
var m = { a: 10, b: 20 }
var n = m;
n.a = 15;

上面这段代码,n = m 这一句仅仅只是把引用类型的地址指针复制过去,实际指向的还是同一个对象,因此m.a也会被改变。

变量对象的创建,依次经历了以下几个过程:

  • 建立arguments对象。检查当前上下文中的参数,建立该对象下的属性与属性值。
  • 检查当前上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖
  • 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。

在上面的规则中我们看出,function声明会比var声明优先级更高一点。

1
2
3
4
5
6
7
function fn(a){
console.log(a);
var a = 2;
function a(){};
console.log(a);
}
fn(1);

举个例子,函数fn执行时,创建新的执行上下文,在创建变量对象时,先建立arguments对象,此时建立了a,值为1。然后检查函数声明,发现存在,于是覆盖,此时a指向该函数。之后检查变量声明,找到后因为a已经存在,于是会跳过。当执行到var a = 2;时,a被赋值为2。最终输出的结果是:
> f a(){}
> 2

未进入执行阶段之前,变量对象中的属性都不能访问。但是进入执行阶段之后,变量对象转变为了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。变量对象和活动对象其实都是同一个对象,只是处于执行上下文的不同生命周期。

执行上下文

每次当控制器转到可执行代码的时候,就会进入一个执行上下文。执行上下文可以理解为当前代码的执行环境,它会形成一个作用域。JavaScript中的运行环境大概包括三种情况。

  • 全局环境:JavaScript代码运行起来会首先进入该环境
  • 函数环境:当函数被调用执行时,会进入当前函数中执行代码
  • eval(不建议使用,可忽略)

因此在一个JavaScript程序中,必定会产生多个执行上下文,JavaScript引擎会以栈的方式来处理它们,这个栈,我们称其为函数调用栈(call stack)。栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。

为了巩固一下执行上下文的理解,我们再来绘制一个例子的演变过程,这是一个简单的闭包例子。

1
2
3
4
5
6
7
8
9
function f1(){
var n=999;
function f2(){
alert(n);
}
return f2;
}
var result=f1();
result(); // 999


注意,只有函数执行时才会创建新的执行上下文。

作用域链

作用域
在JavaScript中,我们可以将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
JavaScript中只有全局作用域与函数作用域

作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。上层环境指的是该函数定义(而不是执行)时所在的作用域。

作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。

let

ES6 新增了let命令,用来声明变量。它的用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效。ES6中新增了块级作用域。 块作用域由 { } 包括,if语句和for语句里面的{ }也属于块作用域。

1
2
3
4
5
6
7
var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 10

如果使用var,最后输出的是10。如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。上面代码中,变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

1
2
3
4
5
6
7
for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域。

闭包

闭包是一种特殊的对象。
它由两部分组成。执行上下文(代号A),以及在该执行上下文中创建的函数(代号B)。
当B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 20;
var b = 30;

function bar() {
return a + b;
}

return bar;
}

var bar = foo();
bar();

上面的例子,首先有执行上下文foo,在foo中定义了函数bar,而通过对外返回bar的方式让bar得以执行。当bar执行时,访问了foo内部的变量a,b。因此这个时候闭包产生。

通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。

this

this的指向,是在函数被调用的时候确定的。在函数执行过程中,this一旦被确定,就不可更改了。
用例子感受一下

1
2
3
4
5
6
7
8
9
10
11
var a = 20;
var obj = {
a: 10,
c: this.a + 20,
fn: function () {
return this.a;
}
}

console.log(obj.c); //40
console.log(obj.fn()); //10

在一个函数上下文中,this由调用者提供,由调用函数的方式来决定。如果调用者函数,被某一个对象所拥有,那么该函数在调用时,内部的this指向该对象。如果函数独立调用,那么该函数内部的this,则指向undefined。但是在非严格模式中,当this指向undefined时,它会被自动指向全局对象。

只要不是obj.func的方式,都不算被某对象所拥有,都是独立调用。

1
2
3
4
5
6
7
8
9
var a = 20;
function getA() {
return this.a;
}
var foo = {
a: 10,
getA: getA
}
console.log(foo.getA()); // 10

再来一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {
console.log(this.a)
}

function active(fn) {
fn(); // 真实调用者,为独立调用
}

var a = 20;
var obj = {
a: 10,
getA: foo
}

active(obj.getA);// 20