什么是执行上下文

当 JS 引擎解析到可执行代码片段(通常是函数调用阶段)的时候,就会先做一些执行前的准备工作,这个 “准备工作”,就叫做 “执行上下文(execution context 简称 EC)” 或者也可以叫做执行环境

执行上下文 为我们的可执行代码块提供了执行前的必要准备工作,例如变量对象的定义、作用域链的扩展、提供调用者的对象引用等信息。

执行上下文的类型

javascript 中有三种执行上下文类型,分别是:

  • 全局执行上下文——这是默认或者说是最基础的执行上下文,一个程序中只会存在一个全局上下文,它在整个 javascript 脚本的生命周期内都会存在于执行堆栈的最底部不会被栈弹出销毁。全局上下文会生成一个全局对象(以浏览器环境为例,这个全局对象是 window),并且将 this 值绑定到这个全局对象上。
  • 函数执行上下文——每当一个函数被调用时,都会创建一个新的函数执行上下文(不管这个函数是不是被重复调用的)
  • Eval 函数执行上下文—— 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但由于并不经常使用 eval,所以在这里不做分析

执行上下文的内容

执行上下文是一个抽象的概念,我们可以将它理解为一个 object ,一个执行上下文里包括以下内容:

  1. 变量对象
  2. 活动对象
  3. 作用域链
  4. 调用者信息

变量对象(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.

每个执行环境文都有一个表示变量的对象——变量对象,全局执行环境的变量对象始终存在,而函数这样局部环境的变量,只会在函数执行的过程中存在,在函数被调用时且在具体的函数代码运行之前,JS 引擎会用当前函数的参数列表arguments)初始化一个 “变量对象” 并将当前执行上下文与之关联 ,函数代码块中声明的 变量函数 将作为属性添加到这个变量对象上。
有一点需要注意,只有函数声明(function declaration)会被加入到变量对象中,而函数表达式(function expression)会被忽略。

// 这种叫做函数声明,会被加入变量对象 function a () {} // b 是变量声明,也会被加入变量对象,但是作为一个函数表达式 _b 不会被加入变量对象 var b = function _b () {}
全局执行上下文和函数执行上下文中的变量对象还略有不同,它们之间的差别简单来说:
1. 全局上下文中的变量对象就是全局对象,以浏览器环境来说,就是 window 对象。
2. 函数执行上下文中的变量对象内部定义的属性,是不能被直接访问的,只有当函数被调用时,变量对象(VO)被激活为活动对象(AO)时,我们才能访问到其中的属性和方法。

执行上下文栈

当一段脚本运行起来的时候,可能会调用很多函数并产生很多函数执行上下文,那么问题来了,这些执行上下文该怎么管理呢?为了解决这个问题,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

尾递归优化

针对递归存在的 “爆栈” 问题,我们可以学习一下 尾递归优化。“递归” 我们已经了解了,那么 “尾” 是什么意思呢?“尾” 的意思是 “尾调用(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.

 

总之,尾递归优化这个东西暂时还是不要想用到了,不过先了解个概念也是好的。