《You don't know JS》 下(类型文法&异步&ES6与未来)

原作:You-Dont-Know-JS
本文的99.9%的内容都来自《You dont know JS》的电子中文版

传送门:《You don’t know JS》 上(入门&作用域&对象)

类型和文法

内建类型

  • 7种类型
  • 值才有类型,变量没有
  • undefined ≠ is not defined(undeclared)。undefined表示定义却没有赋值的变量类型。然而typeof一个未声明的变量也会返回undefined,这是typeof的安全机制,它给了我们更多空间检查变量是否可用。

由于JS里String的只读性,所有String的相关方法都是返回一个新字符串。

使用二进制浮点数的最出名(臭名昭著)的副作用是(记住,这是对 所有 使用 IEEE 754 的语言都成立的 —— 不是许多人认为/假装 仅 在 JavaScript 中存在的问题):0.1 + 0.2 === 0.3 // false。不过可以用Number.EPSILON做最小误差得到足够精确的近似结果。ES6下已经可以用Number.isInteger()Number.isSafeInteger()检查数字是不是整数/安全整数。

特殊值

undefinednull是JS里比较特殊的两类值,它们既是类型又是唯一的值。更加不幸的是,在非strict模式下,undefined还可以作为标识符,像下面这样:

1
undefined = 2;

另外,在特别需要undefined时,void操作符会变得很有用。

Infinity / Infinity == undefined。

针对一些特殊的等价情况(NaN和-0),ES6使用Object.is()判断其相等性。

值与引用

在JS中没有指针,只有引用,同时页没有语法上的提示可以控制值和引用的赋值/传递。取而代之的是,值的类型用来唯一控制值是通过值拷贝,还是引用拷贝来赋予(复合值)。引用指向的是值本身而不是变量,不能使用一个引用来改变另一个引用所指向的值。

底层的基本标量值是不可变的(String和Boolean也一样)。比如一个Number对象持有一个基本标量值2,那么这个Number对象就永远不能再持有另一个值;你只能用一个不同的值创建一个全新的Number对象:

1
2
3
4
5
6
7
8
9
10
function foo(x) {
x = x + 1;
x; // 3
}

var a = 2;
var b = new Number( a ); // 或等价的 `Object(a)`

foo( b );
console.log( b ); // 2, 不是 3

在其中x = x + 1这一步,包装值内的x被取出+1后,赋值给x,将其从一个引用变成一个基本标量值3。

类型转换

对于最简单的值,JSON字符串化行为基本上和toString()转换是相同的,在对String字符串化时,结果也会包含"",如JSON.stringify("11") // ""11""。另外,对于JSON不安全值(即不能移植到消费JSON的语言中),有下面的处理:

  • 忽略undefinedfunctionsymbol
  • Array中遇到这种类型的值,会被替换为null(避免修改位置信息)
  • Object的属性中遇到时,属性会被简单的忽略掉
  • 带有循环引用时,JSON.stringify()会报错

另外,对于有toJSON()方法的对象,JSON字符串化会优先使用该方法。JSON.stringify()的第二个参数可以指定Array或Function说明可以编辑的对象属性。第三个参数是填充符,填充在各级开头,用来友好展示结果,最多取入参的前10个字符。

在对象上使用toNumbertoString方法,首先会找到其原始类型(toPrimitives()),即使用其valueOf()toString()方法(也会在[[prototype]]上寻找)。

-> Number

可以用Date.now()代替+new Date()获取更好的语义。

~除了可以用来检查-1这个特殊的值,还可以通过~~对小数取整,因为执行位操作时会先将数字转为Int32类型。

parseInt以及parseFloat+Number()强制类型转换存在区别。它们的作用是,从字符串中解析出一个number出来。两者是不能相互替换的。后者是不能容忍非数字字符的。另外,**请在字符串上使用parseIntparseFloat**,这也是它们的设计目的。对非字符串类型使用它们可能得到意外的结果:

1
parseInt( 1/0, 19 ); // 18,惊不惊喜,意不意外

原因是,parseInt会把第一个参数toString(这不能责怪它,因为它本来就是设计对String使用的)。类似的例子还能举出很多:

1
2
3
4
5
6
7
parseInt( 0.000008 );       // 0   ("0" from "0.000008")
parseInt( 0.0000008 ); // 8 ("8" from "8e-7")
parseInt( false, 16 ); // 250 ("fa" from "false")
parseInt( parseInt, 16 ); // 15 ("f" from "function..")

parseInt( "0x10" ); // 16
parseInt( "103", 2 ); // 2

另外,parseInt会通过前缀试图猜测数字进制,默认是10进制。以0x开头表示16进制,以0b开头表示2进制,以0o开头表示8进制。

-> Boolean

使用!!强制转换类型。

&&||在JS中的逻辑和C++以及Java中的不大一样,它并不一定返回boolean类型的值,而是根据比较的两个数判断返回哪一个。其中&&可以用来进行短路操作。

另外,对于Symbol来说,只能通过String()的形式转为String类型,却不能转为Boolean类型。

等价

等价分为=====

StringNumber进行比较时,会对String使用强制类型转换(类似+Number());

在和Boolean比较时,会首先把Boolean类型转为Number类型,再进行比较。这会产生下面这样比较迷惑的情况:

1
2
"42" == true  // false
"42" == false // false

Object和非Object比较时,会先对Object进行toPrimtives,即先使用valueOf()看能否转成基本类型,再使用toString()

下面有一些疯狂的例子,但却可以由上面的规则解释:

1
2
3
4
5
6
7
8
"0" == false    // true
false == [] // true
0 == [] // true
[] == ![]; // true
2 == [2]; // true
"" == [] // true
"" == [null]; // true
0 == "\n" // true

通过上面的坑可以看到,等号的两边总有[]""false0。建议在这些情况使用===

下面是由Alex Dorey(@dorey on GitHub)制作的一个方便的表格,将各种比较进行了可视化:

大小关系比较

首先对值进行toPrimitives转换,如果有一个不是String,则使用Number类型比较。见下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// toNumber
var a = [ 42 ];
var b = [ "43" ];

a < b; // true

// toString
var a = { b: 42 };
var b = { b: 43 };

a < b; // false
a == b; // false
a > b; // false

a <= b; // true
a >= b; // true

在下面的例子里,a和b在比较时,都会转成”[object Object]”。而等价比较上会比较引用是否相同。因此都返回false,而JS中的<=>=操作会去对应计算><的结果再取反,从而会得到两个true

语法

语句和表达式

JS中的表达式都有一个隐式的返回值,但是它只会打印在控制台中,并不会真实返回。块语句的返回值是块中最后一个语句的返回值。ES7中可能会引入do语句显式地使用这个返回值。

JS中的++和C风格类似,表示自增,有前后之分。但是++a++这种用法是不合法的。

赋值表达式的返回是赋予的值,这在链式赋值时很好用:

1
2
var a, b, c;
a = b = c = 42;

这里,c = 42被求值得出42(带有将42赋值给c的副作用),然后b = 42被求值得出42(带有将42赋值给b的副作用),而最后a = 42被求值(带有将42赋值给a的副作用)。

另一种用法是直接将之放在&&||的前后,检查赋值语句的真值。

上下文

{}包裹的内容作为表达式结果可以直接赋值给变量,但是直接声明时会被当做代码块,但是可能仍然是合法的,如:

1
2
3
{
foo: bar()
}

因为,JS中允许使用语句标签,便于breakcontinue跳转(JS中没有goto)。而JSON中带有""的键则不会被这么解释,因为语句标签不允许出现引号。

我们现在来解决下面的问题:

1
2
[] + {}; // "[object Object]"
{} + []; // 0

为什么交换顺序会对+的规则有影响?在第一个式子里一切都是正常的,[]转换成""{}转换成[object Object];在第二个式子里,{}被理解成一个空代码块儿,[]被强制转换为0。

操作符优先级

  • &&优先于||这里有完整的表格。
  • &&||有短接的特点,即当第一个表达式为true或false时直接返回结果。
  • 赋值表达式和三元表达式? : 一样是从右向左结合的

ASI(自动分号)

尽量避免ASI,只在确认没有歧义的地方依赖ASI。

错误

  • JS有早期错误一说,即运行前编译期间的错误
  • let会造成块域内的TDZ(Temporal Dead Zone,时间死区),typeof在此时会报错,而不会返回undefined。TDZ是指变量还没到能使用它的时候,还需要初始化。下面还有一个例子:
    1
    2
    3
    4
    5
    var b = 3;

    function foo( a = 42, b = a + b + 5 ) {
    // ..
    }
  • ES6提供了剩余参数来代替原有的arguments对象,这更加安全。

finally子句

try catch在和finally一起使用时,finally的语句一定会被执行,而且一定会在try语句执行完后立即执行,即使try中有return或者throwcontinue等控制语句。可以在finally中修改try中的结果,但是最后不要这么做,因为会影响程序可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 function foo() {
try {
throw 42;
}
finally {
console.log( "Hello" );
}

console.log( "never runs" );
}

console.log( foo() );
// Hello
// Uncaught Exception: 42

宿主环境

由于浏览器的遗留行为,使用id属性创建DOM元素会创建同名的全局变量。

1
<div id="foo"></div>
1
2
3
4
5
if (typeof foo == "undefined") {
foo = 42; // 永远不会运行
}

console.log( foo ); // HTML元素
  • 永远不要修改内建类型。
  • JS的函数和变量声明提升只在同一<script>标签内

保留字

Let this long package float, Goto private class if short. While protected with debugger case, Continue volatile interface. Instanceof super synchronized throw, Extends final export throws.

Try import double enum?

False, boolean, abstract function, Implements typeof transient break! Void static, default do, Switch int native new. Else, delete null public var In return for const, true, char …Finally catch byte.

来自StackOverflow用户“art4theSould”创造性的一首小诗

另外,在ES6+中,可以使用保留字作为对象字面量中的属性名或键。

异步与性能

JS引擎对时间没有天生的感觉,只是一个任意JS代码段的按需执行环境。是周围的宿主环境在不停地安排“事件”(JS代码的执行)。举例来说,当你的JS程序发起一个从服务器取得数据的Ajax请求时,你在一个函数(通常称为回调)中建立好“应答”代码,然后JS引擎就会告诉宿主环境,“嘿,我就要暂时停止执行了,但不管你什么时候完成了这个网络请求,而且你还得到一些数据的话,请回来调这个函数。”

然后浏览器就会为网络的应答设置一个监听器,当它有东西要交给你的时候,它会通过将回调函数插入事件轮询来安排它的执行。

关于事件轮询队列,之前也有过一些介绍。

异步概览

异步≠并行。异步本质上还是串行的。工作依然有先后之分,没有线程、线程池的概念。从而,在JS中的函数都是原子的,即不会与别的函数的代码相互穿插(除非使用Generator)。

并发

并发是当两个或多个“进程”(或任务)在同一时间段内同时执行,而不管构成它们的每个操作是不是同时进行的。在JS中,单线程事件轮询是并发的一种表达。

不互动

当程序中运行多个“进程”(或任务),如果它们之间没有逻辑联系,那么不互动是完全可以接受的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var res = {};

function foo(results) {
res.foo = results;
}

function bar(results) {
res.bar = results;
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

互动

相反,如果它们之间有依赖关系,或者前后次序而产生互动时,let it alone就会出事。

1
2
3
4
5
6
7
8
9
var res = [];

function response(data) {
res.push( data );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

协调

跳过使用全局变量等丑陋的协作手段,有一种方法,将长时间处理的任务打断成多个小段的请求外加setTimeout,以便将任务穿插完成。

Jobs

ES6在事件轮询队列之上引入了一层新概念,称为“工作队列(Job queue)”。它和轮询队列的关系类似于Macrotask和Microtask。

回调

顺序的大脑

回调不符合正常思维逻辑顺序 & 回调地狱。

信任问题

(本人并不完全赞同)回调遭受着控制反转的蹂躏,它们隐含地将控制权交给第三方(通常第三方工具不受你控制!)来调用你程序的延续。

Promise

Promise的thencatch

可靠的Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var p3 = new Promise( function(resolve,reject){
resolve( "B" );
} );

var p1 = new Promise( function(resolve,reject){
resolve( p3 );
} );

var p2 = new Promise( function(resolve,reject){
resolve( "A" );
} );

p1.then( function(v){
console.log( v );
} );

p2.then( function(v){
console.log( v );
} );

// A B <-- 不是你可能期望的 B A

这是因为p1由p3解析的结果所解析,这个过程是异步地。

作者认为Promise在很大程度上,解决了下面的问题:

  • 调的太早/太晚(本人并不赞同)
  • 根本不调回调(勉强成立),Promise通知状态改变是由编程者自己代码控制的,用resolvereject(用户只能借助外部环境API发起异步操作,resolve一样要么放在传统的回调,要么转交给第三方完成)。
  • 调太少或太多次(成立),一个Promise一旦resolve或者reject,状态就不再发生变化
  • 没能传入任何参数/环境(勉强成立),原因与第二条相同
  • 吞掉所有错误和异常(勉强成立),Promise中在catch字句里捕获异常。

Promise.resolve(p)会把thenable的入参p转换为合法的Promise。这里猜测下这个resolve(p)的实现(个人猜想):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Promise.resolve = thenable => {
// if `thenable` is a promise, just return it
// ...

// if `thenable` is plain, just resolve
if (typeof thenable.then != 'function') {
return new Promise(resolve => {
resolve(thenable);
});
}

return new Promise((resolve, reject) => {
thenable.then(resolve, reject);
});
}

// 一个thenable的例子
const p = {
then(cb, err) {
Math.random < 0.5 ? cb(42) : err("oops! Something bad happens.");
}
}

链式调用

看看RxJS的Introduction。就可以很轻松地理解Promise的流程和链式过程了。

Promise模式

Promise.all()Promise.race()。除了这两个官方钦定的方法外,其他的Promise库还实现了像是any()none()first()last()这样的方法,看看RxJS的operators会有更多选择。

Promise的限制

  • 顺序的错误处理
  • 只能传单一的值(其实就是状态改变不可逆)
  • 单次解析(同上),文章也在惰性的上方提到了观察者模式的RxJS,的确在设计时间概念的领域,RxJS要厉害多了
  • 惰性(生产生产Promise函数的工厂函数)
  • 不可反悔(即不能中途撤销)
  • 性能

Generator

使用同步风格书写异步代码的基础在Generator。关于这部分的更详细介绍见本人之前参考阮一峰大神写的博文

打破运行至完成

generator(生成器)是一个可以和别的代码穿插执行的非原子的特殊函数。使用new构造generator得到的只是一个迭代器,迭代器在执行到yield时会让出执行权。真正执行这个迭代器需要用调用或者执行器的方式。

yield和next是generator可以和外部甚至是其他generator双向通信。但是generator只是声明了自己将要以什么样的形式去执行。还需要一个下面这样的帮助函数去推动它执行:

1
2
3
4
5
6
7
8
9
function step(gen) {
var it = gen();
var last;

return function() {
// 不论`yield`出什么,只管在下一次时直接把它塞回去!
last = it.next( last ).value;
};
}

生成器

  • 可以把generator像状态机一样使用。
  • for of需要迭代器的实现
  • 可以在generator上使用for of
  • 使用return而非next可以终止生成器执行

在异步流程中使用generator

generator的yield暂停特性不仅意味着我们可以从异步的函数调用那里得到看起来同步的return值。

带有promise的generator

在ES6的世界中最棒的就是将generator(看似同步的异步代码)与Promise(可靠性和可组合性)组合起来。

co与koa。

ES7中的await和async

像下面这样,没有run函数,没有生成器函数的*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(x,y) {
return request(
"http://some.url.1/?x=" + x + "&y=" + y
);
}

async function main() {
try {
var text = await foo( 11, 31 );
console.log( text );
}
catch (err) {
console.error( err );
}
}

main();

yield委托

使用yield * foo可以把其他的生成器函数整合进当前生成器中。除了生成器外,还可以委托一个非generator的iterator。错误可以委托,promise可以委托,委托还可以递归。

结合yield可以很方便地协调多个generator

thunk

同步的thunk即包装了所有预设形参的函数执行的函数。异步thunk指需要指定callback的包装所有其他预设形参异步函数的函数。像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 同步thunk
function foo(x,y) {
return x + y;
}

function fooThunk() {
return foo( 3, 4 );
}

// 异步thunk
function foo(x,y,cb) {
setTimeout( function(){
cb( x + y );
}, 1000 );
}

function fooThunk(cb) {
foo( 3, 4, cb );
}

一旦来说会有一个工具thunkify帮你完成制造函数thunk的工作(放心,总会有人这么做的)。它的用法是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// 稍后

fooThunk1( function(sum) {
console.log( sum ); // 7
} );

fooThunk2( function(sum) {
console.log( sum ); // 11
} );

包装函数生产一个thunkory,之后指定除cb以外的其他参数得到thunk函数。

thunk和Promise本质上其实是等价的。只不过是回调所在的为之不一样罢了。所以使用Promise.wrap包装得到的promise还是thunkify包装得到的thunk函数其实都可以yield出来。因为,它们都能通过指定回调来让generator进一步推动下去。

当然了无论是在可组合性还是错误处理上,Promise都有更胜一筹。所以,thunk通常作为替代性的前ES6方案。

前ES6的Generator

当然了Generator也是可以通过其他方式实现的。

性能

Web Worker

近HTML5时代被加入web平台的特性,称为“Web Worker”。这是一个浏览器(也就是宿主环境)特性,而且几乎和JS语言本身没有任何关系。这里简单说了下它和Service Worker的区别。

asm.js

asm.js”是可以被高度优化的JavaScript语言子集的标志。通过小心地回避那些特定的很难优化的(垃圾回收,强制转换,等等)机制和模式,asm.js风格的代码可以被JS引擎识别,而且用主动地底层优化进行特殊的处理。

基准分析(BenchMark)和调优

  • Benchmark.js用统计学的方式避免时间戳测量语句性能时的不准确
  • jsPerf.com基于Benchmark.js的代码性能测试平台

编写好的测试

  • 注意上下文的影响
  • “过早的优化是万恶之源”
  • 尾部调用优化

ES6与未来

ES?现在与未来

  • polyfill与转译

语法

尽管ES6算是JS最新的官方特性,下面说的大部分特性已经被很经常地使用了。

  • 块作用域(之前的部分已经提到过了)
  • 扩散、剩余,...操作符,用在函数入参,数组和对象中
  • 函数默认参数值(是不是很神奇),默认参数值可以是合理的表达式甚至是函数调用
  • 解构赋值,也可以有默认参数值
  • 对象字面量拓展,简约声明/简约方法/getter,setter/计算型属性名/__proto__/super
  • 模板字面量
  • 箭头函数,词法this
  • for of和iterator
  • 正则表达式拓展
    • Unicode标识
    • 粘性标志
  • 数字,八进制
  • Unicode
    • 合理的string长度,String.prototype.normalize()
    • charCodeAt => codePointAt
    • fromCharCode => fromCodePoint
    • Unicode标识符名称
  • Symbol,新的基本类型,它是一个新的包装器对象,可以认为每个EVT_LOGIN持有一个不能被其他任何值所(有意或无意地)重复的值。
    • Symbol.for()先查询是否有一个同名的Symbol,如果有就返回,没有就创建一个

组织

迭代器

迭代器Iterator接口有一个必选接口next(),和两个可选接口return()throw(),它的result被规定为包括属性valuedone,下面是一个数组的迭代:

1
2
3
4
5
6
7
8
9
var arr = [1,2,3];

var it = arr[Symbol.iterator]();

it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }

it.next(); // { value: undefined, done: true }

但通常使用for of就足够了。我们可以依照这个接口,定义一个自己的迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var Fib = {
[Symbol.iterator]() {
var n1 = 1, n2 = 1;

return {
// 使迭代器成为一个可迭代对象
[Symbol.iterator]() { return this; },

next() {
var current = n2;
n2 = n1;
n1 = n1 + current;
return { value: current, done: false };
},

return(v) {
console.log(
"Fibonacci sequence abandoned."
);
return { value: v, done: true };
}
};
}
};

Generator

这个上一章已经提到了。它可以用来:

  • 生产一系列值,即状态机
  • 串行执行的任务队列,化异步同步

模块

importexport

  • ES6引入了元属性的概念,用new.target表示。在任意的构造器中,new.target总是指向new实际直接调用的构造器。

集合

ArrayBuffer

它表示一组比特位,但是这些比特的实际意义是由结构化数组控制的,由它表示这些比特上的“视图”究竟是8位有符号整数还是字符串。

1
2
3
4
var buf = new ArrayBuffer( 32 );
buf.byteLength; // 32字节
var arr = new Uint16Array( buf );
arr.length; // 16

一个单独的缓冲可以连接多个视图

1
2
3
4
5
6
7
8
var buf = new ArrayBuffer( 2 );

var view8 = new Uint8Array( buf );
var view16 = new Uint16Array( buf );

view16[0] = 3085;
view8[0]; // 13
view8[1]; // 12

在ES6中可以使用下面的类型化数组构造器:

  • Int8Array(8位有符号整数),Uint8Array(8位无符号整数)
  • Uint8ClampedArray(8位无符号整数,每个值都被卡在0 - 255范围内)
  • Int16Array(16位有符号整数),Uint16Array(16位无符号整数)
  • Int32Array(32位有符号整数),Uint32Array(32位无符号整数)
  • Float32Array(32位浮点数,IEEE-754)
  • Float64Array(64位浮点数,IEEE-754)

Maps

摆脱对象只能使用字符串做键值的限制。有getsetdeletehasclear等方法。类似地还有WeakMap,不过它只能使用对象做键。

Sets

一个集合。类似Map,不过set换成了add,且没有get。Set和Map都有自己的迭代器。也可以通过keysvaluesentries来访问里面的内容。

新增API & 元编程

略,参考原文

ES6以后

  • asnyc function
  • Object.observe
  • 指数运算符**
  • Array#includes替代~Array.indexOf(value)
  • SIMD(多个数据),用于多个元素的并行数学操作,参考下面
    1
    2
    3
    4
    5
    var v1 = SIMD.float32x4( 3.14159, 21.0, 32.3, 55.55 );
    var v2 = SIMD.float32x4( 2.1, 3.2, 4.3, 5.4 );

    SIMD.float32x4.mul( v1, v2 );
    // [ 6.597339, 67.2, 138.89, 299.97 ]
  • WASM(Web Assembly)

-END-