由 ECMA 规范解读 Javascript 可执行上下文概念

前言

其实规范这东西不是给人看的,它更多的是给语言实现者提供参考。但是当碰到问题找不到答案时,规范往往能提供想要的答案 。偶尔读一下能够带来很大的启发和思考,如果只读一章 Javascript 规范,大神们觉得非第10章莫属。

我们来试试看,这次选用的是 ECMA2.2的 5.1 版,整个规范才200页, 而第10章共10页,可以感受到 Javascript 的精简,目前的版本加了太多 ES6 的东西,让人望而生畏。

资料地址:http://www.ecma-international.org/ecma-262/5.1/Ecma-262.pdf

任务

阅读 ECMA262 5.1 第10章 Executable Code and Execution Contexts (可执行代码与执行上下文)
你能针对这章内容提出问题吗? 即知道答案找出问题。
你能使用图来更形象地表达文章内容吗?

开始我们的探险之旅

原汁原味 ECMAScript 5.1 英文版
平易近人 ECMAScript 5.1 中文版

可执行代码类型

v8JavaScript 引擎都是按照 ecma-262 的规范来实现的,JavaScript 引擎在解释 JavaScript 代码时,将可执行代码分为了三种。分别是:

  • 全局代码
    代码加载时首先进入的环境。
    例如加载外部的 JavaScript 文件或者本地 标签内的代码。
    但不包括任何 function 体内的代码。

  • 函数代码
    是指作为 function 被解析的源代码。
    不包括作为其嵌套函数的 function 被解析的源代码。
    因为 JavaScript 函数中还可以嵌套函数,因此这也是三种可执行代码中最复杂的一种。

  • eval代码
    指的是传递给 eval 内置函数的代码。

注:不了解 eval(string) 的小伙伴,请参考 eval() - JavaScript | MDN

JavaScript 引擎开始执行(进入)一段可执行代码之后,会生成一个执行环境(Execution Context),或执行上下文。引擎用执行环境来维护执行当前代码所需要的变量声明、this指向等。

2624009007-59e545c159240_articlex2624009007-59e545c159240_articlex

词法环境 (Lexical Environments)

词法环境 是执行环境的三个组成的状态之一。

官方解释:词法环境是用来定义特定变量和函数标识符的。一个词法环境由一个环境记录项和可能为空的外部词法环境引用构成。

通常词法环境会与 ECMAScript 代码诸如 函数声明(FunctionDeclaration)WithStatement 或者 TryStatementCatch 块这样的特定句法结构相联系,且类似代码每次执行都会有一个新的词法环境被创建出来。

外部词法环境引用 用于表示词法环境的逻辑嵌套关系模型。(内部)词法环境的外部引用是逻辑上包含内部词法环境的词法环境。外部词法环境自然也可能有多个内部词法环境。

例如,如果一个 FunctionDeclaration 包含两个嵌套的 FunctionDeclaration,那么每个内嵌函数的词法环境都是外部函数本次执行所产生的词法环境。

环境记录项 又可以分为两种声明式环境记录项对象式环境记录项

声明式环境记录项 用于标识标识符和函数声明变量声明catch 语句等语法元素的绑定。对象式环境记录项 主要用于定义那些将标识符与具体对象的属性绑定的语法元素。

咬文嚼字,不好理解?

通俗点讲: 词法环境就是 JavaScript 引擎在执行代码过程中用来标识函数声明、变量声明这一类的。我们每次声明一个函数,或者使用 withcatch语句的时候,就会有新的词法环境被创建出来。全局词法环境的外部词法环境就是空的,因为他已经是最外层的词法环境了。

我们用个例子来说明词法环境:

var x = 10;
function foo(y){
var z = 30;
function bar(q){
return x+y+z+q;
}
return bar;
}
var bar = foo(20);
bar(40);

2735347362-59e54b066ff9b_articlex2735347362-59e54b066ff9b_articlex

词法环境的运算

给出一个标识符字符串,首先在当前的词法环境内寻找,如果存在,返回引用的标识符字符串,如果不存在,再在当前词法环境的外部词法环境寻找。

咦,怎么感觉和作用域链的概念很相似?他们有什么关系吗?

  • 执行环境 当执行流进入一个函数时,函数的环境会被推入一个环境栈中。当函数执行之后,环境栈将其弹出,把控制权返回给之前的执行环境。
  • 作用域链 当代码在一个环境中执行时,会创建变量对象的一个作用域链。其用途是保证对执行环境有权访问的所有变量和函数的有序访问。一个包含环境的变量对象到另一个包含环境的变量对象,最后到全局执行环境的变量对象。

函数只要被创建,就会有自己的“地盘”,有自己的作用域。但是只有函数被执行的时候,才会有自己的执行环境。函数执行完毕的时候,执行环境就会退出。而且一个作用域下可能存在多个执行环境,比如闭包。

小总结

1、词法环境分为了两部分:环境记录项和外部词法环境。
2、环境记录项根据绑定的 ECMA 脚本元素的不同也分为了两部分。
3、函数声明或者使用 withcatch语句时,就会有新的词法环境被创建出来。

执行环境(Execution Contexts)

如果我们的 JavaScript 程序有各种函数,函数之间还有嵌套的情况,那 JavaScript 引擎怎么解释各种声明和执行上下文哪?

当控制器转入 ECMA 脚本的可执行代码时,上文已经说了有三种可执行代码,不管进入哪一种控制器都会进入一个执行环境。多个执行环境在逻辑上形成一个栈结构。栈结构最顶层的执行环境称为当前运行的执行环境,最底层是全局执行环境。

用一张图解释

1243889088-59c8b7e123cd6_articlex1243889088-59c8b7e123cd6_articlex

因为 JS 引擎被实现为单线程,也就是同一时间只能发生一件事情,其他的行为就会依次排队。

你可以有任意多个函数执行环境,每次调用函数创建一个新的执行环境,会创建一个私有作用域,函数内部声明的任何变量都不能在当前函数作用域外部直接访问。函数能访问当前执行环境外面的变量声明,但在外部执行环境不能访问内部的变量/函数声明。

小总结

关于执行栈(调用栈)

单线程。
同步执行。
一个全局上下文。
无限制函数上下文。
每次函数被调用创建新的执行上下文,包括调用自己。
return 或者抛出异常退出一个执行环境。

我们用一个具体的函数理解:

function foo(i) {
if (i < 0) return;
console.log('begin:' + i);
foo(i - 1);
console.log('end:' + i);
}
foo(2);

// 输出:

// begin:2
// begin:1
// begin:0
// end:0
// end:1
// end:2

1606300353-59b5b7f1caff1_articlex -1-1606300353-59b5b7f1caff1_articlex -1-

代码的执行流程进入内部函数,创建一个新的执行上下文并把它压入执行栈的顶部。浏览器总会执行位于栈顶的执行上下文,一旦当前上下文函数执行结束,它将被从栈顶弹出,并将上下文控制权交给当前的栈。 这样,堆栈中的上下文就会被依次执行并且弹出堆栈,直到回到全局的上下文。

执行环境包含所有用于追踪与其相关的代码的执行进度的状态。精确地说,每个执行环境包含如下表列出的组件。

执行环境的三个状态

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

当创建一个执行环境时,其词法环境组件和变量环境组件最初是同一个值。在该执行环境相关联的代码的执行过程中,变量环境组件永远不变,而词法环境组件有可能改变。变量环境的不变和词法环境的可能改变都是指引用的改变。

2624009007-59e545c159240_articlex2624009007-59e545c159240_articlex

建立执行环境

解释执行全局代码或使用 eval 函数输入的代码会创建并进入一个新的执行环境。每次调用 ECMA 脚本代码定义的函数也会建立并进入一个新的执行环境,即便函数是自身递归调用的。

每一次 return 都会退出一个执行环境。抛出异常也可退出一个或多个执行环境。

当控制流进入一个执行环境时,会设置该执行环境的 this 绑定组件,定义变量环境和初始词法环境,并执行声明式绑定初始化过程。以上这些步骤的严格执行方式由进入的代码的类型决定。

进入全局代码

执行以下步骤:

1、将变量环境设置为 全局环境 。 
2、将词法环境设置为 全局环境 。
3、将 this 绑定设置为 全局对象 。
4、使用全局代码执行声明式绑定初始化化步骤。

进入函数代码

当控制流根据一个函数对象 F、调用者提供的 thisArg 以及调用者提供的 argumentList,进入函数代码的执行环境时,执行以下步骤

如果函数代码是严格模式下的代码,设 this 绑定 为 thisArg。
否则如果 thisArg 是 null 或 undefined,则设 this 绑定 为全局对象。
否则如果 Type(thisArg) 的结果不为 Object,则设 this 绑定 为 ToObject(thisArg)。
否则设 this 绑定 为 thisArg。
以 F 的 [[Scope]] 内部属性为参数调用 NewDeclarativeEnvironment(新建声明式词法环境),并令 localEnv 为调用的结果。
设 词法环境组件 为 localEnv。
设 变量环境组件 为 localEnv。
令 code 为 F 的 [[Code]] 内部属性的值。
使用函数代码 code 和 argumentList 执行声明式绑定初始化化步骤。

我们用伪代码表示一下:

if(是 严格模式) {
this = thisArg
} else if(thisArg === null || thisArg === undefined) {
this = window
} else if(typeof thisArg != 'object') {
this = Object(thisArg)
} else {
this = thisArg
}

哎,这里的 thisArg 指的是什么?上文说了 thisArg 来自于函数的调用者。

这里代表函数的 applycallbind 等设置 this 绑定的参数:

通过 call 或者 apply 调用函数时,thisArg 的值比较明显,为传入的第一个参数。

Function.prototype.apply (thisArg, argArray)

Function.prototype.call (thisArg [ , arg1 [ , arg2, … ] ] )

当然 thisArg 还有其他的可能,具体的可以参考这篇文章 Javascript this 解析

对上面的伪代码做一下解释:

严格模式: 也就是说,在严格模式下,this 只能为 thisArg,而当 thisArgundefined 时,this 就是 undefined ,而不是 window

非严格模式: 如果 thisArgnull (如 fun.call(null)) 或 undefined (直接调用函数),则 this 为全局对象,浏览器里就是 window

否则,如果 传入了 thisArg, 但不是个对象,则把它转为对象,并赋给 this,比如,当 fun.call('hhh') 时,打印 fun 内的 this

String {0: "h", 1: "h", 2: "h", length: 3, [[PrimitiveValue]]: "hhh"}

否则 ,也就是仅剩的一种情况,显式的传入了一个对象作为 thisArg 参数的情况下,设 this 绑定为 thisArg

声明式绑定初始化

每个执行环境都有一个关联的 变量环境。当在一个执行环境下评估一段 ECMA 脚本时,变量和函数定义会以绑定的形式添加到这个 变量环境 的环境记录中。对于函数代码,参数也同样会以绑定的形式添加到这个 变量环境 的环境记录中。

总结

ECMAScript 代码的执行由运行环境来完成。不同的运行环境可能采取不同的执行方式,但基本的流程是相同的。如浏览器在解析 HTML 页面中遇到 <script> 元素时,会下载对应的代码来运行,或直接执行内嵌的代码。代码的基本执行方式是从上到下,顺序执行。在调用函数之后,代码的执行会进入一个执行上下文之中。由于在一个函数的执行过程中会调用其他的函数,执行过程中的活动执行上下文会形成一个堆栈结构。在栈顶的是当前正在执行的代码。当函数返回时,会退出当前的执行上下文,而回到之前的执行上下文中。如果代码执行中出现异常,则可能从多个执行上下文中退出。

在代码执行过程中很重要的一步是标识符的解析。比如当执行过程中遇到语句 alert(val) 时,首先要做的是解析标识符 val 的值。ECMAScript 不同于 JavaC/C++ 等语言,在进行标识符解析时需要利用词法环境并与函数调用方式相关。具体来说,标识符解析由当前代码所对应的执行上下文来完成。为了描述标识符的解析过程,ECMAScript 规范中使用了词法环境的概念来进行描述。一个词法环境描述了标识符与变量或函数之间的对应关系。一个词法环境由两个部分组成:一部分是记录标识符与变量之间的绑定关系的环境记录,另一部分是包围当前词法环境的外部词法环境。环境记录可以看成是一个标识符与变量或函数之间的映射表。不同词法环境之间可以互相嵌套,而内部词法环境会持有一个包围它的外部词法环境的引用。在进行标识符解析时,如果当前词法环境中找不到标识符所对应的变量或函数,则使用外部词法环境来尝试解析。递归查找下去,直到解析成功或外部词法环境为 null

具体来说,根据标识符关联方式的不同,环境记录可以进一步分成两类。两种类型分别对应不同的 ECMAScript 中不同的语法结构。当使用这些语法结构时,会对环境记录中的内容产生影响,进而影响标识符的解析过程。第一类环境记录是声明式环境记录。顾名思义,声明式环境记录用来绑定 ECMAScript 代码中的变量声明。当使用 var 声明变量或使用类似 function func(){} 的形式声明函数时,对应的变量或函数会被绑定到相应的环境记录中。另一类环境记录是对象环境记录。对象环境记录并不绑定具体的变量或函数,而是绑定另外一个对象中的属性。对象环境变量主要用来描述 ECMAScriptwith 操作符的行为。

每个执行上下文会对应两个不同的词法环境。一个是用来进行标识符解析的词法环境,可能随着代码的执行而发生变化;另外一个是包含执行上下文对应的作用域中的变量或函数声明的词法环境。

提问

读完这篇文章,问问自己,能够回答下面的问题吗?

1、ECMAScript 中可执行代码有几种?
2、什么情况下会创建一个执行环境?
3、什么情况下会退出一个执行环境?
4、作用域链和执行环境的关系?
5、执行环境的存在是为了解决什么?
6、词法环境和变量环境的异同?
7、this 的绑定的几种情况?

参考

ES5/可执行代码与执行环境
深入理解JavaScript系列(11):执行上下文(Execution Contexts)
了解JavaScript的执行上下文
深入理解JavaScript执行上下文、函数堆栈、提升的概念
关于js作用域那些事
从 ECMAScript 规范来看 JS 的 this 绑定规则
深入探讨 ECMAScript 规范

推荐阅读

JavaScript欲速则不达—通过解析过程了解JavaScript

感谢您的阅读。 🙏 关于转载请看这里