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,290 @@
<audio id="audio" title="JavaScript对象你知道全部的对象分类吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/a1/e6da746ef307617e41b7744d4564dea1.mp3"></audio>
你好我是winter。
在前面的课程中我已经讲解了JavaScript对象的一些基础知识。但是我们所讲解的对象只是特定的一部分并不能涵盖全部的JavaScript对象。
比如说我们不论怎样编写代码都没法绕开Array实现一个跟原生的数组行为一模一样的对象这是由于原生数组的底层实现了一个自动随着下标变化的length属性。
并且在浏览器环境中我们也无法单纯依靠JavaScript代码实现div对象只能靠document.createElement来创建。这也说明了JavaScript的对象机制并非简单的属性集合+原型。
我们日常工作中接触到的主要API几乎都是由今天所讲解的这些对象提供的。理解这些对象的性质我们才能真正理解我们使用的API的一些特性。
## JavaScript中的对象分类
我们可以把对象分成几类。
<li>
宿主对象host Objects由JavaScript宿主环境提供的对象它们的行为完全由宿主环境决定。
</li>
<li>
内置对象Built-in Objects由JavaScript语言提供的对象。
<ul>
- 固有对象Intrinsic Objects 由标准规定随着JavaScript运行时创建而自动创建的对象实例。
- 原生对象Native Objects可以由用户通过Array、RegExp等内置构造器或者特殊语法创建的对象。
- 普通对象Ordinary Objects由{}语法、Object构造器或者class关键字定义类创建的对象它能够被原型继承。
下面我会为你一一讲解普通对象之外的对象类型。
### 宿主对象
首先我们来看看宿主对象。
JavaScript宿主对象千奇百怪但是前端最熟悉的无疑是浏览器环境中的宿主了。
在浏览器环境中我们都知道全局对象是windowwindow上又有很多属性如document。
实际上这个全局对象window上的属性一部分来自JavaScript语言一部分来自浏览器环境。
JavaScript标准中规定了全局对象属性W3C的各种标准中规定了Window对象的其它属性。
宿主对象也分为固有的和用户可创建的两种比如document.createElement就可以创建一些DOM对象。
宿主也会提供一些构造器比如我们可以使用new Image来创建img元素这些我们会在浏览器的API部分详细讲解。
## 内置对象·固有对象
我们在前面说过固有对象是由标准规定随着JavaScript运行时创建而自动创建的对象实例。
固有对象在任何JavaScript代码执行前就已经被创建出来了它们通常扮演者类似基础库的角色。我们前面提到的“类”其实就是固有对象的一种。
ECMA标准为我们提供了一份固有对象表里面含有150+个固有对象。你可以通过[这个链接](https://www.ecma-international.org/ecma-262/9.0/index.html#sec-well-known-intrinsic-objects)查看。
但是遗憾的是这个表格并不完整。所以在本篇的末尾我设计了一个小实验小实验获取全部JavaScript固有对象你可以自己尝试一下数一数一共有多少个固有对象。
## 内置对象·原生对象
我们把JavaScript中能够通过语言本身的构造器创建的对象称作原生对象。在JavaScript标准中提供了30多个构造器。按照我的理解按照不同应用场景我把原生对象分成了以下几个种类。
<img src="https://static001.geekbang.org/resource/image/6c/d0/6cb1df319bbc7c7f948acfdb9ffd99d0.png" alt="">
通过这些构造器我们可以用new运算创建新的对象所以我们把这些对象称作原生对象。<br>
几乎所有这些构造器的能力都是无法用纯JavaScript代码实现的它们也无法用class/extend语法来继承。
这些构造器创建的对象多数使用了私有字段,例如:
- Error: [[ErrorData]]
- Boolean: [[BooleanData]]
- Number: [[NumberData]]
- Date: [[DateValue]]
- RegExp: [[RegExpMatcher]]
- Symbol: [[SymbolData]]
- Map: [[MapData]]
这些字段使得原型继承方法无法正常工作,所以,我们可以认为,所有这些原生对象都是为了特定能力或者性能,而设计出来的“特权对象”。
## 用对象来模拟函数与构造器:函数对象与构造器对象
我在前面介绍了对象的一般分类在JavaScript中还有一个看待对象的不同视角这就是用对象来模拟函数和构造器。
事实上JavaScript为这一类对象预留了私有字段机制并规定了抽象的函数对象与构造器对象的概念。
函数对象的定义是:具有[[call]]私有字段的对象,构造器对象的定义是:具有私有字段[[construct]]的对象。
JavaScript用对象模拟函数的设计代替了一般编程语言中的函数它们可以像其它语言的函数一样被调用、传参。任何宿主只要提供了“具有[[call]]私有字段的对象”,就可以被 JavaScript 函数调用语法支持。
>
[[call]]私有字段必须是一个引擎中定义的函数需要接受this值和调用参数并且会产生域的切换这些内容我将会在属性访问和执行过程两个章节详细讲述。
我们可以这样说,任何对象只需要实现[[call]],它就是一个函数对象,可以去作为函数被调用。而如果它能实现[[construct]],它就是一个构造器对象,可以作为构造器被调用。
对于为JavaScript提供运行环境的程序员来说只要字段符合我们在上文中提到的宿主对象和内置对象如Symbol函数可以模拟函数和构造器。
当然了用户用function关键字创建的函数必定同时是函数和构造器。不过它们表现出来的行为效果却并不相同。
对于宿主和内置对象来说,它们实现[[call]](作为函数被调用)和[[construct]](作为构造器被调用)不总是一致的。比如内置对象 Date 在作为构造器调用时产生新的对象,作为函数时,则产生字符串,见以下代码:
```
console.log(new Date); // 1
console.log(Date())
```
而浏览器宿主环境中提供的Image构造器则根本不允许被作为函数调用。
```
console.log(new Image);
console.log(Image());//抛出错误
```
再比如基本类型String、Number、Boolean它们的构造器被当作函数调用则产生类型转换的效果。
值得一提的是在ES6之后 =&gt; 语法创建的函数仅仅是函数,它们无法被当作构造器使用,见以下代码:
```
new (a =&gt; 0) // error
```
对于用户使用 function 语法或者Function构造器创建的对象来说[[call]]和[[construct]]行为总是相似的,它们执行同一段代码。
我们看一下示例。
```
function f(){
return 1;
}
var v = f(); //把f作为函数调用
var o = new f(); //把f作为构造器调用
```
我们大致可以认为,它们[[construct]]的执行过程如下:
- 以 Object.prototype 为原型创建一个新对象;
- 以新对象为 this执行函数的[[call]]
- 如果[[call]]的返回值是对象,那么,返回这个对象,否则返回第一步创建的新对象。
这样的规则造成了个有趣的现象如果我们的构造器返回了一个新的对象那么new创建的新对象就变成了一个构造函数之外完全无法访问的对象这一定程度上可以实现“私有”。
```
function cls(){
this.a = 100;
return {
getValue:() =&gt; this.a
}
}
var o = new cls;
o.getValue(); //100
//a在外面永远无法访问到
```
## 特殊行为的对象
除了上面介绍的对象之外,在固有对象和原生对象中,有一些对象的行为跟正常对象有很大区别。
它们常见的下标运算(就是使用中括号或者点来做属性访问)或者设置原型跟普通对象不同,这里我简单总结一下。
- ArrayArray的length属性根据最大的下标自动发生变化。
- Object.prototype作为所有正常对象的默认原型不能再给它设置原型了。
- String为了支持下标运算String的正整数属性访问会去字符串里查找。
- Argumentsarguments的非负整数型下标属性跟对应的变量联动。
- 模块的namespace对象特殊的地方非常多跟一般对象完全不一样尽量只用于import吧。
- 类型数组和数组缓冲区:跟内存块相关联,下标运算比较特殊。
- bind后的function跟原来的函数相关联。
## 结语
在这篇文章中我们介绍了一些不那么常规的对象并且我还介绍了JavaScript中用对象来模拟函数和构造器的机制。
这是一些不那么有规律、不那么优雅的知识而JavaScript正是通过这些对象提供了很多基础的能力。
我们这次课程留一个挑战任务不使用new运算符尽可能找到获得对象的方法。
例子:
```
var o = {}
var o = function(){}
```
请把自己的答案留言给我,我们来比比看谁找到的多。
## 小实验获取全部JavaScript固有对象
我们从JavaScript标准中可以找到全部的JavaScript对象定义。JavaScript语言规定了全局对象的属性。
三个值:
Infinity、NaN、undefined。
九个函数:
- eval
- isFinite
- isNaN
- parseFloat
- parseInt
- decodeURI
- decodeURIComponent
- encodeURI
- encodeURIComponent
一些构造器:<br>
Array、Date、RegExp、Promise、Proxy、Map、WeakMap、Set、WeakSet、Function、Boolean、String、Number、Symbol、Object、Error、EvalError、RangeError、ReferenceError、SyntaxError、TypeError、URIError、ArrayBuffer、SharedArrayBuffer、DataView、Typed Array、Float32Array、Float64Array、Int8Array、Int16Array、Int32Array、UInt8Array、UInt16Array、UInt32Array、UInt8ClampedArray。
四个用于当作命名空间的对象:
- Atomics
- JSON
- Math
- Reflect
我们使用广度优先搜索查找这些对象所有的属性和Getter/Setter就可以获得JavaScript中所有的固有对象。
请你试着先不看我的代码在自己的浏览器中计算出来JavaScript有多少固有对象。
```
var set = new Set();
var objects = [
eval,
isFinite,
isNaN,
parseFloat,
parseInt,
decodeURI,
decodeURIComponent,
encodeURI,
encodeURIComponent,
Array,
Date,
RegExp,
Promise,
Proxy,
Map,
WeakMap,
Set,
WeakSet,
Function,
Boolean,
String,
Number,
Symbol,
Object,
Error,
EvalError,
RangeError,
ReferenceError,
SyntaxError,
TypeError,
URIError,
ArrayBuffer,
SharedArrayBuffer,
DataView,
Float32Array,
Float64Array,
Int8Array,
Int16Array,
Int32Array,
Uint8Array,
Uint16Array,
Uint32Array,
Uint8ClampedArray,
Atomics,
JSON,
Math,
Reflect];
objects.forEach(o =&gt; set.add(o));
for(var i = 0; i &lt; objects.length; i++) {
var o = objects[i]
for(var p of Object.getOwnPropertyNames(o)) {
var d = Object.getOwnPropertyDescriptor(o, p)
if( (d.value !== null &amp;&amp; typeof d.value === &quot;object&quot;) || (typeof d.value === &quot;function&quot;))
if(!set.has(d.value))
set.add(d.value), objects.push(d.value);
if( d.get )
if(!set.has(d.get))
set.add(d.get), objects.push(d.get);
if( d.set )
if(!set.has(d.set))
set.add(d.set), objects.push(d.set);
}
}
```

View File

@@ -0,0 +1,281 @@
<audio id="audio" title="JavaScript对象我们真的需要模拟类吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/9d/d5fae4132d1af8608110297ffbe87e9d.mp3"></audio>
早期的JavaScript程序员一般都有过使用JavaScript“模拟面向对象”的经历。
在上一篇文章我们已经讲到JavaScript本身就是面向对象的它并不需要模拟只是它实现面向对象的方式和主流的流派不太一样所以才让很多人产生了误会。
那么,随着我们理解的思路继续深入,这些“模拟面向对象”,实际上做的事情就是“模拟基于类的面向对象”。
尽管我认为“类”并非面向对象的全部但我们不应该责备社区出现这样的方案事实上因为一些公司的政治原因JavaScript推出之时管理层就要求它去模仿Java。
所以JavaScript创始人Brendan Eich在“原型运行时”的基础上引入了new、this等语言特性使之“看起来语法更像Java”而Java正是基于类的面向对象的代表语言之一。
但是JavaScript这样的半吊子模拟缺少了继承等关键特性导致大家试图对它进行修补进而产生了种种互不相容的解决方案。
庆幸的是从ES6开始JavaScript提供了class关键字来定义类尽管这样的方案仍然是基于原型运行时系统的模拟但是它修正了之前的一些常见的“坑”统一了社区的方案这对语言的发展有着非常大的好处。
实际上我认为“基于类”并非面向对象的唯一形态如果我们把视线从“类”移开Brendan当年选择的原型系统就是一个非常优秀的抽象对象的形式。
我们从头讲起。
## 什么是原型?
原型是顺应人类自然思维的产物。中文中有个成语叫做“照猫画虎”,这里的猫看起来就是虎的原型,所以,由此我们可以看出,用原型来描述对象的方法可以说是古已有之。
我们在上一节讲解面向对象的时候提到了:在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。
最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java等流行的编程语言。这个流派叫做基于类的编程语言。
还有一种就是基于原型的编程语言它们利用原型来描述对象。我们的JavaScript就是其中代表。
“基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。
与此相对,“基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。
基于原型的面向对象系统通过“复制”的方式来创建新对象。一些语言的实现中,还允许复制一个空对象。这实际上就是创建一个全新的对象。
基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。
这就像专业人士可能喜欢在看到老虎的时候,喜欢用猫科豹属豹亚种来描述它,但是对一些不那么正式的场合,“大猫”可能更为接近直观的感受一些(插播一个冷知识:比起老虎来,美洲狮在历史上相当长时间都被划分为猫科猫属,所以性格也跟猫更相似,比较亲人)。
我们的JavaScript 并非第一个使用原型的语言在它之前self、kevo等语言已经开始使用原型来描述对象了。
事实上Brendan更是曾透露过他最初的构想是一个拥有基于原型的面向对象能力的scheme语言但是函数式的部分是另外的故事这篇文章里我暂时不做详细讲述
在JavaScript之前原型系统就更多与高动态性语言配合并且多数基于原型的语言提倡运行时的原型修改我想这应该是Brendan选择原型系统很重要的理由。
原型系统的“复制操作”有两种实现思路:
<li>
一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;
</li>
<li>
另一个是切实地复制对象,从此两个对象再无关联。
</li>
历史上的基于原型语言因此产生了两个流派显然JavaScript显然选择了前一种方式。
## JavaScript的原型
如果我们抛开JavaScript用于模拟Java类的复杂语法设施如new、Function Object、函数的prototype属性等原型系统可以说相当简单我可以用两条概括
- 如果所有对象都有私有字段[[prototype]],就是对象的原型;
- 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。
这个模型在ES的各个历史版本中并没有很大改变但从 ES6 以来JavaScript提供了一系列内置函数以便更为直接地访问操纵原型。三个方法分别为
- Object.create 根据指定的原型创建新对象原型可以是null
- Object.getPrototypeOf 获得一个对象的原型;
- Object.setPrototypeOf 设置一个对象的原型。
利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用。我用下面的代码展示了用原型来抽象猫和虎的例子。
```
var cat = {
say(){
console.log(&quot;meow~&quot;);
},
jump(){
console.log(&quot;jump&quot;);
}
}
var tiger = Object.create(cat, {
say:{
writable:true,
configurable:true,
enumerable:true,
value:function(){
console.log(&quot;roar!&quot;);
}
}
})
var anotherCat = Object.create(cat);
anotherCat.say();
var anotherTiger = Object.create(tiger);
anotherTiger.say();
```
这段代码创建了一个“猫”对象又根据猫做了一些修改创建了虎之后我们完全可以用Object.create来创建另外的猫和虎对象我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。
但是在更早的版本中程序员只能通过Java风格的类接口来操纵原型运行时可以说非常别扭。
考虑到new和prototype属性等基础设施今天仍然有效而且被很多代码使用学习这些知识也有助于我们理解运行时的原型工作原理下面我们试着回到过去追溯一下早年的JavaScript中的原型和类。
## 早期版本中的类与原型
在早期版本的JavaScript中“类”的定义是一个私有属性 [[class]]语言标准为内置类型诸如Number、String、Date等指定了[[class]]属性,以表示它们的类。语言使用者唯一可以访问[[class]]属性的方式是Object.prototype.toString。
以下代码展示了所有具有内置class属性的对象
```
var o = new Object;
var n = new Number;
var s = new String;
var b = new Boolean;
var d = new Date;
var arg = function(){ return arguments }();
var r = new RegExp;
var f = new Function;
var arr = new Array;
var e = new Error;
console.log([o, n, s, b, d, arg, r, f, arr, e].map(v =&gt; Object.prototype.toString.call(v)));
```
因此在ES3和之前的版本JS中类的概念是相当弱的它仅仅是运行时的一个字符串属性。
在ES5开始[[class]] 私有属性被 Symbol.toStringTag 代替Object.prototype.toString 的意义从命名上不再跟 class 相关。我们甚至可以自定义 Object.prototype.toString 的行为以下代码展示了使用Symbol.toStringTag来自定义 Object.prototype.toString 的行为:
```
var o = { [Symbol.toStringTag]: &quot;MyObject&quot; }
console.log(o + &quot;&quot;);
```
这里创建了一个新对象,并且给它唯一的一个属性 Symbol.toStringTag我们用字符串加法触发了Object.prototype.toString的调用发现这个属性最终对Object.prototype.toString 的结果产生了影响。
但是考虑到JavaScript语法中跟Java相似的部分我们对类的讨论不能用“new运算是针对构造器对象而不是类”来试图回避。
所以我们仍然要把new理解成JavaScript面向对象的一部分下面我就来讲一下new操作具体做了哪些事情。
new 运算接受一个构造器和一组调用参数,实际上做了几件事:
- 以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象;
- 将 this 和调用参数传给构造器,执行;
- 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。
new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式,一是在构造器中添加属性,二是在构造器的 prototype 属性上添加属性。
下面代码展示了用构造器模拟类的两种方法:
```
function c1(){
this.p1 = 1;
this.p2 = function(){
console.log(this.p1);
}
}
var o1 = new c1;
o1.p2();
function c2(){
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
console.log(this.p1);
}
var o2 = new c2;
o2.p2();
```
第一种方法是直接在构造器中修改this给this添加属性。
第二种方法是修改构造器的prototype属性指向的对象它是从这个构造器构造出来的所有对象的原型。
没有Object.create、Object.setPrototypeOf 的早期版本中new 运算是唯一一个可以指定[[prototype]]的方法当时的mozilla提供了私有属性__proto__但是多数环境并不支持所以当时已经有人试图用它来代替后来的 Object.create我们甚至可以用它来实现一个Object.create的不完整的polyfill见以下代码
```
Object.create = function(prototype){
var cls = function(){}
cls.prototype = prototype;
return new cls;
}
```
这段代码创建了一个空函数作为类并把传入的原型挂在了它的prototype最后创建了一个它的实例根据new的行为这将产生一个以传入的第一个参数为原型的对象。
这个函数无法做到与原生的Object.create一致一个是不支持第二个参数另一个是不支持null作为原型所以放到今天意义已经不大了。
## ES6 中的类
好在ES6中加入了新特性classnew跟function搭配的怪异行为终于可以退休了虽然运行时没有改变在任何场景我都推荐使用ES6的语法来定义类而令function回归原本的函数语义。下面我们就来看一下ES6中的类。
ES6中引入了class关键字并且在标准中删除了所有[[class]]相关的私有属性描述类的概念正式从属性升级成语言的基础设施从此基于类的编程方式成为了JavaScript的官方编程范式。
我们先看下类的基本写法:
```
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}
```
在现有的类语法中getter/setter和method是兼容性最好的。
我们通过get/set关键字来创建getter通过括号和大括号来创建方法数据型成员最好写在构造器里面。
类的写法实际上也是由原型运行时来承载的逻辑上JavaScript认为每个类是有共同原型的一组对象类中定义的方法和属性则会被写在原型对象之上。
此外,最重要的是,类提供了继承能力。我们来看一下下面的代码。
```
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(this.name + ' makes a noise.');
}
}
class Dog extends Animal {
constructor(name) {
super(name); // call the super class constructor and pass in the name parameter
}
speak() {
console.log(this.name + ' barks.');
}
}
let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.
```
以上代码创造了Animal类并且通过extends关键字让Dog继承了它展示了最终调用子类的speak方法获取了父类的name。
比起早期的原型模拟方式使用extends关键字自动设置了constructor并且会自动调用父类的构造函数这是一种更少坑的设计。
所以当我们使用类的思想来设计代码时应该尽量使用class来声明类而不是用旧语法拿函数来模拟对象。
一些激进的观点认为class关键字和箭头运算符可以完全替代旧的function关键字它更明确地区分了定义函数和定义类两种意图我认为这是有一定道理的。
## 总结
在新的ES版本中我们不再需要模拟类了我们有了光明正大的新语法。而原型体系同时作为一种编程范式和运行时机制存在。
我们可以自由选择原型或者类作为代码的抽象风格,但是无论我们选择哪种,理解运行时的原型系统都是很有必要的一件事。
在你的工作中是使用class还是仍然在用function来定义“类”为什么这么做如何把使用function定义类的代码改造到class的新语法
欢迎给我留言,我们一起讨论。

View File

@@ -0,0 +1,204 @@
<audio id="audio" title="JavaScript对象面向对象还是基于对象" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/fe/4cdb714b72874758f2e7a7ce975eb1fe.mp3"></audio>
你好我是winter。
与其它的语言相比JavaScript中的“对象”总是显得不那么合群。
一些新人在学习JavaScript面向对象时往往也会有疑惑
- 为什么JavaScript直到ES6有对象的概念但是却没有像其他的语言那样有类的概念呢
- 为什么在JavaScript对象里可以自由添加属性而其他的语言却不能呢
甚至在一些争论中有人强调JavaScript并非“面向对象的语言”而是“基于对象的语言”。这个说法一度流传甚广而事实上我至今遇到的持有这一说法的人中无一能够回答“如何定义面向对象和基于对象”这个问题。
实际上基于对象和面向对象两个形容词都出现在了JavaScript标准的各个版本当中。
我们可以先看看JavaScript标准对基于对象的定义这个定义的具体内容是“语言和宿主的基础设施由对象来提供并且JavaScript程序即是一系列互相通讯的对象集合”。
这里的意思根本不是表达弱化的面向对象的意思,反而是表达对象对于语言的重要性。
那么在本篇文章中我会尝试让你去理解面向对象和JavaScript中的面向对象究竟是什么。
## 什么是面向对象?
我们先来说说什么是对象因为翻译的原因中文语境下我们很难理解“对象”的真正含义。事实上Object对象在英文中是一切事物的总称这和面向对象编程的抽象思维有互通之处。
中文的“对象”却没有这样的普适性,我们在学习编程的过程中,更多是把它当作一个专业名词来理解。
但不论如何,我们应该认识到,对象并不是计算机领域凭空造出来的概念,它是顺着人类思维模式产生的一种抽象(于是面向对象编程也被认为是:更接近人类思维模式的一种编程范式)。
那么,我们先来看看在人类思维模式下,对象究竟是什么。
>
对象这一概念在人类的幼儿期形成,这远远早于我们编程逻辑中常用的值、过程等概念。
在幼年期我们总是先认识到某一个苹果能吃这里的某一个苹果就是一个对象继而认识到所有的苹果都可以吃这里的所有苹果就是一个类再到后来我们才能意识到三个苹果和三个梨之间的联系进而产生数字“3”的概念。
在《面向对象分析与设计》这本书中Grady Booch替我们做了总结他认为从人类的认知角度来说对象应该是下列事物之一
1. 一个可以触摸或者可以看见的东西;
1. 人的智力可以理解的东西;
1. 可以指导思考或行动(进行想象或施加动作)的东西。
有了对象的自然定义后,我们就可以描述编程语言中的对象了。在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象,最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java等流行的编程语言。
而 JavaScript 早年却选择了一个更为冷门的方式:原型(关于原型,我在下一篇文章会重点介绍,这里你留个印象就可以了)。这是我在前面说它不合群的原因之一。
然而很不幸因为一些公司政治原因JavaScript推出之时受管理层之命被要求模仿Java所以JavaScript创始人Brendan Eich在“原型运行时”的基础上引入了new、this等语言特性使之“看起来更像Java”。
在 ES6 出现之前,大量的 JavaScript 程序员试图在原型体系的基础上把JavaScript变得更像是基于类的编程进而产生了很多所谓的“框架”比如PrototypeJS、Dojo。
事实上它们成为了某种JavaScript的古怪方言甚至产生了一系列互不相容的社群显然这样做的收益是远远小于损失的。
如果我们从运行时角度来谈论对象就是在讨论JavaScript实际运行中的模型这是由于任何代码执行都必定绕不开运行时的对象模型。
不过,幸运的是,从运行时的角度看,可以不必受到这些“基于类的设施”的困扰,这是因为任何语言运行时类的概念都是被弱化的。
首先我们来了解一下JavaScript是如何设计对象模型的。
## JavaScript 对象的特征
在我看来不论我们使用什么样的编程语言我们都先应该去理解对象的本质特征参考Grandy Booch《面向对象分析与设计》。总结来看对象有如下几个特点。
- 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。
- 对象有状态:对象具有状态,同一对象可能处于不同状态之下。
- 对象具有行为:即对象的状态,可能因为它的行为产生变迁。
我们先来看第一个特征,对象具有唯一标识性。一般而言,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识。
所以JavaScript程序员都知道任何不同的JavaScript对象其实是互不相等的我们可以看下面的代码o1和o2初看是两个一模一样的对象但是打印出来的结果却是false。
```
var o1 = { a: 1 };
var o2 = { a: 1 };
console.log(o1 == o2); // false
```
关于对象的第二个和第三个特征“状态和行为”不同语言会使用不同的术语来抽象描述它们比如C++中称它们为“成员变量”和“成员函数”Java中则称它们为“属性”和“方法”。
在 JavaScript中将状态和行为统一抽象为“属性”考虑到 JavaScript 中将函数设计成一种特殊对象(关于这点,我会在后面的文章中详细讲解,此处先不用细究),所以 JavaScript中的行为和状态都能用属性来抽象。
下面这段代码其实就展示了普通属性和函数作为属性的一个例子其中o是对象d是一个属性而函数f也是一个属性尽管写法不太相同但是对JavaScript来说d和f就是两个普通属性。
```
var o = {
d: 1,
f() {
console.log(this.d);
}
};
```
所以总结一句话来看在JavaScript中对象的状态和行为其实都被抽象为了属性。如果你用过Java一定不要觉得奇怪尽管设计思路有一定差别但是二者都很好地表现了对象的基本特征标识性、状态和行为。
**在实现了对象基本特征的基础上, 我认为JavaScript中对象独有的特色是对象具有高度的动态性这是因为JavaScript赋予了使用者在运行时为对象添改状态和行为的能力。**
我来举个例子比如JavaScript 允许运行时向对象添加属性这就跟绝大多数基于类的、静态的对象设计完全不同。如果你用过Java或者其它别的语言肯定会产生跟我一样的感受。
下面这段代码就展示了运行时如何向一个对象添加属性一开始我定义了一个对象o定义完成之后再添加它的属性b这样操作是完全没问题的。
```
var o = { a: 1 };
o.b = 2;
console.log(o.a, o.b); //1 2
```
为了提高抽象能力JavaScript的属性被设计成比别的语言更加复杂的形式它提供了数据属性和访问器属性getter/setter两类。
## JavaScript对象的两类属性
对JavaScript来说属性并非只是简单的名称和值JavaScript用一组特征attribute来描述属性property
先来说第一类属性,数据属性。它比较接近于其它语言的属性概念。数据属性具有四个特征。
- value就是属性的值。
- writable决定属性能否被赋值。
- enumerable决定for in能否枚举该属性。
- configurable决定该属性能否被删除或者改变特征值。
在大多数情况下,我们只关心数据属性的值即可。
第二类属性是访问器getter/setter属性它也有四个特征。
- getter函数或undefined在取属性值时被调用。
- setter函数或undefined在设置属性值时被调用。
- enumerable决定for in能否枚举该属性。
- configurable决定该属性能否被删除或者改变特征值。
访问器属性使得属性在读和写时执行代码,它允许使用者在写和读属性时,得到完全不同的值,它可以视为一种函数的语法糖。
我们通常用于定义属性的代码会产生数据属性其中的writable、enumerable、configurable都默认为true。我们可以使用内置函数getOwnPropertyDescriptor来查看如以下代码所示
```
var o = { a: 1 };
o.b = 2;
//a和b皆为数据属性
Object.getOwnPropertyDescriptor(o,"a") // {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor(o,"b") // {value: 2, writable: true, enumerable: true, configurable: true}
```
我们在这里使用了两种语法来定义属性定义完属性后我们用JavaScript的API来查看这个属性我们可以发现这样定义出来的属性都是数据属性writeable、enumerable、configurable都是默认值为true。
如果我们要想改变属性的特征,或者定义访问器属性,我们可以使用 Object.defineProperty示例如下
```
var o = { a: 1 };
Object.defineProperty(o, &quot;b&quot;, {value: 2, writable: false, enumerable: false, configurable: true});
//a和b都是数据属性但特征值变化了
Object.getOwnPropertyDescriptor(o,&quot;a&quot;); // {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor(o,&quot;b&quot;); // {value: 2, writable: false, enumerable: false, configurable: true}
o.b = 3;
console.log(o.b); // 2
```
这里我们使用了Object.defineProperty来定义属性这样定义属性可以改变属性的writable和enumerable。
我们同样用Object.getOwnPropertyDescriptor来查看发现确实改变了writable和enumerable特征。因为writable特征为false所以我们重新对b赋值b的值不会发生变化。
在创建对象时,也可以使用 get 和 set 关键字来创建访问器属性,代码如下所示:
```
var o = { get a() { return 1 } };
console.log(o.a); // 1
```
访问器属性跟数据属性不同每次访问属性都会执行getter或者setter函数。这里我们的getter函数返回了1所以o.a每次都得到1。
这样我们就理解了实际上JavaScript 对象的运行时是一个“属性的集合”属性以字符串或者Symbol为key以数据属性特征值或者访问器属性特征值为value。
对象是一个属性的索引结构索引结构是一类常见的数据结构我们可以把它理解为一个能够以比较快的速度用key来查找value的字典。我们以上面的对象o为例你可以想象一下“a”是key。
`{writable:true,value:1,configurable:true,enumerable:true}`是value。我们在前面的类型课程中已经介绍了Symbol类型能够以Symbol为属性名这是JavaScript对象的一个特色。
讲到了这里,如果你理解了对象的特征,也就不难理解我开篇提出来的问题。
你甚至可以理解为什么会有“JavaScript不是面向对象”这样的说法了。这是由于JavaScript的对象设计跟目前主流基于类的面向对象差异非常大。
可事实上这样的对象系统设计虽然特别但是JavaScript提供了完全运行时的对象系统这使得它可以模仿多数面向对象编程范式下一节课我们会给你介绍JavaScript中两种面向对象编程的范式基于类和基于原型所以它也是正统的面向对象语言。
JavaScript语言标准也已经明确说明JavaScript是一门面向对象的语言我想标准中能这样说正是因为JavaScript的高度动态性的对象系统。
所以,我们应该在理解其设计思想的基础上充分挖掘它的能力,而不是机械地模仿其它语言。
## 结语
要想理解JavaScript对象必须清空我们脑子里“基于类的面向对象”相关的知识回到人类对对象的朴素认知和面向对象的语言无关基础理论我们就能够理解JavaScript面向对象设计的思路。
在这篇文章中我从对象的基本理论出发和你理清了关于对象的一些基本概念分析了JavaScript对象的设计思路。接下来又从运行时的角度介绍了JavaScript对象的具体设计具有高度动态性的属性集合。
很多人在思考JavaScript对象时会带着已有的“对象”观来看问题最后的结果当然就是“剪不断理还乱”了。
在后面的文章中我会继续带你探索JavaScript对象的一些机制看JavaScript如何基于这样的动态对象模型设计自己的原型系统以及你熟悉的函数、类等基础设施。
你还知道哪些面向对象语言,它们的面向对象系统是怎样的?请留言告诉我吧!
# 猜你喜欢
[<img src="https://static001.geekbang.org/resource/image/1a/08/1a49758821bdbdf6f0a8a1dc5bf39f08.jpg" alt="unpreview">](https://time.geekbang.org/course/intro/163?utm_term=zeusMTA7L&amp;utm_source=app&amp;utm_medium=chongxueqianduan&amp;utm_campaign=163-presell)

View File

@@ -0,0 +1,205 @@
<audio id="audio" title="JavaScript执行Promise里的代码为什么比setTimeout先执行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/68/651494b39e6d5304727e745cd50bfa68.mp3"></audio>
你好我是winter。这一部分我们来讲一讲JavaScript的执行。
首先我们考虑一下如果我们是浏览器或者Node的开发者我们该如何使用JavaScript引擎。
当拿到一段JavaScript代码时浏览器或者Node环境首先要做的就是传递给JavaScript引擎并且要求它去执行。
然而执行JavaScript并非一锤子买卖宿主环境当遇到一些事件时会继续把一段代码传递给JavaScript引擎去执行此外我们可能还会提供API给JavaScript引擎比如setTimeout这样的API它会允许JavaScript在特定的时机执行。
所以我们首先应该形成一个感性的认知一个JavaScript引擎会常驻于内存中它等待着我们宿主把JavaScript代码或者函数传递给它执行。
在ES3和更早的版本中JavaScript本身还没有异步执行代码的能力这也就意味着宿主环境传递给JavaScript引擎一段代码引擎就把代码直接顺次执行了这个任务也就是宿主发起的任务。
但是在ES5之后JavaScript引入了Promise这样不需要浏览器的安排JavaScript引擎本身也可以发起任务了。
由于我们这里主要讲JavaScript语言那么采纳JSC引擎的术语我们把宿主发起的任务称为宏观任务把JavaScript引擎发起的任务称为微观任务。
## 宏观和微观任务
JavaScript引擎等待宿主环境分配宏观任务在操作系统中通常等待的行为都是一个事件循环所以在Node术语中也会把这个部分称为事件循环。
不过术语本身并非我们需要重点讨论的内容我们在这里把重点放在事件循环的原理上。在底层的C/C++代码中,这个事件循环是一个跑在独立线程中的循环,我们用伪代码来表示,大概是这样的:
```
while(TRUE) {
r = wait();
execute(r);
}
```
我们可以看到,整个循环做的事情基本上就是反复“等待-执行”。当然,实际的代码中并没有这么简单,还有要判断循环是否结束、宏观任务队列等逻辑,这里为了方便你理解,我就把这些都省略掉了。
这里每次的执行过程,其实都是一个宏观任务。我们可以大概理解:宏观任务的队列就相当于事件循环。
在宏观任务中JavaScript的Promise还会产生异步代码JavaScript必须保证这些异步代码在一个宏观任务中完成因此每个宏观任务中又包含了一个微观任务队列
<img src="https://static001.geekbang.org/resource/image/16/65/16f70a9a51a65d5302166b0d78414d65.jpg" alt="">
有了宏观任务和微观任务机制我们就可以实现JavaScript引擎级和宿主级的任务了例如Promise永远在队列尾部添加微观任务。setTimeout等宿主API则会添加宏观任务。
接下来我们来详细介绍一下Promise。
## Promise
Promise是JavaScript语言提供的一种标准化的异步管理方式它的总体思想是需要进行io、等待或者其它异步操作的函数不返回真实结果而返回一个“承诺”函数的调用方可以在合适的时机选择等待这个承诺兑现通过Promise的then方法的回调
Promise的基本用法示例如下
```
function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
sleep(1000).then( ()=&gt; console.log(&quot;finished&quot;));
```
这段代码定义了一个函数sleep它的作用是等候传入参数指定的时长。
Promise的then回调是一个异步的执行过程下面我们就来研究一下Promise函数中的执行顺序我们来看一段代码示例
```
var r = new Promise(function(resolve, reject){
console.log(&quot;a&quot;);
resolve()
});
r.then(() =&gt; console.log(&quot;c&quot;));
console.log(&quot;b&quot;)
```
我们执行这段代码后,注意输出的顺序是 a b c。在进入console.log(“b”) 之前,毫无疑问 r 已经得到了resolve但是Promise的resolve始终是异步操作所以c无法出现在b之前。
接下来我们试试跟setTimeout混用的Promise。
在这段代码中我设置了两段互不相干的异步操作通过setTimeout执行console.log(“d”)通过Promise执行console.log(“c”)。
```
var r = new Promise(function(resolve, reject){
console.log(&quot;a&quot;);
resolve()
});
setTimeout(()=&gt;console.log(&quot;d&quot;), 0)
r.then(() =&gt; console.log(&quot;c&quot;));
console.log(&quot;b&quot;)
```
我们发现不论代码顺序如何d必定发生在c之后因为Promise产生的是JavaScript引擎内部的微任务而setTimeout是浏览器API它产生宏任务。
为了理解微任务始终先于宏任务我们设计一个实验执行一个耗时1秒的Promise。
```
setTimeout(()=&gt;console.log(&quot;d&quot;), 0)
var r = new Promise(function(resolve, reject){
resolve()
});
r.then(() =&gt; {
var begin = Date.now();
while(Date.now() - begin &lt; 1000);
console.log(&quot;c1&quot;)
new Promise(function(resolve, reject){
resolve()
}).then(() =&gt; console.log(&quot;c2&quot;))
});
```
这里我们强制了1秒的执行耗时这样我们可以确保任务c2是在d之后被添加到任务队列。
我们可以看到即使耗时一秒的c1执行完毕再enque的c2仍然先于d执行了这很好地解释了微任务优先的原理。
通过一系列的实验,我们可以总结一下如何分析异步执行的顺序:
- 首先我们分析有多少个宏任务;
- 在每个宏任务中,分析有多少个微任务;
- 根据调用次序,确定宏任务中的微任务执行次序;
- 根据宏任务的触发规则和调用次序,确定宏任务的执行次序;
- 确定整个顺序。
我们再来看一个稍微复杂的例子:
```
function sleep(duration) {
return new Promise(function(resolve, reject) {
console.log(&quot;b&quot;);
setTimeout(resolve,duration);
})
}
console.log(&quot;a&quot;);
sleep(5000).then(()=&gt;console.log(&quot;c&quot;));
```
这是一段非常常用的封装方法利用Promise把setTimeout封装成可以用于异步的函数。
我们首先来看setTimeout把整个代码分割成了2个宏观任务这里不论是5秒还是0秒都是一样的。
第一个宏观任务中,包含了先后同步执行的 console.log(“a”); 和 console.log(“b”);。
setTimeout后第二个宏观任务执行调用了resolve然后then中的代码异步得到执行所以调用了console.log(“c”),最终输出的顺序才是: a b c。
Promise是JavaScript中的一个定义但是实际编写代码时我们可以发现它似乎并不比回调的方式书写更简单但是从ES6开始我们有了async/await这个语法改进跟Promise配合能够有效地改善代码结构。
## 新特性async/await
async/await是ES2016新加入的特性它提供了用for、if等代码结构来编写异步的方式。它的运行时基础是Promise面对这种比较新的特性我们先来看一下基本用法。
async函数必定返回Promise我们把所有返回Promise的函数都可以认为是异步函数。
async函数是一种特殊语法特征是在function关键字之前加上async关键字这样就定义了一个async函数我们可以在其中使用await来等待一个Promise。
```
function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
async function foo(){
console.log(&quot;a&quot;)
await sleep(2000)
console.log(&quot;b&quot;)
}
```
这段代码利用了我们之前定义的sleep函数。在异步函数foo中我们调用sleep。
async函数强大之处在于它是可以嵌套的。我们在定义了一批原子操作的情况下可以利用async函数组合出新的async函数。
```
function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
async function foo(name){
await sleep(2000)
console.log(name)
}
async function foo2(){
await foo(&quot;a&quot;);
await foo(&quot;b&quot;);
}
```
这里foo2用await调用了两次异步函数foo可以看到如果我们把sleep这样的异步操作放入某一个框架或者库中使用者几乎不需要了解Promise的概念即可进行异步编程了。
此外generator/iterator也常常被跟异步一起来讲我们必须说明 generator/iterator 并非异步代码只是在缺少async/await的时候一些框架最著名的要数co使用这样的特性来模拟async/await。
但是generator并非被设计成实现异步所以有了async/await之后generator/iterator来模拟异步的方法应该被废弃。
## 结语
在今天的文章里我们学习了JavaScript执行部分的知识首先我们学习了JavaScript的宏观任务和微观任务相关的知识。我们把宿主发起的任务称为宏观任务把JavaScript引擎发起的任务称为微观任务。许多的微观任务的队列组成了宏观任务。
除此之外我们还展开介绍了用Promise来添加微观任务的方式并且介绍了async/await这个语法的改进。
最后留给你一个小练习我们现在要实现一个红绿灯把一个圆形div按照绿色3秒黄色1秒红色2秒循环改变背景色你会怎样编写这个代码呢欢迎你留言讨论。

View File

@@ -0,0 +1,315 @@
<audio id="audio" title="JavaScript执行你知道现在有多少种函数吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/18/0093381651a5e580a13209cc6d07c918.mp3"></audio>
在前一篇文章中,我们大致了解了执行上下文是什么,也知道了任何语句的执行都会依赖特定的上下文。
一旦上下文被切换,整个语句的效果可能都会发生改变。那么,切换上下文的时机就显得非常重要了。
在JavaScript切换上下文最主要的场景是函数调用。在这一课我们就来讲讲函数调用切换上下文的事情。我们在讲函数调用之前首先来认识一下函数家族。
## 函数
在ES2018中函数已经是一个很复杂的体系了我在这里整理了一下。
**第一种普通函数用function关键字定义的函数。**
示例:
```
function foo(){
// code
}
```
**第二种,箭头函数:用 =&gt; 运算符定义的函数。**
示例:
```
const foo = () =&gt; {
// code
}
```
**第三种方法在class中定义的函数。**
示例:
```
class C {
foo(){
//code
}
}
```
**第四种生成器函数用function * 定义的函数。**
示例:
```
function* foo(){
// code
}
```
**第五种用class定义的类实际上也是函数。**
示例:
```
class Foo {
constructor(){
//code
}
}
```
**第六/七/八种异步函数普通函数、箭头函数和生成器函数加上async关键字。**
示例:
```
async function foo(){
// code
}
const foo = async () =&gt; {
// code
}
async function foo*(){
// code
}
```
ES6以来大量加入的新语法极大地方便了我们编程的同时也增加了很多我们理解的心智负担。要想认识这些函数的执行上下文切换我们必须要对它们行为上的区别有所了解。
对普通变量而言这些函数并没有本质区别都是遵循了“继承定义时环境”的规则它们的一个行为差异在于this关键字。
那么this关键字是什么呢我们一起来看一看。
## this关键字的行为
this是JavaScript中的一个关键字它的使用方法类似于一个变量但是this跟变量的行为有很多不同上一节课我们讲了一些普通变量的行为和机制也就是var声明和赋值、let的内容
**this是执行上下文中很重要的一个组成部分。同一个函数调用方式不同得到的this值也不同**,我们看一个例子:
```
function showThis(){
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // global
o.showThis(); // o
```
在这个例子中我们定义了函数showThis我们把它赋值给一个对象o的属性然后尝试分别使用两个引用来调用同一个函数结果得到了不同的this值。
普通函数的this值由“调用它所使用的引用”决定其中奥秘就在于我们获取函数的表达式它实际上返回的并非函数本身而是一个Reference类型记得我们在类型一章讲过七种标准类型吗正是其中之一
Reference类型由两部分组成一个对象和一个属性值。不难理解 o.showThis 产生的Reference类型即由对象o和属性“showThis”构成。
当做一些算术运算或者其他运算时Reference类型会被解引用即获取真正的值被引用的内容来参与运算而类似函数调用、delete等操作都需要用到Reference类型中的对象。
在这个例子中Reference类型中的对象被当作this值传入了执行函数时的上下文当中。
至此我们对this的解释已经非常清晰了**调用函数时使用的引用决定了函数执行时刻的this值。**
实际上从运行时的角度来看this跟面向对象毫无关联它是与函数调用时使用的表达式相关。
这个设计来自JavaScript早年通过这样的方式巧妙地模仿了Java的语法但是仍然保持了纯粹的“无类”运行时设施。
如果,我们把这个例子稍作修改,换成箭头函数,结果就不一样了:
```
const showThis = () =&gt; {
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // global
o.showThis(); // global
```
**我们看到改为箭头函数后不论用什么引用来调用它都不影响它的this值。**
接下来我们看看“方法”,它的行为又不一样了:
```
class C {
showThis() {
console.log(this);
}
}
var o = new C();
var showThis = o.showThis;
showThis(); // undefined
o.showThis(); // o
```
这里我们创建了一个类C并且实例化出对象o再把o的方法赋值给了变量showThis。
这时候我们使用showThis这个引用去调用方法时得到了undefined。
所以在方法中我们看到this的行为也不太一样它得到了undefined的结果。
按照我们上面的方法,不难验证出:生成器函数、异步生成器函数和异步普通函数跟普通函数行为是一致的,异步箭头函数与箭头函数行为是一致的。
## this关键字的机制
说完了this行为我们再来简单谈谈在JavaScript内部实现this这些行为的机制让你对这部分知识有一个大概的认知。
函数能够引用定义时的变量如上文分析函数也能记住定义时的this因此函数内部必定有一个机制来保存这些信息。
在JavaScript标准中为函数规定了用来保存定义时上下文的私有属性[[Environment]]。
当一个函数执行时会创建一条新的执行环境记录记录的外层词法环境outer lexical environment会被设置成函数的[[Environment]]。
这个动作就是**切换上下文**了,我们假设有这样的代码:
```
var a = 1;
foo();
在别处定义了foo
var b = 2;
function foo(){
console.log(b); // 2
console.log(a); // error
}
```
这里的foo能够访问b定义时词法环境却不能访问a执行时的词法环境这就是执行上下文的切换机制了。
JavaScript用一个栈来管理执行上下文这个栈中的每一项又包含一个链表。如下图所示
<img src="https://static001.geekbang.org/resource/image/e8/31/e8d8e96c983a832eb646d6c17ff3df31.jpg" alt="">
当函数调用时,会入栈一个新的执行上下文,函数调用结束时,执行上下文被出栈。
而this则是一个更为复杂的机制JavaScript标准定义了 [[thisMode]] 私有属性。
[[thisMode]] 私有属性有三个取值。
- lexical表示从上下文中找this这对应了箭头函数。
- global表示当this为undefined时取全局对象对应了普通函数。
- strict当严格模式时使用this严格按照调用时传入的值可能为null或者undefined。
非常有意思的是方法的行为跟普通函数有差异恰恰是因为class设计成了默认按strict模式执行。
我们可以用strict达成与上一节中方法的例子一样的效果:
```
&quot;use strict&quot;
function showThis(){
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // undefined
o.showThis(); // o
```
函数创建新的执行上下文中的词法环境记录时,会根据[[thisMode]]来标记新纪录的[[ThisBindingStatus]]私有属性。
代码执行遇到this时会逐层检查当前词法环境记录中的[[ThisBindingStatus]]当找到有this的环境记录时获取this的值。
这样的规则的实际效果是嵌套的箭头函数中的代码都指向外层this例如
```
var o = {}
o.foo = function foo(){
console.log(this);
return () =&gt; {
console.log(this);
return () =&gt; console.log(this);
}
}
o.foo()()(); // o, o, o
```
这个例子中,我们定义了三层嵌套的函数,最外层为普通函数,两层都是箭头函数。
这里调用三个函数获得的this值是一致的都是对象o。
JavaScript还提供了一系列函数的内置方法来操纵this值下面我们来了解一下。
## 操作this的内置函数
Function.prototype.call 和 Function.prototype.apply 可以指定函数调用时传入的this值示例如下
```
function foo(a, b, c){
console.log(this);
console.log(a, b, c);
}
foo.call({}, 1, 2, 3);
foo.apply({}, [1, 2, 3]);
```
这里call和apply作用是一样的只是传参方式有区别。
此外,还有 Function.prototype.bind 它可以生成一个绑定过的函数这个函数的this值固定了参数
```
function foo(a, b, c){
console.log(this);
console.log(a, b, c);
}
foo.bind({}, 1, 2, 3)();
```
有趣的是call、bind和apply用于不接受this的函数类型如箭头、class都不会报错。
这时候它们无法实现改变this的能力但是可以实现传参。
## 结语
在这一节课程中我们认识了ES2018中规定的各种函数我一共简单介绍了8种函数。
我们围绕this这个中心介绍了函数的执行上下文切换机制。同时我们还讲解了this中的一些相关知识。包括了操作this的内置函数。
最后,留给你一个问题,你在日常开发中用过哪些函数类型呢?欢迎给我留言,我们一起讨论。
## 补充阅读new与this
我们在之前的对象部分已经讲过new的执行过程我们再来看一下
- 以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象;
- 将 this 和调用参数传给构造器,执行;
- 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。
显然通过new调用函数跟直接调用的this取值有明显区别。那么我们今天讲的这些函数跟new搭配又会产生什么效果呢
这里我整理了一张表:
<img src="https://static001.geekbang.org/resource/image/6a/da/6a9f0525b713a903c6c94f52afaea3da.png" alt="">
我们可以看到仅普通函数和类能够跟new搭配使用这倒是给我们省去了不少麻烦。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="JavaScript执行闭包和执行上下文到底是怎么回事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/9b/0071a87b7ecb2e9a93c7d12a93946d9b.mp3"></audio>
你好我是winter。
在上一课我们了解了JavaScript执行中最粗粒度的任务传给引擎执行的代码段。并且我们还根据“由JavaScript引擎发起”还是“由宿主发起”分成了宏观任务和微观任务接下来我们继续去看一看更细的执行粒度。
一段JavaScript代码可能会包含函数调用的相关内容从今天开始我们就用两节课的时间来了解一下函数的执行。
我们今天要讲的知识在网上有不同的名字,比较常见的可能有:
- 闭包;
- 作用域链;
- 执行上下文;
- this值。
实际上,尽管它们是表示不同的意思的术语,所指向的几乎是同一部分知识,那就是函数执行过程相关的知识。我们可以简单看一下图。
<img src="https://static001.geekbang.org/resource/image/68/52/68f50c00d475a7d6d8c7eef6a91b2152.png" alt="">
看着也许会有点晕,别着急,我会和你共同理一下它们之间的关系。
当然,除了让你理解函数执行过程的知识,理清这些概念也非常重要。所以我们先来讲讲这个有点复杂的概念:闭包。
## 闭包
闭包翻译自英文单词closure这是个不太好翻译的词在计算机领域它就有三个完全不相同的意义编译原理中它是处理语法产生式的一个步骤计算几何中它表示包裹平面点集的凸多边形翻译作凸包而在编程语言领域它表示一种函数。
闭包这个概念第一次出现在1964年的《The Computer Journal》上由P. J. Landin在《The mechanical evaluation of expressions》一文中提出了applicative expression和closure的概念。
<img src="https://static001.geekbang.org/resource/image/9b/0c/9b6c6693afe654b4cfdbf16852b82a0c.png" alt="">
在上世纪60年代主流的编程语言是基于lambda演算的函数式编程语言所以这个最初的闭包定义使用了大量的函数式术语。一个不太精确的描述是“带有一系列信息的λ表达式”。对函数式语言而言λ表达式其实就是函数。
我们可以这样简单理解一下,闭包其实只是一个绑定了执行环境的函数,这个函数并不是印在书本里的一条简单的表达式,闭包与普通函数的区别是,它携带了执行的环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境。
这个古典的闭包定义中,闭包包含两个部分。
<li>环境部分
<ul>
- 环境
- 标识符列表
当我们把视角放在JavaScript的标准中我们发现标准中并没有出现过closure这个术语但是我们却不难根据古典定义在JavaScript中找到对应的闭包组成部分。
<li>环境部分
<ul>
- 环境:函数的词法环境(执行上下文的一部分)
- 标识符列表:函数中用到的未声明的变量
至此我们可以认为JavaScript中的函数完全符合闭包的定义。它的环境部分是函数词法环境部分组成它的标识符列表是函数中用到的未声明变量它的表达式部分就是函数体。
这里我们容易产生一个常见的概念误区有些人会把JavaScript执行上下文或者作用域ScopeES3中规定的执行上下文的一部分这个概念当作闭包。
实际上JavaScript中跟闭包对应的概念就是“函数”可能是这个概念太过于普通跟闭包看起来又没什么联系所以大家才不自觉地把这个概念对应到了看起来更特别的“作用域”吧其实我早年也是这么理解闭包直到后来被朋友纠正查了资料才改正过来
### 执行上下文:执行的基础设施
相比普通函数JavaScript函数的主要复杂性来自于它携带的“环境部分”。当然发展到今天的JavaScript它所定义的环境部分已经比当初经典的定义复杂了很多。
JavaScript中与闭包“环境部分”相对应的术语是“词法环境”但是JavaScript函数比λ函数要复杂得多我们还要处理this、变量声明、with等等一系列的复杂语法λ函数中可没有这些东西所以在JavaScript的设计中词法环境只是JavaScript执行上下文的一部分。
JavaScript标准把一段代码包括函数执行所需的所有信息定义为“执行上下文”。
因为这部分术语经历了比较多的版本和社区的演绎所以定义比较混乱这里我们先来理一下JavaScript中的概念。
**执行上下文在ES3中**,包含三个部分。
- scope作用域也常常被叫做作用域链。
- variable object变量对象用于存储变量的对象。
- this valuethis值。
**在ES5中**,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。
- lexical environment词法环境当获取变量时使用。
- variable environment变量环境当声明变量时使用。
- this valuethis值。
**在ES2018中**执行上下文又变成了这个样子this值被归入lexical environment但是增加了不少内容。
- lexical environment词法环境当获取变量或者this值时使用。
- variable environment变量环境当声明变量时使用。
- code evaluation state用于恢复代码执行位置。
- Function执行的任务是函数时使用表示正在被执行的函数。
- ScriptOrModule执行的任务是脚本或者模块时使用表示正在被执行的代码。
- Realm使用的基础库和内置对象实例。
- Generator仅生成器上下文有这个属性表示当前生成器。
我们在这里介绍执行上下文的各个版本定义是考虑到你可能会从各种网上的文章中接触这些概念如果不把它们理清楚我们就很难分辨对错。如果是我们自己使用我建议统一使用最新的ES2018中规定的术语定义。
尽管我们介绍了这些定义但我并不打算按照JavaScript标准的思路从实现的角度去介绍函数的执行过程这是不容易被理解的。
我想试着从代码实例出发,跟你一起推导函数执行过程中需要哪些信息,它们又对应着执行上下文中的哪些部分。
比如我们看以下的这段JavaScript代码
```
var b = {}
let c = 1
this.a = 2;
```
要想正确执行它,我们需要知道以下信息:
1. var 把 b 声明到哪里;
1. b 表示哪个变量;
1. b 的原型是哪个对象;
1. let 把 c 声明到哪里;
1. this 指向哪个对象。
这些信息就需要执行上下文来给出了,这段代码出现在不同的位置,甚至在每次执行中,会关联到不同的执行上下文,所以,同样的代码会产生不一样的行为。
在这两篇文章中我会基本覆盖执行上下文的组成部分本篇我们先讲var声明与赋值letrealm三个特性来分析上下文提供的信息分析执行上下文中提供的信息。
### var 声明与赋值
我们来分析一段代码:
```
var b = 1
```
通常我们认为它声明了b并且为它赋值为1var声明作用域函数执行的作用域。也就是说var会穿透for 、if等语句。
在只有var没有let的旧JavaScript时代诞生了一个技巧叫做立即执行的函数表达式IIFE通过创建一个函数并且立即执行来构造一个新的域从而控制var的范围。
由于语法规定了function关键字开头是函数声明所以要想让函数变成函数表达式我们必须得加点东西最常见的做法是加括号。
```
(function(){
var a;
//code
}());
(function(){
var a;
//code
})();
```
但是,括号有个缺点,那就是如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,产生完全不符合预期,并且难以调试的行为,加号等运算符也有类似的问题。所以一些推荐不加分号的代码风格规范,会要求在括号前面加上分号。
```
;(function(){
var a;
//code
}())
;(function(){
var a;
//code
})()
```
我比较推荐的写法是使用void关键字。也就是下面的这种形式。
```
void function(){
var a;
//code
}();
```
这有效避免了语法问题同时语义上void运算表示忽略后面表达式的值变成undefined我们确实不关心IIFE的返回值所以语义也更为合理。
值得特别注意的是有时候var的特性会导致声明的变量和被赋值的变量是两个bJavaScript中有特例那就是使用with的时候
```
var b;
void function(){
var env = {b:1};
b = 2;
console.log(&quot;In function b:&quot;, b);
with(env) {
var b = 3;
console.log(&quot;In with b:&quot;, b);
}
}();
console.log(&quot;Global b:&quot;, b);
```
在这个例子中我们利用立即执行的函数表达式IIFE构造了一个函数的执行环境并且在里面使用了我们一开头的代码。
可以看到在Global function with三个环境中b的值都不一样而在function环境中并没有出现var b这说明with内的var b作用到了function这个环境当中。
var b = {} 这样一句对两个域产生了作用从语言的角度是个非常糟糕的设计这也是一些人坚定地反对在任何场景下使用with的原因之一。
### let
let是 ES6开始引入的新的变量声明模式比起var的诸多弊病let做了非常明确的梳理和规定。
为了实现letJavaScript在运行时引入了块级作用域。也就是说在let出现之前JavaScript的 if for 等语句皆不产生作用域。
我简单统计了下以下语句会产生let使用的作用域
- for
- if
- switch
- try/catch/finally。
### Realm
在最新的标准9.0JavaScript引入了一个新概念Realm它的中文意思是“国度”“领域”“范围”。这个英文的用法就有点比喻的意思几个翻译都不太适合JavaScript语境所以这里就不翻译啦。
我们继续来看这段代码:
```
var b = {}
```
在 ES2016 之前的版本中,标准中甚少提及{}的原型问题。但在实际的前端开发中通过iframe等方式创建多window环境并非罕见的操作所以这才促成了新概念Realm的引入。
Realm中包含一组完整的内置对象而且是复制关系。
对不同Realm中的对象操作会有一些需要格外注意的问题比如 instanceOf 几乎是失效的。
以下代码展示了在浏览器环境中获取来自两个Realm的对象它们跟本土的Object做instanceOf时会产生差异
```
var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src=&quot;javascript:var b = {};&quot;
var b1 = iframe.contentWindow.b;
var b2 = {};
console.log(typeof b1, typeof b2); //object object
console.log(b1 instanceof Object, b2 instanceof Object); //false true
```
可以看到由于b1、 b2由同样的代码“ {} ”在不同的Realm中执行所以表现出了不同的行为。
## 结语
在今天的课程中我帮你梳理了一些概念有编程语言的概念闭包也有各个版本中的JavaScript标准中的概念执行上下文、作用域、this值等等。
之后我们又从代码的角度,分析了一些执行上下文中所需要的信息,并从`var``let`、对象字面量等语法中推导出了词法作用域、变量作用域、Realm的设计。
最后留给你一个问题你喜欢使用let还是var听过今天的课程你的想法是否有改变呢为什么

View File

@@ -0,0 +1,191 @@
<audio id="audio" title="JavaScript执行try里面放returnfinally还会执行吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/62/103aec09ba6db2a2e9c485a4fa5c1462.mp3"></audio>
你好我是winter。
在前面几篇文章中,我们已经了解了关于执行上下文、作用域、闭包之间的关系。
今天,我们则要说一说更为细节的部分:语句。
语句是任何编程语言的基础结构与JavaScript对象一样JavaScript语句同样具有“看起来很像其它语言但是其实一点都不一样”的特点。
我们比较常见的语句包括变量声明、表达式、条件、循环等,这些都是大家非常熟悉的东西,对于它们的行为,我在这里就不赘述了。
为了了解JavaScript语句有哪些特别之处首先我们要看一个不太常见的例子我会通过这个例子来向你介绍JavaScript语句执行机制涉及的一种基础类型Completion类型。
## Completion类型
我们来看一个例子。在函数foo中使用了一组try语句。我们可以先来做一个小实验在try中有return语句finally中的内容还会执行吗我们来看一段代码。
```
function foo(){
try{
return 0;
} catch(err) {
} finally {
console.log(&quot;a&quot;)
}
}
console.log(foo());
```
通过实际试验我们可以看到finally确实执行了而且return语句也生效了foo()返回了结果0。
虽然return执行了但是函数并没有立即返回又执行了finally里面的内容这样的行为违背了很多人的直觉。
如果在这个例子中我们在finally中加入return语句会发生什么呢
```
function foo(){
try{
return 0;
} catch(err) {
} finally {
return 1;
}
}
console.log(foo());
```
通过实际执行我们看到finally中的return “覆盖”了try中的return。在一个函数中执行了两次return这已经超出了很多人的常识也是其它语言中不会出现的一种行为。
面对如此怪异的行为,我们当然可以把它作为一个孤立的知识去记忆,但是实际上,这背后有一套机制在运作。
这一机制的基础正是JavaScript语句执行的完成状态我们用一个标准类型来表示Completion Record我在类型一节提到过Completion Record用于描述异常、跳出等语句执行过程
Completion Record 表示一个语句执行完之后的结果,它有三个字段:
- [[type]] 表示完成的类型有break continue return throw和normal几种类型
- [[value]] 表示语句的返回值如果语句没有则是empty
- [[target]] 表示语句的目标通常是一个JavaScript标签标签在后文会有介绍
JavaScript正是依靠语句的 Completion Record类型方才可以在语句的复杂嵌套结构中实现各种控制。接下来我们要来了解一下JavaScript使用Completion Record类型控制语句执行的过程。
首先我们来看看语句有几种分类。
<img src="https://static001.geekbang.org/resource/image/98/d5/98ce53be306344c018cddd6c083392d5.jpg" alt="">
## 普通的语句
在JavaScript中我们把不带控制能力的语句称为普通语句。普通语句有下面几种。
<li>声明类语句
<ul>
- var声明
- const声明
- let声明
- 函数声明
- 类声明
这些语句在执行时从前到后顺次执行我们这里先忽略var和函数声明的预处理机制没有任何分支或者重复执行逻辑。
普通语句执行后,会得到 [[type]] 为 normal 的 Completion RecordJavaScript引擎遇到这样的Completion Record会继续执行下一条语句。
这些语句中,只有表达式语句会产生 [[value]]当然从引擎控制的角度这个value并没有什么用处。
如果你经常使用Chrome自带的调试工具可以知道输入一个表达式在控制台可以得到结果但是在前面加上var就变成了undefined。
<img src="https://static001.geekbang.org/resource/image/a3/67/a35801b1b82654d17e413e51b340d767.png" alt="">
Chrome控制台显示的正是语句的Completion Record的[[value]]。
## 语句块
介绍完了普通语句,我们再来介绍一个比较特殊的语句:语句块。
语句块就是拿大括号括起来的一组语句,它是一种语句的复合结构,可以嵌套。
语句块本身并不复杂我们需要注意的是语句块内部的语句的Completion Record的[[type]] 如果不为 normal会打断语句块后续的语句执行。
比如我们考虑,一个[[type]]为return的语句出现在一个语句块中的情况。
从语句的这个type中我们大概可以猜到它由哪些特定语句产生我们就来说说最开始的例子中的 return。
return语句可能产生return或者throw类型的Completion Record。我们来看一个例子。
先给出一个内部为普通语句的语句块:
```
{
var i = 1; // normal, empty, empty
i ++; // normal, 1, empty
console.log(i) //normal, undefined, empty
} // normal, undefined, empty
```
在每一行的注释中我给出了语句的Completion Record。
我们看到在一个block中如果每一个语句都是normal类型那么它会顺次执行。接下来我们加入return试试看。
```
{
var i = 1; // normal, empty, empty
return i; // return, 1, empty
i ++;
console.log(i)
} // return, 1, empty
```
但是假如我们在block中插入了一条return语句产生了一个非normal记录那么整个block会成为非normal。这个结构就保证了非normal的完成类型可以穿透复杂的语句嵌套结构产生控制效果。
接下来我们就具体讲讲控制类语句。
## 控制型语句
控制型语句带有 if、switch关键字它们会对不同类型的Completion Record产生反应。
控制类语句分成两部分一类是对其内部造成影响如if、switch、while/for、try。
另一类是对外部造成影响如break、continue、return、throw这两类语句的配合会产生控制代码执行顺序和执行逻辑的效果这也是我们编程的主要工作。
一般来说, for/while - break/continue 和 try - throw 这样比较符合逻辑的组合是大家比较熟悉的但是实际上我们需要控制语句跟break 、continue 、return 、throw四种类型与控制语句两两组合产生的效果。
<img src="https://static001.geekbang.org/resource/image/77/d3/7760027d7ee09bdc8ec140efa9caf1d3.png" alt="">
通过这个表我们不难发现知识的盲点也就是我们最初的的case中的try和return的组合了。
因为finally中的内容必须保证执行所以 try/catch执行完毕即使得到的结果是非normal型的完成记录也必须要执行finally。
而当finally执行也得到了非normal记录则会使finally中的记录作为整个try结构的结果。
## 带标签的语句
前文我重点讲了type在语句控制中的作用接下来我们重点来讲一下最后一个字段target这涉及了JavaScript中的一个语法带标签的语句。
实际上任何JavaScript语句是可以加标签的在语句前加冒号即可
```
firstStatement: var i = 1;
```
大部分时候这个东西类似于注释没有任何用处。唯一有作用的时候是与完成记录类型中的target相配合用于跳出多层循环。
```
outer: while(true) {
inner: while(true) {
break outer;
}
}
console.log(&quot;finished&quot;)
```
break/continue 语句如果后跟了关键字会产生带target的完成记录。一旦完成记录带了target那么只有拥有对应label的循环语句会消费它。
## 结语
我们以Completion Record类型为线索为你讲解了JavaScript语句执行的原理。
因为JavaScript语句存在着嵌套关系所以执行过程实际上主要在一个树形结构上进行 树形结构的每一个节点执行后产生Completion Record根据语句的结构和Completion RecordJavaScript实现了各种分支和跳出逻辑。
你遇到哪些语句中的执行的实际效果,是跟你想象的有所出入呢,你可以给我留言,我们一起讨论。

View File

@@ -0,0 +1,366 @@
<audio id="audio" title="JavaScript类型关于类型有哪些你不知道的细节" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/02/5d547304bd595d7ae180de74d8c48302.mp3"></audio>
你好我是winter。今天我们来讲讲JavaScript的内容在这个部分我首先想跟你聊一聊类型。
JavaScript类型对每个前端程序员来说几乎都是最为熟悉的概念了。但是你真的很了解它们吗我们不妨来看看下面的几个问题。
- 为什么有的编程规范要求用void 0代替undefined
- 字符串有最大长度吗?
- 0.1 + 0.2不是等于0.3么为什么JavaScript里不是这样的
- ES6新加入的Symbol是个什么东西
- 为什么给对象添加的方法能用在基本类型上?
如果你答起来还有些犹豫的地方,这就说明你对这部分知识点,还是有些遗漏之处的。没关系,今天我来帮你一一补上。
我在前面提到过我们的JavaScript模块会从运行时、文法和执行过程三个角度去剖析JS的知识体系本篇我们就从运行时的角度去看JavaScript的类型系统。
>
运行时类型是代码实际执行过程中我们用到的类型。所有的类型数据都会属于7个类型之一。从变量、参数、返回值到表达式中间结果任何JavaScript代码运行过程中产生的数据都具有运行时类型。
## 类型
JavaScript语言的每一个值都属于某一种数据类型。JavaScript语言规定了7种语言类型。语言类型广泛用于变量、函数参数、表达式、函数返回值等场合。根据最新的语言标准这7种语言类型是
1. Undefined
1. Null
1. Boolean
1. String
1. Number
1. Symbol
1. Object。
除了ES6中新加入的Symbol类型剩下6种类型都是我们日常开发中的老朋友了但是要想回答文章一开始的问题我们需要重新认识一下这些老朋友下面我们就来从简单到复杂重新学习一下这些类型。
## Undefined、Null
我们的第一个问题为什么有的编程规范要求用void 0代替undefined现在我们就分别来看一下。
Undefined 类型表示未定义,它的类型只有一个值,就是 undefined。任何变量在赋值前是 Undefined 类型、值为 undefined一般我们可以用全局变量undefined就是名为undefined的这个变量来表达这个值或者 void 运算来把任意一个表达式变成 undefined 值。
但是呢因为JavaScript的代码undefined是一个变量而并非是一个关键字这是JavaScript语言公认的设计失误之一所以我们为了避免无意中被篡改我建议使用 void 0 来获取undefined值。
Undefined跟 Null 有一定的表意差别Null表示的是“定义了但是为空”。所以在实际编程时我们一般不会把变量赋值为 undefined这样可以保证所有值为 undefined 的变量,都是从未赋值的自然状态。
Null 类型也只有一个值,就是 null它的语义表示空值与 undefined 不同null 是 JavaScript 关键字,所以在任何代码中,你都可以放心用 null 关键字来获取 null 值。
## Boolean
Boolean 类型有两个值, true 和 false它用于表示逻辑意义上的真和假同样有关键字 true 和 false 来表示两个值。这个类型很简单,我就不做过多介绍了。
## String
我们来看看字符串是否有最大长度。
String 用于表示文本数据。String 有最大长度是 2^53 - 1这在一般开发中都是够用的但是有趣的是这个所谓最大长度并不完全是你理解中的字符数。
因为String 的意义并非“字符串”,而是字符串的 UTF16 编码,我们字符串的操作 charAt、charCodeAt、length 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。
>
Note现行的字符集国际标准字符是以 Unicode 的方式表示的,每一个 Unicode 的码点表示一个字符理论上Unicode 的范围是无限的。UTF是Unicode的编码方式规定了码点在计算机中的表示方法常见的有 UTF16 和 UTF8。 Unicode 的码点通常用 U+??? 来表示,其中 ??? 是十六进制的码点值。 0-65536U+0000 - U+FFFF的码点被称为基本字符区域BMP
JavaScript 中的字符串是永远无法变更的,一旦字符串构造出来,无法用任何方式改变字符串的内容,所以字符串具有值类型的特征。
JavaScript 字符串把每个 UTF16 单元当作一个字符来处理所以处理非BMP超出 U+0000 - U+FFFF 范围)的字符时,你应该格外小心。
JavaScript 这个设计继承自 Java最新标准中是这样解释的这样设计是为了“性能和尽可能实现起来简单”。因为现实中很少用到 BMP 之外的字符。
## Number
下面我们来说说Number类型。Number类型表示我们通常意义上的“数字”。这个数字大致对应数学中的有理数当然在计算机中我们有一定的精度限制。
JavaScript中的Number类型有 18437736874454810627(即2^64-2^53+3) 个值。
JavaScript 中的 Number 类型基本符合 IEEE 754-2008 规定的双精度浮点数规则但是JavaScript为了表达几个额外的语言场景比如不让除以0出错而引入了无穷大的概念规定了几个例外情况
- NaN占用了 9007199254740990这原本是符合IEEE规则的数字
- Infinity无穷大
- -Infinity负无穷大。
另外值得注意的是JavaScript中有 +0 和 -0在加法类运算中它们没有区别但是除法的场合则需要特别留意区分“忘记检测除以-0而得到负无穷大”的情况经常会导致错误而区分 +0 和 -0 的方式,正是检测 1/x 是 Infinity 还是 -Infinity。
根据双精度浮点数的定义Number类型中有效的整数范围是-0x1fffffffffffff至0x1fffffffffffff所以Number无法精确表示此范围外的整数。
同样根据浮点数的定义非整数的Number类型无法用 =====也不行) 来比较一段著名的代码这也正是我们第三题的问题为什么在JavaScript中0.1+0.2不能=0.3
```
console.log( 0.1 + 0.2 == 0.3);
```
这里输出的结果是false说明两边不相等的这是浮点运算的特点也是很多同学疑惑的来源浮点数运算的精度问题导致等式左右的结果并不是严格相等而是相差了个微小的值。
所以实际上这里错误的不是结论而是比较的方法正确的比较方法是使用JavaScript提供的最小精度值
```
console.log( Math.abs(0.1 + 0.2 - 0.3) &lt;= Number.EPSILON);
```
检查等式左右两边差的绝对值是否小于最小精度,才是正确的比较浮点数的方法。这段代码结果就是 true 了。
## Symbol
Symbol 是 ES6 中引入的新类型它是一切非字符串的对象key的集合在ES6规范中整个对象系统被用Symbol 重塑。
在后面的文章中,我会详细叙述 Symbol 跟对象系统。这里我们只介绍Symbol类型本身它有哪些部分它表示什么意思以及如何创建Symbol类型。
Symbol 可以具有字符串类型的描述但是即使描述相同Symbol也不相等。
我们创建 Symbol 的方式是使用全局的 Symbol 函数。例如:
```
var mySymbol = Symbol(&quot;my symbol&quot;);
```
一些标准中提到的 Symbol可以在全局的 Symbol 函数的属性中找到。例如,我们可以使用 Symbol.iterator 来自定义 for…of 在对象上的行为:
```
var o = new Object
o[Symbol.iterator] = function() {
var v = 0
return {
next: function() {
return { value: v++, done: v &gt; 10 }
}
}
};
for(var v of o)
console.log(v); // 0 1 2 3 ... 9
```
代码中我们定义了iterator之后用for(var v of o)就可以调用这个函数然后我们可以根据函数的行为产生一个for…of的行为。
这里我们给对象o添加了 Symbol.iterator 属性并且按照迭代器的要求定义了一个0到10的迭代器之后我们就可以在for of中愉快地使用这个o对象啦。
这些标准中被称为“众所周知”的 Symbol也构成了语言的一类接口形式。它们允许编写与语言结合更紧密的 API。
## Object
Object 是 JavaScript 中最复杂的类型,也是 JavaScript 的核心机制之一。Object表示对象的意思它是一切有形和无形物体的总称。
下面我们来看一看,为什么给对象添加的方法能用在基本类型上?
在 JavaScript 中对象的定义是“属性的集合”。属性分为数据属性和访问器属性二者都是key-value结构key可以是字符串或者 Symbol类型。
关于对象的机制,后面会有单独的一篇来讲述,这里我重点从类型的角度来介绍对象类型。
提到对象,我们必须要提到一个概念:类。
因为 C++ 和 Java 的成功在这两门语言中每个类都是一个类型二者几乎等同以至于很多人常常会把JavaScript的“类”与类型混淆。
事实上JavaScript 中的“类”仅仅是运行时对象的一个私有属性而JavaScript中是无法自定义类型的。
JavaScript中的几个基本类型都在对象类型中有一个“亲戚”。它们是
- Number
- String
- Boolean
- Symbol。
所以,我们必须认识到 3 与 new Number(3) 是完全不同的值,它们一个是 Number 类型, 一个是对象类型。
Number、String和Boolean三个构造器是两用的当跟 new 搭配时,它们产生对象,当直接调用时,它们表示强制类型转换。
Symbol 函数比较特殊,直接用 new 调用它会抛出错误,但它仍然是 Symbol 对象的构造器。
JavaScript 语言设计上试图模糊对象和基本类型之间的关系,我们日常代码可以把对象的方法在基本类型上使用,比如:
```
console.log(&quot;abc&quot;.charAt(0)); //a
```
甚至我们在原型上添加方法,都可以应用于基本类型,比如以下代码,在 Symbol 原型上添加了hello方法在任何 Symbol 类型变量都可以调用。
```
Symbol.prototype.hello = () =&gt; console.log(&quot;hello&quot;);
var a = Symbol(&quot;a&quot;);
console.log(typeof a); //symbola并非对象
a.hello(); //hello有效
```
所以我们文章开头的问题,答案就是`.` 运算符提供了装箱操作,它会根据基础类型构造一个临时对象,使得我们能在基础类型上调用对应对象的方法。
## 类型转换
讲完了基本类型,我们来介绍一个现象:类型转换。
因为JS是弱类型语言所以类型转换发生非常频繁大部分我们熟悉的运算都会先进行类型转换。大部分类型转换符合人类的直觉但是如果我们不去理解类型转换的严格定义很容易造成一些代码中的判断失误。
其中最为臭名昭著的是JavaScript中的“ == ”运算,因为试图实现跨类型的比较,它的规则复杂到几乎没人可以记住。
这里我们当然也不打算讲解==的规则,它属于设计失误,并非语言中有价值的部分,很多实践中推荐禁止使用“ ==”,而要求程序员进行显式地类型转换后,用 === 比较。
其它运算,如加减乘除大于小于,也都会涉及类型转换。幸好的是,实际上大部分类型转换规则是非常简单的,如下表所示:
<img src="https://static001.geekbang.org/resource/image/71/20/71bafbd2404dc3ffa5ccf5d0ba077720.jpg" alt="">
在这个里面较为复杂的部分是Number和String之间的转换以及对象跟基本类型之间的转换。我们分别来看一看这几种转换的规则。
### StringToNumber
字符串到数字的类型转换,存在一个语法结构,类型转换支持十进制、二进制、八进制和十六进制,比如:
- 30
- 0b111
- 0o13
- 0xFF。
此外JavaScript支持的字符串语法还包括正负号科学计数法可以使用大写或者小写的e来表示
- 1e3
- -1e-2。
需要注意的是parseInt 和 parseFloat 并不使用这个转换,所以支持的语法跟这里不尽相同。
在不传入第二个参数的情况下parseInt只支持16进制前缀“0x”而且会忽略非数字字符也不支持科学计数法。
在一些古老的浏览器环境中parseInt还支持0开头的数字作为8进制前缀这是很多错误的来源。所以在任何环境下都建议传入parseInt的第二个参数而parseFloat则直接把原字符串作为十进制来解析它不会引入任何的其他进制。
多数情况下Number 是比 parseInt 和 parseFloat 更好的选择。
### NumberToString
在较小的范围内数字到字符串的转换是完全符合你直觉的十进制表示。当Number绝对值较大或者较小时字符串表示则是使用科学计数法表示的。这个算法细节繁多我们从感性的角度认识它其实就是保证了产生的字符串不会过长。
具体的算法你可以去参考JavaScript的语言标准。由于这个部分内容我觉得在日常开发中很少用到所以这里我就不去详细地讲解了。
### 装箱转换
每一种基本类型Number、String、Boolean、Symbol在对象中都有对应的类所谓装箱转换正是把基本类型转换为对应的对象它是类型转换中一种相当重要的种类。
前文提到,全局的 Symbol 函数无法使用 new 来调用,但我们仍可以利用装箱机制来得到一个 Symbol 对象我们可以利用一个函数的call方法来强迫产生装箱。
我们定义一个函数函数里面只有return this然后我们调用函数的call方法到一个Symbol类型的值上这样就会产生一个symbolObject。
我们可以用console.log看一下这个东西的type of它的值是object我们使用symbolObject instanceof 可以看到它是Symbol这个类的实例我们找它的constructor也是等于Symbol的所以我们无论从哪个角度看它都是Symbol装箱过的对象
```
var symbolObject = (function(){ return this; }).call(Symbol(&quot;a&quot;));
console.log(typeof symbolObject); //object
console.log(symbolObject instanceof Symbol); //true
console.log(symbolObject.constructor == Symbol); //true
```
装箱机制会频繁产生临时对象,在一些对性能要求较高的场景下,我们应该尽量避免对基本类型做装箱转换。
使用内置的 Object 函数我们可以在JavaScript代码中显式调用装箱能力。
```
var symbolObject = Object(Symbol(&quot;a&quot;));
console.log(typeof symbolObject); //object
console.log(symbolObject instanceof Symbol); //true
console.log(symbolObject.constructor == Symbol); //true
```
每一类装箱对象皆有私有的 Class 属性,这些属性可以用 Object.prototype.toString 获取:
```
var symbolObject = Object(Symbol(&quot;a&quot;));
console.log(Object.prototype.toString.call(symbolObject)); //[object Symbol]
```
在 JavaScript 中,没有任何方法可以更改私有的 Class 属性因此Object.prototype.toString 是可以准确识别对象对应的基本类型的方法,它比 instanceof 更加准确。
但需要注意的是call本身会产生装箱操作所以需要配合 typeof 来区分基本类型还是对象类型。
### 拆箱转换
在JavaScript标准中规定了 ToPrimitive 函数,它是对象类型到基本类型的转换(即,拆箱转换)。
对象到 String 和 Number 的转换都遵循“先拆箱再转换”的规则。通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String 或者 Number。
拆箱转换会尝试调用 valueOf 和 toString 来获得拆箱后的基本类型。如果 valueOf 和 toString 都不存在,或者没有返回基本类型,则会产生类型错误 TypeError。
```
var o = {
valueOf : () =&gt; {console.log(&quot;valueOf&quot;); return {}},
toString : () =&gt; {console.log(&quot;toString&quot;); return {}}
}
o * 2
// valueOf
// toString
// TypeError
```
我们定义了一个对象oo有valueOf和toString两个方法这两个方法都返回一个对象然后我们进行o*2这个运算的时候你会看见先执行了valueOf接下来是toString最后抛出了一个TypeError这就说明了这个拆箱转换失败了。
到 String 的拆箱转换会优先调用 toString。我们把刚才的运算从o*2换成 String(o),那么你会看到调用顺序就变了。
```
var o = {
valueOf : () =&gt; {console.log(&quot;valueOf&quot;); return {}},
toString : () =&gt; {console.log(&quot;toString&quot;); return {}}
}
String(o)
// toString
// valueOf
// TypeError
```
在 ES6 之后,还允许对象通过显式指定 @@toPrimitive Symbol 来覆盖原有的行为。
```
var o = {
valueOf : () =&gt; {console.log(&quot;valueOf&quot;); return {}},
toString : () =&gt; {console.log(&quot;toString&quot;); return {}}
}
o[Symbol.toPrimitive] = () =&gt; {console.log(&quot;toPrimitive&quot;); return &quot;hello&quot;}
console.log(o + &quot;&quot;)
// toPrimitive
// hello
```
## 结语
在本篇文章中,我们介绍了 JavaScript 运行时的类型系统。这里回顾一下今天讲解的知识点。
除了这七种语言类型,还有一些语言的实现者更关心的规范类型。
- List 和 Record 用于描述函数传参过程。
- Set主要用于解释字符集等。
- Completion Record用于描述异常、跳出等语句执行过程。
- Reference用于描述对象属性访问、delete等。
- Property Descriptor用于描述对象的属性。
- Lexical Environment 和 Environment Record用于描述变量和作用域。
- Data Block用于描述二进制数据。
有一个说法是:程序 = 算法 + 数据结构,运行时类型包含了所有 JavaScript 执行时所需要的数据结构的定义,所以我们要对它格外重视。
最后我们留一个实践问题如果我们不用原生的Number和parseInt用JavaScript代码实现String到Number的转换该怎么做呢请你把自己的代码留言给我吧
## 补充阅读
事实上,“类型”在 JavaScript 中是一个有争议的概念。一方面,标准中规定了运行时数据类型; 另一方面JavaScript语言中提供了 typeof 这样的运算,用来返回操作数的类型,但 typeof 的运算结果,与运行时类型的规定有很多不一致的地方。我们可以看下表来对照一下。
<img src="https://static001.geekbang.org/resource/image/ec/6b/ec4299a73fb84c732efcd360fed6e16b.png" alt="">
在表格中多数项是对应的但是请注意object——Null和function——Object是特例我们理解类型的时候需要特别注意这个区别。
从一般语言使用者的角度来看,毫无疑问,我们应该按照 typeof 的结果去理解语言的类型系统。但JavaScript之父本人也在多个场合表示过typeof 的设计是有缺陷的,只是现在已经错过了修正它的时机。
# 猜你喜欢
[<img src="https://static001.geekbang.org/resource/image/1a/08/1a49758821bdbdf6f0a8a1dc5bf39f08.jpg" alt="unpreview">](https://time.geekbang.org/course/intro/163?utm_term=zeusMTA7L&amp;utm_source=app&amp;utm_medium=chongxueqianduan&amp;utm_campaign=163-presell)

View File

@@ -0,0 +1,316 @@
<audio id="audio" title="JavaScript词法为什么12.toString会报错" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/ff/85e235c2494e79efda7deeffd6046bff.mp3"></audio>
你好我是winter。
在前面的文章中我们已经从运行时的角度了解过JavaScript的知识内容在接下来的几节课我们来了解一下JavaScript的文法部分。
文法是编译原理中对语言的写法的一种规定,一般来说,文法分成词法和语法两种。
词法规定了语言的最小语义单元token可以翻译成“标记”或者“词”在我的专栏文章中我统一把token翻译成词。
从字符到词的整个过程是没有结构的,只要符合词的规则,就构成词,一般来说,词法设计不会包含冲突。词法分析技术上可以使用状态机或者正则表达式来进行,我们的课程主要是学习词法,关于它们实现的细节就不多谈了。
## 概述
我们先来看一看JavaScript的词法定义。JavaScript源代码中的输入可以这样分类
- WhiteSpace 空白字符
- LineTerminator 换行符
- Comment 注释
<li>Token 词
<ul>
- IdentifierName 标识符名称,典型案例是我们使用的变量名,注意这里关键字也包含在内了。
- Punctuator 符号,我们使用的运算符和大括号等符号。
- NumericLiteral 数字直接量,就是我们写的数字。
- StringLiteral 字符串直接量,就是我们用单引号或者双引号引起来的直接量。
- Template 字符串模板,用反引号``` 括起来的直接量。
这个设计符合比较通用的编程语言设计方式不过JavaScript中有一些特别之处我下面就来讲讲特别在哪里。
首先是除法和正则表达式冲突问题。我们都知道JavaScript不但支持除法运算符“ / ”和“ /= ”,还支持用斜杠括起来的正则表达式“ /abc/ ”。
但是这时候对词法分析来说其实是没有办法处理的所以JavaScript的解决方案是定义两组词法然后靠语法分析传一个标志给词法分析器让它来决定使用哪一套词法。
JavaScript词法的另一个特别设计是字符串模板模板语法大概是这样的
```
`Hello, ${name}`
```
理论上,“ ${ } ”内部可以放任何JavaScript表达式代码而这些代码是以“ } ” 结尾的,也就是说,这部分词法不允许出现“ } ”运算符。
是否允许“ } ”的两种情况与除法和正则表达式的两种情况相乘就是四种词法定义所以你在JavaScript标准中可以看到四种定义
- InputElementDiv
- InputElementRegExp
- InputElementRegExpOrTemplateTail
- InputElementTemplateTail。
为了解决这两个问题,标准中还不得不把除法、正则表达式直接量和“ } ”从token中单独抽出来用词上也把原本的 Token 改为 CommonToken。
但是我认为从理解的角度上出发我们不应该受到影响所以在本课我们依然把它们归类到token来理解。
对一般的语言的词法分析过程来说都会丢弃除了token之外的输入但是对JavaScript来说不太一样换行符和注释还会影响语法分析过程这个我们将会在语法部分给你详细讲解所以要实现JavaScript的解释器词法分析和语法分析非常麻烦需要来回传递信息
接下来我来给你详细介绍一下。
### 空白符号 Whitespace
说起空白符号想必给大家留下的印象就是空格但是实际上JavaScript可以支持更多空白符号。
<li>
`&lt;HT&gt;`(或称`&lt;TAB&gt;`)是U+0009是缩进TAB符也就是字符串中写的 \t 。
</li>
<li>
`&lt;VT&gt;`是U+000B也就是垂直方向的TAB符 \v这个字符在键盘上很难打出来所以很少用到。
</li>
<li>
`&lt;FF&gt;`是U+000CForm Feed分页符字符串直接量中写作 \f 现代已经很少有打印源程序的事情发生了所以这个字符在JavaScript源代码中很少用到。
</li>
<li>
`&lt;SP&gt;`是U+0020就是最普通的空格了。
</li>
<li>
`&lt;NBSP&gt;`是U+00A0非断行空格它是SP的一个变体在文字排版中可以避免因为空格在此处发生断行其它方面和普通空格完全一样。多数的JavaScript编辑环境都会把它当做普通空格因为一般源代码编辑环境根本就不会自动折行……。HTML中很多人喜欢用的 `&amp;nbsp;` 最后生成的就是它了。
</li>
<li>
`&lt;ZWNBSP&gt;`(旧称`&lt;BOM&gt;`)是U+FEFF这是ES5新加入的空白符是Unicode中的零宽非断行空格在以UTF格式编码的文件中常常在文件首插入一个额外的U+FEFF解析UTF文件的程序可以根据U+FEFF的表示方法猜测文件采用哪种UTF编码方式。这个字符也叫做“bit order mark”。
</li>
此外JavaScript支持所有的Unicode中的空格分类下的空格我们可以看下表
<img src="https://static001.geekbang.org/resource/image/dd/60/dd26aa9599b61d26e7de807dee2c6360.png" alt="">
很多公司的编码规范要求JavaScript源代码控制在ASCII范围内那么就只有`&lt;TAB&gt;` `&lt;VT&gt;` `&lt;FF&gt;` `&lt;SP&gt;` `&lt;NBSP&gt;`五种空白可用了。
### 换行符 LineTerminator
接下来我们来看看换行符JavaScript中只提供了4种字符作为换行符。
- `&lt;LF&gt;`
- `&lt;CR&gt;`
- `&lt;LS&gt;`
- `&lt;PS&gt;`
其中,`&lt;LF&gt;`是U+000A就是最正常换行符在字符串中的`\n`
`&lt;CR&gt;`是U+000D这个字符真正意义上的“回车”在字符串中是`\r`在一部分Windows风格文本编辑器中换行是两个字符`\r\n`
`&lt;LS&gt;`是U+2028是Unicode中的行分隔符。`&lt;PS&gt;`是U+2029是Unicode中的段落分隔符。
大部分LineTerminator在被词法分析器扫描出之后会被语法分析器丢弃但是换行符会影响JavaScript的两个重要语法特性自动插入分号和“no line terminator”规则。
### 注释 Comment
JavaScript的注释分为单行注释和多行注释两种
```
/* MultiLineCommentChars */
// SingleLineCommentChars
```
多行注释中允许自由地出现`MultiLineNotAsteriskChar`,也就是除了`*`之外的所有字符。而每一个`*`之后,不能出现正斜杠符`/`
除了四种LineTerminator之外所有字符都可以作为单行注释。
我们需要注意多行注释中是否包含换行符号会对JavaScript语法产生影响对于“no line terminator”规则来说带换行的多行注释与换行符是等效的。
## 标识符名称 IdentifierName
`IdentifierName`可以以美元符“`$`”、下划线“`_`”或者Unicode字母开始除了开始字符以外`IdentifierName`中还可以使用Unicode中的连接标记、数字、以及连接符号。
`IdentifierName`的任意字符可以使用JavaScript的Unicode转义写法使用Unicode转义写法时没有任何字符限制。
`IdentifierName`可以是`Identifier``NullLiteral``BooleanLiteral`或者`keyword`,在`ObjectLiteral`中,`IdentifierName`还可以被直接当做属性名称使用。
仅当不是保留字的时候,`IdentifierName`会被解析为`Identifier`
注意`&lt;ZWNJ&gt;``&lt;ZWJ&gt;`是ES5新加入的两个格式控制字符它们都是0宽的。
我在前面提到了关键字也属于这个部分在JavaScript中关键字有:
```
await break case catch class const continue debugger default delete do else export extends finally for function if import instance of new return super switch this throw try typeof var void while with yield
```
除了上述的内容之外还有1个为了未来使用而保留的关键字:
```
enum
```
在严格模式下,有一些额外的为未来使用而保留的关键字:
```
implements package protected interface private public
```
除了这些之外,`NullLiteral``null`)和`BooleanLiteral``true false`)也是保留字,不能用于`Identifier`
### 符号 Punctuator
因为前面提到的除法和正则问题, /和/=两个运算符被拆分为DivPunctuator因为前面提到的字符串模板问题`}`也被独立拆分。加在一起,所有符号为:
```
{ ( ) [ ] . ... ; , &lt; &gt; &lt;= &gt;= == != === !== + - * % ** ++ -- &lt;&lt; &gt;&gt; &gt;&gt;&gt; &amp; | ^ ! ~ &amp;&amp; || ? : = += -= *= %= **= &lt;&lt;= &gt;&gt;= &gt;&gt;&gt;= &amp;= |= ^= =&gt; / /= }
```
### 数字直接量 NumericLiteral
我们来看看今天标题提出的问题JavaScript规范中规定的数字直接量可以支持四种写法十进制数、二进制整数、八进制整数和十六进制整数。
十进制的Number可以带小数小数点前后部分都可以省略但是不能同时省略我们看几个例子
```
.01
12.
12.01
```
这都是合法的数字直接量。这里就有一个问题,也是我们标题提出的问题,我们看一段代码:
```
12.toString()
```
这时候`12.` 会被当作省略了小数点后面部分的数字而单独看成一个整体所以我们要想让点单独成为一个token就要加入空格这样写
```
12 .toString()
```
数字直接量还支持科学计数法,例如:
```
10.24E+2
10.24e-2
10.24e2
```
这里e后面的部分只允许使用整数。当以`0x` `0b` 或者`0o` 开头时,表示特定进制的整数:
```
0xFA
0o73
0b10000
```
上面这几种进制都不支持小数,也不支持科学计数法。
### 字符串直接量 StringLiteral
JavaScript中的StringLiteral支持单引号和双引号两种写法。
```
" DoubleStringCharacters "
' SingleStringCharacters '
```
单双引号的区别仅仅在于写法,在双引号字符串直接量中,双引号必须转义,在单引号字符串直接量中,单引号必须转义。字符串中其他必须转义的字符是`\`和所有换行符。
JavaScript中支持四种转义形式还有一种虽然标准没有定义但是大部分实现都支持的八进制转义。
第一种是单字符转义。 即一个反斜杠`\`后面跟一个字符这种形式。
有特别意义的字符包括有`SingleEscapeCharacter`所定义的9种见下表
<img src="https://static001.geekbang.org/resource/image/02/75/022c2c77d0a3c846ad0d61b48c4e0e75.png" alt="">
除了这9种字符、数字、x和u以及所有的换行符之外其它字符经过`\`转义后都是自身。
### 正则表达式直接量 RegularExpressionLiteral
正则表达式由Body和Flags两部分组成例如
```
/RegularExpressionBody/g
```
其中Body部分至少有一个字符第一个字符不能是*(因为/*跟多行注释有词法冲突)。
正则表达式有自己的语法规则,在词法阶段,仅会对它做简单解析。
正则表达式并非机械地见到`/`就停止,在正则表达式`[ ]`中的`/`就会被认为是普通字符。我们可以看一个例子:
```
/[/]/.test("/");
```
除了`\``/``[` 三个字符之外JavaScript正则表达式中的字符都是普通字符。
用\和一个非换行符可以组成一个转义,`[ ]`中也支持转义。正则表达式中的flag在词法阶段不会限制字符。
虽然只有ig几个是有效的但是任何IdentifierPartIdentifier中合法的字符序列在词法阶段都会被认为是合法的。
### 字符串模板 Template
从语法结构上Template是个整体其中的 `${ }` 是并列关系。
但是实际上在JavaScript词法中包含 `${ }` 的 Template是被拆开分析的
```
`a${b}c${d}e`
```
它在JavaScript中被认为是
```
`a${
b
}c${
d
}e`
```
它被拆成了五个部分:
- ``a${` 这个被称为模板头
- `}c${` 被称为模板中段
- `}e`` 被称为模板尾
- `b``d` 都是普通标识符
实际上,这里的词法分析过程已经跟语法分析深度耦合了。
不过我们学习的时候,大可不必按照标准和引擎工程师这样去理解,可以认为模板就是一个由反引号括起来的、可以在中间插入代码的字符串。
模板支持添加处理函数的写法,这时模板的各段会被拆开,传递给函数当参数:
```
function f(){
console.log(arguments);
}
var a = "world"
f`Hello ${a}!`; // [["Hello", "!"], world]
```
模板字符串不需要关心大多数字符的转义,但是至少 `${` 和 ``` 还是需要处理的。
模板中的转义跟字符串几乎完全一样,都是使用 `\`。
## 总结
今天我们一起学习JavaScript的词法部分这部分的内容包括了空白符号、换行符、注释、标识符名称、符号、数字直接量、字符串直接量、正则表达式直接量、字符串模板。掌握词法对我们平时调试代码至关重要。
最后,给你留一个问题:用零宽空格和零宽连接符、零宽非连接符,写一段好玩的代码。你可以给我留言,我们一起讨论。
# 猜你喜欢
[<img src="https://static001.geekbang.org/resource/image/1a/08/1a49758821bdbdf6f0a8a1dc5bf39f08.jpg" alt="unpreview">](https://time.geekbang.org/course/intro/163?utm_term=zeusMTA7L&amp;utm_source=app&amp;utm_medium=chongxueqianduan&amp;utm_campaign=163-presell)

View File

@@ -0,0 +1,453 @@
<audio id="audio" title="JavaScript语法在script标签写export为什么会抛错" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/31/17e095d0ecd3e81fce501ffbd047b531.mp3"></audio>
你好我是winter今天我们进入到语法部分的学习。在讲解具体的语法结构之前这一堂课我首先要给你介绍一下JavaScript语法的一些基本规则。
## 脚本和模块
首先JavaScript有两种源文件一种叫做脚本一种叫做模块。这个区分是在ES6引入了模块机制开始的在ES5和之前的版本中就只有一种源文件类型就只有脚本
脚本是可以由浏览器或者node环境引入执行的而模块只能由JavaScript代码用import引入执行。
从概念上我们可以认为脚本具有主动性的JavaScript代码段是控制宿主完成一定任务的代码而模块是被动性的JavaScript代码段是等待被调用的库。
我们对标准中的语法产生式做一些对比不难发现实际上模块和脚本之间的区别仅仅在于是否包含import 和 export。
脚本是一种兼容之前的版本的定义在这个模式下没有import就不需要处理加载“.js”文件问题。
现代浏览器可以支持用script标签引入模块或者脚本如果要引入模块必须给script标签添加type=“module”。如果引入脚本则不需要type。
```
&lt;script type="module" src="xxxxx.js"&gt;&lt;/script&gt;
```
这样就回答了我们标题中的问题script标签如果不加`type=“module”`默认认为我们加载的文件是脚本而非模块如果我们在脚本中写了export当然会抛错。
脚本中可以包含语句。模块中可以包含三种内容import声明export声明和语句。普通语句我们会在下一课专门给你讲解下面我们就来讲讲import声明和export声明。
<img src="https://static001.geekbang.org/resource/image/43/44/43fdb35c0300e73bb19c143431f50a44.jpg" alt="">
### import声明
我们首先来介绍一下import声明import声明有两种用法一个是直接import一个模块另一个是带from的import它能引入模块里的一些信息。
```
import "mod"; //引入一个模块
import v from "mod"; //把模块默认的导出值放入变量v
```
直接import一个模块只是保证了这个模块代码被执行引用它的模块是无法获得它的任何信息的。
带from的import意思是引入模块中的一部分信息可以把它们变成本地的变量。
带from的import细分又有三种用法我们可以分别看下例子
- `import x from "./a.js"` 引入模块中导出的默认值。
- `import {a as x, modify} from "./a.js";` 引入模块中的变量。
- `import * as x from "./a.js"` 把模块中所有的变量以类似对象属性的方式引入。
第一种方式还可以跟后两种组合使用。
- `import d, {a as x, modify} from "./a.js"`
- `import d, * as x from "./a.js"`
语法要求不带as的默认值永远在最前。注意这里的变量实际上仍然可以受到原来模块的控制。
我们看一个例子假设有两个模块a和b。我们在模块a中声明了变量和一个修改变量的函数并且把它们导出。我们用b模块导入了变量和修改变量的函数。
**模块a**
```
export var a = 1;
export function modify(){
a = 2;
}
```
**模块b**
```
import {a, modify} from "./a.js";
console.log(a);
modify();
console.log(a);
```
当我们调用修改变量的函数后b模块变量也跟着发生了改变。这说明导入与一般的赋值不同导入后的变量只是改变了名字它仍然与原来的变量是同一个。
### export声明
我们再来说说export声明。与import相对export声明承担的是导出的任务。
模块中导出变量的方式有两种一种是独立使用export声明另一种是直接在声明型语句前添加export关键字。
独立使用export声明就是一个export关键字加上变量名列表例如
```
export {a, b, c};
```
我们也可以直接在声明型语句前添加export关键字这里的export可以加在任何声明性质的语句之前整理如下
- var
- function (含async和generator)
- class
- let
- const
export还有一种特殊的用法就是跟default联合使用。export default 表示导出一个默认变量值它可以用于function和class。这里导出的变量是没有名称的可以使用`import x from "./a.js"`这样的语法,在模块中引入。
export default 还支持一种语法,后面跟一个表达式,例如:
```
var a = {};
export default a;
```
但是这里的行为跟导出变量是不一致的这里导出的是值导出的就是普通变量a的值以后a的变化与导出的值就无关了修改变量a不会使得其他模块中引入的default值发生改变。
在import语句前无法加入export但是我们可以直接使用export from语法。
```
export a from "a.js"
```
JavaScript引擎除了执行脚本和模块之外还可以执行函数。而函数体跟脚本和模块有一定的相似之处所以接下来给你讲讲函数体的相关知识。
## 函数体
执行函数的行为通常是在JavaScript代码执行时注册宿主环境的某些事件触发的而执行的过程就是执行函数体函数的花括号中间的部分
我们先看一个例子,感性地理解一下:
```
setTimeout(function(){
console.log("go go go");
}, 10000)
```
这段代码通过setTimeout函数注册了一个函数给宿主当一定时间之后宿主就会执行这个函数。
你还记得吗,我们前面已经在运行时这部分讲过,宿主会为这样的函数创建宏任务。
当我们学习了语法之后,我们可以认为,宏任务中可能会执行的代码包括“脚本(script)”“模块module”和“函数体function body”。正因为这样的相似性我们把函数体也放到本课来讲解。
函数体其实也是一个语句的列表。跟脚本和模块比起来函数体中的语句列表中多了return语句可以用。
函数体实际上有四种,下面,我来分别介绍一下。
- 普通函数体,例如:
```
function foo(){
//Function body
}
```
- 异步函数体,例如:
```
async function foo(){
//Function body
}
```
- 生成器函数体,例如:
```
function *foo(){
//Function body
}
```
- 异步生成器函数体,例如:
```
async function *foo(){
//Function body
}
```
上面四种函数体的区别在于能否使用await或者yield语句。
关于函数体、模块和脚本能使用的语句,我整理了一个表格,你可以参考一下:
<img src="https://static001.geekbang.org/resource/image/0b/50/0b24e78625beb70e3346aad1e8cfff50.jpg" alt="">
讲完了三种语法结构我再来介绍两个JavaScript语法的全局机制预处理和指令序言。
这两个机制对于我们解释一些JavaScript的语法现象非常重要。不理解预处理机制我们就无法理解var等声明类语句的行为而不理解指令序言我们就无法解释严格模式。
## 预处理
JavaScript执行前会对脚本、模块和函数体中的语句进行预处理。预处理过程将会提前处理var、函数声明、class、const和let这些语句以确定其中变量的意义。
因为一些历史包袱这一部分内容非常复杂首先我们看一下var声明。
### var声明
var声明永远作用于脚本、模块和函数体这个级别在预处理阶段不关心赋值的部分只管在当前作用域声明这个变量。
我们还是从实例来进行学习。
```
var a = 1;
function foo() {
console.log(a);
var a = 2;
}
foo();
```
这段代码声明了一个脚本级别的a又声明了foo函数体级别的a我们注意到函数体级的`var`出现在console.log语句之后。
但是预处理过程在执行之前所以有函数体级的变量a就不会去访问外层作用域中的变量a了而函数体级的变量a此时还没有赋值所以是undefined。我们再看一个情况
```
var a = 1;
function foo() {
console.log(a);
if(false) {
var a = 2;
}
}
foo();
```
这段代码比上一段代码在`var a = 2`之外多了一段if我们知道if(false)中的代码永远不会被执行但是预处理阶段并不管这个var的作用能够穿透一切语句结构它只认脚本、模块和函数体三种语法结构。所以这里结果跟前一段代码完全一样我们会得到undefined。
我们看下一个例子,我们在运行时部分讲过类似的例子。
```
var a = 1;
function foo() {
var o= {a:3}
with(o) {
var a = 2;
}
console.log(o.a);
console.log(a);
}
foo();
```
在这个例子中我们引入了with语句我们用with(o)创建了一个作用域并把o对象加入词法环境在其中使用了`var a = 2;`语句。
在预处理阶段,只认`var`中声明的变量所以同样为foo的作用域创建了a这个变量但是没有赋值。
在执行阶段,当执行到`var a = 2`作用域变成了with语句内这时候的a被认为访问到了对象o的属性a所以最终执行的结果我们得到了2和undefined。
这个行为是JavaScript公认的设计失误之一一个语句中的a在预处理阶段和执行阶段被当做两个不同的变量严重违背了直觉但是今天在JavaScript设计原则“dont break the web”之下已经无法修正了所以你需要特别注意。
因为早年JavaScript没有let和const只能用var又因为var除了脚本和函数体都会穿透人民群众发明了“立即执行的函数表达式IIFE”这一用法用来产生作用域例如
```
for(var i = 0; i &lt; 20; i ++) {
void function(i){
var div = document.createElement("div");
div.innerHTML = i;
div.onclick = function(){
console.log(i);
}
document.body.appendChild(div);
}(i);
}
```
这段代码非常经典常常在实际开发中见到也经常被用作面试题为文档添加了20个div元素并且绑定了点击事件打印它们的序号。
我们通过IIFE在循环内构造了作用域每次循环都产生一个新的环境记录这样每个div都能访问到环境中的i。
如果我们不用IIFE
```
for(var i = 0; i &lt; 20; i ++) {
var div = document.createElement("div");
div.innerHTML = i;
div.onclick = function(){
console.log(i);
}
document.body.appendChild(div);
}
```
这段代码的结果将会是点每个div都打印20因为全局只有一个i执行完循环后i变成了20。
### function声明
function声明的行为原本跟var非常相似但是在最新的JavaScript标准中对它进行了一定的修改这让情况变得更加复杂了。
在全局脚本、模块和函数体function声明表现跟var相似不同之处在于function声明不但在作用域中加入变量还会给它赋值。
我们看一下function声明的例子
```
console.log(foo);
function foo(){
}
```
这里声明了函数foo在声明之前我们用console.log打印函数foo我们可以发现已经是函数foo的值了。
function声明出现在if等语句中的情况有点复杂它仍然作用于脚本、模块和函数体级别在预处理阶段仍然会产生变量它不再被提前赋值
```
console.log(foo);
if(true) {
function foo(){
}
}
```
这段代码得到undefined。如果没有函数声明则会抛出错误。
这说明function在预处理阶段仍然发生了作用在作用域中产生了变量没有产生赋值赋值行为发生在了执行阶段。
出现在if等语句中的function在if创建的作用域中仍然会被提前产生赋值效果我们会在下一节课继续讨论。
### class声明
class声明在全局的行为跟function和var都不一样。
在class声明之前使用class名会抛错
```
console.log(c);
class c{
}
```
这段代码我们试图在class前打印变量c我们得到了个错误这个行为很像是class没有预处理但是实际上并非如此。
我们看个复杂一点的例子:
```
var c = 1;
function foo(){
console.log(c);
class c {}
}
foo();
```
这个例子中我们把class放进了一个函数体中在外层作用域中有变量c。然后试图在class之前打印c。
执行后我们看到仍然抛出了错误如果去掉class声明则会正常打印出1也就是说出现在后面的class声明影响了前面语句的结果。
这说明class声明也是会被预处理的它会在作用域中创建变量并且要求访问它时抛出错误。
class的声明作用不会穿透if等语句结构所以只有写在全局环境才会有声明作用这部分我们将会在下一节课讲解。
这样的class设计比function和var更符合直觉而且在遇到一些比较奇怪的用法时倾向于抛出错误。
按照现代语言设计的评价标准,及早抛错是好事,它能够帮助我们尽量在开发阶段就发现代码的可能问题。
## 指令序言机制
脚本和模块都支持一种特别的语法叫做指令序言Directive Prologs
这里的指令序言最早是为了use strict设计的它规定了一种给JavaScript代码添加元信息的方式。
```
"use strict";
function f(){
console.log(this);
};
f.call(null);
```
这段代码展示了严格模式的用法我这里定义了函数ff中打印this值然后用call的方法调用f传入null作为this值我们可以看到最终结果是null原封不动地被当做this值打印了出来这是严格模式的特征。
如果我们去掉严格模式的指令需要打印的结果将会变成global。
`"use strict"`是JavaScript标准中规定的唯一一种指令序言但是设计指令序言的目的是留给JavaScript的引擎和实现者一些统一的表达方式在静态扫描时指定JavaScript代码的一些特性。
例如假设我们要设计一种声明本文件不需要进行lint检查的指令我们可以这样设计
```
"no lint";
"use strict";
function doSth(){
//......
}
//......
```
JavaScript的指令序言是只有一个字符串直接量的表达式语句它只能出现在脚本、模块和函数体的最前面。
我们看两个例子:
```
function doSth(){
//......
}
"use strict";
var a = 1;
//......
```
这个例子中,`"use strict"`没有出现在最前,所以不是指令序言。
```
'use strict';
function doSth(){
//......
}
var a = 1;
//......
```
这个例子中,`'use strict'`是单引号,这不妨碍它仍然是指令序言。
## 结语
今天我们一起进入了JavaScript的语法部分在开始学习之前我先介绍了一部分语法的基本规则。
我们首先介绍了JavaScript语法的全局结构JavaScript有两种源文件一种叫做脚本一种叫做模块。介绍完脚本和模块的基础概念我们再来把它们往下分脚本中可以包含语句。模块中可以包含三种内容import声明export声明和语句。
最后我介绍了两个JavaScript语法的全局机制预处理和指令序言。
最后给你留一个小任务我们试着用babel分析一段JavaScript的模块代码并且找出它中间的所有export的变量。

View File

@@ -0,0 +1,276 @@
<audio id="audio" title="JavaScript语法什么是表达式语句" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/23/651c19b1bc1688c685cd577366fc8c23.mp3"></audio>
你好我是winter。
不知道你有没有注意到,我们在语句部分,讲到了很多种语句类型,但是,其实最终产生执行效果的语句不多。
事实上,真正能干活的就只有表达式语句,其它语句的作用都是产生各种结构,来控制表达式语句执行,或者改变表达式语句的意义。
今天的课程,我们就深入到表达式语句中来学习一下。
## 什么是表达式语句
表达式语句实际上就是一个表达式,它是由运算符连接变量或者直接量构成的(关于直接量我们在下一节详细讲解)。
一般来说,我们的表达式语句要么是函数调用,要么是赋值,要么是自增、自减,否则表达式计算的结果没有任何意义。
但是从语法上,并没有这样的限制,任何合法的表达式都可以当做表达式语句使用。比如我们看下面的例子。
```
a + b;
```
这句代码计算了a和b相加的值但是不会显示出来也不会产生任何执行效果除非a和b是getter但是不妨碍它符合语法也能够被执行。
下面我们就一起来了解下都有哪些表达式,我们从粒度最小到粒度最大了解一下。
## PrimaryExpression 主要表达式
首先我们来给你讲解一下表达式的原子项Primary Expression。它是表达式的最小单位它所涉及的语法结构也是优先级最高的。
Primary Expression包含了各种“直接量”直接量就是直接用某种语法写出来的具有特定类型的值。我们已经知道在运行时有各种值比如数字123字符串Hello world所以通俗地讲直接量就是在代码中把它们写出来的语法。
我们在类型部分已经介绍过一些基本类型的直接量。比如我们当时用null关键字获取null值这个用法就是null直接量这里我们仅仅把它们简单回顾一下
```
"abc";
123;
null;
true;
false;
```
除这些之外JavaScript还能够直接量的形式定义对象针对函数、类、数组、正则表达式等特殊对象类型JavaScript提供了语法层面的支持。
```
({});
(function(){});
(class{ });
[];
/abc/g;
```
需要注意在语法层面function、{ 和class开头的表达式语句与声明语句有语法冲突所以我们要想使用这样的表达式必须加上括号来回避语法冲突。
在JavaScript标准中这些结构有的被称作直接量Literal有的被称作表达式**Expression在我看来把它们都理解成直接量比较合适。
Primary Expression还可以是this或者变量在语法上把变量称作“标识符引用”。
```
this;
myVar;
```
任何表达式加上圆括号都被认为是Primary Expression这个机制使得圆括号成为改变运算优先顺序的手段。
```
(a + b);
```
这就是Primary Expression的几种形式了接下来我们讲讲由Primary Expression构成的更复杂的表达式Member Expression。
## MemberExpression 成员表达式
Member Expression通常是用于访问对象成员的。它有几种形式
```
a.b;
a["b"];
new.target;
super.b;
```
前面两种用法都很好理解就是用标识符的属性访问和用字符串的属性访问。而new.target是个新加入的语法用于判断函数是否是被new调用super则是构造函数中用于访问父类的属性的语法。
从名字就可以看出Member Expression最初设计是为了属性访问的不过从语法结构需要以下两种在JavaScript标准中当做Member Expression
```
f`a${b}c`;
```
这是一个是带函数的模板,这个带函数名的模板表示把模板的各个部分算好后传递给一个函数。
```
new Cls();
```
另一个是带参数列表的new运算注意不带参数列表的new运算优先级更低不属于Member Expression。
实际上这两种被放入Member Expression仅仅意味着它们跟属性运算属于同一优先级没有任何语义上的关联。接下来我们看看Member Expression能组成什么。
## NewExpression NEW表达式
这种非常简单Member Expression加上new就是New Expression当然不加new也可以构成New ExpressionJavaScript中默认独立的高优先级表达式都可以构成低优先级表达式
注意这里的New Expression特指没有参数列表的表达式。我们看个稍微复杂的例子
```
new new Cls(1);
```
直观看上去,它可能有两种意思:
```
new (new Cls(1));
```
```
new (new Cls)(1);
```
实际上,它等价于第一种。我们可以用以下代码来验证:
```
class Cls{
constructor(n){
console.log("cls", n);
return class {
constructor(n) {
console.log("returned", n);
}
}
}
}
new (new Cls(1));
```
这段代码最后得到了下面这样的结果。
```
cls 1
returned undefined
```
这里就说明了1被当做调用Cls时的参数传入了。
## CallExpression 函数调用表达式
除了New ExpressionMember Expression还能构成Call Expression。它的基本形式是Member Expression后加一个括号里的参数列表或者我们可以用上super关键字代替Member Expression。
```
a.b(c);
super();
```
这看起来很简单,但是它有一些变体。比如:
```
a.b(c)(d)(e);
a.b(c)[3];
a.b(c).d;
a.b(c)`xyz`;
```
这些变体的形态跟Member Expression几乎是一一对应的。实际上我们可以理解为Member Expression中的某一子结构具有函数调用那么整个表达式就成为了一个Call Expression。
而Call Expression就失去了比New Expression优先级高的特性这是一个主要的区分。
## LeftHandSideExpression 左值表达式
接下来我们需要理解一个概念New Expression 和 Call Expression 统称LeftHandSideExpression左值表达式。
我们直观地讲左值表达式就是可以放到等号左边的表达式。JavaScript语法则是下面这样。
```
a() = b;
```
这样的用法其实是符合语法的只是原生的JavaScript函数返回的值都不能被赋值。因此多数时候我们看到的赋值将会是Call Expression的其它形式
```
a().c = b;
```
另外根据JavaScript运行时的设计不排除某些宿主会提供返回引用类型的函数这时候赋值就是有效的了。
左值表达式最经典的用法是用于构成赋值表达式但是其实如果你翻一翻JavaScript标准你会发现它出现在各种场合凡是需要“可以被修改的变量”的位置都能见到它的身影。
那么接下来我们就讲讲 AssignmentExpression 赋值表达式。
## AssignmentExpression 赋值表达式
AssignmentExpression 赋值表达式也有多种形态,最基本的当然是使用等号赋值:
```
a = b
```
这里需要理解的一个稍微复杂的概念是,这个等号是可以嵌套的:
```
a = b = c = d
```
这样的连续赋值,是右结合的,它等价于下面这种:
```
a = (b = (c = d))
```
也就是说先把d的结果赋值给c再把整个表达式的结果赋值给b再赋值给a。
**当然,这并非一个很好的代码风格,我们讲解语法是为了让你理解这样的用法,而不是推荐你这样写代码。**
赋值表达式的使用,还可以结合一些运算符,例如:
```
a += b;
```
相当于
```
a = a + b;
```
能有这样用的运算符有下面这几种:
`*=``/=``%=``+=``-=``&lt;&lt;=``&gt;&gt;=``&gt;&gt;&gt;=``&amp;=``^=``|=``**=`
我想你已经注意到了,赋值表达式的等号左边和右边能用的表达式类型不一样,在这一课,我们已经关注完了表达式的左边部分(左值表达式)的语法结构,下一节课,我们将会给你重点讲解表达式的右边部分。
## Expression 表达式
赋值表达式可以构成Expression表达式的一部分。在JavaScript中表达式就是用逗号运算符连接的赋值表达式。
在JavaScript中比赋值运算优先级更低的就是逗号运算符了。我们可以把逗号可以理解为一种小型的分号。
```
a = b, b = 1, null;
```
逗号分隔的表达式会顺次执行,就像不同的表达式语句一样。“整个表达式的结果”就是“最后一个逗号后的表达式结果”。比如我们文中的例子,整个`“a = b, b = 1, null;”`表达式的结果就是`“,”`后面的`null`
在很多场合都不允许使用带逗号的表达式比如我们在前面课程中提到export后只能跟赋值表达式意思就是表达式中不能含有逗号。
## 结语
这节课我们开始讲解了运算符和表达式的一些相关知识,这节课上,我们已经学习了赋值表达式和赋值表达式的左边部分。下节课,我们将会讲一讲赋值表达式的右边部分。
最后给你留一个作业,把今天讲到的所有运算符按优先级排列成一个表格,下节课我们会补完剩下的部分。

View File

@@ -0,0 +1,545 @@
<audio id="audio" title="JavaScript语法你知道哪些JavaScript语句" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/36/b98805c10ae51f49492bce8a7103b936.mp3"></audio>
你好我是winter。
我们在上一节课中已经讲过了JavaScript语法的顶层设计接下来我们进入到更具体的内容。
JavaScript遵循了一般编程语言的“语句-表达式”结构,多数编程语言都是这样设计的。我们在上节课讲的脚本,或者模块都是由语句列表构成的,这一节课,我们就来一起了解一下语句。
在JavaScript标准中把语句分成了两种声明和语句不过这里的区分逻辑比较奇怪所以这里我还是按照自己的思路给你整理一下。
普通语句:
<img src="https://static001.geekbang.org/resource/image/81/55/8186219674547691cf59e5c095304d55.png" alt="">
声明型语句:
<img src="https://static001.geekbang.org/resource/image/0e/38/0e5327528df12d1eaad52c4005efff38.jpg" alt="">
我们根据上面的分类,来遍历学习一下这些语句。
## 语句块
我们可以这样去简单理解,语句块就是一对大括号。
```
{
var x, y;
x = 10;
y = 20;
}
```
语句块的意义和好处在于让我们可以把多行语句视为同一行语句这样if、for等语句定义起来就比较简单了。不过我们需要注意的是语句块会产生作用域我们看一个例子
```
{
let x = 1;
}
console.log(x); // 报错
```
这里我们的let声明仅仅对语句块作用域生效于是我们在语句块外试图访问语句块内的变量x就会报错。
## 空语句
空语句就是一个独立的分号,实际上没什么大用。我们来看一下:
```
;
```
空语句的存在仅仅是从语言设计完备性的角度考虑,允许插入多个分号而不抛出错误。
## if语句
if语句是条件语句。我想对多数人来说if语句都是熟悉的老朋友了也没有什么特别需要注意的用法但是为了我们课程的完备性这里还是要讲一下。
if语句示例如下
```
if(a &lt; b)
console.log(a);
```
if语句的作用是在满足条件时执行它的内容语句这个语句可以是一个语句块这样就可以实现有条件地执行多个语句了。
if语句还有else结构用于不满足条件时执行一种常见的用法是利用语句的嵌套能力把if和else连写成多分支条件判断
```
if(a &lt; 10) {
//...
} else if(a &lt; 20) {
//...
} else if(a &lt; 30) {
//...
} else {
//...
}
```
这段代码表示四个互斥的分支分别在满足a&lt;10、a&lt;20、a&lt;30和其它情况时执行。
## switch语句
switch语句继承自JavaJava中的switch语句继承自C和C++原本switch语句是跳转的变形所以我们如果要用它来实现分支必须要加上break。
其实switch原本的设计是类似goto的思维。我们看一个例子
```
switch(num) {
case 1:
print(1);
case 2:
print 2;
case 3:
print 3;
}
```
这段代码当num为1时输出1 2 3当num为2时输出2 3当num为3时输出3。如果我们要把它变成分支型则需要在每个case后加上break。
```
switch(num) {
case 1:
print 1;
break;
case 2:
print 2;
break;
case 3:
print 3;
break;
}
```
在C时代switch生成的汇编代码性能是略优于if else的但是对JavaScript来说则无本质区别。我个人的看法是现在switch已经完全没有必要使用了应该用if else结构代替。
## 循环语句
循环语句应该也是你所熟悉的语句了,这里我们把重点放在一些新用法上。
### while循环和do while循环
这两个都是历史悠久的JavaScript语法了示例大概如下
```
let a = 100
while(a--) {
console.log("*");
}
```
```
let a = 101;
do {
console.log(a);
} while(a &lt; 100)
```
注意这里do while循环无论如何至少会执行一次。
### 普通for循环
首先我们来看看普通的for循环。
```
for(i = 0; i &lt; 100; i++)
console.log(i);
for(var i = 0; i &lt; 100; i++)
console.log(i);
for(let i = 0; i &lt; 100; i++)
console.log(i);
var j = 0;
for(const i = 0; j &lt; 100; j++)
console.log(i);
```
这里为了配合新语法加入了允许let和const实际上const在这里是非常奇葩的东西因为这里声明和初始化的变量按惯例是用于控制循环的但是它如果是const就没法改了。
我想这一点可能是从保持let和const一致性的角度考虑的吧。
### for in循环
for in 循环枚举对象的属性这里体现了属性的enumerable特征。
```
let o = { a: 10, b: 20}
Object.defineProperty(o, "c", {enumerable:false, value:30})
for(let p in o)
console.log(p);
```
这段代码中我们定义了一个对象o给它添加了不可枚举的属性c之后我们用for in循环枚举它的属性我们会发现输出时得到的只有a和b。
如果我们定义c这个属性时enumerable为true则for in循环中也能枚举到它。
### for of循环和for await of循环
for of循环是非常棒的语法特性。
我们先看下基本用法,它可以用于数组:
```
for(let e of [1, 2, 3, 4, 5])
console.log(e);
```
但是实际上它背后的机制是iterator机制。
我们可以给任何一个对象添加iterator使它可以用于for of语句看下示例
```
let o = {
[Symbol.iterator]:() =&gt; ({
_value: 0,
next(){
if(this._value == 10)
return {
done: true
}
else return {
value: this._value++,
done: false
};
}
})
}
for(let e of o)
console.log(e);
```
这段代码展示了如何为一个对象添加iterator。但是在实际操作中我们一般不需要这样定义iterator我们可以使用generator function。
```
function* foo(){
yield 0;
yield 1;
yield 2;
yield 3;
}
for(let e of foo())
console.log(e);
```
这段代码展示了generator function和foo的配合。
此外JavaScript还为异步生成器函数配备了异步的for of我们来看一个例子
```
function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
async function* foo(){
i = 0;
while(true) {
await sleep(1000);
yield i++;
}
}
for await(let e of foo())
console.log(e);
```
这段代码定义了一个异步生成器函数,异步生成器函数每隔一秒生成一个数字,这是一个无限的生成器。
接下来我们使用for await of来访问这个异步生成器函数的结果我们可以看到这形成了一个每隔一秒打印一个数字的无限循环。
但是因为我们这个循环是异步的,并且有时间延迟,所以,这个无限循环的代码可以用于显示时钟等有意义的操作。
## return
return语句用于函数中它终止函数的执行并且指定函数的返回值这是大家非常熟悉语句了也没有什么特殊之处。
```
function squre(x){
return x * x;
}
```
这段代码展示了return的基本用法。它后面可以跟一个表达式计算结果就是函数返回值。
## break语句和continue语句
break语句用于跳出循环语句或者switch语句continue语句用于结束本次循环并继续循环。
这两个语句都属于控制型语句,用法也比较相似,所以我们就一起讲了。需要注意的是,它们都有带标签的用法。
```
outer:for(let i = 0; i &lt; 100; i++)
inner:for(let j = 0; j &lt; 100; j++)
if( i == 50 &amp;&amp; j == 50)
break outer;
outer:for(let i = 0; i &lt; 100; i++)
inner:for(let j = 0; j &lt; 100; j++)
if( i &gt;= 50 &amp;&amp; j == 50)
continue outer;
```
带标签的break和continue可以控制自己被外层的哪个语句结构消费这可以跳出复杂的语句结构。
## with语句
with语句是个非常巧妙的设计但它把JavaScript的变量引用关系变得不可分析所以一般都认为这种语句都属于糟粕。
但是历史无法改写现在已经无法去除with了。我们来了解一下它的基本用法即可。
```
let o = {a:1, b:2}
with(o){
console.log(a, b);
}
```
with语句把对象的属性在它内部的作用域内变成变量。
## try语句和throw语句
try语句和throw语句用于处理异常。它们是配合使用的所以我们就放在一起讲了。在大型应用中异常机制非常重要。
```
try {
throw new Error("error");
} catch(e) {
console.log(e);
} finally {
console.log("finally");
}
```
一般来说throw用于抛出异常但是单纯从语言的角度我们可以抛出任何值也不一定是异常逻辑但是为了保证语义清晰不建议用throw表达任何非异常逻辑。
try语句用于捕获异常用throw抛出的异常可以在try语句的结构中被处理掉try部分用于标识捕获异常的代码段catch部分则用于捕获异常后做一些处理而finally则是用于执行后做一些必须执行的清理工作。
catch结构会创建一个局部的作用域并且把一个变量写入其中需要注意在这个作用域不能再声明变量e了否则会出错。
在catch中重新抛出错误的情况非常常见在设计比较底层的函数时常常会这样做保证抛出的错误能被理解。
finally语句一般用于释放资源它一定会被执行我们在前面的课程中已经讨论过一些finally的特征即使在try中出现了returnfinally中的语句也一定要被执行。
## debugger语句
debugger语句的作用是通知调试器在此断点。在没有调试器挂载时它不产生任何效果。
介绍完普通语句,我们再来看看声明型语句。声明型语句跟普通语句最大区别就是声明型语句响应预处理过程,普通语句只有执行过程。
## var
var声明语句是古典的JavaScript中声明变量的方式。而现在在绝大多数情况下let和const都是更好的选择。
我们在上一节课已经讲解了var声明对全局作用域的影响它是一种预处理机制。
如果我们仍然想要使用var我的个人建议是把它当做一种“保障变量是局部”的逻辑遵循以下三条规则
- 声明同时必定初始化;
- 尽可能在离使用的位置近处声明;
- 不要在意重复声明。
例如:
```
var x = 1, y = 2;
doSth(x, y);
for(var x = 0; x &lt; 10; x++)
doSth2(x);
```
这个例子中两次声明了变量x完成了两段逻辑这两个x意义上可能不一定相关这样不论我们把代码复制粘贴在哪里都不会出错。
当然更好的办法是使用let改造我们看看如何改造
```
{
let x = 1, y = 2;
doSth(x, y);
}
for(let x = 0; x &lt; 10; x++)
doSth2(x);
```
这里我用代码块限制了第一个x的作用域这样就更难发生变量命名冲突引起的错误了。
## let和const
let和const是都是变量的声明它们的特性非常相似所以我们放在一起讲了。let和const是新设计的语法所以没有什么硬伤非常地符合直觉。let和const的作用范围是if、for等结构型语句。
我们看下基本用法:
```
const a = 2;
if(true){
const a = 1;
console.log(a);
}
console.log(a);
```
这里的代码先在全局声明了变量a接下来又在if内声明了aif内构成了一个独立的作用域。
const和let语句在重复声明时会抛错这能够有效地避免变量名无意中冲突
```
let a = 2
const a = 1;
```
这段代码中先用let声明了a接下来又试图使用const声明变量a这时就会产生错误。
let和const声明虽然看上去是执行到了才会生效但是实际上它们还是会被预处理。如果当前作用域内有声明就无法访问到外部的变量。我们来看这段代码
```
const a = 2;
if(true){
console.log(a); //抛错
const a = 1;
}
```
这里在if的作用域中变量a声明执行到之前我们访问了变量a这时会抛出一个错误这说明const声明仍然是有预处理机制的。
在执行到const语句前我们的JavaScript引擎就已经知道后面的代码将会声明变量a从而不允许我们访问外层作用域中的a。
## class声明
我们在之前的课程中已经了解过class相关的用法。这里我们再从语法的角度来看一遍
```
class a {
}
```
class最基本的用法只需要class关键字、名称和一对大括号。它的声明特征跟const和let类似都是作用于块级作用域预处理阶段则会屏蔽外部变量。
```
const a = 2;
if(true){
console.log(a); //抛错
class a {
}
}
```
class内部可以使用constructor关键字来定义构造函数。还能定义getter/setter和方法。
```
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}
```
这个例子来自MDN它展示了构造函数、getter和方法的定义。
以目前的兼容性class中的属性只能写在构造函数中相关标准正在TC39讨论。
需要注意class默认内部的函数定义都是strict模式的。
## 函数声明
函数声明使用 function 关键字。
在上一节课中,我们已经讨论过函数声明对全局作用域的影响了。这一节课,我们来看看函数声明具体的内容,我们先看一下函数声明的几种类型。
```
function foo(){
}
function* foo(){
yield 1;
yield 2;
yield 3;
}
async function foo(){
await sleep(3000);
}
async function* foo(){
await sleep(3000);
yield 1;
}
```
带*的函数是generator我们在前面的部分已经见过它了。生成器函数可以理解为返回一个序列的函数它的底层是iterator机制。
async函数是可以暂停执行等待异步操作的函数它的底层是Promise机制。异步生成器函数则是二者的结合。
函数的参数,可以只写形参名,现在还可以写默认参数和指定多个参数,看下例子:
```
function foo(a = 1, ...other) {
console.log(a, other)
}
```
这个形式可以代替一些对参数的处理代码,表意会更加清楚。
## 结语
今天我们一起学习了语句家族,语句分成了普通语句和声明型语句。
普通语句部分,建议你把重点放在循环语句上面。声明型语句我觉得都很重要,尤其是它们的行为。熟练掌握了它们,我们就可以在工作中去综合运用它们,从而减少代码中的错误。新特性大多可以帮助我们发现代码中的错误。
最后留一个小作业请你找出所有具有Symbol.iterator的原生对象并且看看它们的for of遍历行为。

View File

@@ -0,0 +1,321 @@
<audio id="audio" title="JavaScript语法新加入的**运算符,哪里有些不一样呢?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/4e/afb1ea6ce8222b91e446a4fd72d4da4e.mp3"></audio>
你好我是winter。
上一节课我们已经给你介绍了表达式的一些结构,其中关于赋值表达式,我们讲完了它的左边部分,而留下了它右边部分,那么,我们这节课一起来详细讲解。
在一些通用的计算机语言设计理论中能够出现在赋值表达式右边的叫做右值表达式RightHandSideExpression而在JavaScript标准中规定了在等号右边表达式叫做条件表达式ConditionalExpression不过在JavaScript标准中从未出现过右值表达式字样。
JavaScript标准也规定了左值表达式同时都是条件表达式也就是右值表达式此外左值表达式也可以通过跟一定的运算符组合逐级构成更复杂的结构直到成为右值表达式。
关于这块的知识,我们有时会看到按照运算符来组织的讲解形式。
这样讲解形式是因为:对运算符来说的“优先级”,如果从我们语法的角度来看,那就是“表达式的结构”。讲“乘法运算的优先级高于加法”,从语法的角度看就是“乘法表达式和加号运算符构成加法表达式”。
对于右值表达式来说,我们可以理解为以左值表达式为最小单位开始构成的,接下来我们就来看看左值表达式是如何一步步构成更为复杂的语法结构。
## 更新表达式 UpdateExpression
左值表达式搭配 `++` `--` 运算符,可以形成更新表达式。
```
-- a;
++ a;
a --
a ++
```
更新表达式会改变一个左值表达式的值。分为前后自增,前后自减一共四种。
我们要注意一下这里在ES2018中跟早期版本有所不同前后自增自减运算被放到了同一优先级。
## 一元运算表达式 UnaryExpression
更新表达式搭配一元运算符,可以形成一元运算表达式,我们看下例子:
```
delete a.b;
void a;
typeof a;
- a;
~ a;
! a;
await a;
```
它的特点就是一个更新表达式搭配了一个一元运算符。
## 乘方表达式 ExponentiationExpression
乘方表达式也是由更新表达式构成的。它使用`**`号。
```
++i ** 30
2 ** 30 //正确
-2 ** 30 //报错
```
我们看一下例子,-2这样的一元运算表达式是不可以放入乘方表达式的如果需要表达类似的逻辑必须加括号。
这里我们需要注意一下结合性,**运算是右结合的,这跟其它正常的运算符(也就是左结合运算符)都不一样。
我们来看一个例子。
```
4 ** 3 ** 2
```
事实上,它是这样被运算的:
```
4 ** (3 ** 2)
```
而不是这样被运算的:
```
(4 ** 3) ** 2
```
我们来实际在代码中执行一下试试。最终结果是262144 而不是4096。
## 乘法表达式 MultiplicativeExpression
到这里,我们进入了比较熟悉的表达式类型,乘方表达式可以构成乘法表达式,用乘号或者除号、取余符号连接就可以了,我们看看例子:
```
x * 2;
```
乘法表达式有三种运算符:
```
*
/
%
```
它们分别表示乘、除和取余。它们的优先级是一样的,所以统一放在乘法运算表达式中。
## 加法表达式 AdditiveExpression
加法表达式是由乘法表达式用加号或者减号连接构成的。我们看下例子:
```
a + b * c
```
加法表达式有加号和减号两种运算符。
```
+
-
```
这就是我们小学学的加法和减法的意思了。不过要注意,加号还能表示字符串连接,这也比较符合一般的直觉。
## 移位表达式 ShiftExpression
移位表达式由加法表达式构成,移位是一种位运算,分成三种:
```
&lt;&lt; 向左移位
&gt;&gt; 向右移位
&gt;&gt;&gt; 无符号向右移位
```
移位运算把操作数看做二进制表示的整数然后移动特定位数。所以左移n位相当于乘以2的n次方右移n位相当于除以2取整n次。
普通移位会保持正负数。无符号移位会把减号视为符号位1同时参与移位
```
-1 &gt;&gt;&gt; 1
```
这个会得到2147483647也就是2的31次方跟负数的二进制表示法相关这里就不详细讲解了。
在JavaScript中二进制操作整数并不能提高性能移位运算这里也仅仅作为一种数学运算存在这些运算存在的意义也仅仅是照顾C系语言用户的习惯了。
## 关系表达式 RelationalExpression
移位表达式可以构成关系表达式,这里的关系表达式就是大于、小于、大于等于、小于等于等运算符号连接,统称为关系运算。
```
&lt;=
&gt;=
&lt;
&gt;
instanceof
in
```
需要注意,这里的&lt;= 和 &gt;= 关系运算,完全是针对数字的,所以 &lt;= 并不等价于 &lt; 或 ==。例如:
```
null &lt;= undefined
//false
null == undefined
//true
```
请你务必不要用数学上的定义去理解这些运算符。
## 相等表达式 EqualityExpression
在语法上,相等表达式是由关系表达式用相等比较运算符(如 `==`)连接构成的。所以我们可以像下面这段代码一样使用,而不需要加括号。
```
a instanceof &quot;object&quot; == true
```
相等表达式由四种运算符和关系表达式构成,我们来看一下运算符:
- `==`
- `!=`
- `===`
- `!==`
相等表达式又包含一个JavaScript中著名的设计失误那就是 `==` 的行为。
一些编程规范甚至要求完全避免使用 `==` 运算,我觉得这样规定是比较合理的,但是这里我还是尽量解释一下 `==` 的行为。
虽然标准中写的`==`十分复杂,但是归根结底,类型不同的变量比较时`==`运算只有三条规则:
- undefined与null相等
- 字符串和bool都转为数字再比较
- 对象转换成primitive类型再比较。
这样我们就可以理解一些不太符合直觉的例子了,比如:
- `false == '0'` true
- `true == 'true'` false
- `[] == 0` true
- `[] == false` true
- `new Boolean('false') == false` false
这里不太符合直觉的有两点:
- 一个是即使字符串与boolean比较也都要转换成数字
- 另一个是对象如果转换成了primitive类型跟等号另一边类型恰好相同则不需要转换成数字。
此外,`==` 的行为也经常跟if的行为转换为boolean混淆。总之我建议仅在确认 `==` 发生在Number和String类型之间时使用比如
```
document.getElementsByTagName('input')[0].value == 100
```
在这个例子中等号左边必然是string右边的直接量必然是number这样使用 == 就没有问题了。
## 位运算表达式
位运算表达式含有三种:
- 按位与表达式 BitwiseANDExpression
- 按位异或表达式 BitwiseANDExpression
- 按位或表达式 BitwiseORExpression。
位运算表达式关系比较紧密,我们这里放到一起来讲。
按位与表达式由按位与运算符(`&amp;`)连接按位异或表达式构成,按位与表达式把操作数视为二进制整数,然后把两个操作数按位做与运算。
按位异或表达式由按位异或运算符(`^`连接按位与表达式构成按位异或表达式把操作数视为二进制整数然后把两个操作数按位做异或运算。异或两位相同时得0两位不同时得1。
异或运算有个特征,那就是两次异或运算相当于取消。所以有一个异或运算的小技巧,就是用异或运算来交换两个整数的值。
```
let a = 102, b = 324;
a = a ^ b;
b = a ^ b;
a = a ^ b;
console.log(a, b);
```
按位或表达式由按位或运算符(`|`)连接相等表达式构成,按位或表达式把操作数视为二进制整数,然后把两个操作数按位做或运算。
按位或运算常常被用在一种叫做Bitmask的技术上。Bitmask相当于使用一个整数来当做多个布尔型变量现在已经不太提倡了。不过一些比较老的API还是会这样设计比如我们在DOM课程中提到过的Iterator API我们看下例子
```
var iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT, null, false);
var node;
while(node = iterator.nextNode())
{
console.log(node);
}
```
这里的第二个参数就是使用了Bitmask技术所以必须配合位运算表达式才能方便地传参。
## 逻辑与表达式和逻辑或表达式
逻辑与表达式由按位或表达式经过逻辑与运算符连接构成,逻辑或表达式则由逻辑与表达式经逻辑或运算符连接构成。
这里需要注意的是,这两种表达式都不会做类型转换,所以尽管是逻辑运算,但是最终的结果可能是其它类型。
比如:
```
false || 1;
```
这句将会得到结果 1。
```
false &amp;&amp; undefined;
```
这句将会得到undefined。
另外还有一点,就是逻辑表达式具有短路的特性,例如:
```
true || foo();
```
这里的foo将不会被执行这种中断后面表达式执行的特性就叫做短路。
## 条件表达式 ConditionalExpression
条件表达式由逻辑或表达式和条件运算符构成,条件运算符又称三目运算符,它有三个部分,由两个运算符`?``:`配合使用。
```
condition ? branch1 : branch2
```
这里需要注意条件表达式也像逻辑表达式一样可能忽略后面表达式的计算。这一点跟C语言的条件表达式是不一样的。
条件表达式实际上就是JavaScript中的右值表达式了 RightHandSideExpression是可以放到赋值运算后面的表达式。
## 总结
今天我们讲解了表达式的右边部分讲到了包括更新表达式、一元运算表达式、乘方表达式、乘法表达式、移位表达式等14种表达式。至此为止我们已经讲全了表达式。你如果有不熟悉的地方可以随时回头查阅。
留一个小任务我们试着总结下JavaScript中所有的运算符优先级和结合性。例如
<img src="https://static001.geekbang.org/resource/image/4c/ca/4cb75eb863d5dffe7e9b6b0fb1161aca.jpg" alt="">

View File

@@ -0,0 +1,245 @@
<audio id="audio" title="JavaScript语法预备篇到底要不要写分号呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ac/d1/accffbf5ccf1d32f20fdcba676b784d1.mp3"></audio>
你好我是winter。
在我们介绍JavaScript语法的全局结构之前我们先要探讨一个语言风格问题究竟要不要写分号。
这是一个非常经典的口水问题,“加分号”党和“不写分号”党之间的战争,可谓是经久不息。
实际上行尾使用分号的风格来自于Java也来自于C语言和C++,这一设计最初是为了降低编译器的工作负担。
但是从今天的角度来看行尾使用分号其实是一种语法噪音恰好JavaScript语言又提供了相对可用的分号自动补全规则所以很多JavaScript的程序员都是倾向于不写分号。
这里要特意说一点,在今天的文章中,我并不希望去售卖自己的观点(其实我是属于“加分号”党),而是希望比较中立地给你讲清楚相关的知识,让你具备足够的判断力。
我们首先来了解一下自动插入分号的规则。
## 自动插入分号规则
自动插入分号规则其实独立于所有的语法产生式定义,它的规则说起来非常简单,只有三条。
- 要有换行符,且下一个符号是不符合语法的,那么就尝试插入分号。
- 有换行符,且语法中规定此处不能有换行符,那么就自动插入分号。
- 源代码结束处,不能形成完整的脚本或者模块结构,那么就自动插入分号。
这样描述是比较难以理解的,我们一起看一些实际的例子进行分析:
```
let a = 1
void function(a){
console.log(a);
}(a);
```
在这个例子中第一行的结尾处有换行符接下来void关键字接在1之后是不合法的这命中了我们的第一条规则因此会在void前插入分号。
```
var a = 1, b = 1, c = 1;
a
++
b
++
c
```
这也是个著名的例子我们看第二行的a之后有换行符后面遇到了++运算符a后面跟++是合法的语法但是我们看看JavaScript标准定义中有[no LineTerminator here]这个字样这是一个语法定义中的规则你可以感受一下这个规则的内容下一小节我会给你详细介绍no LineTerminator here
```
UpdateExpression[Yield, Await]:
LeftHandSideExpression[?Yield, ?Await]
LeftHandSideExpression[?Yield, ?Await][no LineTerminator here]++
LeftHandSideExpression[?Yield, ?Await][no LineTerminator here]--
++UnaryExpression[?Yield, ?Await]
--UnaryExpression[?Yield, ?Await]
```
于是这里a的后面就要插入一个分号了。所以这段代码最终的结果b和c都变成了2而a还是1。
```
(function(a){
console.log(a);
})()
(function(a){
console.log(a);
})()
```
这个例子是比较有实际价值的例子这里两个function调用的写法被称作IIFE立即执行的函数表达式是个常见技巧。
这段代码意图上显然是形成两个IIFE。
我们来看第三行结束的位置JavaScript引擎会认为函数返回的可能是个函数那么在后面再跟括号形成函数调用就是合理的因此这里不会自动插入分号。
这是一些鼓励不写分号的编码风格会要求大家写IIFE时必须在行首加分号的原因。
```
function f(){
return/*
This is a return value.
*/1;
}
f();
```
在这个例子中return和1被用注释分隔开了。
根据JavaScript自动插入分号规则**带换行符的注释也被认为是有换行符**而恰好的是return也有[no LineTerminator here]规则的要求。所以这里会自动插入分号f执行的返回值是undefined。
## no LineTerminator here 规则
好了到这里我们已经讲清楚了分号自动插入的规则但是我们要想彻底掌握分号的奥秘就必须要对JavaScript的语法定义做一些数据挖掘工作。
no LineTerminator here规则表示它所在的结构中的这一位置不能插入换行符。
自动插入分号规则的第二条有换行符且语法中规定此处不能有换行符那么就自动插入分号。跟no LineTerminator here规则强相关那么我们就找出JavaScript语法定义中的这些规则。
<img src="https://static001.geekbang.org/resource/image/c3/ad/c3ffbc89e049ad1901d4108c8ad88aad.jpg" alt="">
为了方便你理解,我把产生式换成了实际的代码。
下面一段代码展示了带标签的continue语句不能在continue后插入换行。
```
outer:for(var j = 0; j &lt; 10; j++)
for(var i = 0; i &lt; j; i++)
continue /*no LineTerminator here*/ outter
```
break跟continue是一样的break后也不能插入换行
```
outer:for(var j = 0; j &lt; 10; j++)
for(var i = 0; i &lt; j; i++)
break /*no LineTerminator here*/ outter
```
我们前面已经提到过return和后自增、后自减运算符。
```
function f(){
return /*no LineTerminator here*/1;
}
```
```
i/*no LineTerminator here*/++
i/*no LineTerminator here*/--
```
以及throw和Exception之间也不能插入换行符
```
throw/*no LineTerminator here*/new Exception("error")
```
凡是async关键字后面都不能插入换行符
```
async/*no LineTerminator here*/function f(){
}
const f = async/*no LineTerminator here*/x =&gt; x*x
```
箭头函数的箭头前,也不能插入换行:
```
const f = x/*no LineTerminator here*/=&gt; x*x
```
yield之后不能插入换行
```
function *g(){
var i = 0;
while(true)
yield/*no LineTerminator here*/i++;
}
```
到这里我已经整理了所有标准中的no LineTerminator here规则实际上no LineTerminator here规则的存在多数情况是为了保证自动插入分号行为是符合预期的但是令人遗憾的是JavaScript在设计的最初遗漏了一些重要的情况所以有一些不符合预期的情况出现需要我们格外注意。
## 不写分号需要注意的情况
下面我们来看几种不写分号容易造成错误的情况,你可以稍微注意一下,避免发生同样的问题。
### 以括号开头的语句
我们在前面的案例中,已经展示了一种情况,那就是以括号开头的语句:
```
(function(a){
console.log(a);
})()/*这里没有被自动插入分号*/
(function(a){
console.log(a);
})()
```
这段代码看似两个独立执行的函数表达式,但是其实第三组括号被理解为传参,导致抛出错误。
### 以数组开头的语句
除了括号,以数组开头的语句也十分危险:
```
var a = [[]]/*这里没有被自动插入分号*/
[3, 2, 1, 0].forEach(e =&gt; console.log(e))
```
这段代码本意是一个变量a赋值然后对一个数组执行forEach但是因为没有自动插入分号被理解为下标运算符和逗号表达式我这个例子展示的情况甚至不会抛出错误这对于代码排查问题是个噩梦。
### 以正则表达式开头的语句
正则表达式开头的语句也值得你去多注意一下。我们来看这个例子。
```
var x = 1, g = {test:()=&gt;0}, b = 1/*这里没有被自动插入分号*/
/(a)/g.test("abc")
console.log(RegExp.$1)
```
这段代码本意是声明三个变量然后测试一个字符串中是否含有字母a但是因为没有自动插入分号正则的第一个斜杠被理解成了除号后面的意思就都变了。
注意,我构造的这个例子跟上面的例子一样,同样不会抛错,凡是这一类情况,都非常致命。
### 以Template开头的语句
以Template开头的语句比较少见但是跟正则配合时仍然不是不可能出现
```
var f = function(){
return "";
}
var g = f/*这里没有被自动插入分号*/
`Template`.match(/(a)/);
console.log(RegExp.$1)
```
这段代码本意是声明函数f然后赋值给g再测试Template中是否含有字母a。但是因为没有自动插入分号函数f被认为跟Template一体的进而被莫名其妙地执行了一次。
## 总结
这一节课,我们讨论了要不要加分号的问题。
首先我们介绍了自动插入分号机制又对JavaScript语法中的no line terminator规则做了个整理最后我挑选了几种情况为你介绍了不写分号需要注意的一些常见的错误。
最后留给你一个问题,请找一些开源项目,看看它们的编码规范是否要求加分号,欢迎留言讨论。

View File

@@ -0,0 +1,438 @@
<audio id="audio" title="(小实验)理解编译原理:一个四则运算的解释器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/fe/1aec47c521826e8bb2fbec95d5b15cfe.mp3"></audio>
你好我是winter。
在前面的课程中我在JavaScript和CSS的部分多次提到了编译原理相关的知识。这一部分的知识如果我们从编译原理“龙书”等正规的资料中学习就会耗费掉不少的时间所以我在这里设计了一个小实验帮助你快速理解编译原理相关的知识。
今天的内容比较特殊,我们来做一段详细的代码实验,详细的代码我放在了文章里,如果你正在收听音频,可以点击文章查看详情。
## 分析
按照编译原理相关的知识,我们来设计一下工作,这里我们分成几个步骤。
- 定义四则运算:产出四则运算的词法定义和语法定义。
- 词法分析把输入的字符串流变成token。
- 语法分析把token变成抽象语法树AST。
- 解释执行后序遍历AST执行得出结果。
## 定义四则运算
四则运算就是加减乘除四种运算,例如:
```
1 + 2 * 3
```
首先我们来定义词法,四则运算里面只有数字和运算符,所以定义很简单,但是我们还要注意空格和换行符,所以词法定义大概是下面这样的。
<li>Token
<ul>
- Number: `1` `2` `3` `4` `5` `6` `7` `8` `9` `0` 的组合
- Operator: `+``-``*``/` 之一
这里我们对空白和换行符没有任何的处理,所以词法分析阶段会直接丢弃。
接下来我们来定义语法语法定义多数采用BNF但是其实大家写起来都是乱写的比如JavaScript标准里面就是一种跟BNF类似的自创语法。
不过语法定义的核心思想不会变,都是几种结构的组合产生一个新的结构,所以语法定义也叫语法产生式。
因为加减乘除有优先级,所以我们可以认为加法是由若干个乘法再由加号或者减号连接成的:
```
&lt;Expression&gt; ::=
&lt;AdditiveExpression&gt;&lt;EOF&gt;
&lt;AdditiveExpression&gt; ::=
&lt;MultiplicativeExpression&gt;
|&lt;AdditiveExpression&gt;&lt;+&gt;&lt;MultiplicativeExpression&gt;
|&lt;AdditiveExpression&gt;&lt;-&gt;&lt;MultiplicativeExpression&gt;
```
这种BNF的写法类似递归的原理你可以理解一下它表示一个列表。为了方便我们把普通数字也得当成乘法的一种特例了。
```
&lt;MultiplicativeExpression&gt; ::=
&lt;Number&gt;
|&lt;MultiplicativeExpression&gt;&lt;*&gt;&lt;Number&gt;
|&lt;MultiplicativeExpression&gt;&lt;/&gt;&lt;Number&gt;
```
好了,这就是四则运算的定义了。
## 词法分析:状态机
词法分析部分我们把字符流变成token流。词法分析有两种方案一种是状态机一种是正则表达式它们是等效的选择你喜欢的就好这里我都会你介绍一下状态机。
根据分析我们可能产生四种输入元素其中只有两种token我们状态机的第一个状态就是根据第一个输入字符来判断进入了哪种状态
```
var token = [];
const start = char =&gt; {
if(char === '1'
|| char === '2'
|| char === '3'
|| char === '4'
|| char === '5'
|| char === '6'
|| char === '7'
|| char === '8'
|| char === '9'
|| char === '0'
) {
token.push(char);
return inNumber;
}
if(char === '+'
|| char === '-'
|| char === '*'
|| char === '/'
) {
emmitToken(char, char);
return start
}
if(char === ' ') {
return start;
}
if(char === '\r'
|| char === '\n'
) {
return start;
}
}
const inNumber = char =&gt; {
if(char === '1'
|| char === '2'
|| char === '3'
|| char === '4'
|| char === '5'
|| char === '6'
|| char === '7'
|| char === '8'
|| char === '9'
|| char === '0'
) {
token.push(char);
return inNumber;
} else {
emmitToken("Number", token.join(""));
token = [];
return start(char); // put back char
}
}
```
这个状态机非常简单它只有两个状态因为我们只有Number不是单字符的token。
这里我的状态机实现是非常经典的方式用函数表示状态用if表示状态的迁移关系用return值表示下一个状态。
下面我们来运行一下这个状态机试试看:
```
function emmitToken(type, value) {
console.log(value);
}
var input = "1024 + 2 * 256"
var state = start;
for(var c of input.split(''))
state = state(c);
state(Symbol('EOF'))
```
运行后我们发现输出如下:
```
1024
+
2
*
256
```
这是我们想要的答案。
## 语法分析LL
做完了词法分析我们开始进行语法分析LL语法分析根据每一个产生式来写一个函数首先我们来写好函数名
```
function AdditiveExpression( ){
}
function MultiplicativeExpression(){
}
```
为了便于理解,我们就不做流式处理了,实际上一般编译代码都应该支持流式处理。
所以我们假设token已经都拿到了
```
var tokens = [{
type:"Number",
value: "1024"
}, {
type:"+"
value: "+"
}, {
type:"Number",
value: "2"
}, {
type:"*"
value: "*"
}, {
type:"Number",
value: "256"
}, {
type:"EOF"
}];
```
每个产生式对应着一个函数例如根据产生式我们的AdditiveExpression需要处理三种情况
```
&lt;AdditiveExpression&gt; ::=
&lt;MultiplicativeExpression&gt;
|&lt;AdditiveExpression&gt;&lt;+&gt;&lt;MultiplicativeExpression&gt;
|&lt;AdditiveExpression&gt;&lt;-&gt;&lt;MultiplicativeExpression&gt;
```
那么AddititveExpression中就要写三个if分支来处理三种情况。
AdditiveExpression的写法是根传入的节点利用产生式合成新的节点
```
function AdditiveExpression(source){
if(source[0].type === "MultiplicativeExpression") {
let node = {
type:"AdditiveExpression",
children:[source[0]]
}
source[0] = node;
return node;
}
if(source[0].type === "AdditiveExpression" &amp;&amp; source[1].type === "+") {
let node = {
type:"AdditiveExpression",
operator:"+",
children:[source.shift(), source.shift(), MultiplicativeExpression(source)]
}
source.unshift(node);
}
if(source[0].type === "AdditiveExpression" &amp;&amp; source[1].type === "-") {
let node = {
type:"AdditiveExpression",
operator:"-",
children:[source.shift(), source.shift(), MultiplicativeExpression(source)]
}
source.unshift(node);
}
}
```
那么下一步我们就把解析好的token传给我们的顶层处理函数Expression。
```
Expression(tokens);
```
接下来我们看Expression该怎么处理它。
我们Expression收到第一个token是个Number这个时候Expression就傻了这是因为产生式只告诉我们收到了 AdditiveExpression 怎么办。
这个时候我们就需要对产生式的首项层层展开根据所有可能性调用相应的处理函数这个过程在编译原理中称为求“closure”。
```
function Expression(source){
if(source[0].type === "AdditiveExpression" &amp;&amp; source[1] &amp;&amp; source[1].type === "EOF" ) {
let node = {
type:"Expression",
children:[source.shift(), source.shift()]
}
source.unshift(node);
return node;
}
AdditiveExpression(source);
return Expression(source);
}
function AdditiveExpression(source){
if(source[0].type === "MultiplicativeExpression") {
let node = {
type:"AdditiveExpression",
children:[source[0]]
}
source[0] = node;
return AdditiveExpression(source);
}
if(source[0].type === "AdditiveExpression" &amp;&amp; source[1] &amp;&amp; source[1].type === "+") {
let node = {
type:"AdditiveExpression",
operator:"+",
children:[]
}
node.children.push(source.shift());
node.children.push(source.shift());
MultiplicativeExpression(source);
node.children.push(source.shift());
source.unshift(node);
return AdditiveExpression(source);
}
if(source[0].type === "AdditiveExpression" &amp;&amp; source[1] &amp;&amp; source[1].type === "-") {
let node = {
type:"AdditiveExpression",
operator:"-",
children:[]
}
node.children.push(source.shift());
node.children.push(source.shift());
MultiplicativeExpression(source);
node.children.push(source.shift());
source.unshift(node);
return AdditiveExpression(source);
}
if(source[0].type === "AdditiveExpression")
return source[0];
MultiplicativeExpression(source);
return AdditiveExpression(source);
}
function MultiplicativeExpression(source){
if(source[0].type === "Number") {
let node = {
type:"MultiplicativeExpression",
children:[source[0]]
}
source[0] = node;
return MultiplicativeExpression(source);
}
if(source[0].type === "MultiplicativeExpression" &amp;&amp; source[1] &amp;&amp; source[1].type === "*") {
let node = {
type:"MultiplicativeExpression",
operator:"*",
children:[]
}
node.children.push(source.shift());
node.children.push(source.shift());
node.children.push(source.shift());
source.unshift(node);
return MultiplicativeExpression(source);
}
if(source[0].type === "MultiplicativeExpression"&amp;&amp; source[1] &amp;&amp; source[1].type === "/") {
let node = {
type:"MultiplicativeExpression",
operator:"/",
children:[]
}
node.children.push(source.shift());
node.children.push(source.shift());
node.children.push(source.shift());
source.unshift(node);
return MultiplicativeExpression(source);
}
if(source[0].type === "MultiplicativeExpression")
return source[0];
return MultiplicativeExpression(source);
};
var source = [{
type:"Number",
value: "3"
}, {
type:"*",
value: "*"
}, {
type:"Number",
value: "300"
}, {
type:"+",
value: "+"
}, {
type:"Number",
value: "2"
}, {
type:"*",
value: "*"
}, {
type:"Number",
value: "256"
}, {
type:"EOF"
}];
var ast = Expression(source);
console.log(ast);
```
## 解释执行
得到了AST之后最困难的一步我们已经解决了。这里我们就不对这颗树做任何的优化和精简了那么接下来直接进入执行阶段。我们只需要对这个树做遍历操作执行即可。
我们根据不同的节点类型和其它信息写if分别处理即可
```
function evaluate(node) {
if(node.type === "Expression") {
return evaluate(node.children[0])
}
if(node.type === "AdditiveExpression") {
if(node.operator === '-') {
return evaluate(node.children[0]) - evaluate(node.children[2]);
}
if(node.operator === '+') {
return evaluate(node.children[0]) + evaluate(node.children[2]);
}
return evaluate(node.children[0])
}
if(node.type === "MultiplicativeExpression") {
if(node.operator === '*') {
return evaluate(node.children[0]) * evaluate(node.children[2]);
}
if(node.operator === '/') {
return evaluate(node.children[0]) / evaluate(node.children[2]);
}
return evaluate(node.children[0])
}
if(node.type === "Number") {
return Number(node.value);
}
}
```
## 总结
在这个小实验中我们通过一个小实验学习了编译原理的基本知识小实验的目的是帮助你理解JavaScript课程中涉及到的编译原理基本概念它离真正的编译原理学习还有很大的差距。
通过实验,我们了解了产生式、词法分析、语法分析和解释执行的过程。
最后留给你一些挑战,你可以根据自己的水平选择:
- 补全emmitToken使得我们的代码能完整工作起来。
- 为四则运算加入小数。
- 引入负数。
- 添加括号功能。
欢迎写好的同学留言给我。