likes
comments
collection
share

从 eval 的角度观察严格模式(上)

作者站长头像
站长
· 阅读数 17

从 eval 的角度观察严格模式(上)

引子

动态执行是 JavaScript 最早实现的特性之一,eval() 这个函数是从 JavaScript 1.0 就开始内置了的。并且,最早的 setTimeout() 和 setInterval() 也内置了动态执行的特性:它们的第 1 个参数只允许传入一个字符串,这个字符串将作为代码体动态地定时执行。

关于这一点并不难理解,因为 JavaScript 本来就是脚本语言,它最早也是被作为脚本语言设计出来的。因此,把“装载脚本 + 执行”这样的核心过程,通过一个函数暴露出来成为基础特性既是举手之劳,也是必然之举。

然而,这个特性从最开始就过度灵活,以至于后来许多新特性在设计中颇为掣肘,所以在 ECMAScript 5 的严格模式出现之后,它的特性受到了很多的限制。

接下来,我将帮助你揭开重重迷雾,让你得见最真实的“eval()”。

eval 执行什么

最基本的、也是最重要的问题是:eval 究竟是在执行什么?

在代码eval(x)中,x必须是一个字符串,不能是其他任何类型的值,也不能是一个字符串对象。如果尝试在 x 中传入其他的值,那么 eval() 将直接以该值为返回值,例如:

eval(null) 
null

eval(false)
false 

eval(Object('1234')) 
[String: '1234'] 

eval(Object('1234').toString()) 
1234

eval() 会将参数x强制理解为语句行,这样一来,当按照“语句 -> 表达式”的顺序解析时,“{ }”将被优先理解为语句中的大括号。于是,下面的代码就成了 JavaScript 初学者的经典噩梦:

// 试图返回一个对象 
eval('{abc: 1}')
1

由于第一个字符被理解为块语句,那么“abc:”就将被解析成标签语句;接下来,"1"会成为一个“单值表达式语句”。所以,结果是返回了这个表达式的值,也就是 1。

eval 在哪儿执行

eval 总是将代码执行在当前上下文的“当前位置”。这里的所谓的“当前上下文”并不是它字面意思中的“代码文本上下文”,而是指“(与执行环境相关的)执行上下文”。

你可能将环境和上下文混为一谈?

然而,在讨论 eval()“执行的位置”的时候,这两个东西却必须厘清,因为严格地来讲,环境是 JavaScript 在语言系统中的静态组件,而上下文是它在执行系统中的动态组件。

环境

JavaScript 中,环境可以细分为四种,并由两个类别的基础环境组件构成。这四种环境是:全局(Global)、函数(Function)、模块(Module)和 Eval 环境;两个基础组件的类别分别是:声明环境(Declarative Environment)和对象环境(Object Environment)。

你也许会问:不对啊?我们常说的词法环境到哪里去了呢?不要着急,我们马上就会讲到它的。这里先继续说清楚上面的六个东西。

首先是两个类别,它们是所有其他环境的基础,是两种抽象级别最低的、基础的环境组件。声明环境就是名字表,可以是引擎内核用任何方式来实现的一个“名字 -> 数据”的对照表;对象环境是 JavaScript 的一个对象,用来“模拟 / 映射”成上述的对照表的一个结果,你也可以把它看成一个具体的实现。

所以,所谓四种环境,其实是上述的两种基础组件进一步应用的结果。其中,全局(Global)环境是一个复合环境,它由一对“对象环境 + 声明环境”组成;其他 3 种环境,都是一个单独的声明环境。

你需要关注到的一个事实是:所有的四种环境都与执行相关——看起来它们“像是”为每种可执行的东西都创建了一个环境,但是它们事实上都不是可以执行的东西,也不是执行系统(执行引擎)所理解的东西。更加准确地说:

上述四种环境,本质上只是为 JavaScript 中的每一个“可以执行的语法块”创建了一个名字表的影射而已。

执行上下文

JavaScript 的执行系统由一个执行栈和一个执行队列构成。

在执行队列中保存的是待执行的任务,称为 Job。这是一个抽象概念,它指明在“创建”这个执行任务时的一些关联信息,以便正式“执行”时可以参考它;而“正式的执行”发生在将一个新的上下文被“推入(push)”执行栈的时候。

而每一个上下文只关心两个高度抽象的信息:其一是执行点(包括状态和位置),其二是执行时的参考,也就是前面一再说到的“名字的对照表”。

所以,重要的是:每一个执行上下文都需要关联到一个对照表。这个对照表,就称为“词法环境(Lexical Environment)”。显然,它可以是上述四种环境之任一;并且,更加重要的,也可是两种基础组件之任一!

如上是一般性质的执行引擎逻辑,对于大多数“通用的”执行环境来说,这是足够的。

但对于 JavaScript 来说这还不够,因为 JavaScript 的早期有一个“能够超越词法环境”的东西存在,就是“var 变量”。所谓词法环境,就是一个能够表示标识符在源代码(词法)中的位置的环境,由于源代码分块,所以词法环境就可以用“链式访问”来映射“块之间的层级关系”。但是“var 变量”突破了这个设计限制,例如:

var x = 1;
if (true) { var x = 2; with (new Object) { var x = 3; } }

这个示例中的“1、2、3”所在的“var 变量”x,都突破了它们所在的词法作用域(或对应的词法环境),而指向全局的x。

于是,自 ECMAScript 5 开始约定,ECMAScript 的执行上下文将有两个环境,一个称为词法环境,另一个就称为变量环境(Variable Environment);所有传统风格的“var 声明和函数声明”将通过“变量环境”来管理。

这个管理只是“概念层面”的,实际用起来,并不是这么回事。

管理

如果你仔细读了 ECMAScript,你会发现,所谓的全局上下文(例如 Global Context)中的两个环境其实都指向同一个!这就是在实现中的取巧之处了。

对于 JavaScript 来说,由于全局的特性就是“var 变量”和“词法变量”共用一个名字表,因此你声明了“var 变量”,那么就不能声明“同名的 let/const 变量”。所以,事实上它们“的确就是”同一个环境。

而具体到“var 变量”本身,在传统中,JavaScript 中只有函数和全局能够“保存 var 声明的变量”;而在 ECMAScript 6 之后,模块全局也是可以保存“var 声明的变量”的。因此,事实上也就只有它们的“变量环境(VariableEnvironment)”是有意义的,然而即使如此(也就是说即使从原理上来说它们都是“有用的”),它们仍然是指向同一个环境组件的。也就是说,之前的逻辑仍然是成立的。

那么,非得要“分别地”声明这两个组件又有什么用呢?答案是:对于 eval() 来说,它的“词法环境”与“变量环境”存在着其他的可能性!

eval() 的环境

上面我们说到,所谓“Eval 环境”是主要用于应对“动态执行”的,并且它的词法环境与变量环境“可能会不一样”。这二者其实是相关的,并且,这还与“严格模式”这一特殊机制存在紧密的关系。

当在eval(x)使一般的方式执行代码时,如果x字符串中存在着var变量声明,那么会发生什么事情呢?按照传统 JavaScript 的设计,这意味着在它所在的函数作用域,或者全局作用域会有一个新的变量被创建出来。这也就是 JavaScript 的“动态声明(函数和 var 变量)”和“动态作用域”的效果,例如:

var x = 'outer';
function foo() {
    console.log(x);
    // 'outer'
    eval('var x = 100;');
    console.log(x);
    // '100' 
}
foo();

如果按照传统的设计与实现,这就会要求 eval() 在执行时能够“引用”它所在的函数或全局的“变量作用域”。并且进一步地,这也就要求 eval 有能力“总是动态地”查找这个作用域,并且 JavaScript 执行引擎还需要理解“用户代码中的 eval”这一特殊概念。正是为了避免这些行为,所以 ECMAScript 约定,在执行上下文中加上“变量环境(Variable Environment)”这个东西,以便在执行过程中,仅仅只需要查找“当前上下文”就可以找到这个能用来登记变量的名字表。

也就是说,“变量环境(VariableEnvironment)”存在的意义,就是动态的登记“var 变量”。

因此,它也仅仅只用在“Eval 环境”的创建过程中。“Eval 环境”是唯一一个将“变量环境”指向与它自有的“词法环境”不同位置的环境。

也许你正在思考,为什么 eval() 在严格模式中就不能覆盖 / 重复声明函数、全局等环境中的同名“var 变量”呢?

答案很简单,只是一个小小的技术技巧:在“严格模式的 Eval 环境”对应的上下文中,变量环境与词法环境,都指向它们自有的那个词法环境。于是这样一来,在严格模式中使用 eval("var x...")和eval("let x...")的名字都创建在同一个环境中,它们也就自然不能重名了;并且由于没有引用它所在的(全局或函数的)环境,所以也就不能改写这些环境中的名字了。

那么一个 eval() 函数所需要的“Eval 环境”究竟是严格模式,还是非严格模式呢?

你还记得“严格模式”的使用原则么?eval(x) 的严格模式要么继承自当前的环境,要么就是代码x的第一个指令是字符串“use strict”。对于后一种情况,由于 eval() 是动态 parser 代码x的,所以它只需要检查一下 parser 之后的 AST(抽象语法树)的第一个节点,是不是字符串“use strict”就可以了。

这也是为什么“切换严格模式”的指示指令被设计成这个奇怪模样的原因了。

最后一种情况

(0, eval)("x = 100") ,说的却是最后一种情况。在这种情况下,代码文本将指向一个“未创建即赋值”的变量x,我们知道,按照 ECMAScript 的约定,在非严格模式中,向这样的变量赋值就意味着在全局环境中创建新的变量x;而在严格模式中,这将不被允许,并因此而抛出异常。

由于 Eval 环境通过“词法环境与变量环境分离”来隔离了“严格模式”对它的影响,因此上述约定在两种模式下实现起来其实都比较简单。

对于非严格模式来说,代码可以通过词法环境的链表逆向查找,直到 global,并且因为无法找到x而产生一个“未发现的引用”。我们之前讲过,在非严格模式中,对“未发现的引用”的置值将实现为向全局对象“global”添加一个属性,于是间接地、动态地就实现了添加变量x。对于严格模式呢,向“未发现的引用”的置值触发一个异常就可以了。

这些逻辑都非常简单,而且易于理解。并且,最关键和最重要的是,这些机制与我今天所讲的内容——也就是变量环境和词法环境——完全无关。

然而,接下来你需要动态尝试一下:

如果你按标题中的代码去尝试写 eval(),那么无论如何——无论你处于严格模式还是非严格模式,你都将创建出一个变量 x 来。

标题中的代码突破了“严格模式”的全部限制!这就是我下一篇文章要讲的内容了。

参考:红宝书,犀牛书,你不知道的 JS、 JS 核心原理解析