在块语句中的函数声明

本文深入探讨JavaScript中的块级作用域和函数声明,解释在ES6之前的函数声明行为,以及ES6引入块级作用域后带来的变化。文章通过示例和历史背景分析,展示了不同引擎对函数声明的处理方式,以及ES6后的扩展规范如何影响函数声明的提升和作用域。文章最后总结了在不同环境下函数声明的行为及其应用,并提供了相关的结论和建议。

在块语句中的函数声明

下面这个示例,在JS中会输出什么呢?

{
  a = 1
  function a() {}
  a = 2
  console.log(a) // 输出1
}
console.log(a) // 输出2

只要跑一下程序,就会知道答案是“先2后1”:

2  // 输出1
1  // 输出2

然而“输出2”为什么会是1呢?

这个问题得从远古时期的JavaScript说起,那个时代还没有ES6所谓的“环境(Environment)”,与之相似的概念称为“作用域”。也因为这个缘故,在通常的JavaScript中,把这个在ES6之后才发展起来的东西称为“块级作用域”。

然而仅仅是“块级作用域”,是不足以解释上面这个例子的独特之处的。

在没有块级作用域之前

在ES5及其之前的时代,JavaScript只有“函数”和“全局”两个级别的作用域。这带来了许多的问题,其中之一是“eval执行在哪里”的问题,另一个就是今天要讲到的“在语句中的函数声明的作用域在哪里”的问题。例如:

function foo(tag) {
    if (tag) {
        function bar() {
            console.log("Hello")
        }
    }
    else {
        function bar() {
            console.log("World")
        }
    }
    bar();
}

foo(true);  // 输出1
foo(false); // 输出2

在这个例子中,从正常人的思维来说,我们会认为输出下面的内容:

Hello  // 输出1
World  // 输出2

但是在没有块级作用域之前,不同的JS引擎对上述代码的理解并不相同。例如在微软的JScript中,bar就被作为函数foo()的内嵌函数来理解,因此代码中事实上是连续两次声明了函数bar(),所以只有最后一个声明有效。因此无论foo(true)还是foo(false)都将输出World,上面的结果也就是两个World

然而Mozilla Firefox提出不同的看法,他们认为应该在结果上与“正常人的思维”一致,因此在ES5之前的时代他们就提出并实现了“(类似)块级作用域”的概念,这里被处理成了“条件声明”,也就是说bar()这个函数在foo()中将是未被声明的,只有执行到了if的某个分支之后,它才会“正式地”声明出来。——在它被“正式地”声明出来之前,bar这个函数名并不存在,无需理会。

这其实带来了更多的问题,例如如果函数bar()尚不存在,那么在if条件语句之前就会访问到全局,而执行了语句之后,就会访问到foo()函数内的声明。——不要忘了,如果动态地在函数内使用eval语句,还会动态地创建出“foo”这样的名字来,所以……没有人能真正搞懂函数foo()中有没有bar()这个名字,又或者调用函数foo()的时候会不会访问到全局中的bar——函数或者变量名等等。

所以,在“块级作用域”出来之前,上面的示例的含义就成了悬案。Mozilla Firefox也只是在1.6+的JavaScript扩展语法中支持它们(Spider Monkey JavaScript 1.5支持到ECMAScript 3)。

有了ES6的块级作用域,问题却更大了

好了,ES6提出了块级作用域,也就是说有了“语句”这个级别的作用域。但是有了这个东西,对于上述示例来说,问题却更加复杂。

NOTE1:在《JavaScript语言精髓与编程实践》的第一、二版中都讨论过,JavaScript 1.3~1.5的语言设计中没有补全“语句”和“表达式”这两个级别的作用域。而从ES6开始,才有了“语句”级别,亦即是所谓的“块级作用域”。

NOTE2:并不是只有块语句才有“块级作用域”,也并不是所有的语句都有块级作用域。可以参见《JavaScript语言精髓与编程实践(第3版)》“4.4 语句与代码分块”以及表4-6。

这又得从另一个历史问题来讲起了。首先,与“块级作用域”一起出现的、由块级作用域限制的是let/const语句声明的变量名,而在传统上,函数名是与“var变量名”类似的名字。——因此,函数名事实上也是没有块级作用域的,它“理论上”应该和“var变量名”一样放到函数或全局级别的作用域。这常常被称为“提升(Hoisting)”,例如:

function foo() {
  console.log(x); // undefined
  {  // 块语句
     var x = 100;
     let y = 200; // `y`在块语句的作用域中
     function bar() {
         ...
     }
  }
  console.log(x); // 100, `x`在函数的作用域中
}

既然其中的“var变量名”被提升到了“foo()函数的作用域”,那么“与它性质相似的”bar()函数也应当被提升到foo()函数中啊。

——这看起来很合理啊。然而大家还记得吗,Mozilla firefox在js1.6+的时代已经实现过了所谓的“条件声明”,那么这就意味着Spidermonkey系的js引擎把这样的bar()函数声明留在了“语句一级”,而反过来,这一次反倒是JScript占了优势,他们的设计与新规范站到了一起。

然而两种主要的引擎都有极大量的用户,尤其那已经是Firefox与Chrome开始联手颠覆IE的市场的时代了,所以两种声音都有足够的话语权,最终在这个问题上达成了一个“奇怪的”妥协,并且写到了ECMAScript规范中。

可见的结果,与进一步的真相

当有了”块级作用域“,并且将这它与let/const声明联系到一起之后,var声明就成了”遗产(Legacy)“。所以事实上在ECMAScript 6之后,如果你确实要在块(包括块语句、条件语句等等一切支持块作用域的语句)中声明一个”var变量“,那么他们确实会被提升到该语句外层的函数或全局作用域。但是如果你在块中声明一个函数,那么这个函数名只会在块级作用域里有效,而不会提升。亦即是说,文章开始的示例应该输出的结果是:

2  // 输出1
...  // 异常,显示变量`a`未找到

这是因为在ES6之后,具名函数只会作用在“顶层(top-level)”。——ECMAScript约定函数、全局和模块等作用域在扫描它内部的函数声明时,只处理顶层声明。因此上例在块作用域之外就找不到名字a,从而出现了异常。

然而我相信你在Chrome或者Nodejs,又或者其它的绝大多数引擎中能只能看到最开始的那个输出结果(先“2”后“1”)。其原因呢,则在于这些引擎都“多做了一些事”。

ECMAScript6之后的规范中增加了一个补充提案(兼容性扩展规范),包括之前前端最爱的“__proto__”属性都在这个扩展的规范中。——也就是说,它们并不是“严格的ECMAScript规范”的一部分,而是用来解释历史问题、兼容问题的扩展部分。然而很不幸的是,几乎所有主要的js引擎都是面向浏览器的,都需要解决这些历史问题或兼容问题。

所以……事实上前端能用到的几乎所有环境都基于这个扩展来实现,这也就为什么你只能看到输出结果(先“2”后“1”)的原因。当然,例外总是有的,JavaScriptCore就是其一,另一个则是prepack。——prepack是按照ECMAScript规范逐行实现的,而并没有加入上述的扩展部分。

Ok,你想试一下“没有Web扩展的JS引擎”的话,可以试试prepack:

> git clone https://github.com/aimingoo/prepack-core
> cd prepack-core
> npm install
> npm run build-repl
> npm run repl ./test.js   # 设上述为文件./test.js
....

然而对于现在我们要讨论的、前端通常面临的(实现了扩展规范)的环境来说,这个结果值确实是“先2后1”。对此,简明扼要的回答可以是这样:

  • “代码行a = 1中的值1被写到了全局,因此在最后一行代码输出了全局中a的值1。

但是真正难以回答的问题在于:

  • 这个值1什么时候、以及被谁写到了全局的?

并且,由于代码行a = 2没有覆盖全局中的a值,这就说明在块级作用域中还存在一个同名的变量名a。那么在这种情况下,

  • 1又是如何绕过块内的名字a而写到全局变量a中的呢?

要回答“什么时候(When)、谁(Who),以及如何(How)”的问题,就只能继续向前探索,直面终极真相:扩展规范到底如何强制ECMAScript兼容旧的语言特性。

环境

在说这件事之前,有一个细节是要讲的:在ECMAScript 6之后,新的概念“环境(Environment)”替代了作用域。而环境,则是“执行上下文(Execute Context)”的一部分。作用域的概念淡化、标准化了之后,原来的一些旧规范也被扔进了“非严格模式”中,这才使得ECMAScript成为一个成熟的、根基扎实的规范,这为ES6之后的持续发展打下了新的、良好的基础。

那么,有哪些“环境”呢?从功用上来说,有“变量环境”和“词法环境”两种;从执行逻辑的角度上来说,4种可执行结构都有它们对应的环境(函数、模块、全局、Eval);从与作用域的对照关系来看呢,则有函数、模块、全局、块和对象,等等。——总之,“环境”是在ES5开始提出,并在ES6之后发挥得淋漓尽致的一个东西。几乎所有ECMAScript的实现细节,都要追溯到它的设计与实现上来。

NOTE1:注意这里没有“表达式”级别的作用域和对应环境,这在最新近的一些tc39提案中是有所涉及的。

NOTE2:执行结构只有4种,执行上下文需要它们对应的环境来初始化执行栈,所以是不可或缺的。需要留意的是,Eval有它自己的执行环境,这正是ES6用来解决本文前面提到的"eval执行在哪里“这一问题的。

NOTE3:“对象作用域”在这里是特指用with语句打开的对象闭包。这既是一种环境的实现结构,又是一种作用域的结构,它有些特殊性。另外,在新近的tc39提案中,对象或类是有自己的作用域的,这与本文所述的并不是同一个东西。

NOTE4:参见《JavaScript语言精髓与编程实践(第3版)》第“5.5.3 与闭包类似的实例化环境”小节。

你可能会好奇我为什么要把这些“环境”一一分类地列出来呢?因为,这个扩展规范就包括了6个部分,主要针对的就是上面四种可执行结构的3种(函数、全局和Eval)。其中没有“模块”,是因为模块缺省工作在严格模式下,而严格模式是不支持这一扩展规范的。

NOTE5:可翻阅规范"B.3.3 Block-Level Function Declarations Web Legacy Compatibility Semantics"。

NOTE6:规范对If语句专门做了补充,可翻阅"B.3.4 FunctionDeclarations in IfStatement Statement Clauses"。

环境与它在执行过程中的作用

JavaScript的执行引擎最终识别用户的源代码文本的方式,是通过找到一个个的“词法记号(Tokens)”来进行的,包括变量、函数等等的名字,也包括语法中的运算符等等。当引擎找到一个操作符(Operators,运算符)时,他需要进一步找到它的操作数(Operands);而一旦这两个东西齐备了,就可以完成运算了。——简化这个过程,可以理解成“引擎需要在进行某个计算时,找到它所需要的操作数”。而后者在我们的源代码中就是“名字(Names)”,或者“(字面量)值”。

正是因此,引擎执行过程中其实只需要从源代码中检测到名字。这带来了ECMAScript规范中面向引擎的最核心的设计:执行上下文(EC,Execution Contexts)。ECMAScript约定EC最基础需要两个用于访问源代码中的名字的结构,这就称为变量环境和词法环境。亦即是(考虑到后文方便,我们分别称之为EC.lexEnv和EC.varEnv):

// 参见:https://tc39.es/ecma262/#table-additional-state-components-for-ecmascript-code-execution-contexts
ExecutionContext = {
    lexicalEnvironment: ...
    variableEnvironment: ...
}

之所以每个执行上下文需要两个环境(Environments),就是因为ES6之后需要考虑如何兼容JavaScript早期版本中关于“var变量”的一些特殊处理。这其中要解决的,既包括大家熟知的“变量提升”,也包括之前提到过的“eval在哪里执行”和“语句/块中的函数声明”等等特殊问题。

正如我们前面说的,引擎需要环境的原因,就是它需要找到源代码中的“名字(Names)”。也因此,事实上EC.lexEnv和EC.varEnv这两个环境,也就只提供通过环境记录(Environment Records)来找到名字的能力。——这个能力,在早期的JavaScript中就是通过“作用域(Scope)”来实现的,也因此,二者是新旧替代的关系。至于环境记录,则是更进一步的、细化的名字对照表,它也只提供唯一的一个查询接口,就是“找到标识符/名字所对应的引用(GetIdentifierReference)”。例如在下面这行代码中:

a = a

左侧在引擎中是通过PutValue来写值,而右侧是通过GetValue来取值。然而二者的操作界面都是一样的:

GetValue ( vRef )
PutValue ( VRef, W )

其中的vRef,都将是通过GetIdentifierReference()操作来取到的、变量名a的引用。

所以本质上来说,执行环境也好,引擎也好,在面对一个标识符a时所需要做的就是:

  • 在当前EC中找到EC.lexEnv和EC.varEnv;然后,

  • 调用GetIdentifierReference(a)来取得vRef。

然后它才去执行具体操作,例如GetValue(vRef)等等。

这个过程被封装在EC.ResolveBinding()操作中。可以参考:https://tc39.es/ecma262/#sec-resolvebinding

然而,你到ECMAScript中去读上面提到这一段,那么你会惊奇地发现:ResolveBinding()只操作EC.lexEnv

// 取当前EC.lexEnv
1. If env is not present or if env is undefined, then
	a. Set env to the running execution context's LexicalEnvironment.

...
4. Return ? GetIdentifierReference(env, name, strict).

既然如此,那么EC.varEnv又是如何被使用起来的呢?

变量环境(EC.varEnv)的魔法

如果用“VariableEnvironment”作为关键词搜索一下ECMAScript的全文,你会发现它其实只出现了17次。——也就是说,没什么地方会用到它。然而事实上它对ECMAScript规范的影响是贯穿性的,相关的关键词还有VarDeclaration、varNames、varDeclared等等,就多得不胜枚举了。然而,我们前面说过:本质上执行上下文不是只能认得环境么?所以,只要站在执行引擎的视角下,真正想明白EC.varEnv是如何起到作用的,其它的也就一通百通了。

NOTE1: 对于如下讨论,全部四个执行结构的环境/上下文中只有“模块(module)”未被论及,是因为模块中默认使用严格模式,这种情况下,varEnv/lexEnv将指向同一个,所以是不必区分的。

函数的执行上下文

为函数F创建的执行上下文(calleeContext)中,环境varEnv和lexEnv初始都是指向当前的函数环境的。亦即是:

// @see https://tc39.es/ecma262/#sec-prepareforordinarycall
localEnv = NewFunctionEnvironment(F, ...)
calleeContext.lexEnv = calleeContext.lexEnv = localEnv

但当(实例化该函数,并)调用该函数时,如果是非严格模式,那么将会在lexEnv的内部再添加一层varEnv。亦即是:

// 以下主要解说FunctionDeclarationInstantiation(),参见:https://tc39.es/ecma262/#sec-functiondeclarationinstantiation
calleeEnv = calleeContext.lexEnv;
aNewEnv = NewDeclarativeEnvironment(calleeEnv); // lexEnv作为outer
calleeContext.varEnv = aNewEnv; // 重置varEnv,其outer是lexEnv

也就是说在“非严格模式”的执行上下文中的varEnv和lexEnv不是同一个,而是varEnv的外层指向lexEnv。
并且,重要的是:接下来所有关于参数、内部var以及内嵌函数声明等等的初始化动作,将是发生在“最内层的env”上的,也就是说lexEnv不变,而参数绑定等导致的变化总是发生在varEnv上。

NOTE2: 如果函数带有需要使用参数表达式的非简单参数,那么在这个varEnv内层还会再创建一层Env,用来隔开预设值和初始化过程。后续操作仍然是面向这个最内层的Env的,并且仍然称为varEnv。

接下来ECMAScript开始重置“比varEnv更内层的”的词法环境。也就是说,函数初始时的varEnv/lexEnv将在回溯链上保持不变,而每次调用函数所创建的calleeContext将会在某内部重新创建一套varEnv/lexEnv。

在这个时间点上:

  • 严格模式的函数lexEnv/varEnv指向同一个;
  • 非严格模式有一个更内层的varEnv;
  • 而如果是非简单参数,那么无论是否是严格模式,都至少有一个更内层的varEnv。

并且,后续的操作将发生在最内层的这个varEnv上。

全局的执行上下文

NOTE3: 以下主要解说GlobalDeclarationInstantiation(),参见 https://tc39.es/ecma262/#sec-globaldeclarationinstantiation

全局的执行上下文(scriptContext)中lexEnv和varEnv初始也是指向同一个的,也就是Realm.[[GlobalEnv]]。只不过这个GlobalEnv有点特殊,它是一个复合结构,包括一个对象环境记录(objRec)和一个声明环境记录(dclRec);前者是给”var声明、函数声明“,以及“向未赋值变量赋值”导致的动态创建的变量名来使用的,后者则是给let/const等词法声明用的。GlobalEnv的外部(outer)总是指向null。

由于GlobalEnv具有这种性质,所以如果需要辨别“某个名字是var变量,还是let/const词法名字”,那么它将由GlobalEnv通其内部的两个环境记录表来识别。因此,全局执行上下文并不将lexEnv/varEnv分隔开来使用。亦即是说,在后续讨论之前,我们可以认为:

后续的操作总是将发生在varEnv上。

Eval的执行上下文

ECMAScript专门为eval()调用创建了它专属的执行上下文(evalContext)和环境。这个evalContext将会引用当前正在运行的上下文(runningContext)的varEnv和lexEnv,以使得eval()的代码运行在“当前位置”,其中varEnv是直接指向runningContex.varEnv的,而lexEnv则创建了一个“更内层的(其outer指向runningContext.lexEnv)”。

NOTE4:如果是“间接执行(indirect)”的eval,那么它将引用全局的环境(而非当前上下文的),因此varEnv直接指向GlobalEnv,而新创建的lexEnv也是通过outer来指向全局GlobalEnv的。亦即是说,这种情况下,代码将执行在全局,变量声明和函数声明(的名字)直接作用在全局环境,而词法声明(let/const)将在一个独立的词法环境(块级作用域)中。

后续的“Eval声明实例化(EvalDeclarationInstantiation)”是在如上的基础环境上实现的。在这之前,ECMAScript用一行代码统一规范了“严格模式下的var/let/const声明”在eval块中的行为:

// 如果是严格模式,则强制varEnv指向lexEnv,也就是为eval语句新创建的块级(词法)作用域
// @see https://tc39.es/ecma262/#sec-performeval
16. If strictEval is true, set varEnv to lexEnv.

然而由于eval的特殊性,它还是需要判断每个环境操作“是发生在全局、还是在函数内”。这导致“Eval声明实例化(EvalDeclarationInstantiation)”的实现异常复杂。

NOTE5:eval不需要判断环境操作是否在模块内,是因为模块默认进入严格模式,而严格模式下varEnv和lexEnv被强制指向同一个,也就不再需要这些复杂的检测判断了。

对于这一过程,我们简单地看,可以认为,对于eval(sourceText)来说:

  • lexEnv用于检查中的sourceText中是否有与当前环境中重名的“var变量”名字,这是禁例;
  • lexEnv用于登记sourceText文本中声明的“词法名字(lexDeclarations)”,这是它的基本作用。
  • 所有在sourceText中被声明的函数(functionsToInitialize)将以lexEnv为外部环境创建它们的新的函数环境。——注意对于Eval环境来说,这里的lexEnv总是最内层的(因为`它总是以当前环境创建的一个新的、块级作用域的环境)。

NOTE6:在这个过程中,如果函数名和var变量名同时存在(例如a),那么优先初始化为函数名并绑定这个函数(具名函数声明提升到作用域的顶端并作为变量名)。由于这个名字(跟变量名一样)是可写的,所以对于代码“var a = 100”来说,当执行到这个声明语句之前,a指向该同名函数;执行这行语句之后,a将被重写为值100。

除了上面这三个有关lexEnv的步骤之外,需要注意是:

所有其它的操作都发生在varEnv上

所以在对上述三种可执行结构加以分析之后,我们可以得到的结论是:在**“我们所观察的这个位置上”**,所有后续关于环境的操作,都将发生在varEnv上。

NOTE7:在这里操作的主要是varEnv,但这并不是说将来在使用GetIdentifierReference()会找不到lexEnv。例如虽然说函数最内层“总是”varEnv,但是在函数声明实例化结束前,还会将varEnv置回到lexEnv,二者便保持了一致;而在模块与全局环境中,二者总是指向同一个东西的。至于Eval环境以及这里要讨论的块作用域,它们的最内层总是一个lexEnv。

然而我们为什么要特别地聚焦“这个位置”呢?这是因为,之前所说的ECMAScript扩展规范,对块级作用域的修改,就是发生在这三个可执行结构的相应位置。——在规范的相应位置上最写有一个“Note”,来指向的扩展规范中的实现。准确地说,该扩展规范要求在这些位置上“插入硬代码”,强行改变引擎对varEnv环境的初始化——这里是指“声明实例化(Declaration Instantiation)”——的行为。这分别是指规范的如下小节:

  • B.3.3.1 Changes to FunctionDeclarationInstantiation
  • B.3.3.2 Changes to GlobalDeclarationInstantiation
  • B.3.3.3 Changes to EvalDeclarationInstantiation

下面我们来进一步地分析它们。

扩展规范在varEnv上干了什么黑活?

以我们最开始的示例所在的环境(Global)为例,扩展规范在相应的小节(B.3.3.2)中对块中的函数声明做了如下的处理:

  • 尝试将所有函数声明改写为类似“var a”的声明,以检查代码文本在语义上的可行性。(replacing the FunctionDeclaration f with a VariableStatement that has F as a BindingIdentifier)
  • 如果确定在全局没有同名的词法声明和变量声明,那么就在全局创建一个变量名字a,并且它将是不可删除的(注意,这符合函数声明的原始设计,并且也符合在块语句中使用var a 声明的语法效果)。

至此为止,这意味着一个函数声明的名字“a”隐式地使用类似“var a”这样的方式声明到了全局。下面的代码可以验证这一点:

console.log(Object.getOwnPropertyDescriptor(global, 'a'));
console.log(typeof a);
{
  function a() {}
}

如果执行这段脚本,我们会得到:

{ value: undefined, writable: true, enumerable: true, ...}
undefined

也就是说,与在全局直接声明一个函数a类似的,在块中声明的函数a也被提升到了全局。但与直接声明(初始值将绑定函数对象)不同,块中的声明是以与“var a”类似的语义提升到全局的,它只有函数名字a而没有绑定值(也就是没有绑定函数对象)。

所以现在得到的第一个结论是:

  • 结论1,在进入块级作用域之前,因为函数声明的提升,在全局(gEnv)已经初始化了一个名字a

块级作用域内发生的变化

更确切地说:之所以存在“块级作用域”,就是因为每次“进入块”的时候,在当前词法环境的最内层添加了新一层的lexEnv。类似如下逻辑(参见 https://tc39.es/ecma262/#sec-block-runtime-semantics-evaluation):

// 创建新一层的词法环境(lexEnv)
oldEnv = runningContext.LexicalEnvironment
blockEnv = NewDeclarativeEnvironment(oldEnv)

// 对块中的“语句列表(StatementList)”进行声明实例化
BlockDeclarationInstantiation(StatementList, blockEnv).

// 将新的词法环境(blockEnv)添加到运行上下文(即原来的oldEnv更内层)
runningContext.LexicalEnvironment = blockEnv

因此,回想我们在之前讨论的三种环境的上下文(以全局globalContext为例),当执行到该块语句时,上下文的变量环境和词法环境就会发生如下变化(缺省时它们都指向GlobalEnv):

# 词法环境和变量环境将会不同
globalContext.lexEnv == runningContext.lexEnv == blockEnv
globalContext.varEnv == runningContext.varEnv == GlobalEnv

# 并且,
blockEnv.outer === GlobalEnv

并且函数名a也将初始化在该块级作用域中。——如你所愿地,该函数名也是实现“(块作用域内的)提升”,因此是在它的声明语句之前就可以访问,并且是初始绑定值的。这就是“扩展规范B3.3.6”的作用了。下面的代码可以说明这一点:

var a = 100;
{
    // 这里说明函数a已经被提升到当前块作用域的顶端,因此在声明语句之前就可以使用
    console.log(typeof a); // function
    function a() {}
}

这里可以得到第二个结论:

  • **结论2:**在进入块时也会发生一次函数声明的提升,该块级作用域内(bEnv)将声明变量a并置初值为该函数。

由于当前块级作用域中事实上也存在标识符a,因此下面的代码a = 1就是在向当前块中的a置值:

{
    a = 1; // 这里是向当前块级作用域中的`a`置值(重写)
    function a() {}
    ...
}

然后,该扩展规范要求修改FunctionDeclaration这个小节中的运行期算法(参见 https://tc39.es/ecma262/#sec-function-definitions-runtime-semantics-evaluation ),要求添加的逻辑如下:

  • 在代码中执行到该具名函数a的声明语句时,将进行如下操作:
    • 取当前执行上下文(例如runningContext)的varEnv和lexEnv;
    • 从lexEnv中取出函数名a的值(缺省时是该函数实例),并将它绑定到varEnv中同样名为a的变量中。

NOTE:这个修改对于我们之前的讨论“至关重要”。它是我们之前要对各种执行结构下(三种执行上下文中)每个特定位置上的varEnv加以讨论的关键原因。

这样一来,我们可以得到最后一个结论,扩展规范要求:

  • **结论3:**在“执行函数声明语句”时,从当前上下文中取出blockEnv(必然是最内层的lexEnv),以及varEnv,并从块级作用域(blockEnv)中将函数名a的当前值抄写到varEnv中。

结论及其应用

那么有没有办法简单而准确地概括这些扩展规范的语法效果呢?当然可以,如下:

  • 在支持Web特性扩展(Web Legacy Compatibility Semantics)的引擎中,具名函数声明语句会被提升到它所在的块级作用域的初始化阶段来完成声明和绑定,并且:
    • 该声明会隐式地向它所在的最外层可执行结构(函数或全局)添加同名变量,缺省值为undefined;然后,
    • 在该函数声明语句执行时,该函数名的当前值还将隐式地“动态提升到(或称为绑定到上述可执行结构的变量环境中的)”该同名变量。

下例可以证明上述两个隐式过程:

{
  // 0. 当前词法环境(blockEnv)中存在一个名为`a`的函数
  //  - 函数`a`已提升到当前块作用域的顶端声明和绑定
  console.log(typeof a); // 'function'
    
  // 1. 隐式提升到全局,是一个未绑定值的标识符`a`(相当于`var a`的效果)
  console.log(Object.getOwnPropertyDescriptor(global, 'a'));
  
  function a() {}
  // 2. 在执行上述声明语句之后:
  //  - a的值隐式地绑定到了全局的同名变量
  console.log(Object.getOwnPropertyDescriptor(global, 'a'));
}

输出结果:

'function'
{value: undefined, ...} // 全局有名字`a`且未绑定值
{value: function a() ..., ...}  // 全局名字`a`是已绑定值

这两个隐式的操作是强制的。例如只要全局变量名a可写,那么这个动态的提升(和变量抄写)就会发生:

var a = 100;
{
    function a() {}
}
// 已重写
console.log(typeof a); // `function`

并且提升的总是变量a的当前值(而不一定是它所声明的函数实例)。——这就是我们最开始讨论的全局a = 1的来由了:

{
  a = 1; // 这里值`1`是写到当前【块作用域中的变量`a`】的
  function a() {}

  // 在这里全局变量`a`已经被【块作用域中的`a`的值】覆盖,其值是1
  console.log(Object.getOwnPropertyDescriptor(global, 'a').value); // 1

  ...
}
console.log(a) // 1

但是如果有相同的词法名字(let/const名字),那么由于全局的同名变量名(varName)未能创建,所以也就没有抄写:

let a = 100;
{
    function a() {}
}
// 未重写
console.log(typeof a, a); // `number`, 100
// 变量名不存在
console.log(Object.getOwnPropertyDescriptor(global, 'a')); // undefined

同样的结论(块中的函数存在两个隐式操作)也可以应用到其它的两种可执行结构中。例如对于函数的执行上下文来说,它是会在外层的lexEnv中存放函数名以便于在参数初始化中引用的,而上述逻辑则会重写内层的varEnv。——因此在缺省参数中访问到的a,与函数内访问到的a就不再是同一个(后者将被块内的动态提升重写)。例如:

void function a(f = a) {
    console.log(a); // undefined,未动态提升前
    {
        var b = a; // `b`在函数内可访问
        function a() {}; // 这里发生动态提升
    }
    // 动态提升后a是块作用域内的函数a
    console.log(a === b); // true
    // f是最外面声明的函数表达式a
    console.log(typeof f, f===a); // function, false
}();

其它

对比早期JavaScript与ES6之后的实现来看,历史中的JavaScript对块中的具名函数声明语句,采用的是“静态提升(亦即是在语法分析期提升)”到变量作用域的实现方式;而ES6之后的兼容性规范要求,在该声明语句所在的块级作用域中应该采用“静态提升”,并在(其所在的)变量作用域中采用隐式地进行“动态提升(亦即是执行动该语句时再提升)”。

这个兼容性规范并不能完全实现早期JS的相关特性,这是它与使用者的预期存在差异的根本原因。但它事实上为另一个兼容性提案——“条件声明语句(FunctionDeclarations in IfStatement Statement Clauses)”创造了条件。随着“块级作用域”,以及“在块级作用域中的具名函数声明”的实现,“条件声明语句”的语义变得非常明确:它完全等义于“在条件语句分支的块级作用域中”进行的具名函数声明。因此,扩展规范B3.3.4仅仅只是进行了语法说明,而实现部分完全指向了B3.3.3。

建议总是在eval()中使用严格模式,这种情况下Eval环境中总是存在一个自有的varEnv,这可以保护它所在的外层可执行结构(函数或全局)的变量环境不会受到任何形式的污染。

最后,由于间接执行的eval和使用动态函数创建(new Function)的代码总是工作在非严格模式的全局环境中,因此它们有机会任意的、不加限制地污染全局。——除非,向他们的代码文本中注入“use strict”指示字。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值