This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="18 | a + b动态类型是灾难之源还是最好的特性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/73/fa7c1d4453329b872e9b2a784e49bc73.mp3"></audio>
你好我是周爱民欢迎回到我的专栏。今天我们讲的主题是JavaScript的动态类型系统。
**动态类型**是JavaScript的动态语言特性中最有代表性的一种。
动态执行与动态类型是天生根植于JavaScript语言核心设计中的基础组件它们相辅相成导致了JavaScript在学习上是易学难精在使用中是易用易错。成兹败兹难以得失论。
## 类型系统的简化
从根底上来说JavaScript有着两套类型系统如果仅以此论那么还算不上复杂。
但是ECMAScript对语言类型的约定又与JavaScript原生的、最初的语言设计不同这导致了各种解释纷至沓来很难统一成一个说法。而且ECMAScript又为规范书写而订立了一套类型系统并不停地演进它。这就如同雪上加霜导致JavaScript的类型系统越发地说不清楚了。
在讨论动态类型的时候可以将JavaScript类型系统做一些简化从根底里来说JavaScript也就是typeof()所支持的7种类型其中的“**对象**object”与“**函数**function”算一大类合称为**引用类型**,而其他类型作为**值类型**。
无论如何我们就先以这种简单的类型划分为基础来讨论JavaScript中的动态类型。因为这样一来JavaScript中的类型转换变得很简单、很干净也很易懂可以用两条规则概括如下
1. 从值x到引用调用Object(x)函数。
1. 从引用x到值调用x.valueOf()方法或调用4种值类型的包装类函数例如Number(x)或者String(x)等等。
简单吧?当然不会这么简单。
## 先搞定一半
在**类型转换**这件事中,有“半件”是比较容易搞定的。
这个一半,就是“**从值x到引用**”。因为主要的值类型都有对应的引用类型因此JavaScript可以用简单方法一一对应地将它们转换过去。
使用`Object(x)`来转换是很安全的方法,在用户代码中不需要特别关心其中的`x`是什么样的数据——它们可以是特殊值例如null、undefined等或是一般的值类型数据又或者也可以是一个对象。所有使用`Object(x)`的转换结果,都将是一个尽可能接近你的预期的**对象**。例如,将数字值转换成数字对象:
```
&gt; x = 1234;
&gt; Object(x);
[Number: 1234]
```
类似的还包括字符串、布尔值、符号等。而null、undefined将被转换为一个一般的、空白的对象`new Object`或一个空白字面量对象(也就是`{ }`的效果一样。这个运算非常好用的地方在于如果x已经是一个对象那么它只会返回原对象而不会做任何操作。也就是说它没有任何的副作用对任何数据的预期效果也都是“返回一个对象”。而且在语法上`Object(x)`也类似于一个类型转换运算,表达的是将`任意x`转换成`对象x`
简单的这“半件事”说完后,我们反过来,接着讨论将**对象转换成值**的情况。
## 值VS原始值Primitive values
任何对象都会有继承自原型的两个方法,称为`toString()``valueOf()`这是JavaScript中“对象转换为值”的关键。
一般而言,你可以认为“任何东西都是可以转换为`字符串`的”,这个很容易理解,比如`JSON.stringify()`就利用了这一个简单的假设它“几乎”可以将JavaScript中的任何对象或数据转换成JSON格式的文本。
所以我的意思是说在JavaScript中将任何东西都转换成字符串这一点在核心的原理上以及具体的处理技术上都并不存在什么障碍。
但是如何理解“**将函数转换成字符串**”呢?
从最基础的来说,函数有两个层面的含义,一个是它的可执行代码,也就是文本形式的源代码;另一个则是函数作为对象,也有自己的属性。
所以,“理论上来说”,函数也可以被作为一个对象来转换成字符串,或者说,序列化成文本形式。
又或者再举一个例子,我们需要如何来理解将一个“符号对象”转换成“符号”呢?是的,我想你一定会说,没有“符号对象”这个东西,因为符号是值,不是对象。其实这样讲只是对了一半,因为现实中确实可以将一个“符号值”转换为一个“符号对象”,只需要调用一下我们上面说过的`Object()`这个函数就好了。
```
&gt; x = Object(Symbol())
[Symbol: Symbol()]
```
那么在这种情况下这个“符号对象x”又怎么能转换为字符串呢
所以,“一切都能转换成字符串”只是理论上行得通,而实际上很多情况下是做不到的。
在这些“无法完成转换”的情况下JavaScript仍然会尝试给出一个有效的字符串值。基本上这种转换只能保证“不抛出异常”而无法完成任何有效的计算。例如你在通常情况下将对象转换为字符串就只会得到一个“简单的描述”仅能表示“这是一个对象”而没有任何其它实际意义。
```
&gt; (new Object).toString()
'[object Object]'
```
为了将这个问题“一致化”——也就是将问题收纳成更小的问题JavaScript约定所有“对象 -&gt; 值”的转换结果要尽量地趋近于string、number和boolean三者之一。不过这从来都不是“书面的约定”而是因为JavaScript在早期的作用就是用于浏览器上的开发
- 浏览器可以显示的东西是string
- 可以计算的东西是number
- 可以表达逻辑的东西是boolean。
因此,在一个“最小的、可以被普通人理解的、可计算的程序系统中”,支持的“值类型数据”的最小集合,就应该是这三种。
这个问题不仅仅是浏览器就算是一台放在云端的主机你想要去操作它那么通过控制台登录之后的shell脚本也必须支持它。更远一点地说你远程操作一台计算机与浏览器用户要使用gmail这二者在计算的抽象上是一样的只是程序实现的复杂性不一样而已。
所以对于ECMAScript 5以及之前的JavaScript来说当它支持值转换向“对应的”对象时或者反过来从这些对象转换回值的时候所需要处理的也无非是这三种类型而已。而处理的具体方法也很简单就是在使用`Object(x)`来转换得到的对象实例中添加一个内部槽,存放这个`x`的值。更确切地说,下面两行代码在语义上的效果是一致的(它是在一个称为`PrimitiveValue`的内部槽中置入这个值的):
```
obj = Object(x);
// 等效于(如果能操作内部槽的话)
obj.[[PrimitiveValue]] = x;
```
于是,当需要从对象中转换回来到值类型时,也就是把这个`PrimitiveValue`值取出来就可以了。而“**取出这个值,并返回给用户代码**”的方法,就称为`valueOf()`
到了ECMAScript 6中这个过程就稍稍有些不同这个内部槽是区别值类型的因此为每种值类型设计了一个独立的私有槽名字。加上ES8中出现的大整数类型BigInt一共就有了5个对应的私有槽`[[BooleanData] [[NumberData]]``[[StringData] [[SymbolData]]``[[BigIntData]]`。其中除了`Symbol`类型之外,都是满足在上面所说的:
- 一个“最小的、可以被普通人理解的、可计算的程序系统中”,支持的“值类型数据”的最小集合
这样一个设定的。
那么“符号”这个东西出现的必要性何在呢?
这个问题我就不解释了,算作本讲的课后习题之一,希望你可以踊跃参与讨论。不过就问题的方向来说,仍然是出于**计算系统的完备性**。如果你非要说这个是因为张三李四喜欢某个tc39提案者的心头好这样的答案就算是当事人承认我也是不认可的。
好。回到正题。那么在ECMAScript 6之后`[[PrimitiveValue]]`这个私有槽变成了5种值类型对应的、独立的私有槽之外还有什么不同呢
是的这个你可能也已经注意到了。ECMAScript 6之后还出现了`Symbol.toPrimitive`这个符号。而它,正是将原本的`[[PrimitiveValue]]`这个私有槽以及其访问过程标准化然后暴露给JavaScript用户编程的一个界面。
说到这里,就必须明确**一般的值**Values与**原始值**Primitive values之间的关系了。
不过,在下一步的讨论之前,我要先帮你总结一下前面的内容:
也就是说,从`typeof(x)`的7种结果类型来看其中string、boolean、number、bigint和symbol的值类型与对象类型转换就是将该值存入私有槽或者从私有槽中把相应的值取出来就好了。
在语言中,这些对应的对象类型被称为“包装类”,与此相关的还有“装箱”与“拆箱”等等行为,这也是后续会涉及到的内容。
>
NOTE: 在ECMAScript 6之前由于`[PrimitiveValue]`来存放对应的封装类。也就是说,只有当`obj.[Class]`存放着`false`值时,它才是`false`值所对应的对象实例。
>
而ECMAScript 6将上述的依赖项变成了一个也就是说只要有一个对象有内部槽`[[BooleanData]]`那么它就是某个boolean值对应的对象。这样处理起来就简便了不必每次做两项判断。
所以一种关于“原始值”的简单解释是所有5种能放入私有槽亦即是说它们有相应的包装类的值Values都是原始值并且再加上两个特殊值undefined和null那么就是所谓原始值Primitive values的完整集合了。
接下来,如果转换过程发生在“值与值”之间呢?
## 干掉那两个碍事儿的
bigint这个类型最好说它跟number在语言特性上是一回事儿所以它的转换没什么特殊性下面我会在讲到number的时候一并讲解。
除此之外,还有两个类型在与其他类型的转换中是简单而特殊的。
例如,**symbol**这个值类型,它其实既没有办法转换成别的类型,也没有办法从别的类型转换过来。无论是哪种方式转换,它在语义上都是丢失了的、是没有意义的。当然,现实中你也可以这么用,比如用`console.log()`来将一个符号显示出来,这在控制台里面,是有显示信息输出的。
```
&gt; console.log(Symbol())
Symbol()
```
这里的确发生了一个“symbol -&gt; string”的转换。但它的结果只能表示这是一个符号至于是哪个符号符号a还是符号b全都分不出来。类似于此所有“符号 -&gt; 其他值类型”的转换不需要太特别的讨论,由于所有能发生的转换都是定值,所以你可以做一张表格出来对照参考即可。当然,如果是“其他值类型 -&gt; symbol”的这种转换实际结果就是创建一个新符号而没有“转换”的语义了。
另外一个碍事儿的也特别简单,就是**boolean**。
ECMAScript为了兼容旧版本的JavaScript直接将这个转换定义成了一张表格这个表格在ECMAScript规范或者我们常用的[MDN](https://developer.mozilla.org/)Mozilla Developer Network上可以直接查到。简单地说就是除了undefined、null、0、NaN、""empty string以及BigInt中的0n返回false之外其他的值转换为boolean时都将是true值。
当然不管怎么说要想记住这些类型转换并不容易当然也不难简单的做法就是直接把它们的包装类当作函数来调用转换一下就好了。在你的代码中也可以这么写例如使用“String(x)”就是将x转换成string类型又或者“Boolean(x)”就是将x转换为true/false值。
```
&gt; x = 100n; // `bigint` value
&gt; String(x) // to `string` value
'100n'
&gt; Boolean(x); // to `boolean` value
true
```
这些操作简单易行,也不容易出错,用在代码中还不影响效率,一切都很好。
>
NOTE: 这些可以直接作为函数调用的包装类一共有四个包括String()、Number()、Boolean()和BigInt()。此外Symbol()在形式上与此相同,但执行语义是略有区别的。
但并不那么简单。因为我还没有跟你讨论过字符串和数字值的转换。
以及,还有特别要命的“隐式转换”。
## 隐式转换
由于函数的参数没有类型声明所以用户代码可以传入任何类型的值。对于JavaScript核心库中的一些方法或操作来说这表明它们需要一种统一、一致的方法来处理这种类型差异。例如说要么拒绝“类型不太正确的参数”抛出异常要么用一种方式来使这些参数“变得正确”。
后一种方法就是“隐式转换”。但是就这两种方法的选择来说JavaScript并没有编码风格层面上的约定。基本上早期JavaScript以既有实现为核心的时候倾向于让引擎吞掉类型异常TypeError尽量采用隐式转换来让程序在无异常的情况下运行而后期以ECMAScript规范为主导的时候则倾向于抛出这些异常让用户代码有机会处理类型问题。
隐式转换最主要的问题就是会带来大量的“潜规则”。
例如经典的`String.prototype.search(r)`方法,其中的参数从最初设计时就支持在`r`参数中传入一个字符串,并且将隐式地调用`r = new RegExp(r)`来产生最终被用来搜索的正则表达式。而`new RegExp(r)`这个运算中,由于`RegExp()`构造器又会隐式地将`r`从任何类型转换为字符串类型,因而在这整个过程中,向原始的`r`参数传入任何值都不会产生任何的异常。
例如,其实你写出下面这样的代码也是可以运行的:
```
&gt; &quot;aa1aa&quot;.search(1)
2
&gt; &quot;000false111&quot;.search(0 &gt; 5)
3
```
隐式转换导致的“潜规则”很大程度上增加了理解用户代码的难度也不利于引擎实现。因此ECMAScript在后期就倾向于抛弃这种做法多数的“新方法”在发现类型不匹配的时候都设计为显式地抛出类型错误。一个典型的结果就是在ECMAScript 3的时代TypeError这个词在规范中出现的次数是24次到了ECMAScript 5是114次而ECMAScript 6开始就暴增到419次。
因此,越是早期的特性,越是更多地采用了带有“潜规则”的隐式转换规则。然而很不幸的是,几乎所有的“运算符”,以及大多数常用的原型方法,都是“早期的特性”。
所以在类型转换方面JavaScript成了“潜规则”最多的语言之一。
## 好玩的
@graybernhardt 曾在2012年发布过一个[讲演](https://www.destroyallsoftware.com/talks/wat)A lightning talk by Gary Bernhardt from CodeMash 2012提到一个非常非常著名的案例来说明这个隐式转换以及它所带来的“潜规则”有多么的不可预测。这个经典的示例是
-`[]``{}`相加,会发生什么?
尝试一下这个case你会看到
```
&gt; [] + {}
'[object Object]'
&gt; {} + []
0
&gt; {} + {}
NaN
&gt; [] + []
''
```
嗯!四种情况居然没有一个是相同的!
不过有一点需要注意到的就是输出的结果总是会“收敛”到两种类型字符串或者数值。嗯“隐式转换”其实只是表面现象核心的问题是这种转换的结果总是倾向于“string/number”两种值类型。
这个,才是我们这一讲要讲“大问题”。
## 且听下回分解
到现在为止这一节课其实才开了个头也就是对“a + b”这个标题做了一个题解而已。这主要是因为在JavaScript中有关类型处理的背景信息太多、太复杂而且还处在不停的变化之中。许多稍早的信息与现在的应用环境中的现状或者你手边可备查的资料之间都存在着不可调和的矛盾冲突因此对这些东西加以梳理还原实在是大有必要的。这也就是为什么这一讲会说到现在仍然没有切入正题的原因。
当然,一部分原因也在于:这些絮絮叨叨的东西,也原本就是“正题”的一部分。比如说,你至少应该知道的内容包括:
- 语言中的引用类型和值类型以及ECMAScript中的原始值类型Primitive values之间存在区别
- 语言中的所谓“引用类型”与ECMAScript中的“引用规范类型”是完全不同的概念
- 所有值通过包装类转换成对象时,这个对象会具有一个内部槽,早期它统一称为`[[PrimitiveValue]]`而后来JavaScript为每种包装类创建了一个专属的
- 使用typeof(x)来检查x的数据类型在JavaScript代码中是常用而有效方法
- 原则上来说系统只处理boolean/string/number三种值类型bigint可以理解为number的特殊实现其中boolean与其他值类型的转换是按对照表来处理的。
总的来说类型在JavaScript中的显式转换是比较容易处理的而标题“a + b”其实包含了太多隐式转换的可能性因此尤其复杂。关于这些细节且听下回分解。
这一讲没有复习题。不过如果你愿意,可以把上面讲到的@graybernhardt 的四个示例尝试一下,解释一下它们为什么是这个结果。
而下一讲,我再来为你公布答案,并且做详细解说。

View File

@@ -0,0 +1,340 @@
<audio id="audio" title="19 | a + b动态类型是灾难之源还是最好的特性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2c/f3/2cca3bf74ef3b0bec336e50c2b938ff3.mp3"></audio>
你好,我是周爱民。
上一讲,我们说到如何将复杂的类型转换缩减到两条简单的规则,以及两种主要类型。这两条简单规则是:
1. 从值x到引用调用Object(x)函数。
1. 从引用x到值调用x.valueOf()方法调用四种值类型的包装类函数例如Number(x)或者String(x)等等。
两种主要类型则是**字符串**和**数字值**。
当类型转换系统被缩减成这样之后,有些问题就变得好解释了,但也确实有些问题变得更加难解。例如@graybernhardt 在讲演中提出的灵魂发问,就是:
- 如果将数组跟对象相加,会发生什么?
如果你忘了,那么我们就一起来回顾一下这四个直击你灵魂深处的示例,简单地说,这些示例就是“数组与对象”相加的四种情况,结果都完全不同。
```
&gt; [] + {}
'[object Object]'
&gt; {} + []
0
&gt; {} + {}
NaN
&gt; [] + []
''
```
而这个问题也就是这两讲的标题中“a + b”这个表达式的由来。也就是说如何准确地解释“两个操作数相加”与如何全面理解JavaScript的类型系统的转换规则关系匪浅
## 集中精力办大事
一般来说运算符很容易知道操作数的类型例如“a - b”中的减号我们一看就知道意图是两个数值求差所以a和b都应该是数值又例如“obj.x”中的点号我们一看也知道是取**对象obj**的属性名**字符串x**。
当需要引擎“推断目的”时JavaScript设定推断结果必然是三种基础值boolean、number和string。由于其中的boolean是通过查表来进行的所以就只剩下了number和string类型需要“自动地、隐式地转换”。
但是在JavaScript中“加号+)”是一个非常特别的运算符。像上面那样简单的判断,在加号(+上面就不行因为它在JavaScript中既可能是字符串连结也可能是数值求和。另外还有一个与此相关的情况就是`object[x]`中的`x`其实也很难明确地说它是字符串还是数值。因为计算属性computed property的名字并不能确定是字符串还是数值尤其是现在它还可能是符号类型symbol
>
NOTE在讨论计算属性名computed property nameJavaScript将它作为预期为字符串的一个值来处理`r = ToPrimitive(x, String)`。但是这个转换的结果仍然可能是5种值类型之一因此在得到最终属性名的时候JavaScript还会再调用一次`ToString(r)`
由于“加号(+)”不能通过代码字面来判断意图,因此只能在运算过程中实时地检查操作数的类型。并且,这些类型检查都必须是基于“加号(+运算必然操作两个值数据”这个假设来进行。于是JavaScript会先调用`ToPrimitive()`内部操作来分别得到“a和b两个操作数”可能的原始值类型。
所以,问题就又回到了在上面讲的`Value vs. Primitive values`这个东西上面。对象到底会转换成什么?这个转换过程是如何决定的呢?
这个过程包括如下的四个步骤。
### 步骤一
首先JavaScript约定如果`x`原本就是原始值,那么`ToPrimitive(x)`这个操作直接就返回`x`本身。这个很好理解,因为它不需要转换。也就是说(如下代码是不能直接执行的):
```
# 1. 如果x是非对象则返回x
&gt; _ToPrimitive(5)
5
```
### 步骤二
接下来的约定是:如果`x`是一个对象,且它有对应的五种`PrimitiveValue`内部槽之一,那么就直接返回这个内部槽中的原始值。由于这些对象的`valueOf()`就可以达成这个目的,因此这种情况下也就是直接调用该方法(步骤三)。相当于如下代码:
```
# 2. 如果x是对象则尝试得到由x.valueOf()返回的原始值
&gt; Object(5).valueOf()
5
```
但是在处理这个约定的时候JavaScript有一项特别的设定就是对“引擎推断目的”这一行为做一个预设。如果某个运算没有预设目的而JavaScript也不能推断目的那么JavaScript就会强制将这个预设为“number”并进入“传统的”类型转换逻辑步骤四
所以,简单地说(**这是一个非常重要的结论**
如果一个运算无法确定类型那么在类型转换前它的运算数将被预设为number。
>
<p>NOTE1预设类型在ECMAScript称为PreferredType它可以为undefined或"default"。但是“default”值是“传统的”类型转换逻辑所不能处理的这种情况下JavaScript会先将它重置为“number”。也就是说在传统的转换模式中“number”是优先的。<br>
NOTE2事实上只有对象的符号属性Symbol.toPrimitive所设置的函数才会被要求处理“default”这个预设。这也是在Proxy/Reflect中并没有与类型转换相关的陷阱或方法的原因。</p>
于是,这里会发生两种情况,也就是接下来的步骤三和步骤四。
### 步骤三:作为原始值处理
如果是上述的五种包装类的对象实例(它们有五种`PrimitiveValue`内部槽之一),那么它们的`valueOf()`方法总是会忽略掉“number”这样的预设并返回它们内部确定即内部槽中所保留的的原始值。
所以,如果我们为符号创建一个它的包装类对象实例,那么也可以在这种情况下解出它的值。例如:
```
&gt; x = Symbol()
&gt; obj = Object(x)
&gt; obj.valueOf() === x
true
```
正是因为对象(如果它是原始值的包装类)中的原始值总是被解出来,所以,你要将数字值`5`转换成两个对象类型并且再将这两个对象相加那么其结果也会是数值10。
```
&gt; Object(5) + Object(5)
10
```
这个代码看起来是两个对象“相加”,但是却等效于它们的原始值直接相加。
但是如果考虑“对象属性存取”这样的例子情况就发生了变化,由于“对象属性存取”是一个“有预期”的运算——它的预期是“字符串”,因此会有第二种情况——步骤四。
### 步骤四:进入“传统的类型转换逻辑”
这需要利用到对象的`valueOf()``toString()`方法当预期是“number”时`valueOf()`方法优先调用;否则就以`toString()`为优先。并且,重要的是,上面的预期只决定了上述的优先级,而当调用优先方法仍然得不到非对象值时,还会顺序调用另一方法。
这带来了一个结果如果用户代码试图得到“number”类型`x.valueOf()`返回的是一个对象,那么就还会调用`x.toString()`,并最终得到一个字符串。
到这里,就可以解释前面四种对象与数组相加所带来的特殊效果了。
## 解题1从对象到原始值
`a + b`的表达式中,`a``b`是对象类型时,由于“加号(+”运算符并不能判别两个操作数的预期类型因此它们被“优先地”假设为数字值number进行类型转换。这样一来无论是对象还是数组它们的.valueOf()方法调用的结果都将得到它们本身。如果用typeof()看一下,结果还仍然是`object`类型。接下来,由于这个调用.valueOf()方法的结果不是值类型,所以就会再尝试一下调用.toString()这个方法。
```
# 在预期是'number'时,先调用`valueOf()`方法,但得到的结果仍然是对象类型;
&gt; [typeof ([].valueOf()), typeof ({}.valueOf())]
[ 'object', 'object' ]
# 由于上述的结果是对象类型(而非值),于是再尝试`toString()`方法来得到字符串
&gt; [[].toString(), {}.toString()]
[ '' '[object Object]' ]
```
在这里,我们就会看到有一点点差异了。空数组转换出来,是一个空字符串,而对象的转换成字符串时是’[object Object]’。
所以接下来的四种运算变成了下面这个样子,它们其实是对字符串相加,也就是字符串连接的结果。
```
# [] + {}
&gt; '' + '[object Object]'
'[object Object]'
# {} + []
&gt; ???
0
# {} + {}
&gt; ???
NaN
# [] + []
&gt; '' + ''
''
```
好的,你应该已经注意到了,在第二和第三种转换的时候我打了三个问号“???”。因为如果按照上面的转换过程它们无非是字符串拼接但结果它们却是两个数字值分别是0还有NaN。
怎么会这样?!!
## 解题2“加号+)”运算的戏分很多
现在看看这两个表达式。
```
{} + []
{} + {}
```
你有没有一点熟悉感?嗯,很不幸,它们的左侧是一对大括号,而当它们作为语句执行的时候,会被优先解析成——块语句!并且大括号作为结尾的时候,是可以省略掉语句结束符“分号(;)”的。
所以你碰到了JavaScript语言设计历史中最大的一块铁板就是所谓“自动分号插入ASI”。这个东西的细节我这里就不讲了但它的结果是什么呢上面的代码变成下面这个样子
- `{}; +[]`
- `{}; +{}`
实在是不幸啊!这样的代码仍然是可以通过语法解析,并且仍然是可以进行表达式计算求值的!
于是后续的结论就比较显而易见了。
由于“+”号同时也是“正值运算符”,并且它很明显可以准确地预期后续操作数是一个数值,所以它并不需要调用`ToPrimitive()`内部操作来得到原始值而是直接使用“ToNumber(x)”来尝试将`x`转换为数字值。而上面也讲到“将对象转换为数字值等效于使用它的包装类来转换也就是Number(x)”。所以,上述两种运算的结果就变成了下面的样子:
```
# +[] 将等义于
&gt; + Number([])
0
# +{} 将等义于
&gt; + Number({})
NaN
```
## 解题3预期 vs. 非预期
但是你可能会注意到:当使用“… + {}”时,`ToPrimitive()`转换出来的,是字符串“[object Object]”;而在使用“+ {}”时,`ToNumber(x)`转换出来的却是值NaN。所以在不同的预期下面“对象-&gt;值”转换的结果却并不相同。
这之间有什么规律吗?
我们得先理解哪些情况下JavaScript是不能确定用户代码的预期的。总结起来这其实很有限包括
1. “加号(+)”运算中,不能确定左、右操作数的类型;
1. “等值(==”运算中不能确定左、右操作数的类型JavaScript认为如果左、右操作数之一为string、number、bigint和symbol四种基础类型之一而另一个操作数是对象类型(x)那么就需要将对象类型“转换成基础类型ToPrimitive(x)”来进行比较。操作数将尽量转换为数字来进行比较即最终结果将等效于Number(x) == Number(y)。)
1. “new Date(x)”中如果x是一个非Date()实例的对象那么将尝试把x转换为基础类型x1如果x1是字符串尝试从字符串中parser出日期值否则尝试x2 = Number(x1)如果能得到有效的数字值则用x2来创建日期对象。
1. 同样是在Date()的处理中相对于缺省时优先number类型来说JavaScript内部调整了Date在转换为值类型时的预期。一个Date类型的对象(x)转换为值时将优先将它视为字符串也就是先调用x.toString()之后再调用x.valueOf()。
其他情况下JavaScript不会为用户代码调整或假设预期值。这也就是说按照ECMAScript内部的逻辑与处理过程其他的运算运算符或其他内置操作对于“对象x”都是有目标类型明确的、流程确定的方法来转换为“值类型的值”的。
## 其他
### 显式的 vs. 隐式的转换
很大程度上来说显式的转换其实只决定了“转换的预期”而它内部的转换过程仍然是需要“隐式转换过程”来参与的。例如你调用Number()函数来转换对象`x`
```
&gt; x = new Object
&gt; Number(x)
NaN
```
对于这样的一个显式转换Number()只决定它预期的目标是number类型并最终将调用`ToPrimitive(x, 'Number')`来得到结果。然而一如之前所说的ToPrimitive()会接受任何一个“原始值”作为结果`x1`返回并且要留意的是在这里null值也是原始值因此它并不保证结果符合预期`'number'`
所以最终Number()还会再调用一次转换过程,尝试将`x1`转换为数字。
### 字符串在“+”号中的优先权
另一方面,在“+”号运算中,由于可能的运算包括数据和字符串,所以按照隐式转换规则,在不确定的情况下,优先将运算数作为数字处理。那么就是默认“+”号是做求和运算的。
但是,在实际使用中,结果往往会是字符串值。
这是因为字符串在“+”号运算中还有另一层面的优先级,这是由“+”号运算符自已决定的,因而并不是类型转换中的普遍规则。
“+”号运算符约定,对于它的两个操作数,在通过`ToPrimitive()`得到两个相应的原始值之后,二者之任一是字符串的话,就优先进行字符串连接操作。也就是说,这种情况下另一个操作数会发生一次“值-&gt;值”的转换,并最终连接两个字符串以作为结果返回。
那么我们怎么理解这个行为呢比如说如果对象x转换成数字和字符串的效果如下
```
x = {
valueOf() { console.log('Call valueOf'); return Symbol() },
toString() { console.log('Call toString'); return 'abc' }
}
```
我声明了一个对象x它带有两个定制的toString()和valueOf()方法用来观察类型转换的过程并且其中valueOf()会返回一个symbol符号也就是说它是“值类型”但既不是字符串也不是数字值。
接下来我们尝试用它跟一个任意值做“+”号运算,例如:
```
# 例1与非字符串做“+”运算时
&gt; true + x
Call valueOf
TypeError: Cannot convert a Symbol value to a number
```
“+”号运算在处理这种情况(用对象与非字符串值做加号运算)时,会先调用`x`的valueOf()方法,然后由于“+”号的两个操作数都不是字符串,所以将再次尝试将它们转换成数字并求和。又例如:
```
# 例2与字符串做“+”运算时
&gt; 'OK, ' + x
Call valueOf
TypeError: Cannot convert a Symbol value to a string
```
这种情况下,由于存在一个字符串操作数,因此“字符串连接”运算被优先,于是会尝试将`x`转换为字符串。
然而需要注意的是上述两个操作中都并没有调用x.toString()而“都仅仅是”在ToPrimitive()内部操作中调用了x.valueOf()。也就是说,在检测操作数的值类型“是否是字符串”之后,再次进行的“值-&gt;值”的转换操作是基于ToPrimitive()的结果,而非原对象`x`的。
这也是之前在“解题3”中特别讲述Date()对象这一特例的原因。因为Date()在“调用ToPrimitive()”这个阶段的处理顺序是反的所以它会先调用x.toString从而产生不一样的效果。例如
```
// 创建MyDate类覆盖valueOf()和toString()方法
class MyDate extends Date {
valueOf() { console.log('Call valueOf'); return Symbol() }
toString() { console.log('Call toString'); return 'abc' }
}
```
测试如下:
```
# 示例
&gt; x = new MyDate;
# 与非字符串做“+”运算时
&gt; true + x
Call toString
trueabc
# 与非字符串做“+”运算时
&gt; 'OK, ' + x
Call toString
OK, abc
```
那么对于Date()这个类来说,这又是如何做到的呢?
### Symbol.toPrimitive的处理
简单地说Date类重写了原型对象Date.prototype上的符号属性`Symbol.toPrimitive`。任何情况下,如果用户代码重写了对象的`Symbol.toPrimitive`符号属性,那么`ToPrimitive()`这个转换过程就将由用户代码负责,而原有的顺序与规则就失效了。
我们知道,由于调用`ToPrimitive(hint)`时的入口参数hint可能为`default/string/number`这三种值之一而它要求返回的只是“值类型”结果也就是说结果可以是所有5种值类型之任一。因此用户代码对`ToPrimitive(hint)`的重写可以“参考”这个hint值也可以无视之也可以在许可范围内返回任何一种值。
简单地说,它就是一个超强版的`valueOf()`
事实上,一旦用户代码声明了符号属性`Symbol.toPrimitive`那么valueOf()就失效了ECMAScript采用这个方式“一举”摧毁了原有的隐式转换的全部逻辑。这样一来包括预期的顺序与重置以及toString和valueOf调用等等都“不复存焉”。
一切重归于零:定制`Symbol.toPrimitive`,返回`值类型`;否则抛出异常。
>
NOTEDate()类中仍然是会调用toString或valueOf的这是因为在它的`Symbol.toPrimitive`实现中仅是调整了两个方法的调用顺序,而之后仍然是调用原始的、内置的`ToPrimitive()`方法的。对于用户代码来说,可以自行决定该符号属性(方法)的调用结果,无需依赖`ToPrimitive()`方法。
## 结语与思考
今天我们更深入地讲述了类型转换的诸多细节,除了这一讲的简单题解之外,对于“+”号运算也做了一些补充。
总地来讲我们是在讨论JavaScript语言所谓“动态类型”的部分但是动态类型并不仅限于此。也就是说JavaScript中并不仅仅是“类型转换”表现出来动态类型的特性。例如一个更简单的问题
“x === x”在哪些情况下不为true
这原本是这两讲的另一个备选的标题,它也是讨论动态类型问题的。只不过这个问题所涉及的范围太窄,并不适合展开到这两讲所涵盖的内容,因此被弃用了。这里把它作为一个小小的思考题留给你,你可以试着找找答案。
>
<p>NOTE1我可以告诉你答案不只一个例如“x是NaN”。^^.<br>
NOTE2“x是NaN”这样的答案与动态类型或动态语言这个体系没什么关系所以它不是我在这里想与你讨论的主要话题。</p>
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。

View File

@@ -0,0 +1,312 @@
<audio id="audio" title="20 | (0, eval)("x = 100") 一行让严格模式形同虚设的破坏性设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/e2/4bd2728e9a497f56e8e9cee8e3e2fae2.mp3"></audio>
你好,我是周爱民。
今天我们讨论动态执行。与最初的预告不同 我在这一讲里把原来的第20讲合并掉了变成了20~21的两讲合讲但也分成了上、下两节。所以其实只是课程的标题少了一个内容却没有变。
**动态执行**是JavaScript最早实现的特性之一eval()这个函数是从JavaScript 1.0就开始内置了的。并且最早的setTimeout()和setInterval()也内置了动态执行的特性它们的第1个参数只允许传入一个字符串这个字符串将作为代码体动态地定时执行。
>
NOTEsetTimeout/setInterval执行字符串的特性如今仍然保留在大多数浏览器环境中例如Safari或Mozilla但这在Node.js/Chrome环境中并不被允许。需要留意的是setTimeout/setInterval并不是ECMAScript规范的一部分。
关于这一点并不难理解因为JavaScript本来就是脚本语言它最早也是被作为脚本语言设计出来的。因此把“装载脚本+执行”这样的核心过程,通过一个函数暴露出来成为基础特性既是举手之劳,也是必然之举。
然而这个特性从最开始就过度灵活以至于后来许多新特性在设计中颇为掣肘所以在ECMAScript 5的严格模式出现之后它的特性受到了很多的限制。
接下来我将帮助你揭开重重迷雾让你得见最真实的“eval()”。
## eval执行什么
最基本的、也是最重要的问题是eval究竟是在执行什么
在代码`eval(x)`中,`x`必须是一个字符串不能是其他任何类型的值也不能是一个字符串对象。如果尝试在x中传入其他的值那么eval()将直接以该值为返回值,例如:
```
# 值1
&gt; eval(null)
null
# 值2
&gt; eval(false)
false
# 字符串对象
&gt; eval(Object('1234'))
[String: '1234']
# 字符串值
&gt; eval(Object('1234').toString())
1234
```
这里eval()会按照JavaScript语法规则来尝试解析字符串x包括对一些特殊字面量例如8进制的语法解析。这样的解析会与parseInt()或Number()函数实现的类型转换有所不同例如对8进制的解析在eval()的代码中就可以使用012来表示十进制的10。而使用parseInt()或Number()函数就不支持8进制会忽略前缀字符0得到十进制的12。
```
# JavaScript在源代码层面支持8进制
&gt; eval('012')
10
# 但parseInt()不支持8进制除非显式指定radix参数
&gt; parseInt('012')
12
# Number()也不支持8进制
&gt; Number('012')
12
```
另外eval()会将参数`x`强制理解为语句行,这样一来,当按照“语句-&gt;表达式”的顺序解析时,“{ }”将被优先理解为语句中的大括号。于是下面的代码就成了JavaScript初学者的经典噩梦也就是“尝试将一个对象字面量的字符串作为代码文本执行”所导致的问题。
```
# 试图返回一个对象
&gt; eval('{abc: 1}')
1
```
在这种情况下由于第一个字符被理解为块语句那么“abc:”就将被解析成标签语句;接下来,"1"会成为一个“单值表达式语句”。所以结果是返回了这个表达式的值也就是1而不是一个字面量声明的对象。
>
NOTE这一个示例就是原来用作第20讲的标题的一行代码。只不过在实际写的时候发现能展开讲的内容太少所以做了一下合并。)
## eval在哪儿执行
eval总是将代码执行在当前上下文的“当前位置”。这里的所谓的“当前上下文”并不是它字面意思中的“代码文本上下文”而是指“与执行环境相关的执行上下文”。
我在之前的文章中给你提到过与JavaScript的执行系统相关的两个组件环境和上下文。但我一直在尽力避免详细地讨论它们甚至在一些场合中将它们混为一谈。
然而在讨论eval()“执行的位置”的时候,这两个东西却必须厘清,因为严格地来讲,**环境**是JavaScript在语言系统中的静态组件而**上下文**是它在执行系统中的动态组件。
### 环境
怎么说呢?
JavaScript中环境可以细分为四种并由两个类别的基础环境组件构成。这四种环境是全局Global、函数Function、模块Module和Eval环境两个基础组件的类别分别是声明环境Declarative Environment和对象环境Object Environment
你也许会问:不对啊?我们常说的词法环境到哪里去了呢?不要着急,我们马上就会讲到它的。这里先继续说清楚上面的六个东西。
首先是两个类别,它们是所有其他环境的基础,是两种抽象级别最低的、基础的环境组件。**声明环境**就是名字表,可以是引擎内核用任何方式来实现的一个“名字-&gt;数据”的对照表;**对象环境**是JavaScript的一个对象用来“模拟/映射”成上述的对照表的一个结果,你也可以把它看成一个具体的实现。所以,
- 概念:所有的“环境”本质上只有一个功能,就是用来管理“名字-&gt;数据”的对照表;
- 应用“对象环境”只为全局环境的global对象`with (obj)...`语句中的`对象obj`创建,其他情况下创建的环境,都必然是“声明环境”。
所以所谓四种环境其实是上述的两种基础组件进一步应用的结果。其中全局Global环境是一个复合环境它由一对“对象环境 + 声明环境”组成其他3种环境都是一个单独的声明环境。
你需要关注到的一个事实是:所有的四种环境都与执行相关——看起来它们“像是”为每种可执行的东西都创建了一个环境,但是它们事实上都不是可以执行的东西,也不是执行系统(执行引擎)所理解的东西。更加准确地说:
上述四种环境本质上只是为JavaScript中的每一个“可以执行的语法块”创建了一个名字表的影射而已。
### 执行上下文
JavaScript的执行系统由一个执行栈和一个执行队列构成这在之前也讲过。关于它们的应用原理你可以回顾一下[第6讲](https://time.geekbang.org/column/article/168980)`x: break x`),以及[第10讲](https://time.geekbang.org/column/article/174314)`x = yield x`)中的内容。
在执行队列中保存的是待执行的任务称为Job。这是一个抽象概念它指明在“创建”这个执行任务时的一些关联信息以便正式“执行”时可以参考它而“正式的执行”发生在将一个新的上下文被“推入push”执行栈的时候。
所以,上下文是一个任务“执行/不执行”的关键。如果一个任务只是任务并没有执行那么也就没有它的上下文如果一个上下文从栈中撤出那么就必须有地方能够保存这个上下文否则可执行的信息就丢失了这种情况并不常见如果一个新上下文被“推入push”栈那么旧的上下文就被挂起并压向栈底如果当前活动上下文被“弹出pop”栈那么处在栈底的旧上下文就被恢复了。
>
NOTE很少需要在用户代码在它的执行过程中撤出和保存上下文的过程但这的确存在。比如生成器GeneratorContext或者异步调用AsyncContext
而每一个上下文只关心两个高度抽象的信息:其一是执行点(包括状态和位置),其二是执行时的参考,也就是前面一再说到的“名字的对照表”。
所以重要的是每一个执行上下文都需要关联到一个对照表。这个对照表就称为“词法环境Lexical Environment”。显然它可以是上述四种环境之任一并且更加重要的也可是两种基础组件之任一
如上是一般性质的执行引擎逻辑,对于大多数“通用的”执行环境来说,这是足够的。
但对于JavaScript来说这还不够因为JavaScript的早期有一个“能够超越词法环境”的东西存在就是“var变量”。所谓词法环境就是一个能够表示标识符在源代码词法中的位置的环境由于源代码分块所以词法环境就可以用“链式访问”来映射“块之间的层级关系”。但是“var变量”突破了这个设计限制例如我们常常说到的变量提升也就是在一个变量赋值前就能访问它又例如所有在同一个全局或函数内部的`var x`其实都是同一个,而无论它隔了多少层的块级作用域。于是你可以写出这样一个示例来:
```
var x = 1;
if (true) {
var x = 2;
with (new Object) {
var x = 3;
}
}
```
这个示例中,无论你把`var x`声明在if语句后面的块中还是with语句后面的块中“1、2、3”所在的“var变量”`x`,都突破了它们所在的词法作用域(或对应的词法环境),而指向全局的`x`
于是自ECMAScript 5开始约定ECMAScript的执行上下文将有两个环境一个称为词法环境另一个就称为变量环境Variable Environment所有传统风格的“var声明和函数声明”将通过“变量环境”来管理。
这个管理只是“概念层面”的,实际用起来,并不是这么回事。
### 管理
为什么呢?
如果你仔细读了ECMAScript你会发现所谓的全局上下文例如Global Context中的两个环境其实都指向同一个也就是
```
#(如下示例不可执行)
&gt; globalCtx.LexicalEnvironment === global
true
&gt; globalCtx.VariableEnvironment === global
true
```
这就是在实现中的取巧之处了。
对于JavaScript来说由于全局的特性就是“var变量”和“词法变量”共用一个名字表因此你声明了“var变量”那么就不能声明“同名的let/const变量”。例如
```
&gt; var x = 100
&gt; let x = 200
SyntaxError: Identifier 'x' has already been declared
```
所以,事实上它们“的确就是”同一个环境。
而具体到“var变量”本身在传统中JavaScript中只有函数和全局能够“保存var声明的变量”而在ECMAScript 6之后模块全局也是可以保存“var声明的变量”的。因此事实上也就只有它们的“变量环境VariableEnvironment”是有意义的然而即使如此也就是说即使从原理上来说它们都是“有用的”它们仍然是指向同一个环境组件的。也就是说之前的逻辑仍然是成立的
```
#(如下示例不可执行)
&gt; functionCtx.LexicalEnvironment === functionCtx.VariableEnvironment
true
&gt; moduleCtx.LexicalEnvironment === moduleCtx.VariableEnvironment
true
```
那么非得要“分别地”声明这两个组件又有什么用呢答案是对于eval()来说,它的“词法环境”与“变量环境”存在着其他的可能性!
### 不用于执行的环境
环境在本质上是“作用域的映射”。作用域如果不需要被上下文管理,那么它(所对应的环境)也就不需要关联到上下文。
在早期的JavaScript中作用域与执行环境是一对一的所以也就常常混用而到了ECMAScript 5之后有一些作用域并没有对应用执行环境所有就分开了。在ECMAScript 5之后ECMAScript规范中就很少使用“作用域Scope”这个名词转而使用“环境”这个概念来替代它。
哪些东西的作用域不需要关联到上下文呢?其实,一般的块级作用域都是这样的。例如一般的块级作用域:
```
// 对象闭包
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变量”和“动态作用域”的效果例如
```
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不影响它所在函数或其他块级作用域的原因例如
```
function foo() {
var x = 100;
eval('let x = 200; console.log(x);'); // 200
console.log(x); // 100
}
foo();
```
而同样的示例由于“变量环境”指向它在“当前上下文也就是foo函数的函数执行上下文”的变量环境也就是
```
#(如下示例不可执行)
&gt; evalCtx.VariableEnvironment === fooCtx.VariableEnvironment
true
&gt; fooCtx.VariableEnvironment === fooCtx.LexicalEnvironment
true
&gt; evalCtx.VariableEnvironment = evalCtx.LexicalEnvironment
false
```
所以当eval中执行代码“var x = …”时,就可以通过`evalCtx.VariableEnvironment`来访问到`fooCtx.VariableEnvironment`了。例如:
```
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的核心原理而不是重复那些你可能已经知道的知识。
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。

View File

@@ -0,0 +1,398 @@
<audio id="audio" title="21 | (0, eval)("x = 100") 一行让严格模式形同虚设的破坏性设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/af/543b545017e1234aeecc0291d7d910af.mp3"></audio>
你好,我是周爱民。欢迎回到我的专栏。书接上回,这一讲我们仍然讲动态执行。
之前我说到过setTimeout和setInterval的第一个参数可以使用字符串那么如果这个参数使用字符串的话代码将会在哪里执行呢毕竟当定时器被触发的时候程序的执行流程“很可能”已经离开了当前的上下文环境而切换到未知的地方去了。
所以的确如你所猜测的那样如果采用这种方式来执行代码那么代码片断将在全局环境中执行。并且这也是后来这一功能被部分限制了的原因例如你在某些版本的Firefox中这样做那么你可能会得到如下的错误提示
```
&gt; setTimeout('alert(&quot;HI&quot;)', 1000)
Content Security Policy: The pages settings blocked the loading of a resource at eval (“script-src”).
```
在全局环境中执行代码所带来的问题远远不止于此,接下来,我们就从这个问题开始谈起。
## 在全局环境中的eval
早期的JavaScript是应用于浏览器环境中的因此当网页中使用`&lt;SCRIPT&gt;`标签加载.js文件时候代码就会在浏览器的全局环境中执行。但这个过程是同步的将BLOCK掉整个网页的装载进度因此有了`defer`这个属性来指示代码异步加载将这个加载过程延迟到网页初始化结束之后。不过即使如此JavaScript代码仍然是执行在全局环境中的。
在那个时代,`&lt;SCRIPT&gt;`标签还支持`for``event`属性用于指定将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对象以及任何一个属性名字。例如
```
var obj = { do: eval };
obj.do('alert(&quot;HI&quot;)');
```
## 名字之争
现在“名字”成了一个问题在任何地方、任何位置任何对象以及任何函数的上下文中都能“以不同的名字”来eval()一段代码文本。
这太不友好了!这意味着我们永远无法有效地判断、检测和优化用户代码。一方面,这对于程序员来说是灾难,另一方面,对引擎的实现者来说也非常绝望。
于是从ECMAScript 6开始ECMAScript规定了“标准而规范地使用eval()”的方法你仅仅只能直接使用一个字面文本为“eval”字符串的函数名字并且作为普通函数调用的形式来调用`eval()`,这样才算是“**直接调用的eval()**”。
这个约定是非常非常罕见的。JavaScript历史上几乎从未有过在规范中如此强调一个名字“在字面文本上的规范性”。在ECMAScript 5之后一共也只出现了两个这里的"eval"是一个,而另一个是严格模式(这个稍晚一点我们也会详细讲到)。
根据ECMAScript的约定下面的这些都不是“直接调用的eval()”:
```
// 对象属性
obj = { eval }
obj.eval(x)
// 更名的属性名或变量名(包括全局的或函数内局部的)
e = eval
var e = eval
e(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)
// (或)
(_=&gt;eval)()(x)
```
总之你所有能想到的一切——换个名字或者作为对象属性的方式来调用eval都不再作为“直接调用的eval()”来处理了。
那么你可能会想要知道怎样才算是“直接调用的eval()”,以及它有什么效果呢?
很简单的,在全局、模块、函数的任意位置,以及一个运行中的`eval(...)`的代码文本的任意位置上,你使用的
>
`eval(x)`
这样的代码都被称为“直接调用”。直接调用eval()意味着:
- 在代码所在位置上临时地创建一个“Eval环境”并在该环境中执行代码`x`
而反过来,其他任何将`eval()`调用起来,或者执行到`eval()`函数的方式,都称为“间接调用”。
而这两讲的标题中的写法就是一个经典的“间接调用eval”的写法
```
(0, eval)(x)
```
晚一点,我们会再来详细讲述这个“间接调用”,接下来我们先说说与它相关的一点基础知识,也就是“严格模式”。
>
NOTE之所以称为“经典的”写法是因为在ECMAScript规范的测试项目test262中所有间接调用相关的示例都是采用这种写法的。
## 严格模式是执行限制而不是环境属性
ECMAScript 5中推出的严格模式是一项重大的革新之举它静默无声地拉开了ECMAScript 6~ECMAScript 10这轰轰烈烈的时代序幕。
之所以说它是“静默无声的”是因为这项特性刚出来的时候大多数人并不知道它有什么用有什么益处以及为什么要设计成这个样子。所以它几乎算是一个被“强迫使用”的特性对你的团队来说是这样对整个的JavaScript生态来说也是如此。
但是“严格模式”确实是一个好东西,没有它,后来的众多新特征就无法形成定论,它奠定了一个稳定的、有效的、多方一致的语言特性基础,几乎被所有的引擎开发厂商欢迎、接受和实现。
所以我们如今大多数新写的JavaScript代码其实都是在严格模式环境中运行的。
对吗?
不太对。上面这个结论对于大多数开发者来说是适用的并能理解和接受。但是要是你在ECMAScript规范层面或者在JavaScript引擎层面来看这句话你会发现“严格模式环境”是什么鬼我们从来没见过这个东西
是的所谓“严格模式”其实从来都不是一种环境模式或者说没有一个环境是具有“严格模式”这样的属性的。所有的执行环境——所有在执行引擎层面使用的“执行上下文ExecuteContext以及它们所引用的“环境Environment都没有“严格模式”这样的模式也没有这样的性质。
我们所有的代码都工作在非严格模式中,而“严格模式”不过是代码执行过程中的一个限制。更确切地说,即使你用如下命令行:
```
&gt; node --use-strict
```
来启动Node.js也仍然是运行在一个JavaScript的“非严格模式”环境中的是的是的我知道你可以立即写出来一行代码来反驳上述观点
```
# 在上例启动的Node.js环境中测试
&gt; arguments = 1
SyntaxError: Unexpected eval or arguments in strict mode
```
但是请相信我:上面的示例只是一个执行限制,你绝对是运行在一个“非严格模式”环境中的!
因为所有的四种执行环境包括Eval环境在它们创建和初始化时都并没有“严格模式”这样的性质。并且在全局环境初始化之前在宿主环境中初始化引擎时引擎也根本不知道所谓“严格模式”的存在。严格模式这个特性是在环境创建完之后在执行代码之前从源代码文本中获取的性质例如
```
// (JavaScript引擎的初始化过程
// 初始化全局in InitializeHostDefinedRealm()
CALL SetRealmGlobalObject(realm, global, thisValue)
-&gt; 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中是如下的一段规范
```
...
- 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所有的“间接调用”的代码将默认执行在“非严格模式”中。
也就是说,间接调用将突破引擎对严格模式的任何设置,你总是拥有一个“全局的非严格模式”并在其中执行代码。例如:
```
# (控制台)
&gt; node --use-strict
# Node.js环境, 严格模式的全局环境)
&gt; arguments = 1
SyntaxError: Unexpected eval or arguments in strict mode
&gt; 012
SyntaxError: Octal literals are not allowed in strict mode.
# 间接调用例1
&gt; (0, eval)('arguments = 1') // accept!
&gt; arguments
1
# 间接调用例2
&gt; (0, eval)('012') // accept!
10
# 间接调用例3本讲的标题代码将创建变量x
&gt; (0, eval)('x = 100') // accept!
&gt; x
100
```
## 为什么标题中的代码是严格模式
最后一个疑问,就是为什么“标题中的这种写法”会是一种间接调用。并且,更有对比性地来看,如果是下面这种写法,为什么就“不再是”间接调用了呢?例如
```
# 直接调用
&gt; (eval)('x = 100')
ReferenceError: x is not defined
at eval (eval at ...)
# 间接调用
&gt; (0, eval)('x = 100')
100
```
在JavaScript中表达式的返回结果Result可能是值也可能是“引用规范类型”。在“引用”的情况中有两个例子是比较常见、却又常常被忽略的包括
```
# 属性存取返回的是引用
&gt; obj.x
# 变量的标识符(作为单值表达式)是引用
&gt; x
```
我们之前的课程中说过所有这种“引用规范类型”类型的结果Result在作为左手端的时候它是引用而作为右手端的时候它是值。所以才会有“x = x”这一个表达式的完整语义
- 将右手端x的值赋给左手端的x的引用。
好了然而还存在一个运算符它可以“原样返回”之前运算的结果Result这就是“分组运算符()”。因为这个运算符有这样的特性所以当它作用于属性存取和一般标识符时分组运算返回的也仍然是后者的“运算结果Result”。例如
```
# “结果Result”是`100`的值
&gt; (100)
# “结果Result”是`{}`对象字面量(值)
&gt; ({})
# “结果Result”是`x`的引用
&gt; (x)
# “结果Result”是`obj.x`的引用
&gt; (obj.x)
```
所以,从“引用”的角度上来看,`(eval)``eval`的效果也就完全一致,它们都是`global.eval`在“当前上下文环境”中的一个引用。但是我们接下来看,我们在这一讲的标题中写的这个分组表达式是这样的:
```
(0, eval)
```
这意味着在分组表达式内部还有一个运算称为“连续运算逗号运算符”。连续运算的效果是“计算每一个表达式并返回最后一个表达式的值Value”。注意这里不是“结果Result”。所以它相当于执行了
```
(GetValue(0), GetValue(eval))
```
因此最后一个运算将使结果从“Result-&gt;Value”于是“引用的信息”丢失了。在它外层也就是其后的分组运算得到的、并继续返回的结果就是“GetValue(eval)”了。这样一来,在用户代码中的`(eval)(x)`还是直接调用“eval的引用”`(0, eval)(x)`就已经变成间接调用“eval的值”了。
讲到这里,你可能已经意识到:关键在于`eval`是一个引用还是一个值是的的确如此不过在ECMAScript规范中一个“eval的直接调用”除了必须是一个“引用”之外还有一个附加条件它还必须是一个环境引用
也就是说,属性引用的`eval`仍然是算着间接调用的。例如:
```
# (控制台,直接进入全局的严格模式)
&gt; node --use-strict
# 测试用的代码in Node.js
&gt; var x = 'arguments = 1'; // try source-text
# 作为对象属性
&gt; var obj = {eval};
# 间接调用:这里的确是一个引用,并且名字是字符串文本&quot;eval&quot;,但它是属性引用
&gt; (obj.eval)(x)
1
# 直接调用eval是当前环境中的一个名字引用标识符
&gt; eval(x)
SyntaxError: Unexpected eval or arguments in strict mode
# 直接调用:同上(分组运算符保留了引用的性质)
&gt; (eval)(x)
SyntaxError: Unexpected eval or arguments in strict mode
```
所以无论如何只要这个函数的名字是“eval”并且是“global.eval这个函数在当前环境中的引用”那么它就可以得到豁免成为传统意义上的“直接调用”。例如
```
// (一些豁免的案例,如下是直接调用)
// 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)”。例如:
```
# 在代码文本中直接创建了一个`eval`函数的“引用(规范类型)”
&gt; obj = { foo() { return this === obj } }
# this.foo调用中未丢失`this`这个引用
&gt; obj.foo()
true
# 同上,分组表达式传回引用,所以`this`未丢失
&gt; (obj.foo)()
true
# eval将返回值所以`this`引用丢失了
&gt; eval('obj.foo')()
false
```
## 结语
今天这一讲结束了对标题中代码的全部分析。由于标题中的代码是一个“间接调用的eval”因此它总是运行在一个非严格模式的全局中于是变量`x`也就总是可以被创建或重写。
“间接调用IndriectCall”是JavaScript非常非常少见的一种函数调用性质它与“SuperCall”可以合并起来视为JavaScript中执行系统中的“两大顶级疑难”。对间接调用的详细分析涉及执行引擎的工作原理、环境和环境组件的使用、严格模式、引用规范类型的特殊性以及最为特殊的“eval是作为特殊名字来识别的”等等多个方面的基础特性。
间接调用对“严格模式”并非是一种传统意义上的“破坏”只是它的工作机制正正好地绕过了严格模式。因为严格模式并不是环境的性质而是代码文本层面的执行限制所以当eval的间接调用需要使用全局时无法“得到并进入”这种模式而已。
最后间接调用其实是对传统的window.execScript或window.eval的一个保留。它有着在兼容性方面的实用意义但对系统的性能、安全性和可靠性都存在威胁。无论如何你应该限制它在代码中的使用。不过它的的确确是ECMAScript规范中严格声明和定义过的特性并且可称得上是“黑科技Hack skill”了。
## 思考题
今天有一个作业留给你思考,问题很简单:
- 请你尝试再找出一例豁免案例也就是直接调用eval()的写法。
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
今天的课程就到这里。下一讲,我们将讨论“动态函数”,这既是“动态语言”部分的最后一小节,也将是专栏的最后一讲。

View File

@@ -0,0 +1,270 @@
<audio id="audio" title="22 | new Function('x = 100')();:函数的类化是对动态与静态系统的再次统一" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/d6/6b00f74dae542038f2142da3815e20d6.mp3"></audio>
你好,我是周爱民,欢迎回到我的专栏。
今天是专栏最后一讲我接下来要跟你聊的仍然是JavaScript的动态语言特性主要是动态函数的实现原理。
标题中的代码比较简单是常用、常见的。这里稍微需要强调一下的是“最后一对括号的使用”由于运算符优先级的设计它是在new运算之后才被调用的。也就是说标题中的代码等义于
```
// (等义于)
(new Function('x = 100'))()
// (或)
f = new Function('x = 100')
f()
```
此外,这里的`new`运算符也可以去掉。也就是说:
```
new Function(x)
// vs.
Function(x)
```
这两种写法没有区别,都是动态地创建一个函数。
## 函数的动态创建
如果在代码中声明一个函数,那么这个函数必然是具名的。具名的、静态的函数声明有两个特性:
1. 是它在所有代码运行之前被创建;
1. 它作为语句的执行结果将是“空Empty”。
这是早期JavaScript中的一个硬性的约定但是到了ECMAScript 6开始支持模块的时候这个设计就成了问题。因为模块是静态装配的这意味着它导出的内容“应该是”一个声明的结果或者一个声明的名字因为只有**声明**才是静态装配阶段的特性。但是所有声明语句的完成结果都是Empty是无效的不能用于导出。
>
NOTE关于6种声明请参见《[第02讲](https://time.geekbang.org/column/article/165198)》。
而声明的名字呢?不错,这对具名函数来说没问题。但是匿名函数呢?就成了问题了。
因此在支持匿名函数的“缺省导出export default …”时ECMAScript就引入了一个称为“函数定义Function Definitions”的概念。这种情况下函数表达式是匿名的但它的结果会绑定给一个名字并且最终会导出那个名字。这样一来函数表达式也就有了“类似声明的性质”但它又不是静态声明Declarations所以概念上叫做定义Definitions
>
NOTE关于匿名函数对缺省导出的影响参见《[第04讲](https://time.geekbang.org/column/article/166491)》。
在静态声明的函数、类,以及这里说到的函数定义之外,用户代码还可以创建自己的函数。这同样有好几种方式,其中之一,是使用`eval()`,例如:
```
# 在非严格模式下这将在当前上下文中“声明”一个名为foo的函数
&gt; eval('function foo() {}')
```
还有一种常见的方式,就是使用动态创建。
### 几种动态函数的构造器
在JavaScript中“动态创建”一个东西意味着这个东西是一个对象它创建自类/构造器。其中`Function()`是一切函数缺省的构造器或类。尽管内建函数并不创建自它但所有的内建函数也通过简单的映射将它们的原型指向Function。除非经过特殊的处理所有JavaScript中的函数原型均最终指向`Function()`,它是所有函数的祖先类。
这种处理/设计使得JavaScript中的函数有了“完整的”面向对象特性函数的“类化”实现了JavaScript在函数式语言和面向对象语言在概念上的大一统。于是一个内核级别的概念完整性出现了也就是所谓对象创建自函数函数是对象。如下图所示
<img src="https://static001.geekbang.org/resource/image/7a/38/7a1dfb4942dd0484dd03aba2eb204c38.png" alt="">
>
NOTE关于概念完整性以及它在“体系性”中的价值参见《[加餐3让JavaScript运行起来](https://time.geekbang.org/column/article/175261)》。
在ECMAScript 6之后有赖于类继承体系的提出JavaScript中的函数也获得了“子类化”的能力于是用户代码也可以派生函数的子类了。例如
```
class MyFunction extends Function {
// ...
}
```
但是用户代码无法重载“函数的执行”能力。很明显,这是执行引擎自身的能力,除非你可以重写引擎,否则重载执行能力也就无从谈起。
>
NOTE关于类、派生以及它们在对原生构造器进行派生时的贡献请参见《[第15讲](https://time.geekbang.org/column/article/179238)》。
除了这种用户自定义的子类化的函数之外JavaScript中一共只有四种可以动态创建的函数包括**一般函数**Function、**生成器函数**GeneratorFunction、**异步生成器函数**AsyncGeneratorFunction和**异步函数**AsyncFunction。又或者说用户代码可以从这四种函数之任一开始来派生它们的子类在保留它们的执行能力的同时扩展接口或功能。
但是这四种函数在JavaScript中有且只有`Function()`是显式声明的,其他三种都没有直接声明它们的构造器,这需要你用如下代码来得到:
```
const GeneratorFunction = (function* (){}).constructor;
const AsyncGeneratorFunction = (async function* (){}).constructor
const AsyncFunction = (async x=&gt;x).constructor;
// 示例
(new AsyncFunction)().then(console.log); // promise print 'undefined'
```
### 函数的三个组件
我们提及过函数的三个组件,包括:**参数**、**执行体**和**结果**。其中“结果Result”是由代码中的return子句负责的而其他两个组件则是“动态创建一个函数”所必须的。这也是上述四个函数以及它们的子类拥有如下相同界面的原因
Function (p1, p2, … , pn, body)
>
NOTE关于函数的三个组件以及基于它们的变化请参见《[第8讲](https://time.geekbang.org/column/article/171617)、[第9讲](https://time.geekbang.org/column/article/172636)、[第10讲](https://time.geekbang.org/column/article/174314),它们分别讨论“三个组件”、改造“执行体”,以及改造“参数和结果”》。
其中用户代码可以使用字符串来指定p1…pn的形式参数Formals并且使用字符串来指定函数的执行体Body。类似如下
```
f = new Function('x', 'y', 'z', 'console.log(x, y, z)');
// 测试
f(1,2,3); // 1 2 3
```
JavaScript也允许用户代码将多个参数合写为一个也就是变成类似如下形式
```
f = new Function('x, y, z', ...);
```
或者在字符串声明中使用缺省参数等扩展风格,例如:
```
f = new Function('x = 0, ...args', 'console.log(x, ...args)');
f(undefined, 200, 300, 400); // 0 200 300 400
```
### 动态函数的创建过程
所有的四种动态函数的创建过程都是一致的,它们都将调用内部过程[CreateDynamicFunction()](https://tc39.es/ecma262/#sec-createdynamicfunction)来创建函数对象。但相对于静态声明的函数动态创建CreateDynamicFunction却有自己不同的特点与实现过程。
>
NOTE关于对象的构造过程请参见《[第13讲13 | new X](https://time.geekbang.org/column/article/177397)》。
<img src="https://static001.geekbang.org/resource/image/51/b7/51e63c42eb159ddbc78326da0fb914b7.jpg" alt="">
JavaScript在创建函数对象时会为它分配一个称为“allocKind”的标识。相对于静态创建这个标识在动态创建过程中反而更加简单正好与上述四种构造器一一对应也就不再需要进行语法级别的分析与识别。其中除了`normal`类型(它所对应的构造器是`Function()`之外其他的三种都不能作为构造器来创建和初始化。所以只需要简单地填写它们的内部槽并置相应的原型原型属性F.prototype以及内部槽F.[[Prototype]])就可以了。
最后当函数作为对象实例完成创建之后引擎会调用一个称为“函数初始化FunctionInitialize”的内置过程来初始那些与具体实例相关的内部槽和外部属性。
>
NOTE在ECMAScript 6中动态函数的创建过程主要由FunctionAllocate和FunctionInitialize两个阶段完成。而到了ECMAScript 9中ECMAScript规范将FunctionAllocate的主要功能归入到OrdinaryFunctionCreate中并由此规范了函数“作为对象”的创建过程而原本由FunctionInitialize负责的初始化则直接在动态创建过程中处理了。
然后呢?然后,函数就创建完了。
是的!“好像”什么也没有发生?!事实上,在引擎层面,所谓的“动态函数创建”就是什么也没有发生,因为执行引擎并不理解“声明一个函数”与“动态创建一个函数”之间的差异。
我们试想一下,如果一个执行引擎要分别理解这两种函数并尝试不同的执行模式或逻辑,那么这个引擎的效率得有多差。
## 作为一个函数
通常情况下,接下来还需要一个变量来引用这个函数对象,或者将它作为表达式操作数,它才会有意义。如果它作为引用,那么它跟普通变量或其他类型的数据类似;如果它作为一般操作数,那么它应该按照上一讲所说的规则,转换成“值类型”才能进行运算。
>
NOTE关于引用、操作数以及值类型等等请参见《[第01讲01 | delete 0](https://time.geekbang.org/column/article/164312)》。
所以,如果不讨论“动态函数创建”内在的特殊性,那么它的创建与其他数据并没有本质的不同:创建结果一样,对执行引擎或运行环境的影响也一样。而这种“没有差异”反而体现了“函数式语言”的一项基本特性:函数是数据。也就是说,函数可以作为一般数据来处理,例如对象,又例如值。
函数与其他数据不同之处,仅在于它是可以调用的。那么“动态创建的函数”与一般函数相比较,在调用/执行方面有什么特殊性吗?
答案是仍然没有在ECMAScript的内部方法`Call()`或者函数对象的内部槽`[[Call]] [[Construct]]`中,根本没有任何代码来区别这两种方式创建出来的函数。它们之间毫无差异。
>
NOTE事实上不惟如此我尝试过很多的方式来识别不同类型的函数例如构造器、类、方法等。除了极少的特例之外在用户代码层面是没有办法识别函数的类型的。就现在的进展而言`isBindable()``isCallable()``isConstructor()``isProxy()`这四个函数是可以实现的,其他的类似`isClassConstructor()``isMethod()``isArrowFunction()`都没有有效的识别方式。
>
NOTE如上的这些识别函数需要在不利用toString()方法以及不调用函数的情况下来完成。因为执行函数会带来未知的结果而toString方法的实现在许多引擎中并不标准不可依赖。
不过,如果我们将时钟往回拔一点,考察一下这个函数被创建出来之前所发生的事情,那么,我们还是能找到“唯一一点不同”。而这,也将是我在“动态语言”这个系列中为你揭示的最后一个秘密。
## 唯一一点不同
在“函数初始化FunctionInitialize”这个阶段中ECMAScript破天荒地约定了几行代码这段规范文字如下
>
<p>Let realmF be the value of Fs [[Realm]] internal slot.<br>
Let **scope** be realmF.[[GlobalEnv]].<br>
Perform **FunctionInitialize**(F, Normal, parameters, body, **scope**).</p>
它们是什么意思呢?
规范约定需要从函数对象所在的“域即引擎的一个实例”中取出全局环境然后将它作为“父级的作用域scope传入`FunctionInitialize()`来初始化函数`F`。也就是说,所有的“动态函数”的父级作用域将指向全局!
你绝不可能在“当前上下文(环境/作用域)”中动态创建动态函数。和间接调用模式下的`eval()`一样,所有动态函数都将创建在全局!
一说到跟“间接调用eval()”存有的相似之处,可能你立即会反应过来:这种情况下,`eval()`不仅仅是在全局执行,而且将突破“全局的严格模式”,代码将执行在非严格模式中!那么,是不是说,“动态函数”既然与它有相似之处,是不是也有类似性质呢?
>
NOTE关于间接调用`eval()`,请参见《[第21讲](https://time.geekbang.org/column/article/184589)》。
答案是:的确!
出于与“间接调用eval()”相同的原因——即,在动态执行过程中无法有效地(通过上下文和对应的环境)检测全局的严格模式状态,所以动态函数在创建时只检测代码文本中的第一行代码是否为`use strict`指示字而忽略它“外部scope”是否处于严格模式中。
因此即使你在严格模式的全局环境中创建动态函数它也是执行在非严格模式中的。它与“间接调用eval()”的唯一差异,仅在于“多封装了一层函数”。
例如:
```
# 让NodeJS在启动严格模式的全局
&gt; node --use-strict
# 在上例启动的NodeJS环境中测试
&gt; x = &quot;Hi&quot;
ReferenceError: x is not defined
# 执行在全局,没有异常
&gt; new Function('x = &quot;Hi&quot;')()
undefined
# `x`被创建
&gt; x
'Hi'
# 使用间接调用的`eval`来创建`y`
&gt; (0, eval)('y = &quot;Hello&quot;')
&gt; y
'Hello'
```
## 结尾
所以回到今天这一讲的标题上来。标题中的代码事实与上一讲中提到的“间接调用eval()”的效果一致,同样也会因为在全局中“向未声明变量赋值”而导致创建一个新的变量名`x`。并且,这一效果同样不受所谓的“严格模式”的影响。
在JavaScript的执行系统中出现这两个语法效果的根本原因在于执行系统试图从语法环境中独立出来。如果考虑具体环境的差异性那么执行引擎的性能将会较差且不易优化如果不考虑这种差异性那么“严格模式”这样的性质就不能作为执行引擎理解的环境属性。
在这个两难中ECMAScript帮助我们做出了选择牺牲一致性换取性能。
>
NOTE关于间接调用`eval()`对环境的使用,以及环境相关的执行引擎组件的设计与限制,请参见《[第20讲](https://time.geekbang.org/column/article/183440)》。
当然这也带来了另外一些好处。例如终于有了`window.execScript()`的替代实现,以及通过`new Function`这样来得到的、动态创建的函数,就可以“安全地”应用于并发环境。
至于现在《JavaScript核心原理解析》一共22讲内容就全部结束了。
在这个专栏中我为你讲述了JavaScript的静态语言设计、面向对象语言的基本特性以及动态语言中的类型与执行系统。这看起来是一些零碎的、基本的以及应用性不强的JavaScript特性但是事实上它们是你理解“更加深入的核心原理”的基础。
如果不先掌握这些内容,那么更深入的,例如多线程、并行语言特性等等都是空中楼阁,就算你勉强学来,也不过是花架子,是理解不到真正的“核心”的。
而这也是我像现在这样设计《JavaScript核心原理解析》22讲框架的原因。我希望你能在这些方面打个基础先理解一下ECMAScript作为“语言设计者”这个角色的职责和关注点先尝试一下深入探索JavaScript核心原理的乐趣与艰难。然后希望我们还有机会在新的课程中再见
[<img src="https://static001.geekbang.org/resource/image/a9/c4/a9c16d26dfcdd8ff91002344df2297c4.jpg" alt="">](https://jinshuju.net/f/TmdBMP)
多谢你的收听,最后邀请你填写这个专栏的[调查问卷](https://jinshuju.net/f/TmdBMP),我也想听听你的意见和建议,我将继续答疑解惑、查漏补缺,与你回顾这一路行来的苦乐。
再见。
>
<p>NOTE编辑同学说还有一个“结束语”我真不知道怎么写。不过如果你觉得意犹未尽的话到时候请打开听听吧或许还有好货叱<br>
by aimingoo.</p>