
三、JavaScript是如何运行的-预解析阶段
一、执行上下文 Execution Context EC
- 执行程序需要有执行环境, Java 需要 Java 虚拟机,同样解析 JavaScript 也需要执行环境,我们称它为“执行上下文”。
- 执行上下文是评估和执行JavaScript代码的环境的抽象概念,是对 JavaScript 代码执行环境的一种抽象。每当JavaScript代码在执行的时候,他都是在执行上下文中执行。
二、执行上下文的类型
JavaScript中有三种执行上下文类型:
全局执行上下文
当 JS 引擎执行全局代码的时候,会编译全局代码并创建执行上下文,它会做两件事:
- 创建一个全局的 window 对象(浏览器环境下),
- 将 this 的值设置为该全局对象;全局上下文在整个页面生命周期有效,一个程序中只会有一个全局执行上下文。
函数执行上下文
- 每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
Eval函数执行上下文
- 调用 eval 函数也会创建自己的执行上下文(eval函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢,因此不推荐使用)
看一张图片方便理解
- 图中一共用 4 个执行上下文。紫色的代表全局的上下文;绿色代表 person 函数内的上下文;蓝色以及橙色代表 person 函数内的另外两个函数的上下文。
- 只存在一个全局的上下文,该上下文能被任何其它的上下文所访问到。也就是说,我们可以在 person 的上下文中访问到全局上下 文中的 sayHello 变量,当然在函 firstName 或者 lastName 中同样可以访问到该变量。
- 函数上下文的个数是没有任何限制的,每到调用执行一个函数时,解释器就会自动新建出一个函数上下文,换句话说,就是新建一个局部作用域,可以在该局部作用域中声明私有变量等,在外部的上下文中是无法直接访问到该局部作用域内的元素的。
三、执行栈 Execution Context Stack
- javascript 引擎创建了 “执行上下文栈” (Execution context stack 简称 ECS)来管理执行上下文。
- 执行上下文栈是栈结构的,因此遵循 LIFO(后进先出)的特性,代码执行期间创建的所有执行上下文,都会交给执行上下文栈进行管理。
- 当 JS 引擎开始解析脚本代码时,会首先创建一个全局执行上下文,压入栈底(这个全局执行上下文从创建一直到程序销毁,都会存在于栈的底部)。
- 每当引擎发现一处函数调用,就会创建一个新的函数执行上下文压入栈内,并将控制权交给该上下文,待函数执行完成后,即将该执行上下文从栈内弹出销毁,将控制权重新给到栈内上一个执行上下文。
把执行上下文栈可视化大概是下图这个样子:
- 栈顶是当前活动的执行上下文,也就是程序正在栈顶那个执行环境运行。栈底是全局执行上下文,因为程序一运行立即入栈的就是全局执行上下文,我们写的 JS 代码都是在全局执行上下文环境运行的。
- 通过出栈和入栈,切换当前程序代码的执行环境。类似于原型链,上下文也有父执行上下文,子执行上下文。子执行上下文可以访问父执行上下文,但父执行上下文不能访问子执行上下文。****上下文之间使用 scope 链接起来,我们把它称为 Scope Chain 作用域链。
四、ES3中的执行上下文
执行上下文可以理解成当前的执行环境,与该运行环境相对应。创建执行上下文的过程中,主要是做了下面三件事,如图所示:
1、创建变量对象(variable object)
2、创建作用域链(scope chain)
3、确定this的指向
变量对象 variable object VO
- 每个执行环境文变量对象,全局执行环境的变量对象始终存在,而函数这样局部环境的变量,只会在函数执行的过程中存在;
- 在函数被调用时且在具体的函数代码运行之前,JS 引擎会用当前函数的参数列表(arguments)初始化一个 “变量对象” ,并将当前执行上下文与之关联;
- 函数代码块中声明的变量和函数将作为属性添加到这个变量对象上。
注意:
- 变量对象(VO)是规范上或者是JS引擎上实现的,并不能在JS环境中直接访问。
- 当进入到一个执行上下文后,这个变量对象才会被激活,所以叫活动对象(AO),这时候活动对象上的各种属性才能被访问。
变量对象的创建
- Chrome 浏览器中,变量对象会首先获得函数的参数变量及其值;在 Firefox 浏览器中,是直接将参数对象 arguments 保存在变量对象中。
- 函数声明提升,如果相同函数名则覆盖。
- 变量声明提升,遇到同名的不会重新声明,只会赋值。此处为var声明的变量,let和const声明是执行阶段才开始执行。
相关例子1
1 | var a = 20; |
首先:
1 | VO = { |
接着声明提升:
1 | VO = { |
然后进入执行阶段:
1 | AO = { |
相关例子2
1 | // 函数执行上下文带参数的话,在创建阶段就会被赋值。 |
全局执行上下文和函数执行上下文中的变量对象略有不同:
- 全局上下文中的变量对象就是全局对象,以浏览器环境来说,就是 window 对象。
- 函数执行上下文中的变量对象内部定义的属性,是不能被直接访问的,只有变量对象(VO)被激活为活动对象(AO)时,我们才能访问到其中的属性和方法。
活动对象 activation object AO
- 函数进入执行上下文时,原本不能访问的变量对象被激活成为一个活动对象,自此,我们可以访问到其中的各种属性。其实变量对象和活动对象是一个东西,只不过处于不同的状态和阶段而已。
作用域链 scope chain
- 作用域链规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
- 当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法作用域上的父级,也就是函数定义时的作用域的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。
- 这样由多个执行上下文的变量对象构成的链表就叫做 作用域链。
- 函数的作用域在函数创建时就已经确定了。当函数创建时,会有一个名为 [[scope]] 的内部属性保存所有父变量对象到其中。当函数执行时,会创建一个执行环境,然后通过复制函数的 [[scope]] 属性中的对象构建起执行环境的作用域链,然后,变量对象 VO 被激活生成 AO 并添加到作用域链的前端,完整作用域链创建完成:
1 | Scope = [AO].concat([[Scope]]); |
举个例子
1 | function foo(){ |
函数创建时,各自的 [[scope]] 为:
1 | foo.[[scope]] = [ |
当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用域链的前端。
1 | Scope = [AO].concat([[scope]]) |
至此,作用域链创建完毕。
this
- 在全局执行上下文中,this 的值指向全局对象,在浏览器中 this 的值指向 window 对象。
- 在函数执行上下文中,this 的值取决于函数的调用方式。具体有:默认绑定、隐式绑定、默认绑定(硬绑定)、new 绑定、箭头函数。
五、ES5 中的执行上下文
- ES5 规范又对 ES3 中执行上下文的部分概念做了调整
- 最主要的调整,就是去除了 ES3 中变量对象和活动对象
- 以 词法环境组件( LexicalEnvironment component****) 和 变量环境组件( VariableEnvironment component****) 替代。
创建阶段
ES5执行上下文创建阶段会做三件事:
- 绑定 this
- 创建词法环境
- 创建变量环境
所以执行上下文在概念上表示如下:
1 | ExecutionContext = { |
绑定this
- 在全局执行上下文中,this 的值指向全局对象。(在浏览器中,this 引用 Window 对象)。
- 在函数执行上下文中,this 的值取决于该函数是如何被调用的
- 通过对象方法调用函数,this 指向调用的对象
- 声明函数后使用函数名称普通调用,this 指向全局对象,严格模式下 this 值是 undefined
- 使用 new 方式调用函数,this 指向新创建的对象
- 使用 call、apply、bind 方式调用函数,会改变 this 的值,指向传入的第一个参数
1 | function fn () { |
词法环境
官方解释:词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。
一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。简单来说 词法环境 是一种持有 标识符—变量映射 的结构。这里的 标识符 指的是变量/函数的名字,而 变量 是对实际对象(包含函数类型对象)或原始数据的引用。
每一个词法环境由下面两部分组成:
- 环境记录:变量对象 => 存储声明的变量和函数( let, const, function,函数参数)
- 外部环境引用:作用域链
词法环境有两种类型:
全局环境:(在全局执行上下文中)是一个没有外部环境的词法环境。全局环境的外部环境引用为 null。它拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局对象,this 的值指向这个全局对象。
函数环境:用户在函数中定义的变量被存储在环境记录中。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。
1 | var a = 2; |
上面代码的词法环境类似这样:
1 | lexicalEnvironment = { |
环境记录
所谓的环境记录就是词法环境中记录变量和函数声明的地方,环境记录也有两种类型:
声明类环境记录。顾名思义,它存储的是变量和函数声明,函数的词法环境内部就包含着一个声明类环境记录。
对象环境记录。全局环境中的词法环境中就包含的就是一个对象环境记录。除了变量和函数声明外,对象环境记录还包括全局对象(浏览器的window对象)。因此,对于对象的每一个新增属性(对浏览器来说,它包含浏览器提供给window对象的所有属性和方法),都会在该记录中创建一个新条目。
注意:对函数而言,环境记录还包含一个arguments对象,该对象是个类数组对象,包含参数索引和参数的映射以及一个传入函数的参数的长度属性。举个例子,一个arguments对象像下面这样:
1 | function foo(a, b) { |
- 环境记录对象在**创建阶段也被称为变量对象(VO),在执行阶段被称为活动对象(AO)**。
- 之所以被称为变量对象是因为此时该对象只是存储执行上下文中变量和函数声明,之后代码开始执行,变量会逐渐被初始化或是修改,然后这个对象就被称为活动对象
外部环境引用
- 对于外部环境的引用意味着在当前执行上下文中可以访问外部词法环境。
- 也就是说,如果在当前的词法环境中找不到某个变量,那么Javascript引擎会试图在上层的词法环境中寻找。(Javascript引擎会根据这个属性来构成我们常说的作用域链)
词法环境抽象出来类似下面的伪代码:
1 | // 全局执行上下文 |
变量环境
变量环境 它也是一个 词法环境 ,所以它有着词法环境的所有特性。
- 之所以在ES5的规范中要单独分出一个变量环境的概念是为 ES6 服务的: 在 ES6 中,词法环境组件和变量环境组件的一个不同就是词法环境被用来存储函数声明和变量(let 和 const)绑定,而变量环境只用来存储 var 变量绑定。
看点样例代码来理解上面的概念:
1 | let a = 20; |
执行起来看起来像这样:
1 | // 全局执行上下文 |
注意
- 只有遇到调用函数 multiply 时,函数执行上下文才会被创建。
- let 和 const 定义的变量并没有关联任何值,但 var 定义的变量被设成了 undefined。
- 这是因为在创建阶段时,引擎检查代码找出变量和函数声明,虽然函数声明完全存储在环境中,但是变量最初设置为 undefined(var 情况下),或者未初始化(let 和 const 情况下)。
- 这就是为什么可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 let 和 const 的变量会得到一个引用错误。
执行阶段
- 经过上面的创建执行上下文,就开始执行 JavaScript 代码了。在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined 。
回收阶段
- 执行上下文出栈等待虚拟机回收执行上下文。
六、递归和栈溢出
在了解了调用栈的运行机制后,我们可以考虑一个问题,这个执行上下文栈可以被无限压栈吗?很显然是不行的,执行栈本身也是有容量限制的,当执行栈内部的执行上下文对象积压到一定程度如果继续积压,就会报 “栈溢出(stack overflow)” 的错误。栈溢出错误经常会发生在 递归 中。
递归的使用场景,通常是在运行次数未知的情况下,程序会设定一个限定条件,除非达到该限定条件否则程序将一直调用自身运行下去。递归的适用场景非常广泛,比如累加函数:
1 | // 求 1~num 的累加,此时 num 由外部传入,是未知的 |
在计算 1 ~ 100000 的累加和的时候,执行栈就崩不住了,触发了栈溢出的错误。
七、尾递归优化
- “尾” 的意思是 “尾调用(Tail Call)”,即函数的最后一步是返回一个函数的运行结果:
1 | // 尾调用正确示范1 |
- 尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
- 尾调用由于是函数的最后一步操作,所以不需要保留外层函数的相关信息,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了,这样一来,运行尾递归函数时,执行栈永远只会新增一个上下文。
八、典型面试题
8.1、
1 | // 1 |
8.2、
1 | foo(); //foo2 |
全局执行环境自动创建,过程中生成了变量对象进行函数变量的属性收集,造成了函数声明提升、变量声明提升。
由于函数声明提升更加靠前,且如果var定义变量的时候发现已有同名函数定义则跳过变量定义,直接赋值,上面的代码其实可以写成下面这样:
1 | function foo() { |
8.3、
1 | var foo = 1; |
bar 函数运行,内部变量申明提升,当执行代码块中有访问变量时,先查找本地作用域,找到了 foo 为 undefined ,打印出来。然后 foo 被赋值为 10 ,打印出 10。代码相当于:
1 | function bar() { |
8.4、
1 | function foo() { |
第一段会报错:Uncaught ReferenceError: a is not defined。第二段会打印:1。
这是因为函数中的 “a” 并没有通过 var 关键字声明,所有不会被存放在 AO 中。第一段执行 console 的时候, AO 的值是:
1 | AO = { |
没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1。
九、补充:执行栈应用
利用浏览器查看栈的调用信息
- 我们知道执行栈是用来管理执行上下文调用关系的数据结构,那么我们在实际工作中如何运用它呢。
- 答案是我们可以借助浏览器“开发者工具” source 标签,选择 JavaScript 代码打上断点,就可以查看函数的调用关系,并且可以切换查看每个函数的变量值
我们在 second 函数内部打上断点,就可以看到右边 Call Stack 调用栈显示 second 、first、(anonymous) 调用关系,second 是在栈顶(anonymous 在栈底相当于全局执行上下文),执行second函数我们可以查看该函数作用域 Scope 局部变量a、b 和 num的值,通过查看调用栈的调用关系我们可以快速定位到我们代码执行的情况。
那如果代码执行出错,也不知道在哪个地方打断点调试,那怎么查看出错地方的调用栈呢,告诉大家一个技巧,如下图
我们不用打断点,执行上面两步操作,就可以在代码执行异常的地方自动打上断点。知道这个技巧后,再也不用担心代码出错了。
除了上面通过断点来查看调用栈,还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 second 函数里面加上了 console.trace(),就可以看到控制台输出的结果,如下图:
总结
- JavaScript执行分为两个阶段,编译阶段和执行阶段。
- 编译阶段会经过词法分析、语法分析、代码生成步骤生成可执行代码;
- JS 引擎执行可执行性代码会创建执行上下文,包括绑定this、创建词法环境和变量环境;词法环境创建外部引用(作用域链)和 记录环境(变量对象,let, const, function, arguments),
- JS 引擎创建执行上下完成后开始单线程从上到下一行一行执行 JS 代码了。