对于ES3每个执行上下文,都有三个重要属性:
- 变量对象(Variable object,VO)
- 作用域链(Scope chain)
- this
这篇我们来聊聊这三个重要属性
变量对象
变量对象作为执行上下文的一种属性,每次创建后,根据执行环境不同上下文下的变量对象也稍有不同,我们比较熟悉的就是全局对象
和函数对象
,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。
全局上下文
我们先了解一个概念,什么叫全局对象。在 W3School 中:
全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。
例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。
我们可以根据代码理解
- 可以通过 this 引用,在客户端 JavaScript 中,全局对象就是 Window 对象。
console.log(this); //window
- 全局对象是由 Object 构造函数实例化的一个对象。
console.log(this instanceof Object);//true
- 我们调用的一些方法都在window下。
console.log(Math.random());
console.log(this.Math.random());
4.作为全局变量的宿主。
var a = 1;
console.log(this.a);
5.客户端 JavaScript 中,全局对象有 window 属性指向自身。
var a = 1;
console.log(window.a);//1
this.window.b = 2;
console.log(this.b);//2
我们发现全局上下文中的变量对象就是全局对象
函数上下文
在函数上下文中,不同于全局上下文比较死板,我们用活动对象(activation object, AO)
来表示变量对象。
所以活动对象和变量对象其实是一个东西,只是变量对象是规范上或者说是引擎实现上不可在 JavaScript 环境中直接访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以称为activation object
,只有在激活状态才会对属性进行访问。
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments
属性初始化。arguments
属性值是 Arguments
对象。
执行过程
执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:
- 进入执行上下文
- 代码执行
进入执行上下文
当进入执行上下文时,这时候还没有执行代码,
变量对象会包括:
-
函数的所有形参 (如果是函数上下文)
- 由名称和对应值组成的一个变量对象的属性被创建
- 没有实参,属性值设为 undefined
-
函数声明
- 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
- 如果变量对象已经存在相同名称的属性,则完全替换这个属性
-
变量声明
- 由名称和对应值(undefined)组成一个变量对象的属性被创建
- 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
举个例子:
function foo(a) {
var b = 2;
function c() {}
var d = function() {};
b =3;
}
foo(1);
在进入执行上下文后,这时候的 AO 是:
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}
代码执行
在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值
还是上面的例子,当代码执行完后,这时候的 AO 是:
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}
到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:
- 全局上下文的变量对象初始化是全局对象
- 函数上下文的变量对象初始化只包括 Arguments 对象
- 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
- 在代码执行阶段,会再次修改变量对象的属性值
例子
function foo() {
console.log(a);
a = 1;
}
foo(); // ???
function bar() {
a = 1;
console.log(a);
}
bar(); // ???
第一段会报错:Uncaught ReferenceError: a is not defined
。
第二段会打印:1
。
这是因为函数中的 "a" 并没有通过 var 关键字声明,所有不会被存放在 AO 中。
第一段执行 console 的时候, AO 的值是:
AO = {
arguments: {
length: 0
}
}
没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。
当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1。
但是这个例子在非严格模式下才会成立,因为严格模式并不会主动帮你创建一个变量
再看看另一个例子
console.log(foo);
function foo(){
console.log("foo");
}
var foo = 1;
会打印函数,而不是 undefined 。
这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
作用域
在讲解作用域链之前,先说说作用域
作用域是指程序源代码中定义变量的区域。
作用域对如何查找变量进行了规定,也就是确定当前执行代码对变量的访问权限。
JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。
编译原理
我们都知道JavaScript是一门动态语言或是解释性语言,但事实上它是一门编译语言。
程序中一段源码在执行前虎易经理三个步骤,统称为“编译”
- 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元,例如:var = 2;。这段代码会分解成var、a、=、2、;。如果词法单元生成器在判断a是一个独立的分词单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就称为词法分析。
- 解析/语法分析(Parsing)
这个过程是将词法单元流动(数组)转汉城一个由元素所组成的代表了程序语法结构的书。
这个书称为“抽象语法树(AST)”,var a = 2;的抽象语法树,可能会有一个叫做VariableDeclearation的顶级节点,接下来是一个叫作Identifier(它的值是 a)的子节点,以及一个叫作AssignmentExpresstion的子节点,AssignmentExpresstion节点有一个叫作NumericLiteral(它的值是2)的子节点。
- 代码生产
将AST转换为可执行代码的过程为代码生成
简单来说,就是有某种方法将var a = 2; 的AST转换为一组机器指令,用来创建一个叫作a的变量(包括分配内存),并将一个值储存在a中。
赋值操作
JavaScript在引擎中,变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会给它赋值
在编译器中的过程
先引入两个名词
RHS:负责查找某个变量的值
LHS:找到变量的容器本身,从而对其赋值
现在我们以console.log(a)为例,其中对a的引用进行是一个RHS引用,因为这里a并没有赋予任何值。响应地,需要查找并取得a的值,这样值就传递给console.log()。
相比之下,例如:
a = 2;
这里对a的引用则是LHS的引用,因为实际上我们并不关心当前的值是什么,只是想为= 2这个值操作找个一个目标或是容器
一个例子:
function foo(a){
console.log(a + b)
}
var b = 2
foo(2)
首先会对b进行RHS查询,无法在函数内部获得值,就会在上一级作用域查找,找到b之后再进行RHS查询。就是说,如果该变量如果在该作用域没有找到对应的赋值,就会向上查找,直到找到对应的赋值。
静态作用域与动态作用域
我们大多使用的作用域是词法作用域, 而函数的作用域在函数定义的时候就决定了。
而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
让我们认真看个例子就能明白之间的区别:
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();
假设JavaScript采用静态作用域,让我们分析下执行过程:
执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。
假设JavaScript采用动态作用域,让我们分析下执行过程:
执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。
前面我们已经说了,JavaScript采用的是静态作用域,所以这个例子的结果是 1。
动态作用域
bash 就是动态作用域
例如:
value=1
function foo () {
echo $value;
}
function bar () {
local value=2;
foo;
}
bar
作用域链
说完了作用域,终于到作用域链了。当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。
函数创建
函数的作用域在函数定义的时候就决定了。
这是因为函数有一个内部属性 [[scope]]
,当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]]
就是所有父变量对象的层级链,但是需要注意:[[scope]]
并不代表完整的作用域链
举个例子:
function foo() {
function bar() {
...
}
}
函数创建时,各自的[[scope]]
为:
foo.[[scope]] = [
globalContext.VO
];
bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];
函数激活
当函数激活时,进入函数上下文,创建 VO/AO
后,就会将活动对象添加到作用链的前端。
这时候执行上下文的作用域链,我们命名为Scope
:
Scope = [AO].concat([[Scope]]);
这样我们就创建了一个作用域链。
重新思考
以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:
var scope = "global scope";
function checkscope(){
var scope2 = 'local scope';
return scope2;
}
checkscope();
执行过程如下:
- checkscope 函数被创建,保存作用域链到内部属性[[scope]]
checkscope.[[scope]] = [
globalContext.VO
];
- 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [
checkscopeContext,
globalContext
];
- checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链
checkscopeContext = {
Scope: checkscope.[[scope]],
}
- 第二步:用 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
];
this
好吧,现在在说说this的问题,总结性的东西,面试题都会刷到,我就不多说了,下面我讲讲面试不考的知识,说说this到底是什么
先看一段代码
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log( this.a );
}
foo();
聪明的同学肯定会发现会发现结果是undefined
,在严格模式下会报错,首先,这段代码试图通过 this.bar() 来引用 bar() 函数。但是调用 bar() 最自然的方法是省略前面的 this,直接使用词法引用标识符。
此外,我们发现我们试图通过内部调用函数来改变词法作用域,从而让bar() 可以访问 foo() 作用域里的变量 a。这是不可能实现的。this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动对象。这个对象会包含函数在哪里被调用、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。也就是说this在函数创建的时候,已经形成了。
这样执行上下文的三个属性就讲完了,大概过程如图所示:
回顾
上面我们把三大属性就讲解了一遍,下面说说以前做过的例子:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
两段代码都会打印'local scope'。虽然两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?
具体执行分析
我们分析第一段代码:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
执行过程如下:
- 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
ECStack = [
globalContext
];
- 全局上下文初始化
globalContext = {
VO: [global],
Scope: [globalContext.VO],
this: globalContext.VO
}
- 初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
checkscope.[[scope]] = [
globalContext.VO
];
- 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [
checkscopeContext,
globalContext
];
- checkscope 函数执行上下文初始化:
- 复制函数 [[scope]] 属性创建作用域链,
- 用 arguments 创建活动对象,
- 初始化活动对象,即加入形参、函数声明、变量声明,
- 将活动对象压入 checkscope 作用域链顶端。
同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope: undefined,
f: reference to function f(){}
},
Scope: [AO, globalContext.VO],
this: undefined
}
- 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
ECStack = [
fContext,
checkscopeContext,
globalContext
];
- f 函数执行上下文初始化, 以下跟第 4 步相同:
- 复制函数 [[scope]] 属性创建作用域链
- 用 arguments 创建活动对象
- 初始化活动对象,即加入形参、函数声明、变量声明
- 将活动对象压入 f 作用域链顶端
fContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkscopeContext.AO, globalContext.VO],
this: undefined
}
f 函数执行,沿着作用域链查找 scope 值,返回 scope 值
f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
ECStack = [
checkscopeContext,
globalContext
];
- checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
ECStack = [
globalContext
];
ES5标准
ES5中在 我们改进了命名方式
- 词法环境(lexical environment)
- 变量环境(variable environment)
- this (this value)
所以执行上下文在概念上表示如下:
ExecutionContext = {
ThisBinding = ,
LexicalEnvironment = { ... },
VariableEnvironment = { ... },
}
词法环境
官方的 ES5 文档把词法环境定义为
词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
简单来说词法环境是一种持有标识符—变量映射的结构。(这里的标识符指的是变量/函数的名字,而变量是对实际对象[包含函数类型对象]或原始数据的引用)。
现在,在词法环境的内部有两个组件:(1) 环境记录器和 (2) 一个外部环境的引用。
环境记录器是存储变量和函数声明的实际位置。
外部环境的引用意味着它可以访问其父级词法环境(作用域)。
词法环境有两种类型:
全局环境(在全局执行上下文中)是没有外部环境引用的词法环境。全局环境的外部环境引用是 null。它拥有内建的 Object/Array/等、在环境记录器内的原型函数(关联全局对象,比如 window 对象)还有任何用户定义的全局变量,并且 this的值指向全局对象。
在函数环境中,函数内部用户定义的变量存储在环境记录器中。并且引用的外部环境可能是全局环境,或者任何包含此内部函数的外部函数。
环境记录器也有两种类型:
声明式环境记录器存储变量、函数和参数。
对象环境记录器用来定义出现在全局上下文中的变量和函数的关系。
简而言之,
在全局环境中,环境记录器是对象环境记录器。
在函数环境中,环境记录器是声明式环境记录器。
对于函数环境,声明式环境记录器还包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。
抽象地讲,词法环境在伪代码中看起来像这样:
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
}
outer:
}
}
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
}
outer:
}
}
变量环境
它同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。
如上所述,变量环境也是一个词法环境,所以它有着上面定义的词法环境的所有属性。
在 ES6 中,词法环境组件和变量环境的一个不同就是前者被用来存储函数声明和变量(let 和 const)绑定,而后者只用来存储 var 变量绑定。
我们看点样例代码来理解上面的概念:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
执行上下文看起来像这样:
GlobalExectionContext = {
ThisBinding: ,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer:
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
c: undefined,
}
outer:
}
}
FunctionExectionContext = {
ThisBinding: ,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
Arguments: {0: 20, 1: 30, length: 2},
},
outer:
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
g: undefined
},
outer:
}
}
只有遇到调用函数 multiply 时,函数执行上下文才会被创建。
可能你已经注意到 let 和 const 定义的变量并没有关联任何值,但 var 定义的变量被设成了 undefined。
这是因为在创建阶段时,引擎检查代码找出变量和函数声明,虽然函数声明完全存储在环境中,但是变量最初设置为 undefined(var 情况下),或者未初始化(let 和 const 情况下)。
这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 let 和 const 的变量会得到一个引用错误。
这就是我们说的变量声明提升。
执行阶段
这是整篇文章中最简单的部分。在此阶段,完成对所有这些变量的分配,最后执行代码。
注意 — 在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined。
总结
本篇文章对执行上下文进行了深入的讨论,也对不同的标准进行了大致的分析,意义在于略懂一些底层知识。说了那么多也写不好代码,知道个大概就好了。
JavaScript基础专题系列
JavaScript基础系列目录地址:
JavaScript基础专题之原型与原型链(一)
JavaScript基础专题之执行上下文和执行栈(二)
新手写作,如果有错误或者不严谨的地方,请大伙给予指正。如果这片文章对你有所帮助或者有所启发,还请给一个赞,鼓励一下作者,在此谢过。