Javascript执行上下文

我们都知道,类似Java等传统编程语言,代码通常会先被编译成字节码,然后在虚拟机上转为机器码执行。

JavaScript的编译和执行模式就不太相同了:尽管JavaScript代码片段在执行前也需要进行编译(词法分析,语法分析以及机器码生成等步骤), 但通常情况下编译过程与执行环节两者衔接比较紧密, 编译完成后立刻交给引擎执行。

结合JavaScript的编译与执行过程,本文对函数的执行上下文以及执行过程中的知识进行梳理和总结.

随着JavaScript标准的版本更迭,关于执行上下文中的内容、词法环境的概念定义也在不断更新,本文暂以ES2018中的术语定义为基准。

Overview

在讨论之前,我们先简单介绍一下执行上下文的概念。

我们先看下ES2018标准中关于Execution Context的定义:

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. 简单来讲,执行上下文就是在JavaScript中代码片段执行过程中所需的信息的通常被称作执行上下文(Execution Context)。

JavaScript的执行上下文可以分为两种(Eval函数暂不讨论):

  • 全局执行上下文:全局执行上下文处于最外围且是唯一的,默认情况下代码都在全局环境下执行,在其他的执行上下文中都可以访问全局上下文中的数据。 它会创建一个全局对象(window、global),并将this指针指向该对象。
  • 函数执行上下文:当程序运行到某个函数,会为该函数创建对应的执行上下文,因此函数执行上下文可以有多个。

虽然各个版本的JavaScript标准中关于执行上下文的定义和内容会有变动,但核心的内容与设计思想没有太大变化。

我们知道, JavaScript在浏览器中都是单线程执行的。在代码执行过程中,指定脚本/模块装载,函数调用等操作,都会创建该对应的执行上下文的创建和切换操作,过程如下:

  • 当浏览器首次加载并解析JavaScript脚本, 在程序执行前首先会创建全局执行上下文,并将其推入执行栈中,位于栈顶的就是运行时执行上下文(running execution context)。
  • 如果我们在全局上下文中调用一个函数, 则会创建一个新的函数执行上下文, 并将其推入执行栈中。同理,如果我们在执行函数的过程中又调用了另外一个函数, 则同样会创建新的函数执行上下文并进行入栈操作。
  • 当函数执行完毕, 相应函数的执行上下文从执行栈中弹出, 将程序控制权还给当前栈中上一个执行上下文。

经过上面的简单介绍, 我们对执行上下文的概念和以及执行栈结构有了一个大体上的了解。 那么执行上下文中都包含了哪些内容呢?

Execution Context

在代码执行过程中,执行上下文所关联的代码片段也可以是函数,脚本,模块;执行上下文中则保存了所执行代码过程中所需要的状态和信息:

  • Code Evaluation State: 保存该上下文的关联的代码在执行、挂起和恢复时所需要的状态和数据。
  • Function:如果是函数执行上下文(该上下文所对应的代码片段是函数),则用于保存所执行的函数对象。
  • Realm:所关联代码所能访问的内置对象和基础库。
  • ScriptOrModule:当所关联代码是脚本或者模块时,则表示正在执行的脚本或者代码。

除此之外,执行上下文中还包含了词法环境对象和变量环境对象:

  • LexicalEnvironment: 获取和访问变量时候使用
  • VariableEnvironment: 声明变量时候使用

这两者虽然是作为两个不同的对象存在,事实都都属于词法环境范畴:

The LexicalEnvironment and VariableEnvironment components of an execution context are always Lexical Environments.

下面我们就梳理一下词法环境到底是什么。

词法环境 Lexical Environment

还是先根据ES2018标准中的定义:

A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code.

简单来说,Lexical Environment制订了JavaScript引擎如何根据标识符名称查找变量的规则和机制,因此它维护了一个Identifier-Variable的映射结构(Environment Record)来进行管理: 这里的Identifier是指变量或者函数标识符的名字,Variable是指对应变量的引用(可以是对象,函数,基本值)。

通常情况下,词法环境总是和一些特定的语法结构相关联,例如函数声明(非执行),代码块,try..catch结构的catch块等。

每当执行到这些代码片段,JavaScript就会创建一个新的词法环境,用于记录和存储当前执行代码中所声明的变量,函数(包括函数入参)的对象。 通过Lexical Environment以及对应执行环境下的词法/变量对象,构建了JavaScript变量作用域的概念。

除此之外,Lexical Environment还维护了一个指针引用Outer Environment Reference,它指向了当前词法环境外部词法环境。 通过Outer Environment Reference ,有嵌套代码结构的词法环境被(单向)串接了起来,从而形成了“作用域链”的设计。

词法环境的创建

大致理解了词法环境和执行上下文的概念以后,让我们结合上下文的切换从头回顾一下我们的代码运行过程。

在代码片段执行之前的编译阶段(词法分析,语法分析以及机器码生成),会收集相应词法环境信息。 以函数声明为例,这个过程如下:

  • 首先需要创建一个arguments object对象,用于保存函数入参对象, 完成对入参的初始化.
  • 扫描遍历并查找函数体中声明的函数和变量(注意表达的次序),初始化后保存在变量对象中.

编译结束后,这部分数据信息都被保存在相应的AST节点中。

这里我们需要特别注意的是,编译阶段只是对代码中涉及的对象的声明进行查找和初始化,而真正对变量的赋值操作则是放在真正的执行过程中。

在这里,变量对象的创建过程也解释了函数在执行过程中为什么会有变量提升现象的发生:

JavaScript 中,函数及变量的声明都将被提升到函数的最顶部, 可以先使用再声明。

在代码执行之前,仍然以函数调用为例,需要对函数对象进行初始化(FunctionInitialize ) 在函数对象初始化过程中,除了初始化参数列表,代码块等等,还将函数对象的私有属性[[Environment]]初始化为函数定义时的词法环境信息,根据函数类型(Normal, Method, Arrow)设置[[ThisMode]]字段等等。

在代码执行过程中,像我们之前提到的那样,JavaScript使用执行栈对执行上下文进行管理。 全局执行上下文始终处于栈底部,与之关联的是全局词法环境。

当代码执行过程中发生函数调用,则会创建一个新的函数执行上下文对象入栈,同时也会创建该函数的词法环境:

  • 创建Environment Record对象,保存函数内部的变量
  • 将函数对象的[[Environment]]赋给词法环境的Outer Environment Reference

这样,执行上下文持有了访问对应词法环境的引用,而词法环境又持有了外层词法环境对象的引用,沿着这条链路最终可以走到最外部的全局词法环境(作用域链),因此内部的词法环境可以访问到外部的词法环境,反过来则不行。

执行上下文中的“作用域链”设计保证了对执行上下文有权访问的所有变量和函数的有序访问: 在运行阶段,当代码试图访问某个变量或者函数对象, 引擎会沿着作用域链一级一级地搜索。 这样的设计使内部执行上下文可以沿着作用域链访问外部上下文中的变量或者函数, 而外部上下文无法访问到内部上下文中的数据。

this关键字

在其他编程语言中, this常常用于实例对象中,表示指向当前对象的引用或指针。 同样, JavaScript同样有 this 关键字的概念, 但它的机制和表现行为上有着很大不同。

除了我们在图解Javascript对象的创建介绍过的关于this指针在创建对象过程中的使用之外, this引用也通常用于不同的执行上下文相关联。

对于普通函数而言(非箭头函数),当函数被调用时,调用它的对象将被赋值给该函数执行上下文中的this指针,调用函数时使用的引用,决定了函数执行时候的this值。 而对于箭头函数而言,它的this值不受调用对象的影响。

根据ES2018中的定义,this被划归到了Lexical Environment中.

我们在上面讨论过函数对象的初始化过程的时候,提到函数对象本身有一个私有属性[[Environment]]负责保存函数定义时的词法环境。 另外,函数对象还有一个私有属性[[ThisMode]],它的值是根据函数的类型和预处理指令决定的,也决定了this的取值:

  • 当函数为箭头函数时,[[ThisMode]]值为lexical,表示从当前上下文中找this,不受调用对象影响;
  • 当使用严格模式时,[[ThisMode]]值为strict,表示this指向调用该方法对象,如果调用对象为空,则this为null
  • 其他情况下,[[ThisMode]]值为global,表示当this值如果为undefined,则取global;

总结

执行环境中,变量对象(作用域)负责收集并维护由所有声明的标识符和引用, 而作用域链则设计了对于标识符的查询规则和方式,二者共同确定了当前代码对于这些标识符的访问权限。

当我们理解了整个作用域链的设计,就不难理解JavaScript中关于「闭包」的设计与原理了。