mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-15 13:43:49 +08:00
mod
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="01 | delete 0:JavaScript中到底有什么是可以销毁的" 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是只读的,会发生什么?
|
||||
|
||||
希望你喜欢我的分享。
|
||||
@@ -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 = "outer";
|
||||
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)”在创建之后就没有这项待遇,所以它们在缺省情况下就是“还没有绑定值”的标识符。
|
||||
|
||||
>
|
||||
NOTE:6种声明语句中的函数是按varDecls的规则声明的;类的内部是处于严格模式中,它的名字是按let来处理的,而import导入的名字则是按const的规则来处理的。所以,所有的声明本质上只有三种处理模式:var变量声明、let变量声明和const常量声明。
|
||||
|
||||
|
||||
所以,标题中的`var x = ...`在语义上就是为变量x绑定一个初值。在具体的语言环境中,它将被实现为一个赋值操作。
|
||||
|
||||
## 赋值
|
||||
|
||||
如果是在一门其它的(例如编译型的)语言中,“为变量x绑定一个初值”就可能实现为“在创建环境时将变量x指向一个特定的初始值”。这通常是静态语言的处理方法,然而,如前面说过的,JavaScript是门动态的语言,所以它的“绑定初值”的行为是通过动态的执行过程来实现的,也就是赋值操作。
|
||||
|
||||
那么请你仔细想想,一个赋值操作在语法上怎么表达呢?例如:
|
||||
|
||||
```
|
||||
变量名 = 值
|
||||
|
||||
```
|
||||
|
||||
这样对吗?不对!在JavaScript中,这样讲是非常不正确的。正确的说法是:
|
||||
|
||||
```
|
||||
lRef = rValue
|
||||
|
||||
```
|
||||
|
||||
也就是将右操作数(的值)赋给左操作数(的引用)。它的严格语法表达是:
|
||||
|
||||
>
|
||||
**LeftHandSideExpression** < **=** | ****AssignmentOperator**** > **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环境仍然是通过将全局对象初始化为这样的一个全局闭包来实现的。但是为了得到一个“尽可能”与其它变量环境相似的声明效果(varDecls),ECMAScript规定在这个全局对象之外再维护一个变量名列表(varNames),所有在静态语法分析期或在eval()中使用`var`声明的变量名就被放在这个列表中。然后约定,这个变量名列表中的变量是“直接声明的变量”,不能使用`delete`删除。
|
||||
|
||||
于是,我们得到了这样的一种结果:
|
||||
|
||||
```
|
||||
> var a = 100;
|
||||
> x = 200;
|
||||
|
||||
# `a`和`x`都是global的属性
|
||||
> Object.getOwnPropertyDescriptor(global, 'a');
|
||||
{ value: 100, writable: true, enumerable: true, configurable: false }
|
||||
> Object.getOwnPropertyDescriptor(global, 'x');
|
||||
{ value: 200, writable: true, enumerable: true, configurable: true }
|
||||
|
||||
# `a`不能删除, `x`可以被删除
|
||||
> delete a
|
||||
false
|
||||
> delete x
|
||||
true
|
||||
|
||||
# 检查
|
||||
> a
|
||||
100
|
||||
> x
|
||||
ReferenceError: x is not defin
|
||||
|
||||
```
|
||||
|
||||
所以,表面看起来“泄漏到全局的变量”与使用`var`声明的都是全局变量,并且都实现为global的属性,但事实上它们是不同的。并且当`var`声明发生在eval()中的时候,这一特性又还有所不同,例如:
|
||||
|
||||
```
|
||||
# 使用eval声明
|
||||
> eval('var b = 300');
|
||||
|
||||
# 它的性质是可删除的
|
||||
> Object.getOwnPropertyDescriptor(global, 'b').configurable;
|
||||
true
|
||||
|
||||
# 检测与删除
|
||||
> b
|
||||
300
|
||||
> delete b
|
||||
true
|
||||
> 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
|
||||
> obj = { f: function() { return this === obj } };
|
||||
|
||||
// false,表明赋值表达式的“结果(result)”只是右侧操作数的值,即函数f
|
||||
> (a = obj.f)();
|
||||
false
|
||||
|
||||
```
|
||||
|
||||
到现在为止,我们讲述了整个语句的过程,也就是说,由于“y = 100”的结果是100,所以该值将作为初始值赋值“变量x”。并且,从语义上来说,这是变量“x”的初始绑定。
|
||||
|
||||
之所以强调这一点,是因为相同的分析过程也可以用在const声明上,而const声明是只有一次绑定的,常量的初始绑定也是通过“执行赋值过程”来实现的。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
- var等声明语句总是在变量作用域(变量表)或词法作用域中静态地声明一个或多个标识符。
|
||||
- 全局变量的管理方式决定了“向一个不存在的变量赋值”所导致的变量泄漏是不可避免的。
|
||||
- 动态添加的“var声明”是可以删除的,这是唯一能操作varNames列表的方式(不过它并不存在多少实用意义)。
|
||||
- 变量声明在引擎的处理上被分成两个部分:一部分是静态的、基于标识符的词法分析和管理,它总是在相应上下文的环境构建时作为名字创建的;另一部分是表达式执行过程,是对上述名字的赋值,这个过程也称为绑定。
|
||||
- 这一讲标题里的这行代码中,x和y是两个不同的东西,前者是声明的名字,后者是一个赋值过程可能创建的变量名。
|
||||
|
||||
## 思考题
|
||||
|
||||
根据今天讲解的内容,我希望你可以尝试回答以下问题:
|
||||
|
||||
- 严格来说,声明不是语句。但是,是哪些特性决定了声明不是“严格意义上的”语句呢?
|
||||
|
||||
在下一讲中我会来讲一讲JavaScript社区中的一个历史悬案,这桩悬案与今天讨论的这行代码的唯一区别在于:它不是声明语句,而是赋值表达式。
|
||||
@@ -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); // --> 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 = ... // <- 这里将导致语法出错
|
||||
|
||||
```
|
||||
|
||||
所以,在最初蔡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 = // <- `a` is {n:1}
|
||||
a = // <- `a` is {n:1}
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
这里的“a = …”中的`a`仍然是当前环境中的变量,与上一次暂存的值是相同的。这里仍然没有问题。
|
||||
|
||||
但接下来,发生了赋值:
|
||||
|
||||
```
|
||||
...
|
||||
a.x = // <- `a` is {n:1}
|
||||
a = // <- `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}, // <- 第一次赋值“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); // --> undefined
|
||||
console.log(ref.x); // {n:2}
|
||||
|
||||
```
|
||||
|
||||
这也解释了最初“蔡mc”的疑问:连续两次赋值`elemData.events`有什么用?
|
||||
|
||||
如果`a`(或`elemData`)总是被重写的旧的变量,那么如下代码:
|
||||
|
||||
```
|
||||
a.x = a = {n:2}
|
||||
|
||||
```
|
||||
|
||||
意味着给**旧的变量**添加一个**指向新变量的属性**。因此,一个链表是可以像下面这样来创建的:
|
||||
|
||||
```
|
||||
var i = 10, root = {index: "NONE"}, node = root;
|
||||
|
||||
// 创建链表
|
||||
while (i > 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’?
|
||||
|
||||
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -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 <let/const/var> x ...;
|
||||
export function x() ...
|
||||
export class x ...
|
||||
export {x, y, z, ...};
|
||||
|
||||
|
||||
// 导出“(重命名的)名字”
|
||||
export { x as y, ...};
|
||||
export { x as default, ... };
|
||||
|
||||
|
||||
// 导出“(其它模块的)名字”
|
||||
export ... from ...;
|
||||
|
||||
|
||||
// 导出“值”
|
||||
export default <expression
|
||||
|
||||
```
|
||||
|
||||
关于导出声明的、重命名的和其它模块的名字这三种情况,其实都比较容易理解,就是形成一个名字表,让外部模块能够查看就可以了。
|
||||
|
||||
但是对于最后这种形式,也就是“(导出)值”的形式,事实上是非常特殊的。因为如同我在上面所讲过的,要导出一个模块的全部内容就必须导出“(全部的)名字和值”,然而纯粹的值没有名字,于是也就没法访问了,所以这就与“导出点什么东西”的概念矛盾了。
|
||||
|
||||
因为这个东西要是没名字,也就连“自己是什么”都说不清楚,也就什么也不是了。
|
||||
|
||||
所以ECMAScript 6模块约定了一个称为"default"的名字,用于来导出当前模块中的一个“值”。显然的,由于所谓“值”是表达式的运算结果,所以这里的语法形式就是:
|
||||
|
||||
```
|
||||
export default <expression>;
|
||||
|
||||
```
|
||||
|
||||
其中的“_expression”_就是用于求值的,以便得到一个结果(Result)并导出成为缺省的名字“default”。这里有两个便利的情况,一个是在JavaScript中,一般的字面量也是值、也是单值表达式,因此导出这样一个字面量也是合法的:
|
||||
|
||||
```
|
||||
export default 2; // as state of the module, etc.
|
||||
export default "some messages"; // 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 <expression>;
|
||||
|
||||
```
|
||||
|
||||
中的“**expression**”呢?要知道所谓表达式,就是程序的计算逻辑啊。
|
||||
|
||||
所以,这里先得出了第一个关键结论:
|
||||
|
||||
在处理export/import语句的全程,没有表达式被执行!
|
||||
|
||||
## 导出名字与导出值的差异
|
||||
|
||||
现在,假如:
|
||||
|
||||
```
|
||||
export default <expression>;
|
||||
|
||||
```
|
||||
|
||||
中的“expression”在导入导出中完全不起作用(不执行),那么这行语句又能做什么呢?事实上,这行语句与直接“导出一个名字”并没有任何区别。它与这样的语法相同:
|
||||
|
||||
```
|
||||
export var x = 100;
|
||||
|
||||
```
|
||||
|
||||
它们都只是导出一个名字,只是前者导出的是“default”这个特殊名字,而后者导出的是一个变量名“x”。它们都是确定的、符合语法规则的标识符,也可以表示为一个字符串的字面文本。它们的作用也完全一致:就是在前面所说的“某个名字表”中添加“一个登记项”而已。
|
||||
|
||||
所以,导出名字与导出值本质上并没有差异,在静态装配的阶段,它们都只是表达为一个名字而已。
|
||||
|
||||
然后,也正是如同`var x = 100;`在执行阶段需要有一个将“值100”绑定给“变量x(的引用)”的过程一样,这个`export default ...;`语句也需要有完全相同的一个过程来将它后面的表达式(**expression**)的结果绑定给“default”这个名字。如果不这么做,那么“**export default**”在语义上的就无法实现导出名字“**default**”了——在静态装配阶段,名字“default”只是被初始化为一个“单次绑定的、未初始化的标识符”。
|
||||
|
||||
所以现在你就可以在语义上模拟这样一个过程,即:
|
||||
|
||||
```
|
||||
export default function() {}
|
||||
|
||||
// 类似于如下代码
|
||||
//(但并不在当前模块中声明名字"default")
|
||||
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****
|
||||
|
||||
|
||||
或类似于此的语法风格的。它可以用在一般的赋值表达式、变量声明的右操作数,以及对象声明的成员初始值等等位置。在这些位置上,该函数表达式总是被关联给一个名字。一方面,这种关联不是严格意义上的“名字->值”的绑定语义;另一方面,当该函数关联给名字(`aName`)时,JavaScript又会反向地处理该函数(作为对象`f`)的属性`f.name`,使该名字指向`aName`。
|
||||
|
||||
所以,在本讲中的“export default function() {}”,在严格意义上来说(这是第三个关键结论):
|
||||
|
||||
它并不是导出了一个匿名函数表达式,而是导出了一个匿名函数定义(Anonymous Function Definition)。
|
||||
|
||||
因此,该匿名函数初始化时才会绑定给它左侧的名字“****default****”,这会导致`import f from ...`之后访问`f.name`值会得到“****default****”这个名字。
|
||||
|
||||
类似的,你使用下面的代码也会得到这个“****default****”:
|
||||
|
||||
```
|
||||
var obj = {
|
||||
"default": function() {}
|
||||
};
|
||||
console.log(obj.default.name); // "default"
|
||||
|
||||
```
|
||||
|
||||
## 知识补充
|
||||
|
||||
关于export,还可以有一些补充的知识点。
|
||||
|
||||
- `export ...`语句通常是按它的词法声明来创建的标识符的,例如`export var x = ...`就意味着在当前模块环境中创建的是一个变量,并可以修改等等。但是当它被导入时,在`import`语句所在的模块中却是一个常量,因此总是不可写的。
|
||||
- 由于`export default ...`没有显式地约定名字“default(或**default**)”应该按`let/const/var`的哪一种来创建,因此JavaScript缺省将它创建成一个普通的变量(var),但即使是在当前模块环境中,它事实上也是不可写的,因为你无法访问一个命名为“**default**”的变量——它不是一个合法的标识符。
|
||||
- 所谓匿名函数,仅仅是当它直接作为操作数(而不是具有上述“匿名函数定义”的语法结构)时,才是真正匿名的,例如:
|
||||
|
||||
```
|
||||
console.log((function(){}).name); // ""
|
||||
|
||||
```
|
||||
|
||||
- 由于类表达式(包括匿名类表达式)在本质上就是函数,因此它作为default导出时的性质与上面所讨论的是一致的。
|
||||
- 导出项(的名字)总是作为词法声明被声明在当前模块作用域中的,这意味着它不可删除,且不可重复导出。亦即是说即使是用`var x...`来声明,这个`x`也是在_lexicalNames_中,而不是在_varNames_中。
|
||||
- 所谓“某个名字表”,对于export来说是模块的导出表,对于import来说就是名字空间(名字空间是用户代码可以操作的组件,它映射自内部的模块导入名字表)。不过,如果用户代码不使用“import * as …”的语法来创建这个名字空间,那么该名字表就只存在于JavaScript的词法分析过程中,而不会(或并不必要)创建它在运行期的实例。这也是我一直用“某个名字表”来称呼它的原因,它并不总是以实体形式存在的。
|
||||
- 上述名字表简化了ECMAScript中对导入导出记录(**ImportEntry/ExportEntry Record Fields**)的理解。因此如果你试图了解更多,建议你阅读ECMAScript的具体章节。
|
||||
- 没有模块会导出(传统意义上的)main(),因为ECMAScript为了维护模块的静态语义,而把执行过程及其入口的定义丢回给了引擎或宿主本身。
|
||||
|
||||
## 思考题
|
||||
|
||||
本讲的内容中,你需要重点复习三个关键结论的得出过程。这对于之前几讲中所讨论的内容会是很好的回顾。
|
||||
|
||||
除此之外,建议你思考如下问题:
|
||||
|
||||
- 为什么在import语句中会出现“变量提升”的效果?
|
||||
|
||||
如果你并不了解什么是“变量提升”,不用担心,下一讲中我会再次提到它。
|
||||
@@ -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 */; // <- 这里存在一个块级作用域
|
||||
|
||||
// 例3, 块语句
|
||||
{
|
||||
// 作用域1
|
||||
|
||||
```
|
||||
|
||||
除了这三个语句和“**一个特例**”之外,所有其它的语句都是没有块级作用域的。例如`if`条件语句的几种常见书写形式:
|
||||
|
||||
```
|
||||
if (x) {
|
||||
...
|
||||
}
|
||||
|
||||
// or
|
||||
if (x) {
|
||||
...
|
||||
}
|
||||
else {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这些语法中的“块级作用域”都是一对大括号表示的“块语句”自带的,与上面的“例3”是一样的,而与`if`语句本身无关。
|
||||
|
||||
那么,这所谓的“一个特例”是什么呢?这个特例,就是今天这一讲标题中的`for`循环。
|
||||
|
||||
## 循环语句中的块
|
||||
|
||||
并不是所有的循环语句都有自己的块级作用域,例如while和do..while语句就没有。而且,也不是所有for语句都有块级作用域。在JavaScript中,有且仅有:
|
||||
|
||||
>
|
||||
for (**<let/const>** ...) ...
|
||||
|
||||
|
||||
这个语法有自己的块级作用域。当然,这也包括相同设计的`for await`和`for .. of/in ..`。例如:
|
||||
|
||||
>
|
||||
<p>for await (**<let/const>** x of ...) ...<br>
|
||||
for (**<let/const>** x ... in ...) ...<br>
|
||||
for (**<let/const>** x ... of ...) ...</p>
|
||||
|
||||
|
||||
等等。你应该已经注意到了,这里并没有按照惯例那样列出“var”关键字。关于这一点,后面写到的时候我也会再次提及到。就现在来说,你可能需要关心的问题是:**为什么这是个特例?**以及,**如果它是拥有自己的块级作用域的特例,那么它有多少个块级作用域呢?**
|
||||
|
||||
后面这个问题的答案,是:“说不准”。
|
||||
|
||||
看起来,我是被JavaScript的古怪设计击败了。我居然给出了这么一个显而易见是在糊弄大众的答案!但是要知道,所谓的“块级作用域”有两种形式,一种是静态的词法作用域,这对于上面的for语句来说,它们都只有两个块级作用域。但是对于另一种动态的、“块级作用域”的实例来说,这答案就真的是“说不准”了。
|
||||
|
||||
不过,先放下这个,我接下来先给你解释一下“**为什么这里需要一个特例**”。
|
||||
|
||||
## 特例
|
||||
|
||||
除了语句的关键字和语法结构本身之外,语句中可以包括什么呢?
|
||||
|
||||
如果你归纳一下语句中可以包含的全部内容,你应该可以看到一个简单的结果:所有在语句内可以存在的东西只有四种:表达式、其它语句、标识符声明(取决于声明语句或其它的隐式声明的方式),以及一种特殊的语法元素,称为“标签(例如标签化语句,或break语句指向的目标位置)”。
|
||||
|
||||
所谓“块级作用域”,本质上只包括一组标识符。因此,只有当存在潜在标识符冲突的时候,才有必要新添加一个作用域来管理它们。例如函数,由于函数存在“重新进入”的问题,所以它必须有一个作用域来管理“重新进入之前”的那些标识符。这个东西想必你是听说过的,它被称为“**闭包**”。
|
||||
|
||||
>
|
||||
NOTE: 在语言设计时,有三种需求会促使语句构建自己的作用域,标识符管理只是其中之一。其它两种情况,要么是因为在语法上支持多语句(例如try...catch..finally语句),要么是语句所表达的语义要求有一个块,例如“块语句{ }”在语义上就要求它自己是一个块级作用域。
|
||||
|
||||
|
||||
所以**标签**、**表达式**和**其它语句**这三种东西都不需要使用一个“独立作用域”去管理起来。所谓“其它语句”当然存在这种冲突,不过显然这种情况下它们也应该自己管理这个作用域。所以,对于当前语句来说,就只需要考虑剩下的唯一一种情况,就是在“**语句中包含了标识符声明**”的情况下,需要创建块级作用域来管理这些声明出来的标识符。
|
||||
|
||||
在所有六种声明语句之外,只剩下`for (<let/const>...)...`这一个语句能在它的语法中去做这样的标识符声明。所以,它就成了块级作用域的这个唯一特例。
|
||||
|
||||
那么这个语法中为什么单单没有了“var声明”呢?
|
||||
|
||||
## 特例中的特例
|
||||
|
||||
“var声明”是特例中的特例。
|
||||
|
||||
这一特性来自于**JavaScript远古时代的作用域设计**。在早期的JavaScript中,并没有所谓的块级作用域,那个时候的作用域设计只有“函数内”和“函数外”两种,如果一个标识符不在任何(可以多层嵌套的)函数内的话,那么它就一定是在“全局作用域”里。
|
||||
|
||||
“函数内→全局”之间的作用域,就只有概念上无限层级的“函数内”。
|
||||
|
||||
而在这个时代,变量也就只有“var声明”的变量。由于作用域只有上面两个,所以任何一个“var声明”的标识符,要么是在函数内的,要么就是在全局的,没有例外。按照这个早期设计,如下语句中的变量`x`:
|
||||
|
||||
```
|
||||
for (var x = ...)
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
是不应该出现在“**for语句所在的**”块级作用域中的。它应该出现其外层的某个函数作用域,或者全局作用域中。这种越过当前语法范围,而在更外围的作用域中登记名字行为就称为“**提升(Hoisting/Hoistable)**”。
|
||||
|
||||
ECMAScript 6开始的JavaScript在添加块级作用域特性时充分考虑了对旧有语法的兼容,因此当上述语法中出现“var声明”时,它所声明的标识符是与该语句的块级作用域无关的。在ECMAScript中,这是两套标识符体系,也是使用两套作用域来管理的。确切地说:
|
||||
|
||||
- 所有“var声明”和函数声明的标识符都登记为varNames,使用“**变量作用域**”管理;
|
||||
- 其它情况下的标识符/变量声明,都作为lexicalNames登记,使用“**词法作用域**”管理。
|
||||
|
||||
>
|
||||
NOTE: 考虑到对传统JavaScript的兼容,函数内部的顶层函数名是提升到变量作用域中来管理的。 > > NOTE: 我通常会将“在变量声明语句前使用该变量”也称为一种提升效果(**Hoisting effect**),但这种说法不见于ECMAScript规范。ES规范将这种“提前使用”称为“访问一个未初始化的绑定(**uninitialized mutable/immutable binding**)”。而所谓“var声明能被提前使用”的效果,事实上是“var变量总是被引擎预先初始化为undefined”的一种后果。
|
||||
|
||||
|
||||
所以,语句`for (<const/let> x ...) ...`语法中的标识符`x`是一个**词法名字**,应该由`for`语句为它创建一个(块级的)词法作用域来管理之。
|
||||
|
||||
然而进一步后,新的问题产生了:一个词法作用域是足够的吗?
|
||||
|
||||
## 第二个作用域
|
||||
|
||||
首先,必须要拥有至少一个块级作用域。如之前讲到的,这是出于管理标识符的必要性。下面的示例简单说明这个块级作用域的影响:
|
||||
|
||||
```
|
||||
var x = 100;
|
||||
for (let x = 102; x < 105; x++)
|
||||
console.log('value:', x); // 显示“value: 102~104”
|
||||
console.log('outer:', x); // 显示“outer: 100”
|
||||
|
||||
```
|
||||
|
||||
因为`for`语句的这个块级作用域的存在,导致循环体内访问了一个局部的`x`值(循环变量),而外部的(outer)变量`x`是不受影响的。
|
||||
|
||||
那么在循环体内是否需要一个新的块级作用域呢?这取决于在语言设计上是否支持如下代码:
|
||||
|
||||
```
|
||||
for (let x = 102; x < 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<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(()=>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 )?
|
||||
|
||||
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -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回写道,那道人问道:
|
||||
|
||||
>
|
||||
“你这六位师父都是武林中顶尖儿的人物,……你又不是不用功,为什么十年来进益不多,你可知道什么原因?”
|
||||
|
||||
|
||||
郭靖回答说:
|
||||
|
||||
>
|
||||
“那是因为弟子太笨,师父们再用心教也教不会。”
|
||||
|
||||
|
||||
这时候那位道人就笑了,说了一句古今以来,求学问道最核心的要义,这句话,原话就是:
|
||||
|
||||
>
|
||||
“那未必尽然,这是教而不明其法,学而不得其道。”
|
||||
|
||||
|
||||
所以,关于我们今天说的这一门课程,“学不好、或者学不会”,其关键就在这位道人——马钰(不是马云)——说过的这句话:教者要有其法,而学者要得其道。
|
||||
@@ -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的,而且还不完整,但是要达到“补概念”这个目的,还是够用了。
|
||||
|
||||
## 其他
|
||||
|
||||
今天的思考题跟以前不同,这道题,你答不答得出来,都是不要紧的,就算“想着玩儿”就好了。问题是这样的:
|
||||
|
||||
- 按照前面说的,所谓“会吃”,有三件事情,是食材、味道和“懂”这一个字儿。食材,我们讲了,是课程的内容;味道,我们也讲了,是课程中的教与学的法子,以及“形成体系性”这样的潜在目的。那么,什么是“懂”呢?
|
||||
|
||||
这个问题,就留给你了。我想啊,要知道什么是“懂”,大概才真的算是“会吃”了。
|
||||
|
||||
我今天的课程就聊到这里。希望你吃得好,胃口好,好好消化这一份专属的加餐,然后我们下一讲,再继续学习。
|
||||
Reference in New Issue
Block a user