JavaScript执行上下文

前言:在看js垃圾回收时遇到相关问题,感觉自己对上下文对象理解的还是不够透彻(实在是太菜了)这就回来恶补。

执行上下文(Execution Context)

执行上下文是 JS 引擎为代码执行创建的运行环境,ES6 规范其由:
this 绑定
词法环境(Lexical Environment)
变量环境(Variable Environment)
三部分构成,三者在上下文的「创建阶段」初始化,「执行阶段」完成赋值与执行。

在 JavaScript 中,运行环境主要包含了全局环境函数环境

在 JavaScript 代码运行过程中,最先进入的是全局环境,而在函数被调用时则进入相应的函数环境。全局环境和函数环境所对应的执行上下文我们分别称为全局上下文函数上下文

在一个 JavaScript 文件中,经常会有多个函数被调用,也就是说在 JavaScript 代码运行过程中很可能会产生多个执行上下文,那么如何去管理这多个执行上下文呢?

执行上下文是以栈(一种 LIFO 的数据结构)的方式被存放起来的,我们称之为执行上下文栈(Execution Context Stack)

在 JavaScript 代码开始执行时,首先进入全局环境,此时全局上下文被创建并入栈,之后当调用函数时则进入相应的函数环境,此时相应函数上下文被创建并入栈,当处于栈顶的执行上下文代码执行完毕后,则会将其出栈。

所以在执行上下文栈中,栈底永远是全局上下文,而栈顶则是当前正在执行的函数上下文。

1
2
3
4
5
6
7
8
function fn2() {
console.log('fn2')
}
function fn1() {
console.log('fn1')
fn2();
}
fn1();

对于如上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 伪代码 以数组来表示执行上下文栈 ECStack=[] */
// 代码执行时最先进入全局环境,全局上下文被创建并入栈
ECStack.push(global_EC);
// fn1 被调用,fn1 函数上下文被创建并入栈
ECStack.push(fn1_EC);
// fn1 中调用 fn2,fn2 函数上下文被创建并入栈
ECStack.push(fn2_EC);
// fn2 执行完毕,fn2 函数上下文出栈
ECStack.pop();
// fn1 执行完毕,fn1 函数上下文出栈
ECStack.pop();
// 代码执行完毕,全局上下文出栈
ECStack.pop();

image-20210414214146564

执行上下文的创建阶段,还会完成 this的绑定、词法环境的创建、变量环境的创建:


词法环境(Lexical Environment)初始化

词法环境实际上在函数定义就已经确定了,只是在上下文创建阶段才会进行初始化,生成作用域链。由两个组成部分:

  1. 环境记录(environment record)
  2. 对外部环境的引用
环境记录(Environment Record)

存储当前上下文的变量 / 函数,细分两类:

  • 函数上下文的环境记录:存储 let/const/ 函数声明 /class 声明,包含 arguments 对象(函数参数);
  • 全局上下文的环境记录:存储 let/const/ 函数声明 /class 声明,关联全局对象(如 window);
外部环境引用(Outer Environment Reference)

指向外层函数(这里的外层指的是代码编写时的外层,不是执行上下文的上层调用栈)的词法环境(形成作用域链),全局上下文的外部引用为 null。

环境记录是存储变量和函数声明的实际位置,对外部环境的引用意味着它可以访问其外部词法环境。


变量环境(Variable Environment)初始化

变量环境结构与词法环境完全一致(环境记录 + 外部引用);
唯一差异:仅存储 var 声明的变量(含函数表达式中的 var 变量)

变量环境中,var 声明的变量会被 “提升” 并初始化为 undefined(这是 var 变量提升的根源)。

结合代码理解:

1
2
3
4
5
6
7
8
9
10
let a = 20;  
const b = 30;
var c;

function multiply(e, f) {
var g = 20;
return e * f * g;
}

c = multiply(20, 30);

这段代码的执行上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
GlobalExectionContext = {

ThisBinding: <Global Object>,

LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 标识符绑定在这里
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},

VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 标识符绑定在这里
c: undefined,
}
outer: <null>
}
}

FunctionExectionContext = {

ThisBinding: <Global Object>,

LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 标识符绑定在这里
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},

VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 标识符绑定在这里
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}

注意: 只有在遇到函数 multiply 的调用时才会创建函数执行上下文。
这里的 let 和 const 定义的变量没有任何与之关联的值,但 var 定义的变量设置为 undefined。
这是因为在创建阶段,代码会被扫描并解析变量和函数声明,其中函数声明存储在环境中,而变量会被设置为 undefined(在 var 的情况下)或保持未初始化(在 let 和 const 的情况下)。
这就是为什么可以在声明之前访问 var 定义的变量(尽管是 undefined ),但如果在声明之前访问 let 和 const 定义的变量就会提示引用错误的原因,也就是所谓的变量提升


作用域链

作用域链:决定执行上下文的变量查找机制
作用域链 = 「(当前词法环境 + 当前变量环境) → (外层词法环境 + 外层变量环境) → … → 全局词法环境」
注意:作用域链的构建依据是 “词法位置”(代码书写时的嵌套关系),而非函数调用顺序(与执行上下文栈无关);

全局执行上下文的外部环境引用为 null,是作用域链的终点;
执行上下文栈管理上下文的 “执行顺序”,作用域链管理上下文的 “变量查找规则”,二者独立但配合工作。

以代码示例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 全局执行上下文
var a = 1;
function fn1() {
// fn1 执行上下文:外部环境引用 → 全局词法环境
var b = 2;
function fn2() {
// fn2 执行上下文:外部环境引用 → fn1 词法环境
var c = 3;
console.log(a + b + c); // 查找链:fn2 → fn1 → 全局
}
fn2();
}
fn1();

执行 fn2 时,其作用域链为:fn2 词法环境 → fn1 词法环境 → 全局词法环境;
查找 a:fn2 环境无 → 向上到 fn1 环境无 → 全局环境找到 a=1;
查找 b:fn2 环境无 → fn1 环境找到 b=2;
查找 c:fn2 环境直接找到 c=3。


绑定 this 指向

this 的指向,是在函数被调用的时候确定的。也就是执行上下文被创建时确定的。

关于 this 的指向,其实最主要的是三种场景,分别是全局上下文中 this函数中 this构造函数中 this

全局上下文中 this

在全局上下文中,this 指代全局对象。

1
2
3
4
5
6
7
// 在浏览器环境中,全局对象是 window 对象:
console.log(this === window); // true
a = 1;
this.b = 2;
console.log(window.a); // 1
console.log(window.b); // 2
console.log(b); // 2
函数中 this

函数中的 this 指向是怎样一种情况呢?

如果被调用的函数,被某一个对象所拥有,那么其内部的 this 指向该对象;如果该函数被独立调用,那么其内部的 this 指向 window(严格模式下指向undefined)。

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

还有一种比较特殊的情况

1
2
3
4
5
6
7
8
9
10
11
12
var o = {
a:8,
b:{
a:15,
fn:function(){
console.log(this.a); //undefined
console.log(this); //window
}
}
}
var j = o.b.fn;
j();

this永远指向的是最后调用它的对象,也就是看它执行的时候是谁调用的,上例中虽然函数fn是被对象b所引用,但是在将fn赋值给变量j的时候并没有执行所以最终指向的是window

构造函数中 this

要清楚构造函数中 this 的指向,则必须先了解通过 new 操作符调用构造函数时所经历的阶段。

通过 new 操作符调用构造函数时所经历的阶段如下:

  1. 创建一个新对象;
  2. 将构造函数的 this 指向这个新对象;
  3. 执行构造函数内部代码;
  4. 返回这个新对象。

所以从上述流程可知,对于构造函数来说,其内部 this 指向新创建的对象实例

1
2
3
4
5
6
7
function Person(name, age) {
this.name = name;
this.age = age;
}
var ttsy = new Person('ttsy', 24);
console.log(ttsy.name); // ttsy
console.log(ttsy.age); // 24
箭头函数的 this

箭头函数的 this 是基于词法作用域链查找的,箭头函数的this等于外层上下文创建时绑定的this
相当于箭头函数的this是静态的,绑定后不会因为函数的调用方式或者 call/apply/bind 修改

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 全局上下文 G(this=window)
const obj = {
name: "obj",
// 普通函数:绑定执行时栈上层的上下文
normalFn: function() {
// 普通函数:返回一个新的普通函数
return function() {
console.log("普通函数 this:", this.name);
// 执行时栈上层是全局/调用者,this 动态绑定
};
},

// 箭头函数:绑定定义时的词法外层上下文
arrowFnWrapper: function() {
// 箭头函数定义时的词法外层 = arrowFnWrapper 的上下文(this=obj)
return () => {
console.log("箭头函数 this:", this.name);
// 复用定义时的词法外层 this,与执行时栈无关
};
}
};

// 1. 调用普通函数返回的函数
const normalReturned = obj.normalFn();
normalReturned(); // 执行时栈:G → normalReturned
// 输出:普通函数 this:undefined(this=window,无 name)

// 2. 调用箭头函数返回的函数
const arrowReturned = obj.arrowFnWrapper(); // 箭头函数在这里被定义,绑定 obj
const returned = arrowReturned.call({name: "hhh"}) // 箭头函数无法通过call修改绑定的this
returned();
// 输出:箭头函数 this: obj

示例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
globalThis.a = 100;
function fn() {
return {
a: 200,
m: function() {
console.log(this.a);
},
n: ()=>{
console.log(this.a);
},
k: function() {
return function() {
console.log(this.a)
}
}
};
}

const fn0 = fn();
fn0.m(); // 输出 200,this 指向 {a, m, n}
fn0.n(); // 输出 100,this 指向 globalThis
fn0.k()(); // 输出 100, this 指向 globalThis

const context = {a: 300}
const fn1 = fn.call(context); // 改变箭头函数 this 指向
fn1.m(); // 输出 200,this 指向 {a, m, n}
fn1.n(); // 输出 300,this 指向 context
fn1.k().call(context); // 输出 300,this 指向 context

其他

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

块级作用域({} 包裹的代码块,如 if/for/直接 {})是词法环境的 “子环境”,依附于当前执行上下文(全局 / 函数)存在,不会创建独立的执行上下文;块级作用域仅扩展执行上下文内词法环境的存储与查找规则。

以代码示例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 全局执行上下文(无独立块级执行上下文):
// 词法环境 G:存储 let b = 4
// 变量环境 VG:存储 var a = undefined
// 外层词法环境 OG: 无
var a = 1;
let b = 4
// 块级作用域:创建块级词法环境 B,外部引用 → G
{
// 块级词法环境 B:存储 let b = undefined、const c = undefined
let b = 2;
const c = 3;
var d = 4; // 存入全局变量环境 VG,无块级约束

console.log(a); // 查找:B → G → VG → 找到 a=1
console.log(b); // 查找:B → 找到 b=2
}

console.log(b); // 报错(块级词法环境 B 已销毁,G 中无 b)
console.log(d); // 查找:G → VG → 找到 d=4(var 无块级约束)