目录
正文
什么是 执行上下文
我们首先来看一个例子
function f1() {
console.log('111');
};
f1();
function f1() {
console.log('666');
};
f1();
按照上面的代码顺序,结果应该先输出 111,再输出 666 才对,但是很遗憾,两次输出均为 666。
如果我们将上述代码中的函数声明改为函数表达式,结果又不太一样:
var f1 = function () {
console.log('111');
};
f1(); //111
var f1 = function() {
console.log('666');
};
f1(); //666
是不是很意外,这其中的奥秘其实就在于JS的执行上下文里,看完下面的内容,你就会理解为什么了。
JS代码在执行之前,JS引擎会先做一下准备工作,也就是创建对应的执行上下文。
执行上下文有且只有三类:全局执行上下文,函数上下文,与eval上下文。由于eval一般不会使用,就不深入探究了。
stop,我有话说:
在 JavaScript 中,运行环境主要包含了全局环境和函数环境。 而 JavaScript 代码运行过程中,最先进入的是全局环境,而在函数被调用时则进入相应的函数环境。全局环境和函数环境所对应的执行上下文我们分别称为全局(执行)上下文和函数(执行)上下文。
ok,请继续你的表演!
全局执行上下文
-
全局执行上下文只有一个,在客户端中一般由浏览器创建,也就是我们熟知的window对象,我们能通过 this 直接访问到它。
console.log(this);
-
全局对象window上预定义了大量的方法和属性,我们在全局环境的任意处都能直接访问这些属性方法,如:
console.log(this.Math.random());
-
window对象还是var声明的全局变量的载体。我们通过var创建的全局对象,都可以通过window直接访问。
var a = 1; window.a; // 1
函数执行上下文
- 每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。
注意:每次调用函数时,都会为该函数创建一个新的执行上下文(即使是调用同一个函数)
- 因此,函数执行上下文可存在无数个。
综上,执行上下文可以理解为代码在被解析以前或者在执行时候所处的环境。之所以这么理解,是因为全局上下文是在代码被解析前就已经由浏览器创建好了的,函数上下文是在函数调用时创建的。
到这里,大家应该可以初步理解执行上下文所谓何物了吧 :)
执行上下文的三个重要属性
变量对象(Variable Object)
是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。
因为不同执行上下文下的变量对象稍有不同,所以来理一下全局上下文中的变量对象和函数上下文中的变量对象。
- 全局上下文中的变量对象就是全局对象!
- 在函数上下文中,用活动对象来表示变量对象。
活动对象(activation object, AO)和变量对象其实是同一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。
再看下执行过程:执行上下文的代码会分成两个阶段进行处理:创建(代码预编译)阶段 和 执行(代码执行)阶段
1. 预编译阶段(进入执行上下文,这时候还没有执行代码)
先进行语法分析,没有问题以后,在预编译阶段 分配 js 代码中变量的内存空间(变量提升就是在这个阶段完成的)。
- 变量对象会包括:
- 1)函数的所有形参 (如果是函数上下文)
- 由名称和对应值组成的一个变量对象的属性被创建
- 没有实参,属性值设为
undefined
- 2)函数声明【由名称和对应值(函数对象)组成一个变量对象的属性被创建】
- 3)变量声明【由名称和对应值(
undefined
)组成一个变量对象的属性被创建】
- 1)函数的所有形参 (如果是函数上下文)
举个栗子:
function foo(a) {
var b = 2;
function c() {};
var d = function() {};
b = 3;
}
foo(1);
// 在进入执行上下文后,这时候的AO是:
AO = {
arguments:{
0:1,
length:1
},
a:1, // 函数调用时创建的函数上下文,所以为 1
b:undefined,
c:reference to function c() {},
d:undefined
}
2. 代码执行阶段(执行代码逻辑,修改变量对象的值)
还是上面的栗子,当代码执行完之后,这时候的AO是:
AO = {
arguments:{
0:1,
length:1
},
a:1,
b:3,
c:reference to function c() {},
d:reference to FunctionExpression "d"
}
总结上述所说:
- 全局上下文的变量对象初始化是全局对象
- 函数上下文的变量对象初始化只包括 Arguments 对象
- 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
- 在代码执行阶段,会再次修改变量对象的属性值,同时执行上下文在这个阶段会全部创建完成
作用域链(Scope Chain)
作用域链是指由当前上下文和上层上下文的一系列变量对象组成的层级链。
我们已经知道,执行上下文分为创建和执行两个阶段。在执行上下文的执行阶段,当需要查找某个变量或函数时,会先在当前上下文的变量对象(活动对象)中进行查找,若是没有找到,则会依靠当前上下文中的作用域链,沿着上层上下文的变量对象进行查找,直到全局上下文中的变量对象(全局对象)。
Q:既然如此,那作用域链又是怎么创建的?
A:我们都知道,JavaScript 中主要包含了全局作用域和函数作用域,而函数作用域是在函数被声明的时候确定的。
每一个函数都会包含一个 [[scope]] 内部属性,在函数被声明的时候,该函数的 [[scope]] 属性会保存其上层上下文的变量对象,形成包含上层上下文变量对象的层级链。[[scope]] 属性的值是在函数被声明的时候确定的。
当函数被调用的时候,其执行上下文会被创建并入栈。在创建阶段生成其变量对象后,会将该变量对象添加到作用域链的顶端并将 [[scope]] 添加进该作用域链中。而在执行阶段,变量对象会变为活动对象,其相应属性会被赋值。
所以,作用域链是由当前上下文变量对象及上层上下文变量对象组成的:SC = AO + [[scope]]
看个栗子:
var a = 1;
function fn1() {
var b = 1;
function fn2() {
var c = 1;
}
fn2();
}
fn1();
分析如下:
在 fn1 函数上下文中,fn2 函数被声明,所以
fn2.[[scope]]=[fn1_EC.VO, globalObj]
当 fn2 被调用的时候,其执行上下文被创建并入栈,此时会将生成的变量对象添加进作用域链的顶端,并且将 [[scope]] 添加进作用域链
fn2_EC.SC=[fn2_EC.VO].concat(fn2.[[scope]])
=>
fn2_EC.SC=[fn2_EC.VO, fn1_EC.VO, globalObj]
在这里请允许我小小地拓展另一个知识点:
由上文对执行上下文的定义,我们知道,当程序运行的时候,全局上下文已经创建好了,等函数调用就会创建函数上下文。
Q:接下来问题来了,我们写的函数多了去了,如何管理创建的那么多函数上下文呢?
A1:所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)
【是一种拥有 LIFO(后进先出)数据结构的栈】来管理执行上下文。
A2:当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出
A3:试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext
拓展完毕,下面用个例子总结一下函数执行上下文中作用域链和变量对象的创建过程:
var scope = "global scope";
function checkscope() {
var scope2 = 'local scope';
return scope2;
}
checkscope();
- ①由于先处理函数声明。于是checkscope 函数被创建,[[scope]] 属性会保存其上层上下文的变量对象(也就是全局对象),保存作用域链到 内部属性[[scope]]
checkscope.[[scope]] = [ globalContext.VO ];
- ②执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [ checkscopeContext, globalContext ];
- ③checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链
checkscopeContext = { Scope:checkscope.[[scope]], }
读到这里可能会有以下疑问:
Q1:checkscope函数被创建时保存到[[scope]]的作用域链, 和 checkscope执行前的准备工作中复制函数[[scope]]属性创建的作用域链有什么不同?
A1:checkscope函数创建的时候,保存的是根据词法所生成的作用域链。checkscope执行的时候,会复制这个作用域链,作为自己作用域链的初始化,然后根据环境生成变量对象,然后将这个变量对象,添加到这个复制的作用域链,这才完整的构建了自己的作用域链。Q2:为什么会有两个作用域链?
A2:因为在函数创建的时候并不能确定最终的作用域的样子。而为什么会采用复制的方式而不是直接修改呢?应该是因为函数会被调用很多次吧。 - ④第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
checkscopeContext = { AO: { arguments:{ length:0 }, scope2:undefined }, Scope:checkscope.[[scope]], }
- ⑤第三步:将活动对象压入checkscope 作用域顶端
checkscopeContext = { AO: { arguments:{ length:0 }, scope2:undefined }, Scope:[AO, [[Scope]]] }
- ⑥准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
checkscopeContext = { AO: { arguments:{ length:0 }, scope2:'local scope' }, Scope:[AO, [[Scope]]] }
- ⑦查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ECStack = [ globalContext ];
至此,作用域链的知识点over :)
this
首先需要清楚,this 是执行上下文的一个属性,而不是某个变量对象的属性。this的指向也不是如常识一般指向某某,而是依据调用栈和执行位置决定的(即取决于函数在哪里被调用),并且 this
是在运行时绑定的,并不是在编写时绑定。
this 绑定有五种场景:默认绑定、隐式绑定、显式绑定、new绑定、箭头函数绑定
- 默认绑定
即函数调用时无任何调用前缀。默认绑定时,不管函数在何处调用, this
指向全局对象 window
(非严格模式);在严格模式下,默认绑定的 this
指向 undefined
。
function fn() {
console.log(this); // window
console.log(this.num); // 666
};
function fn1() {
"use strict";
console.log(this); // undefined
console.log(this.num);
};
var num = 666;
fn(); // --> 默认绑定
fn1() // Uncaught TypeError: Cannot read property 'num' of undefined
温馨提示:在严格模式下调用不在严格模式中的函数,并不会影响this指向,如下:
var name = '听风是风';
function fn() {
console.log(this); //window
console.log(this.name); //听风是风
};
(function () {
"use strict";
fn();
}());
- 隐式绑定
如果函数调用时,前面存在调用它的对象,那么this就会隐式绑定到这个对象上
function fn() {
console.log(this.num);
};
let obj = {
num: 666,
func: fn
};
obj.func() // 666
// 上面代码中,this 指向 obj,obj 有 num 属性,所以输出 666
如果函数调用前存在多个对象,this指向距离调用自己最近的对象
function fn() {
console.log(this.num);
};
let obj = {
num: 666,
func: fn,
};
let obj1 = {
num: 111,
o: obj
};
obj1.o.func() // 666
这里稍微拓展一下,如果将obj对象的name属性注释掉,却会输出undefined,如下:
function fn() { console.log(this.name); }; let obj = { func: fn, }; let obj1 = { name: '听风是风', o: obj }; obj1.o.func() // undefined
obj对象虽然是obj1的属性,但它们两个的原型链并不相同,并不是父子关系,由于obj未提供name属性,所以是undefined。注意不要将作用域链和原型链弄混淆了。
既然说到这里了,索性再理清一下作用域链与原型链的区别:
--> 当访问一个变量时,解释器会先在当前作用域查找标识符,如果没有找到就去父作用域找,作用域链顶端是全局对象window,如果window都没有这个变量则报错。
--> 当在对象上访问某属性时,首选i会查找当前对象,如果没有就顺着原型链往上找,原型链顶端是null,如果全程都没找到则返一个undefined,而不是报错。
- 显式绑定
指通过call、apply、bind以及js API中的部分方法改变this指向
// call、apply、bind
let obj1 = {
num: 111
};
let obj2 = {
num: 666
};
let obj3 = {
num: 999
}
function fn() {
console.log(this.num);
};
fn.call(obj1); // 111
fn.apply(obj2); // 666
fn.bind(obj3)(); // 999
// API
let obj = {
num: 666
};
[1, 2, 3].forEach(function () {
console.log(this.num);// 666 * 3
}, obj);
注意,如果在使用call之类的方法改变this指向时,指向参数提供的是null或者undefined,那么 this 将指向全局对象。
- new绑定
function Fn(){
this.num = 666;
};
let echo = new Fn();
console.log(echo.num) // 666
在上方代码中,构造调用创建了一个新对象echo,而在函数体内,this将指向新对象echo上
- 箭头函数this指向:
箭头函数中没有自己的this,箭头函数的this指向取决于外层作用域中的this:外层作用域或函数的this指向谁,箭头函数中的this便指向谁;最终保障是指向 window。
this 参考文章:
js 五种绑定彻底弄懂this,默认绑定、隐式绑定、显式绑定、new绑定、箭头函数绑定详解
伢羽之不同的角度看this:JavaScript深入之从ECMAScript规范解读this
最后,如果一个函数调用存在多种绑定方法,this最终指向谁?下面是前面四种绑定方法的优先级:
显式绑定 > 隐式绑定 > 默认绑定;
new绑定 > 隐式绑定 > 默认绑定。为什么显式绑定不和new绑定比较呢?因为不存在这种绑定同时生效的情景,如果同时写这两种代码会直接抛错。
说到这里,执行上下文的三个属性终于说完了,以上。
执行上下文栈和执行上下文的具体变化过程
直接上栗子:
var scope = "global scope";
function checkscope() {
var scope = "local scope";
function f() {
return scope;
}
return f();
}
checkscope();
1. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
ECStack = [
globalContext
];
2. 全局上下文初始化
globalContext = {
VO:[global],
Scope:[globalContext.VO],
this:globalContext.VO
}
- 初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
checkscope.[[scope]] = [ globalContext.VO ];
3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [
checkscopeContext,
globalContext
];
4. checkscope 函数执行上下文初始化:
- 1)复制函数 [[scope]] 属性创建作用域链,
- 2)用 arguments 创建活动对象,
- 3)初始化活动对象,即加入形参、函数声明、变量声明,
- 4)将活动对象压入 checkscope 作用域链顶端。
- 5)同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
checkscopeContext = { AO:{ arguments:{ length:0 }, scope:undefined, f:reference to function f() {} }, Scope:[AO, globalContext.VO], this:undefined }
5. 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
ECStack = [
fContext,
checkscopeContext,
globalContext
];
6. f 函数执行上下文初始化, 以下跟第 4 步相同:
- 1)复制函数 [[scope]] 属性创建作用域链
- 2)用 arguments 创建活动对象
- 3)初始化活动对象,即加入形参、函数声明、变量声明
- 4)将活动对象压入 f 作用域链顶端
fContext = { AO:{ arguments:{ length:0 } }, Scope:[AO, checkscopeContext.AO, globalContext.VO], this:undefined }
7. f 函数执行,沿着作用域链查找 scope 值,返回 scope 值
8. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
ECStack = [
checkscopeContext,
globalContext
];
9. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
ECStack = {
globalContext
};
到这里,这篇文章就 over 了,希望诸位看官能够有所收获!
把孤独藏进耳机,也许内心会得到治愈
Syandeg
2021.09.08