当 JS 引擎处理一段脚本内容的时候,它是以怎样的顺序解析和执行的?脚本中的那些变量是什么时候被定义的?它们之间错综复杂的访问关系又是怎样建立和连接的?要解释这些问题,就必须了解 JS 执行上下文的概念。javascript
当 JS
引擎解析到可执行代码片断(一般是函数调用阶段)的时候,就会先作一些执行前的准备工做,这个 “准备工做”,就叫作 "执行上下文(execution context 简称 EC
)" 或者也能够叫作执行环境。html
执行上下文 为咱们的可执行代码块提供了执行前的必要准备工做,例如变量对象的定义、做用域链的扩展、提供调用者的对象引用等信息。前端
javascript
中有三种执行上下文类型,分别是:java
全局执行上下文——这是默认或者说是最基础的执行上下文,一个程序中只会存在一个全局上下文,它在整个 javascript
脚本的生命周期内都会存在于执行堆栈的最底部不会被栈弹出销毁。全局上下文会生成一个全局对象(以浏览器环境为例,这个全局对象是 window
),而且将 this
值绑定到这个全局对象上。node
函数执行上下文——每当一个函数被调用时,都会建立一个新的函数执行上下文(无论这个函数是否是被重复调用的)webpack
Eval 函数执行上下文—— 执行在 eval
函数内部的代码也会有它属于本身的执行上下文,但因为并不常用 eval
,因此在这里不作分析。git
执行上下文是一个抽象的概念,咱们能够将它理解为一个 object
,一个执行上下文里包括如下内容:github
variable object
简称 VO
)原文:Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.web
每一个执行环境文都有一个表示变量的对象——变量对象,全局执行环境的变量对象始终存在,而函数这样局部环境的变量,只会在函数执行的过程当中存在,在函数被调用时且在具体的函数代码运行以前,JS 引擎会用当前函数的参数列表(arguments
)初始化一个 “变量对象” 并将当前执行上下文与之关联 ,函数代码块中声明的 变量 和 函数 将做为属性添加到这个变量对象上。面试
有一点须要注意,只有函数声明(function declaration)会被加入到变量对象中,而函数表达式(function expression)会被忽略。
// 这种叫作函数声明,会被加入变量对象 function a () {} // b 是变量声明,也会被加入变量对象,可是做为一个函数表达式 _b 不会被加入变量对象 var b = function _b () {} 复制代码
全局执行上下文和函数执行上下文中的变量对象还略有不一样,它们之间的差异简单来讲:
window
对象。 VO
)被激活为活动对象(AO
)时,咱们才能访问到其中的属性和方法。activation object
简称 AO
)原文:When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below. The activation object is then used as the variable object for the purposes of variable instantiation.
函数进入执行阶段时,本来不能访问的变量对象被激活成为一个活动对象,自此,咱们能够访问到其中的各类属性。
其实变量对象和活动对象是一个东西,只不过处于不一样的状态和阶段而已。
scope chain
)做用域 规定了如何查找变量,也就是肯定当前执行代码对变量的访问权限。当查找变量的时候,会先从当前上下文的变量对象中查找,若是没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫作 做用域链。
函数的做用域在函数建立时就已经肯定了。当函数建立时,会有一个名为 [[scope]]
的内部属性保存全部父变量对象到其中。当函数执行时,会建立一个执行环境,而后经过复制函数的 [[scope]]
属性中的对象构建起执行环境的做用域链,而后,变量对象 VO
被激活生成 AO
并添加到做用域链的前端,完整做用域链建立完成:
Scope = [AO].concat([[Scope]]);
复制代码
若是当前函数被做为对象方法调用或使用 bind
call
apply
等 API
进行委托调用,则将当前代码块的调用者信息(this value
)存入当前执行上下文,不然默认为全局对象调用。
关于 this
的建立细节,有点烦,有兴趣的话能够进入 传送门 学习。
若是将上述一个完整的执行上下文使用代码形式表现出来的话,应该相似于下面这种:
executionContext:{ [variable object | activation object]:{ arguments, variables: [...], funcions: [...] }, scope chain: variable object + all parents scopes thisValue: context object } 复制代码
执行上下文的生命周期有三个阶段,分别是:
函数执行上下文的建立阶段,发生在函数调用时且在执行函数体内的具体代码以前,在建立阶段,JS 引擎会作以下操做:
用当前函数的参数列表(arguments
)初始化一个 “变量对象” 并将当前执行上下文与之关联 ,函数代码块中声明的 变量 和 函数 将做为属性添加到这个变量对象上。在这一阶段,会进行变量和函数的初始化声明,变量统必定义为 undefined
须要等到赋值时才会有确值,而函数则会直接定义。
有没有发现这段加粗的描述很是熟悉?没错,这个操做就是 变量声明提高(变量和函数声明都会提高,可是函数提高更靠前)。
构建做用域链(前面已经说过构建细节)
肯定 this
的值
执行阶段中,JS 代码开始逐条执行,在这个阶段,JS 引擎开始对定义的变量赋值、开始顺着做用域链访问变量、若是内部有函数调用就建立一个新的执行上下文压入执行栈并把控制权交出……
通常来说当函数执行完成后,当前执行上下文(局部环境)会被弹出执行上下文栈而且销毁,控制权被从新交给执行栈上一层的执行上下文。
注意这只是通常状况,闭包的状况又有所不一样。
闭包的定义:有权访问另外一个函数内部变量的函数。简单说来,若是一个函数被做为另外一个函数的返回值,并在外部被引用,那么这个函数就被称为闭包。
function funcFactory () { var a = 1; return function () { alert(a); } } // 闭包 var sayA = funcFactory(); sayA(); 复制代码
当闭包的父包裹函数执行完成后,父函数自己执行环境的做用域链会被销毁,可是因为闭包的做用域链仍然在引用父函数的变量对象,致使了父函数的变量对象会一直驻存于内存,没法销毁,除非闭包的引用被销毁,闭包再也不引用父函数的变量对象,这块内存才能被释放掉。过分使用闭包会形成 内存泄露 的问题,这块等到闭包章节再作详细分析。
对于 ES3
中的执行上下文,咱们能够用下面这个列表来归纳程序执行的整个过程:
arguments object
检查上下文中的参数,初始化名称和值并建立引用副本var
的变量声明:
undefined
来初始化this
值ES5
规范又对 ES3
中执行上下文的部分概念作了调整,最主要的调整,就是去除了 ES3
中变量对象和活动对象,以 词法环境组件( LexicalEnvironment component
) 和 变量环境组件( VariableEnvironment component
) 替代。因此 ES5
的执行上下文概念上表示大概以下:
ExecutionContext = { ThisBinding = <this value>, LexicalEnvironment = { ... }, VariableEnvironment = { ... }, } 复制代码
ES6 官方 中的词法环境定义:
词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
简单来讲 词法环境 是一种持有 标识符—变量映射 的结构。这里的 标识符 指的是变量/函数的名字,而 变量 是对实际对象(包含函数类型对象)或原始数据的引用。
这块看不懂不要紧,你能够把它理解为 ES3 中的 变量对象,由于它们本质上作的是相似的事情,这里只是先把官方给出的定义放上来。这块概念比较烦:词法环境还分为两种,而后内部有个环境记录器还分两种,,这些概念在后面会用列表的形式概括整理出来详细说明。
变量环境 它也是一个 词法环境 ,因此它有着词法环境的全部特性。
之因此在 ES5
的规范力要单独分出一个变量环境的概念是为 ES6
服务的: 在 ES6
中,词法环境组件和 变量环境 的一个不一样就是前者被用来存储函数声明和变量(let
和 const
)绑定,然后者只用来存储 var
变量绑定。
在上下文建立阶段,引擎检查代码找出变量和函数声明,变量最初会设置为 undefined(var 状况下),或者未初始化(let 和 const 状况下)。这就是为何你能够在声明以前访问 var 定义的变量(虽然是 undefined),可是在声明以前访问 let 和 const 的变量会获得一个引用错误。
对于 ES5
中的执行上下文,咱们能够用下面这个列表来归纳程序执行的整个过程:
let
和 const
定义的变量)null
var
定义的变量,初始值为 undefined
形成声明提高)null
this
值为全局对象(以浏览器为例,就是 window
)arguments
对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。(负责处理 let
和 const
定义的变量)arguments
对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。(负责处理 var
定义的变量,初始值为 undefined
形成声明提高)this
值关于 ES5 中执行上下文的变动,我的感受就是变了个概念,本质和 ES3 差异并不大。至于变动的目的,应该是为了 ES6 中的 let 和 const 服务的。
当一段脚本运行起来的时候,可能会调用不少函数并产生不少函数执行上下文,那么问题来了,这些执行上下文该怎么管理呢?为了解决这个问题,javascript
引擎就建立了 “执行上下文栈” (Execution context stack
简称 ECS
)来管理执行上下文。
顾名思义,执行上下文栈是栈结构的,所以遵循 LIFO
(后进先出)的特性,代码执行期间建立的全部执行上下文,都会交给执行上下文栈进行管理。
当 JS 引擎开始解析脚本代码时,会首先建立一个全局执行上下文,压入栈底(这个全局执行上下文从建立一直到程序销毁,都会存在于栈的底部)。
每当引擎发现一处函数调用,就会建立一个新的函数执行上下文压入栈内,并将控制权交给该上下文,待函数执行完成后,即将该执行上下文从栈内弹出销毁,将控制权从新给到栈内上一个执行上下文。
在了解了调用栈的运行机制后,咱们能够考虑一个问题,这个执行上下文栈能够被无限压栈吗?很显然是不行的,执行栈自己也是有容量限制的,当执行栈内部的执行上下文对象积压到必定程度若是继续积压,就会报 “栈溢出(stack overflow
)” 的错误。栈溢出错误常常会发生在 递归 中。
程序调用自身的编程技巧称为递归(recursion)。
递归的使用场景,一般是在运行次数未知的状况下,程序会设定一个限定条件,除非达到该限定条件不然程序将一直调用自身运行下去。递归的适用场景很是普遍,好比累加函数:
// 求 1~num 的累加,此时 num 由外部传入,是未知的 function recursion (num) { if (num === 0) return num; return recursion(num - 1) + num; } recursion(100) // => 5050 recursion(1000) // => 500500 recursion(10000) // => 50005000 recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded 复制代码
从代码中能够看到,这个递归的累加函数,在计算 1 ~ 100000 的累加和的时候,执行栈就崩不住了,触发了栈溢出的错误。
针对递归存在的 “爆栈” 问题,咱们能够学习一下 尾递归优化。“递归” 咱们已经了解了,那么 “尾” 是什么意思呢?“尾” 的意思是 “尾调用(Tail Call
)”,即函数的最后一步是返回一个函数的运行结果:
// 尾调用正确示范1 function a(x){ return b(x); } // 尾调用正确示范2 // 尾调用不必定要写在函数的最后为止,只要保证执行时是最后一部操做就好了。 function c(x) { if (x > 0) { return d(x); } return e(x); } 复制代码
尾调用之因此与其余调用不一样,就在于它的特殊的调用位置。尾调用因为是函数的最后一步操做,因此不须要保留外层函数的相关信息,由于调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就能够了,这样一来,运行尾递归函数时,执行栈永远只会新增一个上下文。
咱们可使用尾调用的方式改写下上面的累加递归:
// 尾递归优化 function recursion (num, sum = 0) { if (num === 0) return sum; return recursion(num - 1, sum + num); } recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded 复制代码
运行以后怎么仍是报错了 😳 ??裂开了呀。。。
其实,尾递归优化这种东西,如今没有任何一个浏览器是支持的(听说 Safari 13 是支持的),babel
编译也不支持。那 nodejs
里的 V8
引擎呢?它作好了,可是不给你用,官方回答以下:
Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.
理由呢,它也颇有道理:
抱着就是不服的心态,我开始 google
看 js 怎么才能支持尾递归。
看 stackoverflow
上的意思是说,只有 Safari
支持尾递归优化,看来有戏,先弄个 safari
下下看。
下好了,怎么是祖传界面???算了,先运行。
说好的 Safari
能够呢??继续找缘由,我找到了下面这个图,相似于 caniuse
:
看样子只有 Safari 13+
支持,我电脑上的版本是 5.1
的,硬着头皮,找 13+
的版本。一路摸,摸到苹果官网:
没有 win10
版本的下载??我不能再买个 mbp 吧?(点开支付宝看了看,算了算了)有 mbp
的大佬们能够试试看可不可行,好像 iOS12+
也能支持。
总之,尾递归优化这个东西暂时仍是不要想用到了,不过先了解个概念也是好的。
在网上找了几条执行上下文比较典型的面试题,你们能够试一试:
var foo = function () { console.log('foo1'); } foo(); var foo = function () { console.log('foo2'); } foo(); 复制代码
第一题没什么,应该能写出来。
foo(); var foo = function foo() { console.log('foo1'); } function foo() { console.log('foo2'); } foo(); 复制代码
全局执行环境自动建立,过程当中生成了变量对象进行函数变量的属性收集,形成了函数声明提高、变量声明提高。因为函数声明提高更加靠前,且若是 var
定义变量的时候发现已有同名函数定义则跳过变量定义,上面的代码其实能够写成下面这样:
function foo () { console.log('foo2'); } foo(); foo = function foo() { console.log('foo1'); }; foo(); 复制代码
var foo = 1; function bar () { console.log(foo); var foo = 10; console.log(foo); } bar(); 复制代码
bar
函数运行,内部变量申明提高,当执行代码块中有访问变量时,先查找本地做用域,找到了 foo
为 undefined
,打印出来。而后 foo
被赋值为 10
,打印出 10
。
var foo = 1; function bar () { console.log(foo); foo = 2; } bar(); console.log(foo); 复制代码
这题也是考察的做用域链查找,bar
里操做的 foo
本地没有定义,因此应该是上层做用域的变量。
var foo = 1; function bar (foo) { console.log(foo); foo = 234; } bar(123); console.log(foo); 复制代码
运行 bar
函数的时候将 123
数字做为实参传入,因此操做的仍是本地做用域的 foo
。
var a = 1; function foo () { var a = 2; return function () { console.log(a); } } var bar = foo(); bar(); 复制代码
这道题目主要考察闭包和函数做用域的概念,咱们只要记住:函数可以访问到的上层做用域,是在函数声明时候就已经肯定了的,函数声明在哪里,上层做用域就在哪里,和拿到哪里执行没有关系。这道题目中,匿名函数被做为闭包返回并在外部调用,但它内部的做用域链引用到了父函数的变量对象中的 a
,因此做用域链查找时,打印出来的是 2
。
"use strict"; var a = 1; function foo () { var a = 2; return function () { console.log(this.a); } } var bar = foo().bind(this); bar(); 复制代码
这题考察的是执行环境中的 this
指向的问题,因为闭包内明确指定访问 this
中的 a
属性,而且闭包被 bind
绑定在全局环境下运行,因此打印出的是全局对象中的 a
。
关于最后一题,评论区有朋友说 bind 加和不加都同样,因而我改用了严格模式。须要注意的是,严格模式下禁止函数内的 this 指向全局变量。
undefined
。[[scope]]
属性里,和函数拿到哪里去执行没有关系。this
指向,取决于它的调用者,一般有如下几种方式能够改变函数的 this
值:对象调用、call
、bind
、apply
。本篇文章已收录入 前端面试指南专栏