This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
<audio id="audio" title="01 | delete 0JavaScript中到底有什么是可以销毁的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/f5/866ed6d1256f70b5727bea4313c145f5.mp3"></audio>
你好,我是周爱民,感谢你来听我的专栏。
今天是这个系列的第一讲我将从JavaScript中最不起眼的、使用率最低的一个运算——delete讲起。
你知道JavaScript是一门面向对象的语言。它很早就支持了delete运算这是一个元老级的语言特性。但细追究起来delete其实是从JavaScript 1.2中才开始有的,与它一同出现的,是对象和数组的字面量语法。
有趣的是JavaScript中最具恶名的typeof运算其实是在1.1版本中提供的比delete运算其实还要早。这里提及typeof这个声名狼藉的运算符主要是因为delete的操作与类型的识别其实是相关的。
## 习惯中的“引用”
早期的JavaScript在推广时仍然采用传统的数据类型的分类方法也就是说它宣称自己同时支持值类型和引用类型的数据并且所谓值类型中的字符串是按照引用来赋值和传递引用而不是传递值的。这些都是当时“开发人员的概念集”中已经有的、容易理解的知识不需要特别解释。
但是什么是引用类型呢?
在这件事上JavaScript偷了个懒它强行定义了“Object和Function就是引用类型”。这样一来引用类型和值类型就给开发人员讲清楚了对象和函数呢也就可以理解了它们按引用来传递和使用。
绝大多数情况下这样解释起来是行得通的。但是到了delete运算这里就不行。
因为这样一来,`delete 0`就是删除一个值,而`delete x`就既可能是删除一个值也可能是删除一个引用。然而当时JavaScript又同时约定那些在global对象上声明的属性就“等同于”全局变量。于是这就带来了第三个问题`delete x`还可能是删除一个global对象上的属性。而它在执行这个操作的时候看起来却像是一个全局变量的名字
这中间有哪些细节的区别呢?
delete这个运算的表面意思是该运算试图销毁某种东西。然而`delete 0`中的0是一个具体的、字面量表示的“值”。一个字面量值“0”如何在现实世界中销毁呢假定它销毁了那是不是说在这个语言当前的运行环境中就不能使用0这个值了呢显然这不合理。
所以JavaScript认为“**所有删除值的delete就直接返回true**”表明该行为过程中没有异常。很不幸JavaScript 1.2的时代并没有结构化异常处理即try…catch语句。所以通过函数调用中返回true来表明“没有异常”其实是很常规的做法。
然而返回值只表明执行过程中没有异常但实际的执行行为是“什么也没发生”。你显然不可能真的将“0”从执行系统中清理出去。
那么接下来,就还剩下删除变量和删除属性。由于全局变量实际上是通过全局对象的属性来实现的,因此删除变量也就存在识别这两种行为的必要性。例如:
```
delete x
```
这行代码究竟是在删除什么呢出于JavaScript是动态语言这项特性从根本上来说我们是没有办法在语法分析期来判断`x`的性质的。所以现在,需要有一种方法在运行期来标识`x`的性质,以便进一步地处理它。
这就导致了一种新的“引用”类型呼之欲出。
## 到底在删除什么?
探索工作往往如此,是所谓“进五退一”,甚至是“进五退四”。在今后的专栏文章中,你往往会看到,我在碰触到一种新东西的时候会竭力向前,但随后又后退好几步,再来讨论一些更基础层面的东西。这是因为如果不把这些基础概念说得清楚明白,那么往前冲的那几步常常就被带偏了方向。
一如现在这个问题:`delete 0`到底是在删除什么?
对于一门编译型语言来说所谓“0”就是上面所述的一个值它可以是基础值Primitive values也可以是数值类型。但如果将这个问题上升到编译之前的、所谓语法分析的阶段那么“0”就会被称为一个记号Tokens。一个记号是没有语义的记号既可以是语言能识别的也可以是语言不能识别的。唯有把这二者同时纳入语言范畴那么这个语言才能识别所谓的“语法错误”。
delete不仅仅是要操作0或x这样的单个记号或标识符例如变量。因为这个语法实际起作用的是一个对象的属性也就是“删除对象的成员”。那么它真正需要的语法其实是
```
delete obj.x
```
只不过因为全局对象的成员可以用全局变量的形式来存取,所以它才有了
```
delete x
```
这样的语法语义而已。所以这正好将你之前所认识的倒转过来是删除x这个成员而不是删除x这个值。不过终归有一点是没错的既然没办法表达异常而delete 0又不产生异常那么它自然就该返回true。
然而,如果你理解了`delete obj.x`,那么就一定会想到:`obj.x`既不是之前说过的引用类型,也不是之前说过的值类型,它与`typeof(x)`识别的所有类型都无关。因为,它是一个表达式。
所以delete这个操作的正式语法设计并不是“删除某个东西”而是“删除一个表达式的结果”
```
delete UnaryExpression
```
## 表达式的结果是什么?
在JavaScript中表达式是一个很独特的东西所有一切表达式运算的终极目的都是为了得到一个值例如字符串。然后再用另外一些操作将这个值输出出来例如变成网页中的一个元素element。这是JavaScript语言创生的原力也是它的基础设计。也只是因为有了这种设计它才变得既像面向对象的又像函数式语言的样子。
表达式的执行特性,以及表达式与语句的关系等等细节,回头我放在第二阶段的内容中讲给你听。现在我们只需要关注一个要点,表达式计算的结果到底是什么?因为就像上面所说的,这个结果,才是`delete`这个操作要删除的东西。
在JavaScript中有两个东西可以被执行并存在执行结果Result包括语句和表达式。比如你用`eval()`来执行一个字符串,那么实际上,你执行的是一个语句,并返回了语句的值;而如果你使用一对括号来强制一个表达式执行,那么这个括号运算得到的,就是这个表达式的值。
表达式的值在ECMAScript的规范中称为“引用”。
这是一种称为“规范类型”的东西。
## 规范中的“引用”
实际上这个概念出现得也很早。从JavaScript 1.3开始ECMAScript规范就在语言定义的层面正式地将上述的天坑补起来推出了上面说到的这个“真正的引用类型”。
但是由于这个时候规范的影响力在开发人员中并不那么大所以开发人员还是习惯性地将对象和函数称为引用而其它类型就称为值并且继续按照传统的理解来解释JavaScript中对数据的处理。
这种情况下,一个引用只是在语法层面上表达“它是对某种语法元素的引用”,而与在执行层面的值处理或引用处理没关系。所以,下面这行简短的语句:
```
delete 0
```
实际上是在说JavaScript将0视为一个表达式并尝试删除它的求值结果。
所以现在这里的0其实不是值Value类型的数据而是一个表达式运算的结果Result。而在进一步的删除操作之前JavaScript需要检测这个Result的类型
- 如果它是值则按照传统的JavaScript的约定返回true
- 如果它是一个引用,那么对该引用进行分析,以决定如何操作。
这个检测过程说明ECMAScript约定任何表达式计算的结果Result要么是一个值要么是一个引用。并且需要留意的是在这个描述中所谓对象其实也是值。准确地说是“非引用类型”。例如
```
delete {}
```
那么显然,这里要删除的一对大括号是表示一个字面量的对象,当它被作为表达式执行的时候,结果也是一个值。这也是我常常将所有这类表达式称为“单值表达式”的原因,这里并没有所谓的“引用”。
你可以像下面这样,非常细致而准确地解释这一行代码:单值表达式的运算结果返回那个“对象字面量”的单值。然后,`delete`运算发现它的操作数是“值/非引用类型”就直接返回了true。
所以,什么也没有发生。
## 还会发生什么
那么到底还会发生什么呢?
在JavaScript的内部所谓“引用”是可以转换为“值”以便参与值运算的。因为表达式的本质是求值运算所以引用是不能直接作为最终求值的操作数的。这依赖于一个非常核心的、称为“GetValue()”的内部操作。所谓内部操作也称为内部抽象操作internal abstract operations是ECMAScript描述一个符合规范的引擎在具体实现时应当处理的那些行为。
`GetValue()`是从一个引用中取出值来的行为。这有什么用呢?比如说下面这行代码:
```
x = x
```
我们上面说过所谓x其实是一个引用。上面的表达式其实是一个赋值表达式那么“引用x赋值给引用x”有什么意义呢其实这在语法层面来解释是非常直接的
>
所有赋值操作的含义,是将右边的“值”,赋给左边用于包含该值的“引用”。
那么上面的`x=x`,其实就是被翻译成:
```
x = GetValue(x)
```
来执行的。而JavaScript识别两个x的不同的方法就称为“手性”即是所谓“左手端(**lhs, left hand side**)”和“右手端(**rhs**)”。它本来是用来描述自然语言的语法中一个修饰词应该是放在它的主体的前面或是后面的。而在程序设计语言中它用来说明一个记号Token是放在了赋值符号例如“=”号的左边或是右边。作为一个简单的结论区别上例中的两个x的方法就是
>
如果x放在左边作为lhs那么它是引用如果放在右边作为rhs那么就是值。
所以`x=x`的语义并不是“x赋给x”而是“**把值x赋给引用x**”。
所以“delete x”归根到底是在**删除一个表达式的、引用类型的结果Result**,而不是在**删除x表达式**,或者这个**删除表达式的值Value**。
是的在JavaScript中的`delete`是一个很罕见的、能直接操作“引用”的语法元素。由于这里的“引用”是在ECMAScript规范层面的概念因此在JavaScript语言中能操作它的语法元素其实非常少。
然而很不幸delete就是其中之一。
## 告诉我这些有什么用
等等,我想你一定会问了:神啊,让我知道这些究竟有什么用呢?我永远也不会去执行`delete 0`这样的操作啊!
是的。但是我接下来要告诉你的事实是:`obj.x`也是一个引用。对象属性存取是JavaScript的面向对象的基本操作之一所以本质上我们早就在使用“引用”这个东西了只不过它太习以为常所以大家都视而不见。
“属性存取("."运算符”返回一个关于“x”的引用然后它可以作为下一个操作符例如函数调用运算“()”)的左手端来使用,这才有了著名的“对象方法调用”运算:
```
obj.x()
```
因为在对象方法调用的时候函数_x()_是来自于`obj.x`这个引用的,所以这个引用将`obj`这个对象传递给x()这才会让函数_x()_内部通过this来访问到obj。
根本上来说,如果`obj.x`只是值或者它作为右手端那么它就不能“携带”obj这个对象也就完成不了后续的方法调用操作。
>
对象存取 + 函数调用 = 方法调用
这是JavaScript通过连续表达式运算来实现新的语义/语法的经典示例。
而所谓“连续运算”其实是函数式运算范式的基本原则。也就是说,`obj.x()`是在JavaScript中集合了“引用规范类型操作”“函数式”“面向对象”和“动态语言”等多种特性于一体的一个简单语法。
而它对语言的基础特性的依赖,就在于:
- `delete 0`中的这个`0`是一个表达式求值;
- `delete x`中的`x`是一个引用;
- `delete obj.x``obj.x`是一组表达式连续运算的结果Result/引用);
于是我们现在可以解释当x是全局对象global的属性时所谓`delete x`其实只需要返回`global.x`这个引用就可以了。而当它不是全局对象global的属性时那么就需要从当前环境中找到一个名为`x`的引用。找到这两种不同的引用的过程称为ResolveBinding而这两种不同的`x`,称为不同环境下绑定的标识符/名字。
## 知识回顾
下一讲我将给你讲述的,就是这个名字从声明到发现的全过程。至于现在,这一讲就要告一段落了。今天的内容中,有一些知识点我来带你回顾一下。
- delete 运算符尝试删除值数据时会返回true用于表示没有错误Error
- delete 0的本质是删除一个表达式的值Result
- delete x与上述的区别只在于Result是一个引用Reference
- delete其实只能删除一种引用即对象的成员Property
所以,只有在`delete x`等值于`delete obj.x`时delete才会有执行意义。例如`with (obj) ...`语句中的delete x以及全局属性global.x。
## 思考题
- delete x中如果x根本不存在会发生什么
- delete object.x中如果x是只读的会发生什么
希望你喜欢我的分享。

View File

@@ -0,0 +1,252 @@
<audio id="audio" title="02 | var x = y = 100声明语句与语法改变了JavaScript语言核心性质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/04/877fe81349f521061eac66e153dddd04.mp3"></audio>
你好,我是周爱民。
如果你听过上一讲的内容,心里应该会有一个问题,那就是——在规范中存在的“引用”到底有什么用?它对我们的编程有什么实际的影响呢?
当然,除了已经提及过的`delete 0``obj.x`之外,在今后的课程中,我还会与你讨论这个“引用”的其它应用场景。而今天的内容,就从标题来看,若是我要说与这个“引用”有关,你说不定得跳起来说我无知;但若说是跟“引用”无关的话呢,我觉得又不能把标题中的这一行代码解释清楚。
为什么这行代码看起来与规范类型中的“引用”无关呢因为这行代码出现的时候连ECMAScript这个规范都不存在。
我大概是在JavaScript 1.2左右的时代就接触到这门语言,在我写的最早的一些代码中就使用过它,并且——没错,你一定知道的:它能执行!
有很多的原因会促使你在JavaScript写出表达式连等这样的代码。从C/C++走过来的程序员对这样的一行代码是不会陌生的。它能用,而且结果也与你的预期会一致,例如:
```
var x = y = 100;
console.log(x); // 100
console.log(y); // 100
```
它既没错、又好用还很酷你说我们为什么不用它呢然而很不幸这行代码可能是JavaScript中最复杂和最容易错用的表达式了。
所以今天我要和你一起好好地盘盘它。
## 声明
至今为止除标签声明之外JavaScript中一共只有六条声明用的语句。注意所有真正被定义“声明”的语法结构都一定是“语句”并且都用于声明一个或多个标识符。这里的标识符包括变量、常量等。
严格意义上讲JavaScript只有变量和常量两种标识符六条声明语句中
- **let** **x**
声明变量x。不可在赋值之前读。
- **const** **x**
声明常量x。不可写。
- **var** **x**
声明变量x。在赋值之前可读取到undefined值。
- **function** **x**
声明变量x。该变量指向一个函数。
- **class** **x**
声明变量x。该变量指向一个类该类的作用域内部是处理严格模式的
- **import** …
导入标识符并作为常量(可以有多种声明标识符的模式和方法)。
除了这六个语句之外,还有两个语句有潜在的声明标识符的能力,不过它们并不是严格意义上的声明语句(声明只是它们的语法效果)。这两个语句是指:
- **for** (****var****|****let****|****const**** **x** …) …
for语句有多种语法来声明一个或多个标识符用作循环变量。
- **try** … **catch** (**x**) …
catch子句可以声明一个或多个标识符用作异常对象变量。
总的来说,除上述的语法,用户是没有其它方式来在当前的代码上下文中“声明”出一个标识符来的。而我之所以在这里严格强调这一“汇总性的”结果,是因为下面的一个简单论断,所有的“声明”:
>
<ul>
- 都意味着JavaScript将可以通过“静态”语法分析发现那些声明的标识符
- 标识符对应的变量/常量“一定”会在用户代码执行前就已经被创建在作用域中。
</ul>
这个标题中的`var x`就是一个声明。在这个声明的后半部分,使用“=”这个符号引导了一个初始化语法——通常情况下可以将它理解为一个赋值运算。
## 从读取值到赋值
声明是在语法分析阶段就处理的,并且因此它会使得当前代码上下文在正式执行之前就拥有了被声明的标识符,例如`x`
这其实非常有趣因为这表明JavaScript虽然被称为是“动态语言”但确实是拥有静态语义的。而在JavaScript的早期这个静态语义其实并没有处理得太好一个典型的问题就是所谓的“变量提升”。也就是可以在变量声明之前访问该变量。例如
```
console.log(x); // undefined
var x = 100;
console.log(x); // 100
```
这个“变量提升”还包括“变量被创建于声明它的语法块”之外的意思,但这并不是这里要讨论的内容,我会在今后再讲它。在今天的课程里,你只需要留意这个变量的读写过程就好了。那么,关于读取值,之前声明的变量与常量又有什么不同呢?
如上面已经说过的由于标识符是在用户代码执行之前就已经由静态分析得到并且创建在环境中因此let声明的变量和var声明的变量在这一点上没有不同它们都是在读取一个“已经存在的”标识符名。例如
```
var y = &quot;outer&quot;;
function f() {
console.log(y); // undefined
console.log(x); // throw a Exception
let x = 100;
var y = 100;
...
}
```
正是由于`var y`所声明的那个标识符在函数f()创建(它自己的闭包)时就已经存在,所以才阻止了`console.log(y)`访问全局环境中的`y`。类似的,`let x`所声明的那个`x`其实也已经存在f()函数的上下文环境中。访问它之所以会抛出异常Exception不是因为它不存在而是因为这个标识符被拒绝访问了。
在ECMAScript 6之后出现的`let/const`变量在“声明(和创建)一个标识符”这件事上,与`var`并没有什么不同只是JavaScript拒绝访问还没有绑定值的`let/const`标识符而已。
回到ECMAScript 6之前JavaScript是允许访问还没有绑定值的`var`所声明的标识符的。这种标识符后来统一约定称为“变量声明varDelcs而“let/const”则称为“词法声明lexicalDecls”。JavaScript环境在创建一个“变量名varName in varDecls”后会为它初始化绑定一个undefined值而”词法名字lexicalNames”在创建之后就没有这项待遇所以它们在缺省情况下就是“还没有绑定值”的标识符。
>
NOTE6种声明语句中的函数是按varDecls的规则声明的类的内部是处于严格模式中它的名字是按let来处理的而import导入的名字则是按const的规则来处理的。所以所有的声明本质上只有三种处理模式var变量声明、let变量声明和const常量声明。
所以,标题中的`var x = ...`在语义上就是为变量x绑定一个初值。在具体的语言环境中它将被实现为一个赋值操作。
## 赋值
如果是在一门其它的例如编译型的语言中“为变量x绑定一个初值”就可能实现为“在创建环境时将变量x指向一个特定的初始值”。这通常是静态语言的处理方法然而如前面说过的JavaScript是门动态的语言所以它的“绑定初值”的行为是通过动态的执行过程来实现的也就是赋值操作。
那么请你仔细想想,一个赋值操作在语法上怎么表达呢?例如:
```
变量名 = 值
```
这样对吗不对在JavaScript中这样讲是非常不正确的。正确的说法是
```
lRef = rValue
```
也就是将右操作数(的值)赋给左操作数(的引用)。它的严格语法表达是:
>
**LeftHandSideExpression** &lt; **=** | ****AssignmentOperator**** &gt; **AssignmentExpression**
也就是说在JavaScript中一个赋值表达式的左边和右边其实“都是”表达式
## 向一个不存在的变量赋值
接下来我要给你介绍的是从JavaScript 1.0开始就遗留下来的一个巨坑也就是所谓的“变量泄漏”问题。这在早期的JavaScript中的确是一个好用的特性如果你向一个不存在的变量名赋值那么JavaScript会在全局范围内创建它。
也就是说,代码中不需要显式地声明一个变量了,变量可以随用随声明,也不用像后来的`let`语句一样,还要考虑在声明语句之前能不能访问的问题了。这非常简单,在少量的代码中也相当易用。
但是,如果代码规模扩大,变成百千万行代码,那么“**一个全局变量是在哪里声明和创建的**”就变成一个非常要紧的问题。
如果随时都可能泄露一个代码给全局或者随时都可能因为忘记本地的声明而读写了全局变量那对调试除错将是一场灾难。另外晚一些出现的运行期优化技术也不能很好地处理这种情况。所以从ECMAScript5开始的严格模式就禁止了这种特性试图避免用户将变量泄露到全局环境。
然而现实中,即使在严格模式下这种漏露也未能避免。这称为“**间接执行**”这将是另一个巨大的议题并且是ECMAScript6之后开始的一种新的机制。但是现在这里发生的事情也就是这个“向不存在的变量赋值”的问题是从JavaScript 1.0时代就遗留下来的问题也是ECMAScript为JavaScript填补的最大设计漏洞之一。
那么,在具体技术细节上,这个变量声明是如何发生的呢?
事实上这是因为在早期设计中JavaScript的全局环境是引擎使用一个称为“**全局对象**”东西管理起来的。
这个全局对象几乎类似或完全等同于一个普通对象。只不过JavaScript引擎将全局的一些缺省对象、运行期环境的原生对象等东西都初始化在这个全局对象的属性中并使用这个对象创建了一个称为“**全局对象闭包**”的东西从而得到了JavaScript的全局环境。
早期的JavaScript的引擎实现非常简洁许多基础的技术组件都是直接复用的例如这里的所谓全局环境、全局闭包或者全局对象的实现方法就与“**with语句**”的效果完全相同——他们是相互复用的。
当向一个不存在的变量赋值的时候由于全局对象的属性表是可以动态添加的因此JavaScript将变量名作为属性名添加给全局对象。而访问所谓全局变量时就是访问这个全局对象的属性。因此实际效果就变成了“可以动态地向全局环境中添加一个变量”。并且显然地我们在第一讲已经讲过这个结果——你可以删除掉这个动态添加的“变量”因为本质上就是在删除全局对象的属性。
那么现在我是指在ECMAScript6之后的JavaScript的全局环境有什么不同吗
为了兼容旧的JavaScript语言设计现在的JavaScript环境仍然是通过将全局对象初始化为这样的一个全局闭包来实现的。但是为了得到一个“尽可能”与其它变量环境相似的声明效果varDeclsECMAScript规定在这个全局对象之外再维护一个变量名列表varNames所有在静态语法分析期或在eval()中使用`var`声明的变量名就被放在这个列表中。然后约定,这个变量名列表中的变量是“直接声明的变量”,不能使用`delete`删除。
于是,我们得到了这样的一种结果:
```
&gt; var a = 100;
&gt; x = 200;
# `a`和`x`都是global的属性
&gt; Object.getOwnPropertyDescriptor(global, 'a');
{ value: 100, writable: true, enumerable: true, configurable: false }
&gt; Object.getOwnPropertyDescriptor(global, 'x');
{ value: 200, writable: true, enumerable: true, configurable: true }
# `a`不能删除, `x`可以被删除
&gt; delete a
false
&gt; delete x
true
# 检查
&gt; a
100
&gt; x
ReferenceError: x is not defin
```
所以,表面看起来“泄漏到全局的变量”与使用`var`声明的都是全局变量并且都实现为global的属性但事实上它们是不同的。并且当`var`声明发生在eval()中的时候,这一特性又还有所不同,例如:
```
# 使用eval声明
&gt; eval('var b = 300');
# 它的性质是可删除的
&gt; Object.getOwnPropertyDescriptor(global, 'b').configurable;
true
# 检测与删除
&gt; b
300
&gt; delete b
true
&gt; b
ReferenceError: b is not define
```
这种情况下使用`var`声明的变量名尽管也会添加到varNames列表但它也可以从varNames中移除这是唯一一种能从varNames中移除项的特例而lexicalNames中的项是不可移除的
## 发生了什么?
所以,现在回到今天讨论的这行代码`var x = y = 100`,在这行代码中,等号的右边是一个表达式`y = 100`,它发生了一次“向不存在的变量赋值”,所以它隐式地声明了一个全局变量`y`并赋值为100。
而一个赋值表达式操作本身也是有“结果Result”的它是右操作数的值。注意这里是“值”而非“引用”例如下面的测试中的`a`将是一个函数而不是带着“this对象”信息的方法
```
// 调用obj.f()时将检测this是不是原始的obj
&gt; obj = { f: function() { return this === obj } };
// false表明赋值表达式的“结果(result)”只是右侧操作数的值即函数f
&gt; (a = obj.f)();
false
```
到现在为止我们讲述了整个语句的过程也就是说由于“y = 100”的结果是100所以该值将作为初始值赋值“变量x”。并且从语义上来说这是变量“x”的初始绑定。
之所以强调这一点是因为相同的分析过程也可以用在const声明上而const声明是只有一次绑定的常量的初始绑定也是通过“执行赋值过程”来实现的。
## 知识回顾
- var等声明语句总是在变量作用域变量表或词法作用域中静态地声明一个或多个标识符。
- 全局变量的管理方式决定了“向一个不存在的变量赋值”所导致的变量泄漏是不可避免的。
- 动态添加的“var声明”是可以删除的这是唯一能操作varNames列表的方式不过它并不存在多少实用意义
- 变量声明在引擎的处理上被分成两个部分:一部分是静态的、基于标识符的词法分析和管理,它总是在相应上下文的环境构建时作为名字创建的;另一部分是表达式执行过程,是对上述名字的赋值,这个过程也称为绑定。
- 这一讲标题里的这行代码中x和y是两个不同的东西前者是声明的名字后者是一个赋值过程可能创建的变量名。
## 思考题
根据今天讲解的内容,我希望你可以尝试回答以下问题:
- 严格来说,声明不是语句。但是,是哪些特性决定了声明不是“严格意义上的”语句呢?
在下一讲中我会来讲一讲JavaScript社区中的一个历史悬案这桩悬案与今天讨论的这行代码的唯一区别在于它不是声明语句而是赋值表达式。

View File

@@ -0,0 +1,349 @@
<audio id="audio" title="03 | a.x = a = {n:2}:一道被无数人无数次地解释过的经典面试题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/83/53fc95378939b6b1bef341be705b2983.mp3"></audio>
你好,我是周爱民。
在前端的历史中有很多人都曾经因为同一道面试题而彻夜不眠。这道题出现在9年之前它的提出者“蔡mc蔡美纯”曾是JQuery的提交者之一如今已经隐去多年不复现身于前端。然而这道经典面试题仍然多年挂于各大论坛被众多后来者一遍又一遍地分析。
在2010年10月[Snandy](https://github.com/snandy)于iteye/cnblogs上发起对这个话题的讨论之后淘宝的玉伯lifesinger也随即成为这个问题早期的讨论者之一并写了一篇“**a.x = a = { }, 深入理解赋值表达式**”来专门讨论它。再后来,随着它在各种面试题集中频繁出现,这个问题也就顺利登上了知乎,成为一桩很有历史的悬案。
蔡mc最初提出这个问题时用的标题是“**赋值运算符:"=", 写了10年javascript未必全了解的"="**”,原本的示例代码如下:
```
var c = {};
c.a = c = [];
alert(c.a); //c.a是什么
```
蔡mc是在阅读JQuery代码的过程中发现了这一使用模式
```
elemData = {}
...
elemData.events = elemData = function(){};
elemData.events = {};
```
并质疑,为什么`elemData.events`需要连续两次赋值。而Snandy在转述的时候换了一个更经典、更有迷惑性的示例
```
var a = {n:1};
a.x = a = {n:2};
alert(a.x); // --&gt; undefined
```
Okay这就是今天的主题。
接下来,我就为你解释一下,为什么在第二行代码之后`a.x`成了undefined值。
## 与声明语句的不同之处
你可能会想三行代码中出问题的为什么不是第1行代码
在上一讲的讨论中,声明语句也可以是一个连等式,例如:
```
var x = y = 100;
```
在这个示例中“var”关键字所声明的事实上有且仅有“x”一个变量名。
在可能的情况下变量“y”会因为赋值操作而导致JavaScript引擎“**意外**”创建一个全局变量。所以声明语句“var/let/const”的一个关键点在于语句的关键字var/let/const只是用来“声明”变量名x的去除掉“var x”之后剩下的部分并不是一个严格意义上的“赋值运算”而是被称为“初始器Initializer”的语法组件它的词法描述为
>
**Initializer**: **=** **AssignmentExpression**
在这个描述中,“=”号并不是运算符,而是一个语法分隔符号。所以,之前我在讲述这个部分的时候,总是强调它“被实现为一个赋值操作”,而不是直接说“它是一个赋值操作”,原因就在这里。
如果说在语法“var x = 100”中“= 100”是向x绑定值那么“var x”就是单纯的标识符声明。这意味着非常重要的一点——**“x”只是一个表达名字的、静态语法分析期作为标识符来理解的字面文本而不是一个表达式**。
而当我们从相同的代码中去除掉“var”关键字之后
```
x = y = 100;
```
其中的“x”却是一个表达式了它被严格地称为“赋值表达式的左手端lhs操作数”。
所以关键的区别在于赋值表达式左侧的操作数可以是另一个表达式——这在专栏的第一讲里就讲过了而“var声明”语句中的等号左边绝不可能是一个表达式
也许你会质疑难道ECMAScript 6之后的模板赋值的左侧也不是表达式确实答案是如果它用在声明语句中那么就“不是”。
对于声明语句来说紧随于“var/let/const”之后的一定是变量名标识符且无论是一个或多个都是在JavaScript语法分析阶段必须能够识别的。
如果这里是赋值模板那么“var/let/const”语句也事实上只会解析那些用来声明的变量名并在运行期使用“初始器Initializer”来为这些名字绑定值。这样“变量声明语句”的语义才是确定的不至于与赋值行为混淆在一起。
因此根本上来说在“var声明”语法中变量名位置上就是写不成`a.x`的。例如:
```
var a.x = ... // &lt;- 这里将导致语法出错
```
所以在最初蔡mc提出这个问题时以及其后Sanady和玉伯的转述中都不约而同地在代码中绕过了第一行的声明而将问题指向了第二行的连续赋值运算。
```
var a = {n:1}; // 第一行
a.x = a = {n:2}; // 第二行
...
```
## 来自《JavaScript权威指南》的解释
有人曾经引述《JavaScript权威指南》中的一段文字4.7.7 运算顺序),来解释第二行的执行过程:
>
JavaScript总是严格按照从左至右的顺序来计算表达式。
并且还举了一个例子:
>
例如,在表达式`w = x + y * z`将首先计算子表达式w然后计算x、y和z然后y的值和z的值相乘再加上x的值最后将其赋值给表达式w所指代的变量或属性。
《JavaScript权威指南》的解释是没有问题的。首先在这个赋值表达式的右侧`x + y*z`中,`x``y*z`是求和运算的两个操作数任何运算的操作数都是严格从左至右计算的因此x先被处理然后才会尝试对`y``z`求乘积。这里所谓的“x先被处理”是JavaScript中的一个特异现象
>
一切都是表达式,一切都是运算。
这一现象在语言中是函数式的特性,类似“一切被操作的对象都是函数求值的结果,一切操作都是函数”。
这对于以过程式的,或编译型语言为基础的学习者来说是很难理解的,因为在这些传统的模式或语言范型中,所谓“标识符/变量”就是一个计算对象,它可能直接表达为某个内存地址、指针,或者是一个编译器处理的东西。对于程序员来说,将这个变量直接理解为“操作对象”就可以了,没有别的、附加的知识概念。例如:
```
a = 100
b * c
```
这两个例子中a、b、c都是确定的操作数我们只需要
- 将第一行理解为“a有了值100”
- 将第二行理解为“b与c的乘积”
就可以了,至于引擎怎么处理这三个变量,我们是不管的。
然而在JavaScript中上面一共是有六个操作的。以第二行为例包括
-`b`理解为单值表达式,求值并得到`GetValue(evalute('b'))`
-`c`理解为单值表达式,求值并得到`GetValue(evalute('c'))`
- 将上述两个值理解为求积表达式’*'的两个操作数,计算
>
`evalute('*', GetValue(evalute('b')), GetValue(evalute('c')))`
所以,关键在于`b``c`在表达式计算过程中都并不简单的是“一个变量”而是“一个单值表达式的计算结果”。这意味着在面对JavaScript这样的语言时你需要关注“变量作为表达式是什么以及这样的表达式如何求值以得到变量”。
那么,现在再比较一下今天这一讲和上一讲的示例:
```
var x = y = 100;
a.x = a = {n:2}
```
在这两个例子中,
- x是一个标识符不是表达式而y和100都是表达式`y = 100`是一个赋值表达式。
- a.x是一个表达式`a = {n:2}`也是表达式,并且后者的每一个操作数(本质上)也都是表达式。
这就是“语句与表达式”的不同。正如上一讲的所强调的:“**var x”从来都不进行计算求值所以也就不能写成“var a.x …**”。
>
所以严格地说在上一讲的例子中并不存在连续赋值运算因为“var x = …”是**值绑定操作**而不是“将…赋值给x”。在代码`var x = y = 100;`中实际只存在一个赋值运算那就是“y = 100”。
## 两个连续赋值的表达式
所以,今天标题中的这行代码,是真正的、两个连续赋值的表达式:
```
a.x = a = {n:2}
```
并且,按照之前的理解,`a.x`总是最先被计算求值的(从左至右)。
回顾第一讲的内容你也应该记得所谓“a.x”也是一个表达式其结果是一个“引用”。这个表达式“a.x”本身也要再计算它的左操作数也就是“a”。完整地讲“a.x”这个表达式的语义是
- 计算单值表达式`a`,得到`a`的引用;
- 将右侧的名字`x`理解为一个标识符,并作为“.”运算的右操作数;
- 计算“a.x”表达式的结果Result
表达式“a.x”的计算结果是一个引用因此通过这个引用保存了一些计算过程中的信息——例如它保存了“a”这个对象以备后续操作中“可能会”作为`this`来使用。所以现在在整行代码的前三个表达式计算过程中“a”是作为一个**引用**被暂存下来了的。
那么这个“a”现在是什么呢
```
var a = {n:1};
a.x = ...
```
从代码中可见保存在“a.x”这个引用中的“a”是当前的“{n:1}”这个对象。好的,接下来再继续往下执行:
```
var a = {n:1};
a.x = // &lt;- `a` is {n:1}
a = // &lt;- `a` is {n:1}
...
```
这里的“a = …”中的`a`仍然是当前环境中的变量,与上一次暂存的值是相同的。这里仍然没有问题。
但接下来,发生了赋值:
```
...
a.x = // &lt;- `a` is {n:1}
a = // &lt;- `a` is {n:1}
{n:2}; // 赋值,覆盖当前的左操作数(变量`a`
```
于是,左操作数`a`作为一个引用被覆盖了,这个引用仍然是当前上下文中的那个变量`a`。因此,这里真实地发生了一次`a = {n:2}`
那么现在表达式最开始被保留在“一个结果Result”中的引用`a`会更新吗?
不会的。这是因为那是一个“**运算结果**Result这个结果有且仅有引擎知道它现在是一个引擎才理解的“**引用**(规范对象)”,对于它的可能操作只有:
- 取值或置值GetValue/PutValue以及
- 作为一个引用向别的地方传递等。
当然如同第一讲里强调的它也可以被typeof和delete等操作引用的运算来操作。但无论如何在JavaScript用户代码层面能做的主要还是**取值**和**置值**。
现在,在整个语句行的最左侧“**空悬**”了一个已经求值过的“a.x”。当它作为赋值表达式的左操作数时它是一个被赋值的引用这里是指将`a.x`的整体作为一个引用规范对象。而它作为结果Result所保留的“a”是在被第一次赋值操作覆盖之前的、那个“原始的变量`a`”。也就是说如果你试图访问它的“a.n”那应该是值“1”。
这个被赋值的引用“a.x”其实是一个未创建的属性赋值操作将使得那个“原始的变量`a`”具有一个新属性,于是它变成了下面这样:
```
// a.x中的“原始的变量`a`”
{
x: {n: 2}, // &lt;- 第一次赋值“a = {n:2}”的结果
n: 1
}
```
这就是第二次赋值操作的结果。
## 复现现场
上面发生了两次赋值第一次赋值发生于“a = {n: 2}”,它覆盖了“原始的变量`a`第二次赋值发生于被“a.x”引用暂存的“原始的变量`a`”。
我可以给出一段简单的代码,来复现这个现场,以便你看清这个结果。例如:
```
// 声明“原始的变量a”
var a = {n:1};
// 使它的属性表冻结(不能再添加属性)
Object.freeze(a);
try {
// 本节的示例代码
a.x = a = {n:2};
}
catch (x) {
// 异常发生说明第二次赋值“a.x = ...”中操作的`a`正是原始的变量a
console.log('第二次赋值导致异常.');
}
// 第一次赋值是成功的
console.log(a.n); //
```
第二次赋值操作中,将尝试向“原始的变量`a`”添加一个属性“a.x“且如果它没有冻结的话属性“a.x”会指向第一次赋值的结果。
## 回到标题中的示例
那标题中的这行代码的最终结果是什么呢?答案是:
- 有一个新的`a`产生,它覆盖了原始的变量`a`,它的值是`{n:2}`
- 最左侧的“a.x”的计算结果中的“原始的变量`a`”在引用传递的过程中丢失了且“a.x”被同时丢弃。
所以第二次赋值操作“a.x = …”实际是无意义的。因为它所操作的对象,也就是“原始的变量`a`”被废弃了。但是,如果有其它的东西,如变量、属性或者闭包等,持有了这个“原始的变量`a`”,那么上面的代码的影响仍然是可见的。
>
事实上由于JavaScript中支持属性读写器因此向“a.x”置值的行为总是可能存在“某种执行效果”而与“a”对象是否被覆盖或丢弃无关。
例如:
```
var a = {n:1}, ref = a;
a.x = a = {n:2};
console.log(a.x); // --&gt; undefined
console.log(ref.x); // {n:2}
```
这也解释了最初“蔡mc”的疑问连续两次赋值`elemData.events`有什么用?
如果`a`(或`elemData`)总是被重写的旧的变量,那么如下代码:
```
a.x = a = {n:2}
```
意味着给**旧的变量**添加一个**指向新变量的属性**。因此,一个链表是可以像下面这样来创建的:
```
var i = 10, root = {index: &quot;NONE&quot;}, node = root;
// 创建链表
while (i &gt; 0) {
node.next = node = new Object;
node.index = i--; // 这里可以开始给新node添加成员
}
// 测试
node = root;
while (node = node.next) {
console.log(node.index);
}
```
最后,我做这道面试题做一点点细节上的补充:
- 这道面试题与运算符优先级无关;
- 这里的运算过程与“栈”操作无关;
- 这里的“引用”与传统语言中的“指针”没有可比性;
- 这里没有变量泄漏;
- 这行代码与上一讲的例子有本质的不同;
- 上一讲的例子“var x = y = 100”严格说来并不是连续赋值。
## 知识回顾
前三讲中我通过对几行特殊代码的分析希望能帮助你理解“引用规范类型”在JavaScript引擎内部的基本运作原理包括
- 引用在语言中出现的历史;
- 引用与值的创建与使用以及它的销毁delete
- 表达式(求值)和引用之间的关系;
- 引用如何在表达式连续运算中传递计算过程的信息;
- 仔细观察每一个表达式(及其操作数)计算的顺序;
- 所有声明,以及声明语句的共性。
## 复习题
下面有几道复习题,希望你尝试解答一下:
1. 试解析`with ({x:100}) delete x;` 将发生什么。
1. 试说明`(eval)()``(0, eval)()`的不同。
1. 设“a.x === 0”试说明“(a.x) = 1”为什么可行。
1. 为什么`with (obj={}) x = 100;` 不会给obj添加一个属性x
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,294 @@
<audio id="audio" title="04 | export default function() {}:你无法导出一个匿名函数表达式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/67/d4/67e0433087a241e999716b1a5196c0d4.mp3"></audio>
你好,我是周爱民,欢迎回到我的专栏。
今天我要讲述的内容是从ECMAScript 6开始在JavaScript中出现的**模块技术**这对许多JavaScript开发者来说都是比较陌生的。
一方面在于它出现得较晚另一方面则是因为在普遍使用的Node.js环境带有自己内置的模块加载技术。因此ECMAScript 6模块需要通过特定的命令行参数才能开启它的应用一直以来也就不够广泛。
导致这种现象的根本原因在于**ECMAScript 6模块是静态装配的**而传统的Node.js模块却是动态加载的。因而两种模块的实现效果与处理逻辑都大相径庭Node.js无法在短期内提供有效的手段帮助开发者将既有代码迁移到新的模块规范下。
总结起来确实是这些更为现实的原因阻碍了ECMAScript 6模块技术的推广而非是ECMAScript 6模块是否成熟或者设计得好与不好。
不过即使如此ECMAScript 6模块仍然在JavaScript的一些大型应用库、包或者对新规范更友好的项目中得到了不错的运用和不俗的反响尤其是在使用转译器例如Babel的项目中开发者通常是首选ECMAScript 6模块语法的。
因此ECMAScript 6模块也有着非常好的应用环境与前景。
## 导出的内容
上一讲我提到过有且仅有六种声明语法而本质上export也就只能导出这六种声明语法所声明的标识符并且在导出时将它们统一称为“名字”。
在语言设计中所谓“标识符”与“名字”是有语义差别的export将之称为名字就意味着这是一个标识符的子集。类似的其它子集也是存在的例如“保留字是标识符名但不能用作标识符A reserved word is an IdentifierName that cannot be used as an Identifier”。
在JavaScript语言的设计上除了那些预设的标点符号例如大括号、运算符之类以及部分的保留字和关键字之外事实上用户代码可以书写的只有三种东西。这包括
- 标识符:(通常是)一个**名字**
- 字面量:表明由它的字面含义所决定的一个**值**
- 模板:一个可计算结果的字符串**值**。
所以如果在这个层面上解构一份你所书写的JavaScript代码那么你所能书写/声明的,就一定只有“名字和值”。
这个结论是非常非常关键的。为什么呢因为export事实上就只能导出“名字和值”。然而一旦它能导出“名字和值”也就意味着它能导出一个模块中的“全部内容”因为如上所面所讲的
“名字和值”正是你所书写的代码的全部。
## 我的代码去哪儿了呢?
你是不是一刹那之间觉得自己的代码都白写了。:)
确实是的,真的是白写了。不过,我在前面讲的都是纯粹的“语言设计”,在语言设计层面上来讲,代码就是文本,是没有应用逻辑的。而你所写的代码绝大多数都是应用逻辑,当去除掉这些应用逻辑之后,那些剩下的死气沉沉的、纯粹的符号,才是语言层面的所谓“代码文本”。
去掉了执行逻辑所表达的那些行为、动作、结果和用户操作的代码就是静态代码了。而事实上ECMAScript 6中的模块就是用来理解你的程序中的那些静态代码的也就是那些没有任何生气的字符和符号。因此它也就只能理解上面所谓的6种声明以及它们声明出来的那些“名字和值”。
再无其它。
## 解析export
所以将所有export语法分类其实也就只有两个大类。如下
```
// 导出“(声明的)名字”
export &lt;let/const/var&gt; x ...;
export function x() ...
export class x ...
export {x, y, z, ...};
// 导出“(重命名的)名字”
export { x as y, ...};
export { x as default, ... };
// 导出“(其它模块的)名字”
export ... from ...;
// 导出“值”
export default &lt;expression
```
关于导出声明的、重命名的和其它模块的名字这三种情况,其实都比较容易理解,就是形成一个名字表,让外部模块能够查看就可以了。
但是对于最后这种形式,也就是“(导出)值”的形式,事实上是非常特殊的。因为如同我在上面所讲过的,要导出一个模块的全部内容就必须导出“(全部的)名字和值”,然而纯粹的值没有名字,于是也就没法访问了,所以这就与“导出点什么东西”的概念矛盾了。
因为这个东西要是没名字,也就连“自己是什么”都说不清楚,也就什么也不是了。
所以ECMAScript 6模块约定了一个称为"default"的名字,用于来导出当前模块中的一个“值”。显然的,由于所谓“值”是表达式的运算结果,所以这里的语法形式就是:
```
export default &lt;expression&gt;;
```
其中的“_expression”_就是用于求值的以便得到一个结果Result并导出成为缺省的名字“default”。这里有两个便利的情况一个是在JavaScript中一般的字面量也是值、也是单值表达式因此导出这样一个字面量也是合法的
```
export default 2; // as state of the module, etc.
export default &quot;some messages&quot;; // data or information
...
```
第二个便利的情况是因为JavaScript中对象也是字面量、也是值、也是单值表达式。而对象成员可以组合其它任何数据所以通过上述的语法几乎可以导出当前模块中全部的“值”亦即是任何可以导出的数据。例如
```
var varName = 100;
export default {
varName, // 直接导出名字
propName: 123, // 导出值
funcName: function() { }, // 导出函数
foo() { // 或导出与主对象相关联的方法
// method
}
}
```
所以,事实上`export default ...`虽然简单,却是对“导出名字”的非常必要的补充。这样一来,用户既可以导出那些有名字的数据,也可以导出那些没有名字的数据,即一个模块中所有的数据都可以被导出了。
那么接下来,就要讲到标题中的这个语法了:
```
export default function() {}
```
你知道在这个语法中export到底导出了什么吗是名字还是值
## 导出语句的处理逻辑
在讨论这个问题之前你得先思考一个更关键的问题“export如何导出名字”。这个问题的关键之处在于如果只是导出一个名字那么它其实在“某个名字表”中做一个登记项就可以了。并且JavaScript中也的确是这样处理的。但是实际使用的时候这个名字还是要绑定一个具体的值才是可以使用的。因此一个export也必须理解为这样两个步骤
1. 导出一个名字
1. 为上述名字绑定一个值
这两个步骤其实与使用“var x = 100”来声明一个变量的过程是一致的。因此以如下代码为例注意六种声明在名字处理上是类似的
```
export var x = 100;
```
在导出的时候其实是先在“某个名字表”中登记一个“名字x”就可以了。这个过程也就是JavaScript在模块装载之前对export所做的全部工作。不过如果是从另一端亦即是import语句的角度看过来那么就会多出来一个步骤。import语句会例如`import {x} from ...`
1. 与export类似按照语法在当前模块中声明名字例如上面的`x`
1. 添加一个当前模块对目标模块的依赖项。
有了上述的第二步操作JavaScript就可以依据所有它能在静态文本中发现的`import`语句来形成模块依赖树,最后就可以找到这个模块依赖树最顶端的根模块,并尝试加载之。
所以关键的是在“模块export/import”语法中 JavaScript是依赖import来形成依赖树的与export无关。但是直到目前为止我的意思是直到找到所有导入和导出的名字并完成所有模块的装配的现在为止没有任何一行用户的JavaScript代码是被执行过的。至于原因从本讲的最开始我就讲过了这个export/import过程中源代码只被理解为静态的、没有逻辑的“代码文本”。那么既然“没有逻辑”又怎么可能执行类似于
```
export default &lt;expression&gt;;
```
中的“**expression**”呢?要知道所谓表达式,就是程序的计算逻辑啊。
所以,这里先得出了第一个关键结论:
在处理export/import语句的全程没有表达式被执行
## 导出名字与导出值的差异
现在,假如:
```
export default &lt;expression&gt;;
```
中的“expression”在导入导出中完全不起作用不执行那么这行语句又能做什么呢事实上这行语句与直接“导出一个名字”并没有任何区别。它与这样的语法相同
```
export var x = 100;
```
它们都只是导出一个名字只是前者导出的是“default”这个特殊名字而后者导出的是一个变量名“x”。它们都是确定的、符合语法规则的标识符也可以表示为一个字符串的字面文本。它们的作用也完全一致就是在前面所说的“某个名字表”中添加“一个登记项”而已。
所以,导出名字与导出值本质上并没有差异,在静态装配的阶段,它们都只是表达为一个名字而已。
然后,也正是如同`var x = 100;`在执行阶段需要有一个将“值100”绑定给“变量x的引用”的过程一样这个`export default ...;`语句也需要有完全相同的一个过程来将它后面的表达式(**expression**的结果绑定给“default”这个名字。如果不这么做那么“**export default**”在语义上的就无法实现导出名字“**default**”了——在静态装配阶段名字“default”只是被初始化为一个“单次绑定的、未初始化的标识符”。
所以现在你就可以在语义上模拟这样一个过程,即:
```
export default function() {}
// 类似于如下代码
//(但并不在当前模块中声明名字&quot;default&quot;
export var default = function() {}
```
你可以进一步地模拟JavaScript后续的装配过程。这个过程其实非常简单
- 找到并遍历模块依赖树的所有模块(这个树是排序的),然后
- 执行这些模块最顶层的代码(**Top Level Module Evaluation**)。
在执行到上述`var default ....`(或类似对应的`export default ...`语句时执行后面的表达式并将执行结果Result绑定给左侧的那个变量就可以了。如此直到所有模块的顶层代码都执行完毕那么所有的导出名字和它们的值也都必然是绑定完成了的。
同样由于import的名字与export的名字只是一个映射关系所以import的名字所对应的值也就初始化完成了。
再确切地说(这是第二个关键结论):
所谓模块的装配过程,就是执行一次顶层代码而已。
## 匿名函数表达式的执行结果
接下来讨论语句中的`... function() {}`这个匿名函数表达式。
按照JavaScript的约定匿名函数表达式可以理解为一个函数的“字面量”。理解“字面量值”这个说法是很有意义的因为它意味着它没有名字。你可不要在心中暗骂哦这绝不是废话。
“字面量没有名字”就意味着执行这个“单值表达式”不会在当前作用域中产生一个名字即使这个函数是具名的也必然是如此。所以这才带来了JavaScript中的经典示例具名函数作为表达式时名字在块级作用域中无意义。例如
```
// 具名函数作为表达式
var x1 = function x2() {
...
}
// 具名函数(声明)
function x3() {
...
}
```
上面的例子中x1~3都是具有不同的语义的。其中x2是不会在当前作用域示例中是全局中登记为名字的。而现在就这一讲的主题来说在使用下面的语法
```
export default function() { }
export default function x() { }
```
导出一个匿名函数,或者一个具名的函数的时候,这两种情况下是不同的。但无论它是否具名,它们都是不可能在当前作用域中绑定给`default`这个名字,作为这个名字对应的值的。
这段处理逻辑被添加在语法:
>
**ExportDeclaration**: **export** **default** **AnonymousFunctionDefinition**;
>
NOTE: ECMAScript是将这里导出的对象称为_Expression_/**AssignmentExpression**这里所谓_AnonymousFunctionDefinition_则是其中_AssignmentExpression_的一个具体实例。
的执行(**Evaluation**)处理过程中。也就是说当执行这行声明时,如果后面的表达式是匿名函数声明,那么它将强制在当前作用域中登记为“****<em>default**</em>**”这样一个特殊的名字,并且在执行时绑定该匿名函数。所以,尽管语义上我们需要将它登记为类似`var default ...`所声明的名字“****default****”,但事实上它被处理成了一个不可访问的中间名字,然后影射给该模块的“某个名字表”。
不过需要注意的是,这是一个**匿名函数定义****AnonymousFunctionDefinition**),而不是一个匿名函数表达式(**Anonymous FunctionExpression**)。一般函数的语句则被称为声明(或更严谨地称为宣告,**Function Declarations**)。而所谓**匿名函数定义**,其本身是表述为:
>
**aName** = ****FunctionExpression****
或类似于此的语法风格的。它可以用在一般的赋值表达式、变量声明的右操作数,以及对象声明的成员初始值等等位置。在这些位置上,该函数表达式总是被关联给一个名字。一方面,这种关联不是严格意义上的“名字-&gt;值”的绑定语义;另一方面,当该函数关联给名字(`aName`JavaScript又会反向地处理该函数作为对象`f`)的属性`f.name`,使该名字指向`aName`
所以在本讲中的“export default function() {}”,在严格意义上来说(这是第三个关键结论):
它并不是导出了一个匿名函数表达式而是导出了一个匿名函数定义Anonymous Function Definition
因此,该匿名函数初始化时才会绑定给它左侧的名字“****default****”,这会导致`import f from ...`之后访问`f.name`值会得到“****default****”这个名字。
类似的,你使用下面的代码也会得到这个“****default****”:
```
var obj = {
&quot;default&quot;: function() {}
};
console.log(obj.default.name); // &quot;default&quot;
```
## 知识补充
关于export还可以有一些补充的知识点。
- `export ...`语句通常是按它的词法声明来创建的标识符的,例如`export var x = ...`就意味着在当前模块环境中创建的是一个变量,并可以修改等等。但是当它被导入时,在`import`语句所在的模块中却是一个常量,因此总是不可写的。
- 由于`export default ...`没有显式地约定名字“default或**default**)”应该按`let/const/var`的哪一种来创建因此JavaScript缺省将它创建成一个普通的变量var但即使是在当前模块环境中它事实上也是不可写的因为你无法访问一个命名为“**default**”的变量——它不是一个合法的标识符。
- 所谓匿名函数,仅仅是当它直接作为操作数(而不是具有上述“匿名函数定义”的语法结构)时,才是真正匿名的,例如:
```
console.log((function(){}).name); // &quot;&quot;
```
- 由于类表达式包括匿名类表达式在本质上就是函数因此它作为default导出时的性质与上面所讨论的是一致的。
- 导出项(的名字)总是作为词法声明被声明在当前模块作用域中的,这意味着它不可删除,且不可重复导出。亦即是说即使是用`var x...`来声明,这个`x`也是在_lexicalNames_中而不是在_varNames_中。
- 所谓“某个名字表”对于export来说是模块的导出表对于import来说就是名字空间名字空间是用户代码可以操作的组件它映射自内部的模块导入名字表。不过如果用户代码不使用“import * as …”的语法来创建这个名字空间那么该名字表就只存在于JavaScript的词法分析过程中而不会或并不必要创建它在运行期的实例。这也是我一直用“某个名字表”来称呼它的原因它并不总是以实体形式存在的。
- 上述名字表简化了ECMAScript中对导入导出记录**ImportEntry/ExportEntry Record Fields**的理解。因此如果你试图了解更多建议你阅读ECMAScript的具体章节。
- 没有模块会导出传统意义上的main()因为ECMAScript为了维护模块的静态语义而把执行过程及其入口的定义丢回给了引擎或宿主本身。
## 思考题
本讲的内容中,你需要重点复习三个关键结论的得出过程。这对于之前几讲中所讨论的内容会是很好的回顾。
除此之外,建议你思考如下问题:
- 为什么在import语句中会出现“变量提升”的效果
如果你并不了解什么是“变量提升”,不用担心,下一讲中我会再次提到它。

View File

@@ -0,0 +1,263 @@
<audio id="audio" title="05 | for (let x of [1,2,3]) ...for循环并不比使用函数递归节省开销" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/fe/454307d34b873ef16a9f3a78bafaccfe.mp3"></audio>
你好我是周爱民。欢迎回到我的专栏我将为你揭示JavaScript最为核心的那些实现细节。
语句是JavaScript中组织代码的基础语法组件包括函数声明等等在内的六种声明其实都被归为“语句”的范畴。因此如果将一份JavaScript代码中的所有语句抽离掉那么大概就只会剩下为数不多的、在全局范围内执行的表达式了。
所以理解“语句”在JavaScript中的语义是重中之重。
尽管如此,你实际上要了解的也无非是**顺序**、**分支**、**循环**这三种执行逻辑而已,相比于它们,其它语句在语义上的复杂性通常不值一提。而这三种逻辑中尤其复杂的就是循环,今天的这一讲,我就来给你讲讲它。
## 块
在ECMAScript 6之后JavaScript实现了**块级作用域**。因此现在绝大多数语句都基于这一作用域的概念来实现。近乎想当然的几乎所有开发者都认为一个JavaScript语句就有一个自己的块级作用域。这看起来很好理解因为这样处理是典型的、显而易见的**代码分块的结果**。
然而,事实上正好相反。
真正的状况是,**绝大多数JavaScript语句都并没有自己的块级作用域**。从语言设计的原则上来看,越少作用域的执行环境调度效率也就越高,执行时的性能也就越好。
基于这个原则,`switch`语句被设计为有且仅有一个作用域无论它有多少个case语句其实都是运行在一个块级作用域环境中的。例如
```
var x = 100, c = 'a';
switch (c) {
case 'a':
console.log(x); // ReferenceError
break;
case 'b':
let x = 200;
break;
}
```
在这个例子中switch语句内是无法访问到外部变量`x`的,即便声明变量`x`的分支`case 'b'`永远都执行不到。这是因为所有分支都处在同一个块级作用域中,所以任意分支的声明都会给该作用域添加这个标识符,从而覆盖了全局的变量`x`
一些简单的、显而易见的块级作用域包括:
```
// 例1
try {
// 作用域1
}
catch (e) { // 表达式e位于作用域2
// 作用域2
}
finally {
// 作用域3
}
// 例2
//(注:没有使用大括号)
with (x) /* 作用域1 */; // &lt;- 这里存在一个块级作用域
// 例3, 块语句
{
// 作用域1
```
除了这三个语句和“**一个特例**”之外,所有其它的语句都是没有块级作用域的。例如`if`条件语句的几种常见书写形式:
```
if (x) {
...
}
// or
if (x) {
...
}
else {
...
}
}
```
这些语法中的“块级作用域”都是一对大括号表示的“块语句”自带的与上面的“例3”是一样的而与`if`语句本身无关。
那么,这所谓的“一个特例”是什么呢?这个特例,就是今天这一讲标题中的`for`循环。
## 循环语句中的块
并不是所有的循环语句都有自己的块级作用域例如while和do..while语句就没有。而且也不是所有for语句都有块级作用域。在JavaScript中有且仅有
>
for (**&lt;let/const&gt;** ...) ...
这个语法有自己的块级作用域。当然,这也包括相同设计的`for await``for .. of/in ..`。例如:
>
<p>for await (**&lt;let/const&gt;** x of ...) ...<br>
for (**&lt;let/const&gt;** x ... in ...) ...<br>
for (**&lt;let/const&gt;** x ... of ...) ...</p>
等等。你应该已经注意到了这里并没有按照惯例那样列出“var”关键字。关于这一点后面写到的时候我也会再次提及到。就现在来说你可能需要关心的问题是**为什么这是个特例?**以及,**如果它是拥有自己的块级作用域的特例,那么它有多少个块级作用域呢?**
后面这个问题的答案,是:“说不准”。
看起来我是被JavaScript的古怪设计击败了。我居然给出了这么一个显而易见是在糊弄大众的答案但是要知道所谓的“块级作用域”有两种形式一种是静态的词法作用域这对于上面的for语句来说它们都只有两个块级作用域。但是对于另一种动态的、“块级作用域”的实例来说这答案就真的是“说不准”了。
不过,先放下这个,我接下来先给你解释一下“**为什么这里需要一个特例**”。
## 特例
除了语句的关键字和语法结构本身之外,语句中可以包括什么呢?
如果你归纳一下语句中可以包含的全部内容你应该可以看到一个简单的结果所有在语句内可以存在的东西只有四种表达式、其它语句、标识符声明取决于声明语句或其它的隐式声明的方式以及一种特殊的语法元素称为“标签例如标签化语句或break语句指向的目标位置”。
所谓“块级作用域”,本质上只包括一组标识符。因此,只有当存在潜在标识符冲突的时候,才有必要新添加一个作用域来管理它们。例如函数,由于函数存在“重新进入”的问题,所以它必须有一个作用域来管理“重新进入之前”的那些标识符。这个东西想必你是听说过的,它被称为“**闭包**”。
>
NOTE: 在语言设计时有三种需求会促使语句构建自己的作用域标识符管理只是其中之一。其它两种情况要么是因为在语法上支持多语句例如try...catch..finally语句要么是语句所表达的语义要求有一个块例如“块语句{ }”在语义上就要求它自己是一个块级作用域。
所以**标签**、**表达式**和**其它语句**这三种东西都不需要使用一个“独立作用域”去管理起来。所谓“其它语句”当然存在这种冲突,不过显然这种情况下它们也应该自己管理这个作用域。所以,对于当前语句来说,就只需要考虑剩下的唯一一种情况,就是在“**语句中包含了标识符声明**”的情况下,需要创建块级作用域来管理这些声明出来的标识符。
在所有六种声明语句之外,只剩下`for (&lt;let/const&gt;...)...`这一个语句能在它的语法中去做这样的标识符声明。所以,它就成了块级作用域的这个唯一特例。
那么这个语法中为什么单单没有了“var声明”呢
## 特例中的特例
“var声明”是特例中的特例。
这一特性来自于**JavaScript远古时代的作用域设计**。在早期的JavaScript中并没有所谓的块级作用域那个时候的作用域设计只有“函数内”和“函数外”两种如果一个标识符不在任何可以多层嵌套的函数内的话那么它就一定是在“全局作用域”里。
“函数内→全局”之间的作用域,就只有概念上无限层级的“函数内”。
而在这个时代变量也就只有“var声明”的变量。由于作用域只有上面两个所以任何一个“var声明”的标识符要么是在函数内的要么就是在全局的没有例外。按照这个早期设计如下语句中的变量`x`
```
for (var x = ...)
...
```
是不应该出现在“**for语句所在的**”块级作用域中的。它应该出现其外层的某个函数作用域,或者全局作用域中。这种越过当前语法范围,而在更外围的作用域中登记名字行为就称为“**提升Hoisting/Hoistable**”。
ECMAScript 6开始的JavaScript在添加块级作用域特性时充分考虑了对旧有语法的兼容因此当上述语法中出现“var声明”时它所声明的标识符是与该语句的块级作用域无关的。在ECMAScript中这是两套标识符体系也是使用两套作用域来管理的。确切地说
- 所有“var声明”和函数声明的标识符都登记为varNames使用“**变量作用域**”管理;
- 其它情况下的标识符/变量声明都作为lexicalNames登记使用“**词法作用域**”管理。
>
NOTE: 考虑到对传统JavaScript的兼容函数内部的顶层函数名是提升到变量作用域中来管理的。 &gt; &gt; NOTE: 我通常会将“在变量声明语句前使用该变量”也称为一种提升效果(**Hoisting effect**)但这种说法不见于ECMAScript规范。ES规范将这种“提前使用”称为“访问一个未初始化的绑定**uninitialized mutable/immutable binding**”。而所谓“var声明能被提前使用”的效果事实上是“var变量总是被引擎预先初始化为undefined”的一种后果。
所以,语句`for (&lt;const/let&gt; x ...) ...`语法中的标识符`x`是一个**词法名字**,应该由`for`语句为它创建一个(块级的)词法作用域来管理之。
然而进一步后,新的问题产生了:一个词法作用域是足够的吗?
## 第二个作用域
首先,必须要拥有至少一个块级作用域。如之前讲到的,这是出于管理标识符的必要性。下面的示例简单说明这个块级作用域的影响:
```
var x = 100;
for (let x = 102; x &lt; 105; x++)
console.log('value:', x); // 显示“value: 102~104”
console.log('outer:', x); // 显示“outer: 100”
```
因为`for`语句的这个块级作用域的存在,导致循环体内访问了一个局部的`x`循环变量而外部的outer变量`x`是不受影响的。
那么在循环体内是否需要一个新的块级作用域呢?这取决于在语言设计上是否支持如下代码:
```
for (let x = 102; x &lt; 105; x++)
let x = 200;
```
也就是说,如果循环体(单个语句)允许支持新的变量声明,那么为了避免它影响到循环变量,就必须为它再提供另一个块级作用域。很有趣的是,**在这里JavaScript是不允许声明新的变量的**。上述的示例会抛出一个异常,提示你“单语句不支持词法声明”:
>
**SyntaxError: Lexical declaration cannot appear in a single-statement context**
这个语法错误并不常见,因为很少有人会尝试构建这样的特殊代码。然而事实上,它是一个普遍存在的语法禁例,例如以下语句语法:
```
// if语句中的禁例
if (false) let x = 100;
// while语句中的禁例
while (false) let x = 200;
// with语句中的禁例
with (0) let x = 300
```
所以,现在可以确定:循环语句(对于支持“**let/const**”的for语句来说“通常情况下”只支持一个块级作用域。更进一步地说在上面的代码中我们并没有机会覆盖for语句中的“let/const”声明。
但是如果在for语句支持了let/const的情况下仅仅只有一个块级作用域是不方便的。例如
```
for (let i=0; i&lt;2; i++) /* 用户代码 */;
```
在这个例子中“只有一个块级作用域”的设计将会导致“用户代码”直接运行在与“let声明”相同的词法作用域中。对于这个例子来说这一切还好因为“let i = 0”这个代码只执行了一次因为它是for语句的初始化表达式。
但是对于下面这个例子来说,“只有一个块级作用域”就不够了:
```
for (let i in x) ...;
```
在这个例子中“let i ...”在语义上就需要被执行多次——因为在静态结构中它的多次迭代都作用于同一个语法元素。而你是知道的let语句的变量不能重复声明的。所以这里就存在了一个冲突“let/const”语句的单次声明不可覆盖的设计与迭代多次执行的现实逻辑矛盾了。
这个矛盾的起点就是“只有一个块级作用域”。所以在JavaScript引擎实现“支持_let/const_的for语句”时就在这个地方做了特殊处理为循环体增加一个作用域。
这样一来“let i”就可以只执行一次然后将“i in x”放在每个迭代中来执行这样避免了与“let/const”的设计冲突。
上面讲的其实是JavaScript在语法设计上的处理也就是在语法设计上需要为使用`let/const`声明循环变量的for语句多添加一个作用域。然而这个问题到了具体的运行环境中变量又有些不同了。
## for循环的代价
在JavaScript的具体执行过程中作用域是被作为环境的上下文来创建的。如果将for语句的块级作用域称为**forEnv**,并将上述为循环体增加的作用域称为**loopEnv**,那么**loopEnv**它的外部环境就指向**forEnv**。
于是在loopEnv看来变量`i`其实是登记在父级作用域forEnv中并且loopEnv只能使用它作为名字“i”的一个引用。更准确地说在loopEnv中访问变量`i`在本质上就是通过环境链回溯来查找标识符Resolve identifier, or Get Identifier Reference
上面的矛盾“貌似”被解决了,但是想想程序员可以在每次迭代中做的事情,这个解决方案的结果就显得并不那么乐观了。例如:
```
for (let i in x)
setTimeout(()=&gt;console.log(i), 1000);
```
这个例子创建了一些定时器。当定时器被触发时函数会通过它的闭包这些闭包处于loopEnv的子级环境中来回溯并试图再次找到那个标识符`i`。然而当定时器触发时整个for迭代有可能都已经结束了。这种情况下要么上面的forEnv已经没有了、被销毁了要么它即使存在那个`i`的值也已经变成了最后一次迭代的终值。
所以要想使上面的代码符合预期这个loopEnv就必须是“随每次迭代变化的”。也就是说需要为每次迭代都创建一个新的作用域副本这称为**迭代环境**iterationEnv)。因此每次迭代在实际上都并不是运行在loopEnv中而是运行在该次迭代自有的iterationEnv中。
也就是说,在语法上这里只需要两个“块级作用域”,而实际运行时却需要为其中的第二个块级作用域创建无数个副本。
这就是for语句中使用“let/const”这种块级作用域声明所需要付出的代价。
## 知识回顾
今天我讲述了for循环为了支持局部的标识符声明而付出的代价。
在传统的JavaScript中是不存在这个问题的因为“var声明”是直接提升到函数的作用域中登记的不存在上面的矛盾。这里讲的for语句的特例是在ECMAScript 6支持了块级作用域之后才出现的特殊语法现象。当然它也带来了便利也就是可以在每个for迭代中使用独立的循环变量了。
当在这样的for循环中添加块语句时这是很常见的块语句是作为iterationEnv的子级作用域的因此块语句在每个迭代中都会都会创建一次它自己的块级作用域副本。这个循环体越大支持的层次越多那么这个环境的创建也就越频繁代价越高昂。再加上可以使用函数闭包将环境传递出去或交给别的上下文引用这里的负担就更是雪上加霜了。
注意无论用户代码是否直接引用loopEnv中的循环变量这个过程都是会发生的。这是因为JavaScript允许动态的eval()所以引擎并不能依据代码文本静态地分析出循环体ForBody中是否引用哪些循环变量。
你应该知道一种理论上的观点,也就是所谓“**循环与函数递归在语义上等价**”。所以在事实上,上述这种**for循环并不比使用函数递归节省开销**。在函数调用中这里的循环变量通常都是通过函数参数传递来处理的。因而那些支持“let/const”的for语句本质上也就与“在函数参数界面中传递循环控制变量的递归过程”完全等价并且在开销上也是完全一样的。
因为每一次函数调用其实都会创建一个**新的闭包**——也就是函数的作用域的一个副本。
## 思考题
- 为什么单语句single-statement中不能出现词法声明 lexical declaration
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="加餐 | 捡豆吃豆的学问(上):这门课讲的是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/3a/97d543162f55c7b180cd3189d743943a.mp3"></audio>
你好我是周爱民今天又到了我的《JavaScript核心原理解析》课。
不过与以往不同今天不上正经课讲点课外的话。前两天极客时间的编辑老师找我开了一个会认认真真地讨论了一下这门课程认为当务之急是给同学们开个小灶要好好讲一下“怎么学这门课程”。因为这才短短地讲了1/3许多同学就已经跟不上了。
说到开小灶,我第一个就是想到了“吃”。
为什么呢?因为我父亲就是厨师,正正经经地考过厨师证,说不得多有名气,但一个县城里面,能跟他水平看齐的,也就三两个人而已。我的哥哥后来也随了父业,做了厨师,精通京、粤、淮扬多个菜系,更是拿了一级厨师的证书。
而我呢,没学厨,从父亲手上学到的唯一功夫,就是一个“吃”字。
## 什么是“会吃”?
会吃这件事,其实不是下嘴的功夫。这跟绝大多数人想的不同。下嘴就吃,就算有再多的法子,也无非是生啃细嚼;花样再多,也不过是甩动大腮帮子,劳力活而已。
真正会吃,说的是三件事,第一是食材,第二是味道,第三件,就单单一个“懂”字。
吃货世界里的“食材”,搁在我们今天的话题里,就是这二十来讲的课程。开课之前,有同学便过来打探,问我这个课程都讲些什么,看看值不值得一听。我就索性问了问他,你想听些什么呢?
- VUE没有。
- HTML、CSS没有。
- 前端客户端浏览器手机App……没有统统没有。
那位同学不死心又悻悻然地问了一句总该讲点Node.js、React Native又或者是NPM之类的吧
我索性给他摊了底牌:二十节课程里面,就只算是提到这些名词,大概也不超过五次。
那位同学回了一句:那你让我学什么?
## “食材”的问题
好问题啊!学什么呢?
我记得在豌豆荚的时候,有参与一些招聘的工作。老实说,作为架构师,招聘工作通常不是在第一轮,大概会是到第二、三轮的时候,才会轮到我上场。也就是说,我需要面试的,大抵都是其他“面试官”认为“这家伙还有点料”的。
有一次我的一个面试结束得比较快在内部的HR系统中填写评论的时候正经才写了四个字便被其他面试官打断了他嚷嚷着几乎快要被整个公司都听到了“爱民老师人家十几年的一线开发大型系统都做过那么多经验很丰富的你怎么这么点评呢
我在HR系统中写评论起头里的四个字是**水平一般**。
我想了想,停下写评论,给这个面试官聊聊开头这四个字。我说,我其实不太常用这种措辞来评论候选人,但这个也算是例外了。为什么呢?因为这个人真的是能力很强,下手很快,做的产品和项目也多,经验非常丰富,但是他确实就是水平一般。
什么叫“水平一般”呢?
因为他学的东西,别人也都学;会的东西,别人也都会;他强的东西,别人一样也强。即使别人今天不如他学得多、会得多,又或者不比他强多少,但是只要花点时间、下点功夫,也就一样儿也不会比他差。他十几年的一线开发,把自己做成了熟手、熟练工,东西会得再多,技巧再熟练,也不过是卖油翁的“但手熟尔”。
我在面试里面,确实问了他几个偏向核心的问题,他也确实知道,很清晰、很准确。但再进一步问原理时,他却是一无所知。所以,我又引导、设问,说:“如果现在让你来考虑这个问题,你会从哪儿入手呢?”那位候选人想了好几个招数,中规中矩,然而无一可用。
这就是关键所在。
“核心原理”不是一些招数技法,不是拿来多练多用就行了的。所谓“核心”呢,不见得是大家都知道的——一眼望去,万千条路径之中,找到最正确的那一条,才是核心。但是这个东西可以教,也可以记,下次看见这个路,照着走就是了。所以,大公司里有所谓的“核心团队”,新人进去,不消半年工夫,功力就大增了,出来能带一个团队了。咦,带团队做什么呢?冲锋陷阵啊,杀敌交人头啊!反正,和上面说的“吃”一样,还是劳力活。
所以,就算是在“核心团队”里,他们也只是帮你指出核心之所在,最多教你会一些套路,让人净增功力。但是这样的核心只是“死东西”,不懂得核心的原理,就如同上面说过的那位面试候选人一样,出的都是些中规中矩的招数。
“中规中矩”有什么不好么?
确实,有些时候就是不好。“中规中矩”就是所谓的“一般”。所谓的“一般”不是指你能力一般,而是指你眼界一般、思想一般,对你面前这个事物的理解程度,也是一般。
所以,所谓“水平一般”,是因为学的是“一般”的东西,再怎么学,还是一般。
>
汉语中,“一般”这个词,是指跟别的一样,例如“一般无二”,而不仅仅是指“普通”,也并非贬义。
## 这门课到底讲什么呢?
《JavaScript核心原理解析》这门课核心不是在讲JavaScript如何用或者如何学习JavaScript。我在开这门课列出这二十多行代码作为标题时就说过我要讲的是语言。
语言最核心的部分也有两个,第一个是**语言的构成**,第二个是**如何构成**。我在这门课里主要还是讲“如何构成”的因为ECMAScript就是以“如何构成也就是如何按照ECMAScript规范来实现一个JavaScript”为制订规范的主要目标和方向的。
在内容上面这门课跳过了对“构成一门语言的那些基础元素也就是语言的构成”的讨论而是直接进入到“如何将基础元素组织起来成为JavaScript”。比如我就没讲什么是**动态语言**,或者也没有讲什么是**标识符**。这里有两点是很不幸的:
1. ECMAScript中没有明确地写“我是动态语言”
1. ECMAScript里面明确地使用了“标识符”这个名词。
为什么“这两点”都是很不幸的呢?
因为无论有没有明确地使用这些名词在ECMAScript中这些概念都没有规范性质的定义。这是因为ECMAScript是直接面向“语言/引擎的编写者”,因此这个规范就默认它的读者是了解这些基础的或者学术的概念的。就好象它不会解释什么是二进制,什么是位运算一样,因为它默认是在计算机这个领域里的。
然而,我想你应该不是“计算机语言/编程语言学”的专家,因此那些所谓“构成一门语言的基础元素”,其实**你不懂,也是正常的**。在听这门课的大多数同学,其实也都一样的不懂。
然而,注意这里的这个词——“一样的”。这也就意味着,只有了解了那些“不一样的”“不懂的”东西,你才会成为“不一般的”。
**而这门课程的目的,也正是要让你成为那个“不一般的”。**
所以,你需要放下以前你认为你懂的那些东西。如同开篇词中所说的,它们正是阻碍了你前进的东西。比如说,有同学就从[第1讲](https://time.geekbang.org/column/article/164312)开始就一直被“引用”这个概念困扰因为他所理解的JavaScript的概念与我所讲的完全不同而且混淆不清。这种状况在评论区中表现得很明显大多数同学都是在旧有的概念中构建新的概念集合如同浮沙高塔原本基础就搭得不对你再怎么努力也是上不了这个台阶的。
然而,又如同评论区里的“海绵薇薇”同学一样,只要突破了“引用”这个概念,把旧的东西扔掉,基于这个新的东西来理解,那么再看前几讲的东西,就豁然开朗了。
我所讲的东西,原本并不是什么特别高明的、高深的技巧。问题只是,你要把原本依赖的那些概念、想法、设定,或者你认为正确的那些逻辑一一扔掉,你才能“看到”这些新的东西。
这个“扔掉”的过程太难。很多年前我在给一个Borland的纪念网页上留下的题字就是
所见即所得,所见即所碍。
>
Borland是“所见即所得”这一开发理念的大成者而最终他也是死于对这一概念的固执坚守。
关于“引用”这个概念的突破我想如果有机会我会请极客时间的编辑们约请“海绵薇薇”同学来讲一下他如何理解这个东西以及在理解这个概念前后对JavaScript有哪些不一样的认识。
回头来说这门课程。语言这个东西,其实是你最终要在这门课中看到的“真相的全部”。
我希望你通过这门课程,能真正地了解语言:语言是什么,它长得怎么样,它为什么长成这个样子,它如何成长、长大,又或者变化的。对于语言来说,它的生命的原点在哪里?源动力又在哪里?
举一例来说在第一模块也就是前五讲如果你理解了“x = x”表达的意思是“将值赋给引用”当然前提是你需要知道并接受“这里的引用和值不是JavaScript中的而是ECMAScript中的概念”。那么你就几乎能贯通整个JavaScript语言的构造过程了解所有语句、词法以及引擎内核结构的设计原理与应用原则。
所以你现在再看看第一模块的总标题“从零开始JavaScript语言是如何构建起来的 5讲”。
## 马钰曾经说过
说到这里,可能就会有一些同学听出不同的意思来了:咦,爱民老师好像是在说,他的课程是屠龙秘籍,所以不是一般人学的,或者是一般人学不会的。
当然,绝不是这样的。相反,我对屠龙术与杀鸡刀向来没有偏见,我只是在这里要强调一个东西:这个课程讲的东西,跟平常你听到的并不一样。
尺长寸短,但只要是用在合适的地方,就都是好东西。
相反的,你非得像公孙大娘舞剑一般,去百万军中杀敌,那么你大概就是最先中了黑箭的那一个。关于这一点的不同,我在之前写过的一篇名为[《前端要给力之:代码可以有多烂?》](https://kb.cnblogs.com/page/83497/)的博客文章里面也讲过,那篇文章从一个简单题目一直谈到了模板范型,最后呢,我还是建议大家用最保守的版本来编程序。
为什么?
研究是研究,实战是实战,不要把二者混为一谈。就好比在[第3讲](https://time.geekbang.org/column/article/165985)里说到过的那个面试题最终我给出了一个应用场景用来形成链表。然而如果你真的要在工程项目中这样去写链表那么大概第二天就被开除了。将这个代码写入jQuery的那个提交者有没有被开除我可不知道反正我见到这样的提交者是要打板子的。
说回正题。我仅仅告诉你这个东西“与众不同”是不够的。毕竟,你听这堂课的目的,还是要学懂它,对吧。
然而你可知道历史上有一个人正好是怎么学都学不会东西的。他有七个师父个个是江湖知名的大侠个个也都呕心沥血然而却教出来个一个弱鸡还没行走江湖初遇敌手就被人抓住了脚给扔出去了。这个历史人物来自金庸大师的《射雕英雄传》就是郭靖。你看我在这里给你说这个故事可不是闲扯因为书中有人说了一句话道出了这个郭靖“学不会”的实质。书中第32回写道那道人问道
>
“你这六位师父都是武林中顶尖儿的人物,……你又不是不用功,为什么十年来进益不多,你可知道什么原因?”
郭靖回答说:
>
“那是因为弟子太笨,师父们再用心教也教不会。”
这时候那位道人就笑了,说了一句古今以来,求学问道最核心的要义,这句话,原话就是:
>
“那未必尽然,这是教而不明其法,学而不得其道。”
所以,关于我们今天说的这一门课程,“学不好、或者学不会”,其关键就在这位道人——马钰(不是马云)——说过的这句话:教者要有其法,而学者要得其道。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="加餐 | 捡豆吃豆的学问(下):这门课该怎么学?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/f5/d7b9ea5009dc03ee82a63296f0710af5.mp3"></audio>
你好,我是周爱民,在上一讲中讨论了这门课程所学的内容“到底是什么”。接下来,我们再来看看“怎么学这门课程”。
## 教的方法
我先来说说这门课的教法。有没有简单、明晰的授课方式呢?有的,你在极客时间上也好,学校的课程里也好,常见的一个教法套路便是:
- 开篇立个题,把问题抛出来,说我们今天要讲什么,关键点有一二三四;
- 接下来就讲这一二三四,分析也好,解说也罢,趣谈也可,总之让你听得开心有味;
- 最后收纳主题,我们讲了一二三四,你看看你听懂没有。
听不听得懂?能!你认真听下来,只要老师不差,绝对能懂,如果正好是你没听过的内容,还感觉耳目一开,受用无穷。
但是你仔细想想,你至多知道了老师所讲的,还能知道别的什么吗?几乎不能。清楚明白、无有疏漏,但也毫无差别,你听到的跟别人听到的,你学到的跟别人学到的,完全一样。
所以,这也就是“一般的”知识。
如同我刚才说过的:你学的跟人家一样,听到的跟人家一样,知道的跟人家一样,充其量学了个跟老师讲的一模一样的,可不就是“水平一般”么?
核心原理可不可以这样讲?你可不可以这样学?答案是,其实也是可以的。我如果是把这个课程当成一门功法,一一二二地讲给你听,你全然听去了,一字不落下,那这个核心原理课也可以讲得如同流水清风,让你很是舒坦。
但是你可记得,我在这门课的开篇词里说到的,学这门课的目的是什么呢?我说过,我希望**你能够构建自己的“语言学习体系”**。体系性,才是所有学习中最难得的。如果你有自己学习的体系,甚至是构建体系的体系,那么学这门课又算什么难事呢?这门课真的在讲什么高深的佛法,玄妙的奥义么?都不是的,它是在讲“另一个体系”下的东西!
准确地说,这门课程的讲法,与你过去二三十年来的学习方法是两个不同的体系;这门课程的内容,与你从业经历中所熟悉的语言,也分属于不同的知识体系!既然如此,你怎么可能指望用你现在的法子,在你的体系中去理解这些知识,又或者为这些知识构建“自己的语言学习体系”呢?
所以这门课程,一开始的讲法就大不相同。
这门课程的“标题”是一行代码,它通常很奇怪,有可能很有用,也有可能根本就不能用,它本身或许就难解,就是一个“问题”。然而,你需要知道,这个“标题”,或者这个标题中的“问题”,其实一点儿也不重要,我讲课不去奔着这个问题去,你学习中也不必奔着这个问题来。“求解这个问题”根本就是一件没有什么意义的事情。
所以在从一开始听课,一直听到现在的同学中,还有一些是困于[第1讲](https://time.geekbang.org/column/article/164312)的“delete 0”这行代码的希望明白这行代码在讲什么、有什么用的同学可以暂时地收收你的思想因为——解决这个问题其实没有什么大用。
既然“主题没什么用”,那么我怎么讲呢?其实我每一讲的开始,就无非是拿这个标题做个引子,然后无所谓背景、历史、相关的知识点,以及各种各样的问题纷纷地抛出来,貌似东讲讲、西讲讲,一直都绕开了这个“标题中的代码”在走。
事实上,我把这整个过程称为“撒豆子”。
怎么“撒”呢整个课程大概2/3甚至4/5的内容就是一大把豆子一股脑儿地撒出来没什么章法也没什么技巧也没有什么道理。只有一个原则这些豆子都是围绕“标题”的这个话题来的它们或是互有相关性或者是彼此有相似性等等。
总之,简单地说:它们是“同一个系统”下的东西。
这是我组织每一节课程的基本原则,这个原则就是:在标题之下,东拉西扯,直到一地豆子,四处乱滚。
最后我告诉你,学习这门课程的终极秘密:
>
把这些碎纸片捡起来,捡起来的,就是你的体系。
## 学的方法
所以,这门课的听法也就不同。
你非要去盯着每节课的标题,把它弄得一清二楚,知道它怎么来的,怎么解释,以及怎么去应用到项目中,老实说,也无不可,也会有所得。但终究是“捡了芝麻丢了西瓜”。我既然说了,这“大西瓜”就是这一地的豆子,关键是在于你怎么捡,而不是在于我怎么讲。
所以,我再来讲讲这个“捡豆子”的方法。
**1.设问,列问题**
我可能在讲课中会“问”一些问题,但多数情况下,那是为了讲课的上下文连贯,那些问题本身并没有太明确的指向性。而且,即使是“有指向性”,又能如何呢?你求解了,也不过是多解了一个问题,于你无益。
**真正有用的,是你自己学会“提问题”**
- 找一张纸,列一下这个标题给你带来的问题;
- 列一下在这个主题下面你不知道的,或者你想知道的问题;
- 列一下听课过程中发现的不解的、难解的问题;
- 列一下你的理解跟我所讲的内容之间,那些貌似“不可调和”的概念问题。
这些仍然不够重要。更重要的设问是:
- 为什么会有这些问题?
- 这些问题指向哪个“黑暗未知的方向”?(这个方向是你的知识盲点)
- 老师为什么要撒这些豆子?(这些豆子有内在的相关性,而这就是我撒他们的目的)
- 为什么会存在跟既有知识的矛盾?
- 为什么在JavaScript语言的层面“看不到这些问题”
- 为什么……要问上面这些问题?
- ……
总之,**带着问题来学习,学会从你的问题中求解**。这个过程,就已经与你之前的学习方法不同了。
是你接受“我所讲的知识”好呢?还是你“找到自己的答案”好呢?
**2.求解,在知识域中找答案**
既然我在每一个大段落中划了一个知识域,那么上面这些问题也就应该在这个知识域里去求解。
比如说你有人生、事业、理想的困惑了,那么你该去找知心小姐姐,非得在这么二十讲的课程中去寻找答案,你肯定是找不到的。所以,上面你可以尽量宽泛地设问,到了这个求解的时候,却应该把它限定到我们讲的这个问题域里面来。这二十讲一共有四个大主题,每个大主题是一个领域。所以你得想想,你的问题可以放在哪个领域里,为什么这么放,为什么是这个领域,为什么不在其他的领域范围内。
- 这件事跟主题有什么关系?
- 这个东西的哪方面跟其他东西“有关系”?
- 怎么表达这种关系?
- 如何把它们放在同一个体系下(逻辑下或者抽象概念下)来解释?
- ……
总之,多问几个为什么。
求解、答案都可以是错的,没关系,先做着,直到你能得到一个“貌似可能的解”。
**3.推翻,找到反例,精化抽象**
有了“貌似可能的解”只是个开头,如果你止步于此,那之前的努力就全部白费了。这跟“一般的学法”并没有什么不同,甚至还远远不如别的老师的教法,直接给你来个“三段式”的立题求解。真正对整个学习起到提升效果的,正是这第三步的“推翻”。
问题是你提出的,答案是你找到的,而推翻也由你来行使。
正是因为问题是你提出的,所以你知道“源起”;正是因为答案是你找到的,所以你知道“经由”。你知道一件事情的源起与经由,那么要找到这件事情的关键处,其实只需要看看那些“自相矛盾”的地方,就好了。你找到你的逻辑的、过程的、结果的任何一处反例,进而重新按上述过程来思考,重新找到“貌似可能的(第二个)解”。
如此往复,最终你就看到了一些事物最初,以及最终的面貌。
有了这个面貌,你为它命个名字,抽出个概念,于是就得到了一个“抽象”。有了抽象概念,你就可以在概念的层面上描述事物,以及进行事物的推演。而这,就是架构的基本功。**有了体系性,有了概念抽象,有了推演过程,你做的,就是体系架构的工作,而不是“写代码”。**代码是你架构的表现方式,仅此而已。
我想这个过程以及这个过程的可能的结果已经超出了多数同学的“需要”。是的暂时的你并不需要变成“架构师”我这门课也并不是要教你“做一个用JavaScript的架构师”。
## 最佳实践
但是,正因为这个最后“收官”的过程比较抽象、比较虚。所以,我给你在[第1讲](https://time.geekbang.org/column/article/164312)的时候就留了个伏笔你回顾一下我在第1讲的结束的时候提过一个问题
>
delete x 中,如果 x 根本不存在,会发生什么?
这个问题在“潇潇雨歇”同学的答案后面(他的答案是正确的)。在他的答案里面,我又提了两个潜在的问题。其一是:
>
如果x根本不存在delete x什么也不做返回true的这种情况下x是什么呢它显然是语法可以识别的东西但如果这样在语法上它是什么且在执行环境中它又是什么
这个问题其实问得很深,也正是我们这里说的:如果你找到了“貌似可能的解”,那么就进一步地找一下反例,进一步地“精化抽象”。
为什么呢?
其实啊,我们得问一个很深层的、有些哲学性的问题:不知,是不是“知”的一种?
对于JavaScript来说如果一个标识符x“根本不存在”那么就是真正的“不知道它存在”吗不是的JavaScript必须知道——“这里有一个未知的标识符”。对于JavaScript引擎来说我不能确定它是什么我的整个引擎中都找不到它但是我必须把它“标识”出来只有把它标识出来我才能处理它
所以,在语法概念上,**词法记号**Tokens是比**标记**Identifer更底层的抽象概念——也就是更“精化”的抽象。
但在JavaScript中不需要理解所谓“词法记号”因为它不需要在这种引擎层面的“对代码文本的理解”。而在引擎层面是将代码文本解析成词法记号序列的它认为所有这样的序列——也就是一串字符要么能解释成标识符要么就是一个不能识别的序列。
当“不能识别的序列”出现的时候就是语法解析期错误简单地说就是代码错了。接下来当词法记号是有效的标记时它可能是能识别的、环境中有的也就是说它是能被引擎从环境中发现Resolve到的引用因此它就称为“可发现引用ResolvableReference反之——例如上面提到的“未声明的x”就称为“不可发现的引用IsUnresolvableReference”。
>
注意这些概念不是我生造的你在读ECMAScript规范时就会看到这些概念名词。只是ECMAScript并不解释这些概念的由来以及它们之间的抽象关系。
所以,引擎必须能识别“不能识别的标识符”。能识别才能处理,即使这个处理“仅仅是”抛出一个异常。
你想想,要是不能识别、不能抛出异常,那么这个引擎就该出现完全未知的逻辑了,这种情况下,引擎的更外层,例如宿主程序,又例如操作系统就会无法处理了,就会中止进程。引擎要么抛出一个异常,然后退出程序;要么操作系统直接将引擎杀死,连异常也没有。
我们都是有经验的程序员,上面哪种处理更好,是一目了然的事情。而上溯整个处理过程,就在于在“精化抽象”的过程中,有没有处理“不可发现的引用”,又或者说,“未发现”是不是被当成了一个需要处理的抽象概念。
少了一个抽象概念,少了一个处理逻辑,你的程序就“莫名其妙”地退出了。如果这是一个框架,或者这是一个库,一个平台系统,那么这个抽象概念一旦少了,就没有人会去使用它了。因为,你知道的,系统中怕的不是出错,而是,出了错却不知道。
“知未知”,就是这个概念系统中最顶层的抽象了。
>
这是一个在“概念完整性”方面的实践。
>
对于一个体系来说,概念完整性是很重要的,如果缺乏关键概念,那么这个体系构建就会出现漏洞。习惯性上,人们用“概念对称性”来解决这个完整性的问题,例如“能发现的 vs. 不能发现的”,这两个概念在抽象层面上,就是指“所有的”;又例如,索引数组对应连续存储,而关联数组对应非连续存储,所以“连续的 vs. 非连续的”,就意味着“数组能处理所有存储(的数据)”。
## 别担心,还有
到这里,可能就有同学说了,这个讲课的方法是很新颖,学习的方法看起来也可行,但是我就是这么做的呀,问题我想不到“有效的解”啊。
对啊,如果你一次两次就能想到有效的解了,一遍两遍就学成收工了,那也只能说明这个东西还是“一般的东西”,这个方法也就是“一般的方法”,而照着这个路子做下去,你也就还是个“一般的你”。
所以,不要担心,你没学明白也正常,上面的做法找不到“有效的解”也正常,这门课听到现在,以及后面要听的内容,都无非是给你一个“使用这种学习方法”的训练营,你在这个过程中,多练多试,多出错多反思,就成了。
学习要“知味”,你一旦从这个过程中得到了收获,你就如同食髓,乐此不疲了。所以,不要气馁,放松心态,坚持就好了。
并且呢这门课程后续还为大家准备了更“丰盛”的加餐。按照编辑们为你制定好的学习计划我还会在第10讲之后给大家再补一个加餐。这份加餐跟今天的大有不同。我会将前10讲的课程串联起来精讲每一讲的主题对内容详加梳理列提纲、划重点敲黑板也就是帮你把豆子们都找出来、串起来。
当然,我需要在这里强调的事情是,这件事情一做过,就意味着“你自己找豆子”就结束了。豆子是你找来的,还是我拿给你看的,大不相同。
所以我觉得啊,你还是自己多努力找找。如果你需要补补课,加强一些基础概念方面的知识,那么我希望你有时间读一下[《程序原本》](https://github.com/aimingoo/my-ebooks)限于这堂课要讲的内容你只需要读一下《程序原本》前10章的内容就可以了并且有许多内容可以跳过去。是的即使不懂、“不求甚解”也是可以的。有些东西就可以先“存而不论”而这些等到你将来回头来看时便可以立时了然。
另外,如果你的英语还不错,那么仍然推荐你看看[《ECMAScript规范》](https://tc39.es/ecma262)一些概念上它定义得严谨得多。不过这些概念背后的东西就得你自己去体会了ECMAScript里面是不讲的。这里还有一份[W3C组织的中文译本](https://www.w3.org/html/ig/zh/wiki/ES5)虽然只是ECMAScript5的而且还不完整但是要达到“补概念”这个目的还是够用了。
## 其他
今天的思考题跟以前不同,这道题,你答不答得出来,都是不要紧的,就算“想着玩儿”就好了。问题是这样的:
- 按照前面说的,所谓“会吃”,有三件事情,是食材、味道和“懂”这一个字儿。食材,我们讲了,是课程的内容;味道,我们也讲了,是课程中的教与学的法子,以及“形成体系性”这样的潜在目的。那么,什么是“懂”呢?
这个问题,就留给你了。我想啊,要知道什么是“懂”,大概才真的算是“会吃”了。
我今天的课程就聊到这里。希望你吃得好,胃口好,好好消化这一份专属的加餐,然后我们下一讲,再继续学习。