{T}

eval与严格模式

今天我们讨论动态执行。与最初的预告不同 ,我在本文里把原来的第 20 讲合并掉了,变成了 20~21 的两讲合讲,但也分成了上、下两节。所以,其实只是课程的标题少了一个,内容却没有变。动态执行是 JavaScript 最早实现的特性之一,eval() 这个函数是从 JavaScript 1.0 就开始内置了的。并且,最早的 setTimeout() 和 setInterval() 也内置了动态执行的特性:它们的第 1 个参数只允许传入一个字符串,这个字符串将作为代码体动态地定时执行。NOTE:setTimeout/setInterval 执行字符串的特性如今仍然保留在大多数浏览器环境中,例如 Safari 或 Mozilla,但这在 Node.js/Chrome 环境中并不被允许。需要留意的是,setTimeout/setInterval 并不是 ECMAScript 规范的一部分。关于这一点并不难理解,因为 JavaScript 本来就是脚本语言,它最早也是被作为脚本语言设计出来的。因此,把“装载脚本 + 执行”这样的核心过程,通过一个函数暴露出来成为基础特性既是举手之劳,也是必然之举。然而,这个特性从最开始就过度灵活,以至于后来许多新特性在设计中颇为掣肘,所以在 ECMAScript 5 的严格模式出现之后,它的特性受到了很多的限制。接下来,我将帮助你揭开重重迷雾,让你得见最真实的“eval()”。

eval 执行什么

最基本的、也是最重要的问题是:eval 究竟是在执行什么?在代码eval(x)中,x必须是一个字符串,不能是其他任何类型的值,也不能是一个字符串对象。如果尝试在 x 中传入其他的值,那么 eval() 将直接以该值为返回值,例如:

typescript

# 值 1> eval(null)null# 值 2> eval(false)false# 字符串对象> eval(Object('1234'))[String: '1234']# 字符串值> eval(Object('1234').toString())1234

eval() 会按照 JavaScript 语法规则来尝试解析字符串 x,包括对一些特殊字面量(例如 8 进制)的语法解析。这样的解析会与 parseInt() 或 Number() 函数实现的类型转换有所不同,例如:

typescript

# JavaScript 在源代码层面支持 8 进制> eval('012')10# 但 parseInt() 不支持 8 进制(除非显式指定 radix 参数)> parseInt('012')12# Number() 也不支持 8 进制> Number('012')12

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

python

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

由于第一个字符被理解为块语句,那么“abc:”就将被解析成标签语句;接下来,"1"会成为一个“单值表达式语句”。所以,结果是返回了这个表达式的值,也就是 1。NOTE:这一个示例就是原来用作第 20 讲的标题的一行代码。只不过,在实际写的时候发现能展开讲的内容太少,所以做了一下合并。:)

eval 在哪儿执行

eval 总是将代码执行在当前上下文的“当前位置”。这里的所谓的“当前上下文”并不是它字面意思中的“代码文本上下文”,而是指“(与执行环境相关的)执行上下文”。我在之前的文章中给你提到过与 JavaScript 的执行系统相关的两个组件:环境和上下文。但我一直在尽力避免详细地讨论它们,甚至在一些场合中将它们混为一谈。然而,在讨论 eval()“执行的位置”的时候,这两个东西却必须厘清,因为严格地来讲,环境是 JavaScript 在语言系统中的静态组件,而上下文是它在执行系统中的动态组件。

环境

怎么说呢?JavaScript 中,环境可以细分为四种,并由两个类别的基础环境组件构成。这四种环境是:全局(Global)、函数(Function)、模块(Module)和 Eval 环境;两个基础组件的类别分别是:声明环境(Declarative Environment)和对象环境(Object Environment)。你也许会问:不对啊?我们常说的词法环境到哪里去了呢?不要着急,我们马上就会讲到它的。这里先继续说清楚上面的六个东西。首先是两个类别,它们是所有其他环境的基础,是两种抽象级别最低的、基础的环境组件。声明环境就是名字表,可以是引擎内核用任何方式来实现的一个“名字 -> 数据”的对照表;对象环境是 JavaScript 的一个对象,用来“模拟 / 映射”成上述的对照表的一个结果,你也可以把它看成一个具体的实现。所以,概念:所有的“环境”本质上只有一个功能,就是用来管理“名字 -> 数据”的对照表;应用:“对象环境”只为全局环境的 global 对象,或with (obj)...语句中的对象obj创建,其他情况下创建的环境,都必然是“声明环境”。所以,所谓四种环境,其实是上述的两种基础组件进一步应用的结果。其中,全局(Global)环境是一个复合环境,它由一对“对象环境 + 声明环境”组成;其他 3 种环境,都是一个单独的声明环境。你需要关注到的一个事实是:所有的四种环境都与执行相关——看起来它们“像是”为每种可执行的东西都创建了一个环境,但是它们事实上都不是可以执行的东西,也不是执行系统(执行引擎)所理解的东西。更加准确地说:上述四种环境,本质上只是为 JavaScript 中的每一个“可以执行的语法块”创建了一个名字表的影射而已。

执行上下文

JavaScript 的执行系统由一个执行栈和一个执行队列构成,这在之前也讲过。关于它们的应用原理,你可以回顾一下第 6 讲(x: break x),以及第 10 讲(x = yield x)中的内容。在执行队列中保存的是待执行的任务,称为 Job。这是一个抽象概念,它指明在“创建”这个执行任务时的一些关联信息,以便正式“执行”时可以参考它;而“正式的执行”发生在将一个新的上下文被“推入(push)”执行栈的时候。所以,上下文是一个任务“执行 / 不执行”的关键。如果一个任务只是任务,并没有执行,那么也就没有它的上下文;如果一个上下文从栈中撤出,那么就必须有地方能够保存这个上下文,否则可执行的信息就丢失了(这种情况并不常见);如果一个新上下文被“推入(push)”栈,那么旧的上下文就被挂起并压向栈底;如果当前活动上下文被“弹出(pop)”栈,那么处在栈底的旧上下文就被恢复了。NOTE:很少需要在用户代码(在它的执行过程中)撤出和保存上下文的过程,但这的确存在。比如生成器(GeneratorContext),或者异步调用(AsyncContext)。而每一个上下文只关心两个高度抽象的信息:其一是执行点(包括状态和位置),其二是执行时的参考,也就是前面一再说到的“名字的对照表”。所以,重要的是:每一个执行上下文都需要关联到一个对照表。这个对照表,就称为“词法环境(Lexical Environment)”。显然,它可以是上述四种环境之任一;并且,更加重要的,也可是两种基础组件之任一!如上是一般性质的执行引擎逻辑,对于大多数“通用的”执行环境来说,这是足够的。但对于 JavaScript 来说这还不够,因为 JavaScript 的早期有一个“能够超越词法环境”的东西存在,就是“var 变量”。所谓词法环境,就是一个能够表示标识符在源代码(词法)中的位置的环境,由于源代码分块,所以词法环境就可以用“链式访问”来映射“块之间的层级关系”。但是“var 变量”突破了这个设计限制,例如:

javascript

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)中的两个环境其实都指向同一个!也就是:

python

#(如下示例不可执行)> globalCtx.LexicalEnvironment === globaltrue> globalCtx.VariableEnvironment === globaltrue

这就是在实现中的取巧之处了。对于 JavaScript 来说,由于全局的特性就是“var 变量”和“词法变量”共用一个名字表,因此你声明了“var 变量”,那么就不能声明“同名的 let/const 变量”。例如:

javascript

> var x = 100> let x = 200SyntaxError: Identifier 'x' has already been declared

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

shell

#(如下示例不可执行)> functionCtx.LexicalEnvironment === functionCtx.VariableEnvironmenttrue> moduleCtx.LexicalEnvironment === moduleCtx.VariableEnvironmenttrue

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

不用于执行的环境

环境在本质上是“作用域的映射”。作用域如果不需要被上下文管理,那么它(所对应的环境)也就不需要关联到上下文。在早期的 JavaScript 中,作用域与执行环境是一对一的,所以也就常常混用,而到了 ECMAScript 5 之后,有一些作用域并没有对应用执行环境,所有就分开了。在 ECMAScript 5 之后,ECMAScript 规范中就很少使用“作用域(Scope)”这个名词,转而使用“环境”这个概念来替代它。哪些东西的作用域不需要关联到上下文呢?例如一般的块级作用域:

javascript

// 对象闭包with (x) ...

很显然的,这里的with语句为对象x创建了一个对象闭包,就是对象作用域,也是我们在上面讨论过的“对象环境”。然而,由于这个语句其实只需要执行在当前的上下文环境(函数 / 模块 / 全局)中,因此它不需要“被关联到”一个执行上下文,也不需要作为一个独立的可执行组件“推入(push)”到执行栈。所以,这时创建出来的环境,就是一个不用于执行的环境。只有前面所说过的四种环境是用于执行的环境,而其他的所有环境(以及反过来对应的作用域)都是不用于执行的,它们与上下文无关。并且,既然与上下文没有关联,那么也就不存在“词法环境”和“变量环境”了。从语法上,(在代码文本中)你可以找到除了上述四种环境之外的其他任何一种块级作用域,事实上它们每个作用域都有一个对应的环境:with 语句的环境用“对象环境”创建出来,而其他的(例如 for 语句的迭代环境,又例如 swith/try 语句的块)是用“声明环境”创建出来的。对于这些用于执行的环境中的其中三个,ECMAScript 直接约定了它们(也就是 Global/Module/Function)的创建过程。例如全局环境,就称为 NewGlobalEnvironment()。因为它们都可以在代码解析(Parser)的阶段得到,并且在代码运行之前由引擎创建出来。而唯有一个环境,是没有独立创建过程,并且在程序运行过程中动态创建的,这就是“Eval 环境”。所以 Eval 环境是主要用于应对“动态执行”的环境。

eval() 的环境

上面我们说到,所谓“Eval 环境”是主要用于应对“动态执行”的,并且它的词法环境与变量环境“可能会不一样”。这二者其实是相关的,并且,这还与“严格模式”这一特殊机制存在紧密的关系。当在eval(x)使一般的方式执行代码时,如果x字符串中存在着var变量声明,那么会发生什么事情呢?按照传统 JavaScript 的设计,这意味着在它所在的函数作用域,或者全局作用域会有一个新的变量被创建出来。这也就是 JavaScript 的“动态声明(函数和 var 变量)”和“动态作用域”的效果,例如:

javascript

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 环境”是唯一一个将“变量环境”指向与它自有的“词法环境”不同位置的环境。NOTE: 其实函数中也存在一个类似的例外。但这个处理过程是在函数的环境创建之后,在函数声明实例化阶段来完成的,因此与这里的处理略有区别。由于是函数声明的实例化(FunctionDeclaration Instantiation)阶段来处理,因此这也意味着每次实例化(亦即是每次调用函数并导致闭包创建)时都会重复一次这个过程:在执行上下文的内部重新初始化一次变量环境与词法环境,并根据严格模式的状态来确定词法环境与变量环境是否是同一个。这里既然提到了“Eval 自有的词法环境”,那么也稍微解释一下它的作用。对于 Eval 环境来说,它也需要一个自己的、独立的作用域,用来确保在“eval(x)”的代码 x 中存在的那些 const/let 声明有自己的名字表,而不影响当前环境。这与使用一对大括号来表示的一个块级作用域是完全一一致的,并且也使用相同的基础组件(即声明环境、Declarative Environment)来创建得到。这就是在 eval() 中使用 const/let 不影响它所在函数或其他块级作用域的原因,例如:

javascript

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

而同样的示例,由于“变量环境”指向它在“当前上下文(也就是 foo 函数的函数执行上下文)”的变量环境,也就是:

shell

#(如下示例不可执行)> evalCtx.VariableEnvironment === fooCtx.VariableEnvironmenttrue> fooCtx.VariableEnvironment === fooCtx.LexicalEnvironmenttrue> evalCtx.VariableEnvironment = evalCtx.LexicalEnvironmentfalse

所以,当 eval 中执行代码“var x = …”时,就可以通过evalCtx.VariableEnvironment来访问到fooCtx.VariableEnvironment了。例如:

javascript

function foo() { var x = 100; eval('var x = 200; console.log(x);'); // 200, x 指向 foo() 中的变量 x console.log(x); // 200}foo();

也许你正在思考,为什么 eval() 在严格模式中就不能覆盖 / 重复声明函数、全局等环境中的同名“var 变量”呢?答案很简单,只是一个小小的技术技巧:在“严格模式的 Eval 环境”对应的上下文中,变量环境与词法环境,都指向它们自有的那个词法环境。于是这样一来,在严格模式中使用eval("var x...")eval("let x...")的名字都创建在同一个环境中,它们也就自然不能重名了;并且由于没有引用它所在的(全局或函数的)环境,所以也就不能改写这些环境中的名字了。那么一个 eval() 函数所需要的“Eval 环境”究竟是严格模式,还是非严格模式呢?你还记得“严格模式”的使用原则么?eval(x) 的严格模式要么继承自当前的环境,要么就是代码x的第一个指令是字符串“use strict”。对于后一种情况,由于 eval() 是动态 parser 代码x的,所以它只需要检查一下 parser 之后的 AST(抽象语法树)的第一个节点,是不是字符串“use strict”就可以了。这也是为什么“切换严格模式”的指示指令被设计成这个奇怪模样的原因了。NOTE:按照 ECMAScript 6 之后的约定,模块默认工作在严格模式下(并且不能切换回非严格模式),所以它其中的 eval() 也就必然处于严格模式。这种情况下(即严格模式下),eval() 的“变量环境”与它的词法环境是同一个,并且是自有的。因此模块环境中的变量环境(moduleCtx.VariableEnvironment)将永远不会被引用到,并且用户代码也无法在其中创建新的“var 变量”。

最后一种情况

标题中的 eval() 的代码文本,说的却是最后一种情况。在这种情况下,代码文本将指向一个“未创建即赋值”的变量x,我们知道,按照 ECMAScript 的约定,在非严格模式中,向这样的变量赋值就意味着在全局环境中创建新的变量x;而在严格模式中,这将不被允许,并因此而抛出异常。由于 Eval 环境通过“词法环境与变量环境分离”来隔离了“严格模式”对它的影响,因此上述约定在两种模式下实现起来其实都比较简单。对于非严格模式来说,代码可以通过词法环境的链表逆向查找,直到 global,并且因为无法找到x而产生一个“未发现的引用”。我们之前讲过,在非严格模式中,对“未发现的引用”的置值将实现为向全局对象“global”添加一个属性,于是间接地、动态地就实现了添加变量x。对于严格模式呢,向“未发现的引用”的置值触发一个异常就可以了。这些逻辑都非常简单,而且易于理解。并且,最关键和最重要的是,这些机制与我今天所讲的内容——也就是变量环境和词法环境——完全无关。然而,接下来你需要动态尝试一下:如果你按标题中的代码去尝试写 eval(),那么无论如何——无论你处于严格模式还是非严格模式,你都将创建出一个变量 x 来。标题中的代码突破了“严格模式”的全部限制!这就是我下一部分要为你讲述的内容了。今天没有设置知识回顾,也没有作业。但我建议你尝试一下标题中的代码,也可以回顾一下本文中提到的诸多概念与名词。我相信,它与你平常使用的和理解的,有许多不一致的地方,甚至有矛盾之处。但是,相信我,这就是这些内容最独特的地方:它讲述 JavaScript 的核心原理,而不是重复那些你可能已经知道的知识。欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。 img


21 | (0, eval)("x = 100") :一行让严格模式形同虚设的破坏性设计(下)

欢迎回到我的专栏。书接上回,本文我们仍然讲动态执行。之前我说到过,setTimeout 和 setInterval 的第一个参数可以使用字符串,那么如果这个参数使用字符串的话,代码将会在哪里执行呢?毕竟当定时器被触发的时候,程序的执行流程“很可能”已经离开了当前的上下文环境,而切换到未知的地方去了。所以,的确如你所猜测的那样,如果采用这种方式来执行代码,那么代码片断将在全局环境中执行。并且,这也是后来这一功能被部分限制了的原因,例如你在某些版本的 Firefox 中这样做,那么你可能会得到如下的错误提示:

javascript

> setTimeout('alert("HI")', 1000)Content Security Policy: The page’s settings blocked the loading of a resource at eval (“script-src”).

在全局环境中执行代码所带来的问题远远不止于此,接下来,我们就从这个问题开始谈起。

在全局环境中的 eval

早期的 JavaScript 是应用于浏览器环境中的,因此,当网页中使用<SCRIPT>标签加载.js 文件时候,代码就会在浏览器的全局环境中执行。但这个过程是同步的,将 BLOCK 掉整个网页的装载进度,因此有了defer这个属性来指示代码异步加载,将这个加载过程延迟到网页初始化结束之后。不过即使如此,JavaScript 代码仍然是执行在全局环境中的。在那个时代,<SCRIPT>标签还支持forevent属性,用于指定将 JavaScript 代码绑定给指定的 HTML 元素或事件响应。当采用这种方式的时候,代码还是在全局环境中执行,只不过可能初始化为一个函数(的回调),并且this指向元素或事件。很不幸,有许多浏览器并不实现这些特性,尤其是for属性,它也许在 IE 中还存在,这一特性与 ActiveXObject 的集成有关。关于脚本的动态执行,你能想像的绝大多数能在浏览器中玩的花样大概都在这里了。当然,你还可以在 DOM 中动态地插入一个SCRIPT标签来装载脚本,这在 Ajax 还没有那么流行的时候是唯二之选。另一种选择,是在 Document 初始化结束之前使用document.write()。总而言之,为了动态执行一点什么,古典时代的 WEB 程序员是绞尽脑汁。那么为什么不用eval()呢?按照 JavaScript 脚本的执行机制,所有的.js 文件加载之后,它的全局代码只会执行一次。无论是在浏览器还是在 Node.js 环境中,以及它们的模块加载环境中,都是如此。这意味着放在这些全局代码中的eval()事实上也就只在初始化阶段执行一次而已。而eval()又有一个特别的性质,那就是它“总是在”当前上下文中执行代码。因此,所有其他的、放在函数中的eval()代码都只会影响函数内的、局部的上下文,而无法影响全局。也就是说,除了初始化,eval()无法在全局执行。不同的浏览器都有各自的内置机制来解决这个问题。IE 会允许用户代码调用window.execScript(),实现那些希望eval()执行在全局的需求。而 Firefox 采用了另外的一条道路,称为window.eval()。这个从字面上就很好理解,就是“让eval()代码执行在 window 环境中”。而window就是浏览器中的全局对象 global,也就是说,window.eval 与 global.eval 是等义的。这带来了另外一个著名的、在 Firefox 早期实现的 JavaScript 特性,称为“对象的 eval”。如果你试图执行obj.eval(x),那么就是将代码文本x执行在obj的对象闭包中(类似于with (obj) eval(x))。因为全局环境就是使用 global 来创建的“对象环境(对象闭包)”,所以这是在实现“全局 eval()”的时候“顺手”就实现了的特性。但这意味着用户代码可以将eval函数作为一个方法赋给任何一个 JavaScript 对象,以及任何一个属性名字。例如:

java

var obj = { do: eval };obj.do('alert("HI")');

名字之争

现在,“名字”成了一个问题,在任何地方、任何位置,任何对象以及任何函数的上下文中都可以“以不同的名字”来 eval() 一段代码文本。这太不友好了!这意味着我们永远无法有效地判断、检测和优化用户代码。一方面,这对于程序员来说是灾难,另一方面,对引擎的实现者来说也非常绝望。于是,从 ECMAScript 6 开始,ECMAScript 规定了“标准而规范的使用 eval()”的方法:你仅仅只能直接使用一个字面文本为“eval”字符串的函数名字,并且作为普通函数调用的形式来调用eval(),这样才算是“直接调用的 eval()”。这个约定是非常非常罕见的。JavaScript 历史上几乎从未有过在规范中如此强调一个名字“在字面文本上的规范性”。在 ECMAScript 5 之后,一共也只出现了两个,这里的"eval"是一个,而另一个是严格模式(这个稍晚一点我们也会详细讲到)。根据 ECMAScript 的约定,下面的这些都不是“直接调用的 eval()”:

javascript

// 对象属性obj = { eval }obj.eval(x)// 更名的属性名或变量名(包括全局的或函数内局部的)e = evalvar e = evale(x)// super 引用中的父类属性(包括原型方法和静态方法)class MyClass { eval() { } }MyClass.eval = eval;class MyClassEx extends MyClass { foo() { super.eval(x) } static foo() { super.eval(x) }}// 作为函数(或其他大多数表达式)的返回function foo() { return eval }foo()(x)// (或)(_=>eval)()(x)

总之,你所有能想到的一切——换个名字,或者作为对象属性的方式来调用 eval,都不再作为“直接调用的 eval()”来处理了。那么,你可能会想要知道,怎样才算是“直接调用的 eval()”,以及它有什么效果呢?很简单的,在全局、模块、函数的任意位置,以及一个运行中的eval(...)的代码文本的任意位置上,你使用的eval(x)这样的代码,都被称为“直接调用”。直接调用 eval() 意味着:在代码所在位置上,临时地创建一个“Eval 环境”,并在该环境中执行代码x。而反过来,其他任何将eval()调用起来,或者执行到eval()函数的方式,都称为“间接调用”。而这两讲的标题中的写法,就是一个经典的“间接调用 eval”的写法:

javascript

(0, eval)(x)

晚一点,我们会再来详细讲述这个“间接调用”,接下来我们先说说与它相关的一点基础知识,也就是“严格模式”。NOTE:之所以称为“经典的”写法,是因为在 ECMAScript 规范的测试项目 test262 中,所有间接调用相关的示例都是采用这种写法的。

严格模式是执行限制而不是环境属性

ECMAScript 5 中推出的严格模式是一项重大的革新之举,它静默无声地拉开了 ECMAScript 6~ECMAScript 10 这轰轰烈烈的时代序幕。之所以说它是“静默无声的”,是因为这项特性刚出来的时候,大多数人并不知道它有什么用,有什么益处,以及为什么要设计成这个样子。所以,它几乎算是一个被“强迫使用”的特性,对你的团队来说是这样,对整个的 JavaScript 生态来说也是如此。但是“严格模式”确实是一个好东西,没有它,后来的众多新特征就无法形成定论,它奠定了一个稳定的、有效的、多方一致的语言特性基础,几乎被所有的引擎开发厂商欢迎、接受和实现。所以,我们如今大多数新写的 JavaScript 代码其实都是在严格模式环境中运行的。对吗?不太对。上面这个结论对于大多数开发者来说是适用的,并能理解和接受。但是,要是你在 ECMAScript 规范层面,或者在 JavaScript 引擎层面来看这句话,你会发现:咦?!“严格模式环境”是什么鬼?我们从来没见过这个东西!是的,所谓“严格模式”,其实从来都不是一种环境模式,或者说,没有一个环境是具有“严格模式”这样的属性的。所有的执行环境——所有在执行引擎层面使用的“执行上下文(ExecuteContext)”,以及它们所引用的“环境(Environment)”,都没有“严格模式”这样的模式,也没有这样的性质。我们所有的代码都工作在非严格模式中,而“严格模式”不过是代码执行过程中的一个限制。更确切地说,即使你用如下命令行:

shell

> node --use-strict

来启动 Node.js,也仍然是运行在一个 JavaScript 的“非严格模式”环境中的!是的是的,我知道,你可以立即写出来一行代码来反驳上述观点:

typescript

# (在上例启动的 Node.js 环境中测试)> arguments = 1SyntaxError: Unexpected eval or arguments in strict mode

但是请相信我:上面的示例只是一个执行限制,你绝对是运行在一个“非严格模式”环境中的!因为所有的四种执行环境(包括 Eval 环境),在它们创建和初始化时都并没有“严格模式”这样的性质。并且,在全局环境初始化之前,在宿主环境中初始化引擎时,引擎也根本不知道所谓“严格模式”的存在。严格模式这个特性,是在环境创建完之后,在执行代码之前,从源代码文本中获取的性质,例如:

sql

// (JavaScript 引擎的初始化过程)// 初始化全局,in InitializeHostDefinedRealm()CALL SetRealmGlobalObject(realm, global, thisValue) -> CALL NewGlobalEnvironment(globalObj, thisValue)// 执行全局任务(含解析源代码文本等),in ScriptEvaluationJob()s = ParseScript(sourceText, realm, hostDefined)CALL ScriptEvaluation(s)// 执行全局代码,in ScriptEvaluation(s)result = GlobalDeclarationInstantiation(scriptBody, globalEnv)if (result.[[Type]] === normal) { result = ENGING_EVALUATING(scriptBody) ...

在这整个过程中,ParseScript() 解析源代码文本时,如果发现“严格模式的指示字符串”,那么就会将解析结果(例如抽象语法树 ast)的属性 ast.IsStrict 置为 true。但这个标记仅仅只作用于抽象语法树层面,而环境中并没有相关的标识——在模块中,这个过程是类似的,只是缺省就置为 true 而已。而另一方面,例如函数,它的“严格模式的指示字符串”也是在语法解析阶段得到的,并作为函数对象的一个内部标记。但是函数环境创建时却并不使用它,因此也不能在环境中检测到它。我列举所有这些事实,是试图说明:“严格模式”是它们相关的可执行对象的一个属性,但并不是与之对应的执行环境的属性。因此,当“执行引擎”通过“词法环境或变量环境”来查找时,是看不到这些属性的,也就是说,执行引擎所知道的环境并没有“严格 / 不严格”的区别。那么严格模式是怎么被实现的呢?答案是,绝大多数严格模式特性都是在“相关的可执行对象”创建或初始化阶段就被处理掉的。例如,严格模式约定“没有 arguments.caller 和 arguments.callee”,那么,就在初始化这个对象的时候不创建这两个属性就好了。另外一部分特性是在语法分析阶段识别和处理的。例如“禁止掉 8 进制字面量”,由于“严格模式的指示字符串(‘use strict’)”总是在第一行代码,所以在其他代码 parser 之前,解析器就已经根据指示字符串配置好了解析逻辑,对“8 进制字面量”可以直接抛出异常了。从等等类似于此的情况,你能看到“严格模式”的所有限制特性,其实都并不需要执行引擎参与。进一步地来说,引擎设计者也并不愿意掺合这件事,因为这种模式识别将大幅度地降低引擎的执行效能,以及使引擎优化的逻辑复杂化。但是,现在来到了“eval()”调用,怎么处理它的严格模式问题呢?

直接调用 VS 间接调用

绝大多数严格模式的特性都与语法分析结束后在指定对象上置的“IsStrict”这样的标记有关,它们可以指导引擎如何创建、装配和调用代码。但是到了执行器内部,由于不可能从执行上下文开始反向查找环境,并进一步检测严格模式标识,所以eval()在原则上也不能知道“当前的”严格模式状态。这有例外,因为“直接调用 eval()”是比较容易处理的,因为在使用eval()的时候,调用者——注意不是执行引擎——可以在当前自己的状态中得到严格模式的值,并将该值传入eval()的处理过程。这在 ECMAScript 中是如下的一段规范:

javascript

... - If strictCaller is true, let strictEval be true. - Else, let strictEval be IsStrict of script....

也就是说,如果 caller 的严格模式是 true,那么eval(x)就继承这个模式,否则就从x(也就是 script)的语法解析结果中检查 IsStrict 标记。那么间接调用呢?所谓间接调用,是 JavaScript 为了避免代码侵入,而对所有非词法方式的(即直接书写在代码文本中的)eval()调用所做的定义。并且 ECMAScript 约定:约定 1:所有的“间接调用”的代码总是执行在“全局环境”中。这样一来,你就没有办法向函数内传入一个对象,并用该对象来“在函数内部”执行一堆侵入代码了。但是回到前面的问题:如果是间接调用,那么这里的strictCaller是谁呢?又处于哪种“严格模式”状态中呢?答案是:不知道。因为当这样来引用全局的时候,上下文 / 环境中并没有全局的严格模式性质;反向查找源代码文本或解析过的 ast 树呢,既不经济也不可靠。所以,就有另外一个约定:约定 2:所有的“间接调用”的代码将默认执行在“非严格模式”中。也就是说,间接调用将突破引擎对严格模式的任何设置,你总是拥有一个“全局的非严格模式”并在其中执行代码。例如:

typescript

# (控制台)> node --use-strict# (Node.js 环境, 严格模式的全局环境)> arguments = 1SyntaxError: Unexpected eval or arguments in strict mode> 012SyntaxError: Octal literals are not allowed in strict mode.# 间接调用(例 1)> (0, eval)('arguments = 1') // accept!> arguments1# 间接调用(例 2)> (0, eval)('012') // accept!10# 间接调用(例 3,本文的标题代码,将创建变量 x)> (0, eval)('x = 100') // accept!> x100

为什么标题中的代码是严格模式

最后一个疑问,就是为什么“标题中的这种写法”会是一种间接调用。并且,更有对比性地来看,如果是下面这种写法,为什么就“不再是”间接调用了呢?例如

typescript

# 直接调用> (eval)('x = 100')ReferenceError: x is not defined at eval (eval at ...)# 间接调用> (0, eval)('x = 100')100

在 JavaScript 中,表达式的返回结果(Result)可能是值,也可能是“引用(规范类型)”。在“引用”的情况中,有两个例子是比较常见、却又常常被忽略的,包括:

shell

# 属性存取返回的是引用> obj.x# 变量的标识符(作为单值表达式)是引用> x

我们之前的课程中说过,所有这种“引用(规范类型)”类型的结果(Result),在作为左手端的时候,它是引用;而作为右手端的时候,它是值。所以,才会有“x = x”这一个表达式的完整语义:将右手端 x 的值,赋给左手端的 x 的引用。好了,然而还存在一个运算符,它可以“原样返回”之前运算的结果(Result),这就是“分组运算符 ()”。因为这个运算符有这样的特性,所以当它作用于属性存取和一般标识符时,分组运算返回的也仍然是后者的“运算结果(Result)”。例如:

shell

# “结果(Result)”是`100`的值> (100)# “结果(Result)”是`{}`对象字面量(值)> ({})# “结果(Result)”是`x`的引用> (x)# “结果(Result)”是`obj.x`的引用> (obj.x)

所以,从“引用”的角度上来看,(eval)eval的效果也就完全一致,它们都是global.eval在“当前上下文环境”中的一个引用。但是我们接下来看,我们在本文的标题中写的这个分组表达式是这样的:

javascript

(0, eval)

这意味着在分组表达式内部还有一个运算,称为“连续运算(逗号运算符)”。连续运算的效果是“计算每一个表达式,并返回最后一个表达式的值(Value)”。注意,这里不是“结果(Result)”。所以它相当于执行了:

javascript

(GetValue(0), GetValue(eval))

因此最后一个运算将使结果从“Result->Value”,于是“引用(的信息)”丢失了。在它外层(也就是其后的)分组运算得到的、并继续返回的结果,就是“GetValue(eval)”了。这样一来,在用户代码中的(eval)(x)还是直接调用“eval 的引用”,而(0, eval)(x)就已经变成间接调用“eval 的值”了。讲到这里,你可能已经意识到:关键在于eval是一个引用,还是一个值?是的,的确如此!不过在 ECMAScript 规范中,一个“eval 的直接调用”除了必须是一个“引用”之外,还有一个附加条件:它还必须是一个环境引用!也就是说,属性引用的eval仍然是算着间接调用的。例如:

typescript

# (控制台,直接进入全局的严格模式)> node --use-strict# 测试用的代码(in Node.js)> var x = 'arguments = 1'; // try source-text# 作为对象属性> var obj = {eval};# 间接调用:这里的确是一个引用,并且名字是字符串文本 "eval",但它是属性引用> (obj.eval)(x)1# 直接调用:eval 是当前环境中的一个名字引用(标识符)> eval(x)SyntaxError: Unexpected eval or arguments in strict mode# 直接调用:同上(分组运算符保留了引用的性质)> (eval)(x)SyntaxError: Unexpected eval or arguments in strict mode

所以,无论如何,只要这个函数的名字是“eval”,并且是“global.eval 这个函数在当前环境中的引用”,那么它就可以得到豁免,成为传统意义上的“直接调用”。例如:

javascript

// (一些豁免的案例,如下是直接调用)// with 中的对象属性(对象环境)with ({ eval }) eval(x)// 直接名字访问(作为缺省参数引用)function foo(x, eval=eval) { return eval(x)}// 不更改名字的变量名(位于函数环境内部的词法 / 变量环境中)function foo(x) { var eval = global.eval; // 引用自全局对象 return eval(x)}

eval 怎么返回结果

那么最后一个问题,是“eval 怎么返回结果呢”?这个问题的答案反倒非常简单。由于eval(x)是将代码文本x作为语句执行,所以它将返回语句执行的结果值。所有语句执行都只返回值,而不返回引用。所以, 即使代码x的运算结果(Result)是一个“引用(规范类型)”,那么eval()也只返回它的值,即“GetValue(Result)”。例如:

typescript

# 在代码文本中直接创建了一个`eval`函数的“引用(规范类型)”> obj = { foo() { return this === obj } }# this.foo 调用中未丢失`this`这个引用> obj.foo()true# 同上,分组表达式传回引用,所以`this`未丢失> (obj.foo)()true# eval 将返回值,所以`this`引用丢失了> eval('obj.foo')()false

结语

今天本文结束了对标题中代码的全部分析。由于标题中的代码是一个“间接调用的 eval”,因此它总是运行在一个非严格模式的全局中,于是变量x也就总是可以被创建或重写。“间接调用(IndriectCall)”是 JavaScript 非常非常少见的一种函数调用性质,它与“SuperCall”可以合并起来,视为 JavaScript 中执行系统中的“两大顶级疑难”。对间接调用的详细分析,涉及执行引擎的工作原理、环境和环境组件的使用、严格模式、引用(规范类型)的特殊性,以及最为特殊的“eval 是作为特殊名字来识别的”等等多个方面的基础特性。间接调用对“严格模式”并非是一种传统意义上的“破坏”,只是它的工作机制正正好地绕过了严格模式。因为严格模式并不是环境的性质,而是代码文本层面的执行限制,所以当 eval 的间接调用需要使用全局时,无法“得到并进入”这种模式而已。最后,间接调用其实是对传统的 window.execScript 或 window.eval 的一个保留。它有着在兼容性方面的实用意义,但对系统的性能、安全性和可靠性都存在威胁。无论如何,你应该限制它在代码中的使用。不过,它的的确确是 ECMAScript 规范中严格声明和定义过的特性,并且可称得上是“黑科技(Hack skill)”了。

思考题

今天有一个作业留给你思考,问题很简单:请你尝试再找出一例豁免案例,也就是直接调用 eval() 的写法。欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。今天的课程就到这里。下一部分,我们将讨论“动态函数”,这既是“动态语言”部分的最后一小节,也将是专栏的最后一讲。 img