一堆概念
关于JavaScript的运行机制,网上一查,各种各样的问题,提及各种各样的概念:执行环境、执行上下文、浏览器上下文、变量对象、活动对象、词法环境、变量环境、执行栈等等。那么这些概念跟JavaScript的运行机制有什么关系呢?我们先把这些东西放一边,从我们最熟悉的变量提升聊起来。
变量提升
下面的代码大家肯定很熟悉
js
console.log(a); // undefined
foo(); // 123
var a = 10;
function foo() {
console.log(123);
}
在a
和foo
的定义语句之前,使用了变量和方法,结果是居然可以执行,这在其他语言里面简直不敢想。这就是JavaScript中的变量提升机制。这个机制是怎么实现的呢?跟上面那一堆概念又有什么关系呢?我们一个一个来解读。
js的作用域
变量提升的边界是作用域,先要了解变量提升要先了解作用域。 js中的作用域分成两种:
- 全局作用域
- 函数作用域
看看下面这段代码
js
var name = '二狗子';
function A() {
console.log(name)
}
function B() {
var name = '二傻子';
A();
}
B(); // 二狗子
上面的代码中,虽然函数 A 是在函数 B 中执行的,但是name
变量还是指向了全局的name
变量,而不是函数 B 中定义的name
变量。 大家都知道这个是作用域链的关系,A作用域中找不到的变量会到上级作用域查找,但是为什么A的上级作用域不是调用它的B而是全局呢?这个是因为作用域的设计决定的。
静态作用域
js使用的是静态作用域 ,大部分编程语言的都是使用这种作用域的设计,与之相反的是动态作用域 ,两者的区别主要是作用域定义的时机,动态作用域是代码运行时被定义的,例如上面的代码如果放在动态作用域的语言中应该输出二傻子
。而静态作用域被定义了之后就不会变化,在我们编写代码的时候作用域就被定义了,可以通过阅读源码就可以直观确认一个声明的作用域。在代码编译的过程中,作用域是在词法解析的阶段被定义的,所以静态作用域又被称为词法作用域 ,使用词法作用域的变量被称为词法变量。
变量收集
一个js文件下载到本地后,大概可以分为解析和执行两个阶段,解析就是将js编译成中间码的过程,包括词法解析、语法分析,语义分析等阶段。在执行阶段引擎会将中间码解析并执行。
前面说过在词法解析阶段,作用域就确认了,那么作用域内部的变量,包括形参和变量都可以确认,js引擎会在这个阶段收集作用域及作用域下的变量。
这里的 "变量" 包括作用域下的:
- 变量(var 声明的变量)
- 函数声明
- 函数的形参
问题来了,收集了这些作用域和变量后,引擎要怎么去存储呢?变量与作用域的关系怎么维系的?这个行为跟变量提升又有什么关系呢?
执行上下文/执行环境(Execution Context)
关于执行上下文的,我们来看看ES给的说明:
An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation.
简单翻译一下:执行上下文是一个用来跟踪可执行的ECMAScript代码运行时的值的规则模型。
首先js引擎解析中间码的时候是逐行进行的,当引擎进入到一段可执行代码 时,会为其创建一个执行上下文。 在js中可执行代码包括:
- 全局的代码
- 函数体的代码
- eval代码(eval函数创建的)
也可以这么理解这句话:当js引擎执行到一个作用域的时候,它就会为其创建一个执行上下问题。
是的,你没看错,eval
函数执行的时候会创建一个独立的作用域(可以把代码看做是在eval函数中执行的),看下面的代码:
js
let a = 2;
eval('let a = 3;');
console.log(a); // 2
如果不是独立作用域的话,那么这段代码应该是报错,但是我们可以正常的打印a
,证明eval
会创建一个独立的作用域。
因为eval
的安全问题,一般是禁止使用的,我们这里就不讨论它,只讨论最常见的全局作用域 和函数作用域。
当js代码开始执行的时候,引擎会创建一个全局的执行上下文,当进入函数的时候会为这个函数创建一个执行上下文。函数的执行上下文是临时,用完就销毁。
在一个前端项目中,我们会有很多函数,函数之间的执行顺序是不定,函数与函数之间还有嵌套,像下面的代码:
js
function foo1(a) {
console.log(a);
}
function foo2() {
console.log(2);
foo1(3);
}
function foo3() {
foo2();
console.log(2);
}
foo1(1);
foo3();
在这段代码里面,会有4个执行上下文,那么引擎是怎么去管理这些上下文的呢?怎么去确定他们的先后关系呢?这就要了解下执行栈。
执行栈 (Execution Context Stack)
执行上下文具备一个特性:永远都有且只有一个活跃的执行上下文。也就是说有且只有一个活跃的作用域。js引擎通过一个逻辑栈来管理的执行上下文,当进去一个作用域时,创建该作用域的执行上下文,压入栈顶,离开作用域时,栈顶的执行上下文出栈并销毁。
上面的示例代码的执行栈变化如下:
- 开始执行代码时,生成全局执行上下文,压入栈底
- 执行
foo1(1)
的时候,生成foo1(1)
执行上下文压入栈顶 foo1(1)
执行完成后,其执行上下文出栈销毁- 开始执行
foo3()
,生成foo3()
的执行上下文压入栈顶 - 执行到
foo3
中的foo2()
时,生成foo2()
的执行上下文压入栈顶 - 同理在
foo2
的foo1(3)
,也会生成对应的执行上下文并压入栈顶 - 按照执行顺序
foo1(3)
最先执行完成,所以foo1(3)
的执行上线文最先出栈销毁 - 接下来是
foo2()
的执行上下文 - 然后是
foo3()
的执行上下文 - 最后只剩下全局执行上下文在栈底
从上面的流程可以看出,所有执行上下文都在一个栈内维护,栈底的执行上下文永远都是当前活跃的执行上下文,并且有且永远只有一个活跃的执行上下文。
执行上下文的内容
执行上下文有哪些内容?它是如何实现作用域的能力的?在不同的规范中内容会有所不同,我们主要讨论的是ES3 和ES5,ES3之前的规范年代太久远了就不讨论了,ES4和ES6的实现方式是都是沿用上一个版本的,所以我们只讨论这两个变革的版本。
ES3的执行上下文
ES3中的执行上下文主要由以下几个部分内容组成:
- 当前环境的this值
- 作用域链 [[Scope]]
- 变量对象Variable Object(VO),作用域中的"变量"就存放在这里面
- 如果是函数的执行上下文,还会创建一个和形参绑定的arguments对象
经历的阶段
来自ES3的规范文档说明:
When control is transferred to ECMAScript executable code, control is entering an execution context.
当控制器开始解析可执行的ECMAScript代码时,控制器进入一个执行上下文。
执行上下文需要经历两个阶段:生成 和执行
- 生成阶段,会先创建上述的EC的内容
- 执行阶段,把创建的执行上下文压入执行栈,执行阶段根据上下文的类型会做一些不同的处理
变量对象(Variable Object)
每个执行上下文都会关联一个变量对象,变量对象是一种机制,一种针对执行环境来存放"变量"的机制。我们都知道js中的"变量"在内存中存储的方法,那么对于引擎来说,怎么识别不同作用域下的变量,特别是不同作用域下的同名变量怎么识别,修改非本作用域下的变量怎么寻找这个变量,所以需要一个机制来处理。而对于执行环境来说,变量对象就是一种存储执行环境中产生的变量和函数的数据结构(就是一个key-value的结构,内部实现就是一个对象)。
分类
根据执行上下文的类型,变量对象可以分为全局变量对象 和函数变量对象。
全局变量对象:Global Object 是一个特殊的存在,在控制流进入到js代码的时候就创建,有且只有一个这样的对象,它的属性在任何执行上下文中都可以被访问,在程序退出的时候终止生命周期。 在创建时,还会把全局相关的变量、函数都放到全局变量对象中,例如:Math
, String
, Date
, parseInt
等等,其实就是相当global 的存在。 在浏览器环境中,window 也是Global Object 中的一个属性,但是同时window 又指向Global Object自身。
js
global = {
Math: {...},
String: {...},
Date: function() {...},
...
window: global
}
也就是说,在浏览器环境中,全局执行下上文的存在这样的关系
js
VO === this === global === window
函数变量对象:Function variable object是局部对象,与对应函数的上下文绑定,与全局变量对象不一样的是,除了收集函数内部的变量和函数定义,还有函数的形参。另一个点则是函数的VO是不能修改的,所以在生成执行上下文的时候会使用**活动对象(Activation Object)**来代替VO,所以在函数执行上下文中,使用的是AO
js
VO(functionContext) === AO
AO是在进入函数执行上下文的时候创建并关联到当前上下文的,同时为其赋予arguments属性:
js
AO = {
arguments : <Array Object>
}
并且引擎会赋予AO { DontDelete } 的属性,使得AO不可删除,同时对于执行上下文来说AO是一个纯粹的规范机制,执行上下文无法直接访问整个AO对象,只能访问AO的成员,这个机制主要是防止执外部篡改AO导致不可预料的后果。
虽然,变量对象有全局和函数的区分,一些细节上也不一样,但是其实现的方式和内部的操作行为都是一致的。
变量对象的运行机制
下面,我们以函数的执行上下文为例,了解变量对象的执行机制,在进入函数的执行上下文的时候,VO被激活生成AO,这时候AO包含以下几个内容:
- 函数的形参:由形参名和value组成的一个属性,默认值为undefined,如果函数被调用时有传入对应的参数则该形参的值为传入的值。
- 内部的函数声明:由函数名和value(这里是一个指向函数对象(function-object)的指针)组成的一个属性
- 变量声明:由变量名和初始值(undefined)组成的一个属性
这里需要注意的一个点:函数表达式会别当做对象一样收集。
js
function demo(x, y) {
var a = 1;
function b() {
console.log('二狗子');
}
var c = function c() {
console.log('empty');
};
(function d(){});
}
demo(1);
这个示例函数产生的变量对象如下:
js
AO = {
x:1,
y:undefined,
a:undefined,
b:0xf90909, // 指针
c:undefined,
}
d
为什么没在变量对象里面?因为(FunctionDeclaration);
这种写法会被认为是函数表达式,不是函数声明,而这个函数表达式没有指定对应的变量名,所以不会被收集。
上面的例子只是一个简单的例子,实际上有很多情况要处理,主要就是同名的情况,我们分两个阶段来看: 初始化阶段
- 函数:如果变量对象已经存在相同名称的属性,则直接替换这个属性的值
- 变量和形参:如果名字与形参或者已声明的函数相同,不会影响这类属性
其实也就是说在变量对象中,属性名一定是唯一的,没有其他变通方案。
执行阶段 执行阶段比较简单,执行到赋值语句的时候,会将值直接赋给这个属性,替换掉原来的值
这样下面的代码就很好解释:
js
function demo(a) {
a();
function a() {
console.log('123');
}
var a = 2;
console.log(a);
}
demo(3);
我们都知道代码的执行结果是:
js
123
2
可以看下demo
的变量对象的变化: 初始化:
js
AO = {
a: 3 // 形参的初始值
}
js
AO = {
a: <Pointer> // 指向函数a的指针
}
所以第一行的a()
能正常执行。 当执行到var a = 2;
时,变量对象变为:
js
AO = {
a: 2 // 赋值语句
}
所以console.log(a);
输出2
变量对象的问题
变量对象虽然可以很好的支持js这种变量提升的机制,但是它也把作用域限制为只有去全局作用域和函数作用域(毕竟是跟执行上下文绑定,js只有全局和函数上下文),那么后续要增加的语言特性:局部变量、块级作用域就无法实现,所以需要一个新的解决方案。于是在ES5中,删除变量对象的实现并加入了变量环境 和词法环境来替换变量对象。
ES5的执行上下文
ES5的上下文同样由几个部分组成:
- LexicalEnvironment 词法环境
- VariableEnvironment 变量环境
- 当前环境的this值
这个组成到ES6后发生了变化,this值被收拢进变量环境中。
这里面的词法环境和变量环境都是通过一个叫"词法环境对象"的基础对象创建的,也就是说变量环境其实是一个特殊的词法环境,那么变量环境特殊在哪里呢?
变量环境和词法环境的区别
其实,在创建执行上下文的时候,变量环境和词法环境都是一样的(大家都是一个初始值:初始的词法环境对象),两者的不同点主要是:变量环境在执行上下文的生命周期里面是不变的,而词法环境的内容会随着执行控制流的 执行而变化,为什么是这样的?主要是变量环境和词法环境在执行上下文中的作用不同。
先来看看ES5和ES6中对词法环境的定义。
词法环境 词法环境是一种内置的规范类型,用于记录根据ECMAScript词法嵌套结构定义的标识符与指定的变量或函数的关联关系。说句人话就是词法环境其实跟前说的变量对象的作用是一样的,记录了执行上下文中的变量或函数。
词法环境由两个部分组成:
- Environment Record 环境记录,记录在对应的词法作用域内创建的标识符绑定(变量、函数声明)
- outer Lexical Environment 外部词法环境,用于创建词法环境的逻辑嵌套的模型,其实就是提供了词法环境访问父级词法环境的能力。当然这里是一个逻辑上的模型(套娃),外部词法环境也可能会有自己的外部词法环境,而一个词法环境也可以作为多个词法环境的外部环境。
PS: 有些说法会把this
也算到词法环境的组成里面,不过个人认为这个是错误的,首先是规范里面并没有说明,而且this
值是在执行上下文创建的时候创建的,被收拢进词法环境中的。
从实现的角度来看,其实词法环境跟变量对象是类似的,都是用于存储作用域内定义的键值对,不过实现的方式不太一样。这个后面再说。
词法环境的类型
-
全局词法环境:不管是web还是node,全局环境总是存在的,全局词法环境属于全局执行上下文,因为它已经是最外边的一层了,所以它是没有外部词法环境的,也就是说:词法环境的外部词法环境可能是空的 全局词法环境跟上面的全局变量对象一样,在全局执行上下文创建的时时候被创建,创建之后也会把一些内置的全局方法添加到全局词法环境中。
-
函数词法环境:跟函数变量对象一样,函数词法环境就是存储函数内定义的变量和函数声明
-
模块词法环境:主要是针对ESModule的实现,跟其他词法环境一样,模块词法环境主要是包含了模块顶级声明的绑定和引入(import的)模块的绑定,而它的外部环境就是全局环境。
变量环境 前面说了,变量环境只是一个特殊的词法环境,特殊点就是词法环境是可变的,而变量环境是不变的。这个主要是针对块级作用域的方式实现。变量对象的实现是无法区分作用域的类型的,也不适合在执行上下文中填入多个变量对象来实现,所以需要一个新的实现。
要识别ES6中的块级作用域主要是要把let
、const
和var
、函数声明、形参等其他标识符区分开来。
在ES6的执行上下文中,var
、函数声明、形参会被放入到变量环境中,这个操作跟ES3的变量对象类似。所以说变量环境是不变的,这里的不变指的是变量环境只要绑定了标识符后,建就是不变的,值还是可以变的。而词法环境是可变的,是因为词法环境会识别每个块级作用域,绑定其中的let
和const
定义的标识符,因此词法环境会根据代码执行而发生变化。
执行流程
看个例子:
js
var a = 2;
let b = 6;
function demo() {
var a = 3;
function d() {
console.log('function d');
}
{
let a = 4;
let c = 7;
console.log(b);
{
let a = 5;
console.log(c);
}
}
}
demo(); // 6、7
执行栈变化如下:
-
首先载入全局执行上下文 可以看到,
var
定义的变量放到变量环境中,let
定义的放到词法环境中 -
代码开始执行的后,执行了前面两行的代码后:
这个流程跟变量对象的赋值操作是一样的。
-
当进入到的函数
demo
的执行上下文时,这里要说明一下,关于函数体有个说法是:V8在实现的时候函数体的代码要先做编译,编译之后就会立刻创建上下文,而函数如果调用超过三次的话会把函数体的标记为"hot code","hot code"就是编译后的字节码,标记了的函数体代码再次运行的时候就不会执行编译操作而是直接执行字节码。 根据这个说法,函数demo
在编译阶段回收集内部的标识符并赋予初始值 -
函数代码开始执行,执行到
var a = 3;
为变量环境中的a赋值 -
接着往下,进入到第一个块级作用域时
{}
,将let
的定义变量放入词法环境 -
执行赋值
-
这时候执行栈是这样的: 执行
console.log(b)
,需要查找标识符b
,查询的路径如下: 可以看到查找是优先查找当前的词法环境,再到当前执行上下文的变量环境,没有则查找对应的外部环境,这里要注意外部环境不一定是执行栈的上一个,是当前词法环境的外部环境属性指向的词法环境。 -
代码继续执行,进入下一个
{}
,词法环境更新
可以看到词法环境的内部实现了一个小型的栈,每一个块级作用域是一个独立的区域,形成一个栈。
console.log(c)
的变量c
的查找也是类似的- 继续执行,最里面的
{}
执行完成,词法环境将其对应的区域销毁 - 最后函数执行完成,函数的执行上下文销毁
TDZ
ok,了解了变量环境和词法环境是怎么工作之后,我们来看看TDZ 是怎么形成的。 ES规范里面对let
和const
的定义有一句:
let and const declarations define variables that are scoped to the running execution context's LexicalEnvironment.The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated
首先规范说明了,let
和const
定义的变量会绑定到其所在的执行上下文的词法环境中,当词法环境被初始化的时候变量被创建,但是在变量被赋值之前,无法通过任何方式来访问该变量。
回到上面的代码,我们可以看到
js
{
let a = 4;
let c = 7;
console.log(b);
}
在创建的时候是这样的: 引擎为这两个变量分配了内存,但是并没有把它们赋值为undefined
。我们修改下代码:
js
let a = 3;
{
console.log(a);
let a = 4;
let c = 7;
console.log(b);
}
当执行到console.log(a)
的时候,可以在当前的词法环境找到a,但是a并没有值,只是一个指向内存块的指针,那么这时候就会报错,也就形成了TDZ。
总结
本文从变量提升这个基础知识点切入,分析了ES3和ES5对于执行环境的运行机制,最后基于这个机制分析了TDZ形成的原因。希望对大家有所帮助,本文内容主要是个人总结,如果有遗漏或者错误,请在评论区指出。
主要参考文献
ES6规范:262.ecma-international.org/6.0/#sec-ex... ES5规范:262.ecma-international.org/5.1/#sec-15... ES3规范:www.ecma-international.org/wp-content/...