【进阶第 1 期】 调用栈与执行上下文

504 阅读8分钟

尽管很多开发者每天都在使用 JavaScript,却不知道这背后发生了什么。本文作为是【进阶系列】文章的第一篇,旨在深入探讨调用栈与执行上下文

前言

先来看下栈的定义:栈是一种后进先出(LIFO)的数据结构.
那么延伸一下,调用栈就可以理解为:用来管理函数调用关系的一种数据结构。这里面会记录我们在程序调用过程中的大概位置。当程序执行进入一个函数,把它置于栈的顶部,如果从函数中返回(或执行到 return语句)则从栈顶部移除函数。那为什么先讲执行上下文呢?它这么重要吗?可以这么说,只有理解了 JavaScrip 的执行上下文,你才能更好地理解 JavaScript 这门语言本身。

什么是调用栈?

正如我们所知道的,javascript解释器被实现为单线程,同一时间只能处理一个任务,JS 程序中多个执行环境通过栈的方式来管理,这个栈叫做执行栈(调用栈Call Stack)。在函数编程中,通常代码中会声明多个函数,函数调用会在内存形成一个调用记录,又称调用帧(call frame),用来保存调用位置和内部变量等信息(执行上下文),因此对应的执行上下文也会存在多个。

举个简单例子:
如果在函数 A 的内部又调用函数 B,那么在 A 的调用记录上方,还会形成一个 B 的调用记录。等到 B 运行结束,将结果返回到 A,B 的调用记录才会消失。如果函数 B 内部还调用函数 C,那就还有一个 C 的调用记录栈,以此类推。所有的调用记录,就形成一个调用栈。

function A() {
    B();
}
function B() {
    C()
}
function C() {
    let aa = 1;
    console.trace()
}
A()

了解调用栈有什么好处?

了解调用栈至少有以下四点点好处:

  • 帮助你了解 JavaScript 引擎背后的工作原理(执行上下文相关);
  • 调试 JavaScript 代码的能力;
  • 避免栈溢出(Stack Overflow): 调用栈是有大小的,递归调用避免栈溢出
  • 帮助你搞定面试,因为面试过程中,调用栈也是出境率非常高的题目。

什么是执行上下文(Execution Context)?

当我们的执行一段代码时,会进入到不同的执行环境(Global Code、Function Code或 eval)。在不同的执行环境中,有着不同的 scope(作用域),代码所能访问到的资源也就不同。 所以可以这么理解:执行上下文是javascript执行一段代码的运行环境。执行上下文是一个逻辑上的堆栈结构(Stack)。堆栈中最顶层的执行上下文就是正在运行的执行上下文。

再来对比看下ES5 规范 如何定义的: An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation

执行上下文的类型

根据可执行代码类型,执行上下文分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文

执行上下文类执行环境描述
全局执行上下文全局代码代码默认运行的环境,代码执行时会首先进入全局执行上下文的环境中
函数执行上下文函数代码函数被调用执行时,所创建的执行执行环境
eval 函数执行上下文eval 代码(已不推荐使用)

当某个执行环境中的所有代码执行完毕后,该段代码的的执行上下文也会被销毁,即对应的变量和函数也随之销毁(内存回收)

执行上下文的组成

前端这些年迅猛发展,规范也在快速迭代升级,从ES3到ES5,再到如今的ES6。同样地,执行上下文定义以及JS引擎的实现也在相应发生变化,如果不看ECMAScript规范文档,只从别人博客文档粗略了解,不免给我们造成困扰,到底哪个是正确的。这里梳理了几个阶段,笔者对执行上下文的理解(有不当之处,请指出)

执行上下文在 ES3 中,包含三个部分:

组成作用目的
变量对象/活动对象用于存储当前环境的变量信息,变量对象和活动对象其实是一个东西,只不过处于不同的状态和阶段而已(当函数进入执行阶段时,原本不能访问的变量对象被激活成为一个活动对象)
作用域链由多个执行上下文的变量对象构成的链表。当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象
this指定该执行环境内的 ECMA 脚本代码中 this 关键字所关联的值

用伪代码实现如下:

// Execution context in ES3

ExecutionContext = {
    [variable object | activation object]:{ // 变量对象、激活对象
        arguments,
        variables: [...],
        funcions: [...]
    },
    scope chain: variable object + all parents scopes // 作用域链
    thisValue: context object
}

ES5 中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

组件作用目的
词法环境组件指定一个词法环境对象,用于解析该执行环境内的代码创建的标识符引用
变量环境组件指定一个词法环境对象,其环境数据用于保存由该执行环境内的代码通过 VariableStatementFunctionDeclaration 创建的绑定
this 绑定指定该执行环境内的 ECMA 脚本代码中 this 关键字所关联的值

用伪代码实现如下:

// Execution context in ES5

ExecutionContext = {
	LexicalEnvironment:{// 词法环境,当获取变量时使用。
    	OuterEnv:< ... >,
        EnvironmentRecord:<...>
    },
	VariableEnvironment:{ // 变量环境,当声明变量时使用。
        OuterEnv:< ... >,
        EnvironmentRecord:<...> // 环境记录, 标识符绑定在这里  
    },
	ThisBinding:<...>	//
}

ES2018(通俗地讲 ES6) 中,执行上下文又变成了这个样子,this 值被归入 lexical

// Execution context in ES6

// 还未深入,有空补上
ExecutionContext = {
	environment,但是增加了不少内容。
    lexical environment:词法环境,当获取变量或者 this 值时使用。
	code evaluation state:用于恢复代码执行位置。
    Function:执行的任务是函数时使用,表示正在被执行的函数。
    ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
    Realm:使用的基础库和内置对象实例。
    Generator:仅生成器上下文有这个属性,表示当前生成器。
}

执行上下文的生命周期

上面提到某个执行环境中的所有代码执行完毕后,该环境的执行上下文也会被销毁,这表明执行上下文是存在生命周期的。 执行上下文生命周期包含两个阶段: 编译阶段执行阶段

在编译阶段,变量和函数会被存放到变量环境(Viriable Environment)中,变量的默认值会被设置为 undefined; 在代码执行阶段,JavaScript 引擎会从(变量环境/词法环境)中去查找自定义的变量和函数

文字怎么阅读都有点抽象、生涩,让我们跑一段代码来加深对它的理解:

console.log(bar) // (1)
var bar = 'this is bar' // (2)
function foo() {
    console.log('bar'); // (4)
}
foo() // (3)

先来看看结果

// 输出 undefined
// 输出 this is foo

小试牛刀,使用ES3规范定义的执行上下文来分析,第一步 创建阶段(变量提升)

globalExecutionContext = {    
    VO: {    
        bar: undefined,
        foo: function foo(){...}
    },    
    scopeChain: [],    
    this: { ... }   
} 

第二步 执行阶段


ExecutionStack = [ globalExecutionContext ]
globalExecutionContext = {    
    AO: {    // VO => AO
        bar: undefined,
        foo: function foo(){...}
    },    
    scopeChain: [],    
    this: <...>   
}

// 执行到 (1)处,访问bar,返回undefined 输出

// 执行到(2)处 ,变量bar被赋值
globalExecutionContext = {    
    AO: {    // VO => AO
        bar: 'this is bar',
        foo: function foo(){...}
    },    
    scopeChain: [AO],    
    this: <...>   
}

// 执行到(3)处, 函数被调用,函数执行上下文入栈 
fooExecutionContext = {    
    VO: {    
        arguments: <...>
    },    
    scopeChain: [globalExecutionContext.AO],    
    this: <...> 
} 
ExecutionStack = [ globalExecutionContext,fooExecutionContext]

// 执行到(4)处, 当前执行上下文 fooExecutionContext未找到变量bar,沿着作用域链,在globalExecutionContext.AO中查到已经被赋值的bar,输出'this is bar'


// foo函数执行结束,fooExecutionContext 从调用栈中移除
ExecutionStack = [globalExecutionContext]

结语

本文主要回答了这几个方面的问题:

Q:什么是调用栈? A:一种存储执行上下文(保存代码位置及变量信息)的数据结构。
Q: 什么是执行上下文? A:执行上下文是javascript执行一段代码的运行环境,对于不同代码对应三种类型执行上下文,分别为全局执行上下文,函数执行上下文、eval执行上下文(不推荐)。
Q:执行上下文的组成? A:分别介绍了ES3、ES5、ES6 三个规范对执行上下文的定义,梳理解释了当前造成前端小白对执行上下文理解偏差、混乱的原因。

本文其实还有很多概念未详细展开,如作用域和闭包、变量环境、词法环境、环境记录。下篇文章将详细讲解作用域和闭包