ECMAScript 6.0(以下简称ES6)就是 ECMA 在 2015 年推出的 JavaScript 语言的下一代标准。

兼容程度

各大浏览器的最新版本,对ES6的支持可以查看 kangax.github.io/es5-compat-table/es6/

Node.js是JavaScript语言的服务器运行环境,对ES6的支持度比浏览器更高。通过Node,可以体验更多ES6的特性。建议使用版本管理工具nvm,来安装Node,因为可以自由切换版本。

使用下面的命令,可以查看Node所有已经实现的ES6特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ node --v8-options | grep harmony

--harmony_typeof
--harmony_scoping
--harmony_modules
--harmony_symbols
--harmony_proxies
--harmony_collections
--harmony_observation
--harmony_generators
--harmony_iteration
--harmony_numeric_literals
--harmony_strings
--harmony_arrays
--harmony_maths
--harmony

Babel是一个广泛使用的ES6转码器,可以将ES6代码转为ES5代码,从而在现有环境执行。

Facebook 的 React Native 同样使用 babel ,以支持 ES5、ES6、ES7 的部分语法特性。见 React Native - JavaScript Runtime

let 和 const

let

ES6新增了let命令,用来声明变量。它的用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效。

1
2
3
4
5
6
7
{
let a = 10;
var b = 1;
}

a // ReferenceError: a is not defined.
b // 1

上面代码在代码块之中,分别用let和var声明了两个变量。然后在代码块之外调用这两个变量,结果let声明的变量报错,var声明的变量返回了正确的值。这表明,let声明的变量只在它所在的代码块有效。

for循环的计数器,就很合适使用let命令。

1
2
3
4
for (let i = 0; i < arr.length; i++) {}

console.log(i);
//ReferenceError: i is not defined

上面代码的计数器i,只在for循环体内有效。

下面的代码如果使用var,最后输出的是10。

1
2
3
4
5
6
7
var a = [];
for (var i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 10

上面代码中,变量i是var声明的,在全局范围内都有效。所以每一次循环,新的i值都会覆盖旧值,导致最后输出的是最后一轮的i的值。

如果使用let,声明的变量仅在块级作用域内有效,最后输出的是6。

1
2
3
4
5
6
7
var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 6

上面代码中,变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。

不存在变量提升

let不像var那样会发生“变量提升”现象。所以,变量一定要在声明后使用,否则报错。

1
2
3
4
5
console.log(foo); // 输出undefined
console.log(bar); // 报错ReferenceError

var foo = 2;
let bar = 2;

上面代码中,变量foo用var命令声明,会发生变量提升,即脚本开始运行时,变量foo已经存在了,但是没有值,所以会输出undefined。变量bar用let命令声明,不会发生变量提升。这表示在声明它之前,变量bar是不存在的,这时如果用到它,就会抛出一个错误。

暂时性死区

只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

1
2
3
4
5
6
var tmp = 123;

if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}

上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。

ES6明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称TDZ)。

1
2
3
4
5
6
7
8
9
10
11
if (true) {
// TDZ开始
tmp = 'abc'; // ReferenceError
console.log(tmp); // ReferenceError

let tmp; // TDZ结束
console.log(tmp); // undefined

tmp = 123;
console.log(tmp); // 123
}

上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。

“暂时性死区”也意味着typeof不再是一个百分之百安全的操作。

1
2
typeof x; // ReferenceError
let x;

上面代码中,变量x使用let命令声明,所以在声明之前,都属于x的“死区”,只要用到该变量就会报错。因此,typeof运行时就会抛出一个ReferenceError。

作为比较,如果一个变量根本没有被声明,使用typeof反而不会报错。

1
typeof undeclared_variable // "undefined"

上面代码中,undeclared_variable是一个不存在的变量名,结果返回“undefined”。所以,在没有let之前,typeof运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。

有些“死区”比较隐蔽,不太容易发现。

1
2
3
4
5
function bar(x = y, y = 2) {
return [x, y];
}

bar(); // 报错

上面代码中,调用bar函数之所以报错(某些实现可能不报错),是因为参数x默认值等于另一个参数y,而此时y还没有声明,属于”死区“。如果y的默认值是x,就不会报错,因为此时x已经声明了。

1
2
3
4
function bar(x = 2, y = x) {
return [x, y];
}
bar(); // [2, 2]

ES6规定暂时性死区和let、const语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在ES5是很常见的,现在有了这种规定,避免此类错误就很容易了。 总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

不允许重复声明

let不允许在相同作用域内,重复声明同一个变量。

1
2
3
4
5
6
7
8
9
10
11
// 报错
function () {
let a = 10;
var a = 1;
}

// 报错
function () {
let a = 10;
let a = 1;
}

因此,不能在函数内部重新声明参数。

1
2
3
4
5
6
7
8
9
function func(arg) {
let arg; // 报错
}

function func(arg) {
{
let arg; // 不报错
}
}

块级作用域

let实际上为JavaScript新增了块级作用域。

1
2
3
4
5
6
7
function f1() {
let n = 5;
if (true) {
let n = 10;
}
console.log(n); // 5
}

上面的函数有两个代码块,都声明了变量n,运行后输出5。这表示外层代码块不受内层代码块的影响。如果使用var定义变量n,最后输出的值就是10。

ES6允许块级作用域的任意嵌套。

1
{{{{{let insane = 'Hello World'}}}}};

上面代码使用了一个五层的块级作用域。外层作用域无法读取内层作用域的变量。

1
2
3
4
5
{{{{
{let insane = 'Hello World'}
console.log(insane); // 报错
}}}};

内层作用域可以定义外层作用域的同名变量。

1
2
3
4
{{{{
let insane = 'Hello World';
{let insane = 'Hello World'}
}}}};

块级作用域的出现,实际上使得获得广泛应用的立即执行匿名函数(IIFE)不再必要了。

1
2
3
4
5
6
7
8
9
10
11
// IIFE写法
(function () {
var tmp = ...;
...
}());

// 块级作用域写法
{
let tmp = ...;
...
}

const命令

const声明一个只读的常量。一旦声明,常量的值就不能改变。

1
2
3
4
5
const PI = 3.1415;
PI // 3.1415

PI = 3;
// TypeError: Assignment to constant variable.

上面代码表明改变常量的值会报错。

const声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。

1
2
const foo;
// SyntaxError: Missing initializer in const declaration

上面代码表示,对于const来说,只声明不赋值,就会报错。

const的作用域与let命令相同:只在声明所在的块级作用域内有效。

1
2
3
4
5
6
if (true) {
const MAX = 5;
}


MAX // Uncaught ReferenceError: MAX is not defined

const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。

1
2
3
4
if (true) {
console.log(MAX); // ReferenceError
const MAX = 5;
}

上面代码在常量MAX声明之前就调用,结果报错。

const声明的常量,也与let一样不可重复声明。

1
2
3
4
5
6
var message = "Hello!";
let age = 25;

// 以下两行都会报错
const message = "Goodbye!";
const age = 30;

对于复合类型的变量,变量名不指向数据,而是指向数据所在的地址。const命令只是保证变量名指向的地址不变,并不保证该地址的数据不变,所以将一个对象声明为常量必须非常小心

1
2
3
4
5
6
7
const foo = {};
foo.prop = 123;

foo.prop
// 123

foo = {}; // TypeError: "foo" is read-only

上面代码中,常量foo储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。

下面是另一个例子。

1
2
3
4
const a = [];
a.push('Hello'); // 可执行
a.length = 0; // 可执行
a = ['Dave']; // 报错

上面代码中,常量a是一个数组,这个数组本身是可写的,但是如果将另一个数组赋值给a,就会报错。

如果真的想将对象冻结,应该使用Object.freeze方法。
1
2
3
4
5
const foo = Object.freeze({});

// 常规模式时,下面一行不起作用;
// 严格模式时,该行会报错
foo.prop = 123;

上面代码中,常量foo指向一个冻结的对象,所以添加新属性不起作用,严格模式时还会报错。

除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。

1
2
3
4
5
6
7
8
var constantize = (obj) => {
Object.freeze(obj);
Object.keys(obj).forEach( (key, value) => {
if ( typeof obj[key] === 'object' ) {
constantize( obj[key] );
}
});
};

ES5之前只有两种声明变量的方法:var命令和function命令。ES6除了添加let和const命令,后面章节还会提到,另外两种声明变量的方法:import命令和class命令。所以,ES6一共有6种声明变量的方法。

全局对象的属性

全局对象是最顶层的对象,在浏览器环境指的是window对象,在Node.js指的是global对象。ES5之中,全局对象的属性与全局变量是等价的。

1
2
3
4
5
window.a = 1;
a // 1

a = 2;
window.a // 2

上面代码中,全局对象的属性赋值与全局变量的赋值,是同一件事。(对于Node来说,这一条只对REPL环境适用,模块环境之中,全局变量必须显式声明成global对象的属性。)

未声明的全局变量,自动成为全局对象window的属性,这被认为是JavaScript语言最大的设计败笔之一。这样的设计带来了两个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道,其次程序员很容易不知不觉地就创建了全局变量(比如打字出错)。另一方面,从语义上讲,语言的顶层对象是一个有实体含义的对象,也是不合适的。

ES6为了改变这一点,一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是全局对象的属性;另一方面规定,let命令、const命令、class命令声明的全局变量,不属于全局对象的属性。也就是说,从ES6开始,全局变量将逐步与全局对象的属性脱钩。

1
2
3
4
5
6
7
var a = 1;
// 如果在Node的REPL环境,可以写成global.a
// 或者采用通用方法,写成this.a
window.a // 1

let b = 1;
window.b // undefined

上面代码中,全局变量a由var命令声明,所以它是全局对象的属性;全局变量b由let命令声明,所以它不是全局对象的属性,返回undefined。

变量的解构赋值

数组的解构赋值

基本用法

ES6允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

以前,为变量赋值,只能直接指定值。

1
2
3
var a = 1;
var b = 2;
var c = 3;

ES6允许写成下面这样。

1
var [a, b, c] = [1, 2, 3];

上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。

本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let [foo, [[bar], baz]] = [1, [[2], 3]];
foo // 1
bar // 2
baz // 3

let [ , , third] = ["foo", "bar", "baz"];
third // "baz"

let [x, , y] = [1, 2, 3];
x // 1
y // 3

let [head, ...tail] = [1, 2, 3, 4];
head // 1
tail // [2, 3, 4]

let [x, y, ...z] = ['a'];
x // "a"
y // undefined
z // []

如果解构不成功,变量的值就等于undefined。

1
2
var [foo] = [];
var [bar, foo] = [1];

以上两种情况都属于解构不成功,foo的值都会等于undefined。

另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。

1
2
3
4
5
6
7
8
let [x, y] = [1, 2, 3];
x // 1
y // 2

let [a, [b], d] = [1, [2, 3], 4];
a // 1
b // 2
d // 4

上面两个例子,都属于不完全解构,但是可以成功。

如果等号的右边不是数组(或者严格地说,不是可遍历的结构),那么将会报错。

1
2
3
4
5
6
7
// 报错
let [foo] = 1;
let [foo] = false;
let [foo] = NaN;
let [foo] = undefined;
let [foo] = null;
let [foo] = {};

上面的表达式都会报错,因为等号右边的值,要么转为对象以后不具备Iterator接口(前五个表达式),要么本身就不具备Iterator接口(最后一个表达式)。

解构赋值不仅适用于var命令,也适用于let和const命令。

1
2
3
var [v1, v2, ..., vN ] = array;
let [v1, v2, ..., vN ] = array;
const [v1, v2, ..., vN ] = array;

对于Set结构,也可以使用数组的解构赋值。

1
2
let [x, y, z] = new Set(["a", "b", "c"]);
x // "a"

事实上,只要某种数据结构具有Iterator接口,都可以采用数组形式的解构赋值。

1
2
3
4
5
6
7
8
9
10
11
function* fibs() {
var a = 0;
var b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}

var [first, second, third, fourth, fifth, sixth] = fibs();
sixth // 5

上面代码中,fibs是一个Generator函数,原生具有Iterator接口。解构赋值会依次从这个接口获取值。

注意,ES6内部使用严格相等运算符(===),判断一个位置是否有值。所以,如果一个数组成员不严格等于undefined,默认值是不会生效的。

1
2
3
4
5
var [x = 1] = [undefined];
x // 1

var [x = 1] = [null];
x // null

上面代码中,如果一个数组成员是null,默认值就不会生效,因为null不严格等于undefined。

如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。

1
2
3
4
5
function f() {
console.log('aaa');
}

let [x = f()] = [1];

上面代码中,因为x能取到值,所以函数f根本不会执行。上面的代码其实等价于下面的代码。

1
2
3
4
5
6
let x;
if ([1][0] === undefined) {
x = f();
} else {
x = [1][0];
}

默认值可以引用解构赋值的其他变量,但该变量必须已经声明。

1
2
3
4
let [x = 1, y = x] = [];     // x=1; y=1
let [x = 1, y = x] = [2]; // x=2; y=2
let [x = 1, y = x] = [1, 2]; // x=1; y=2
let [x = y, y = 1] = []; // ReferenceError

上面最后一个表达式之所以会报错,是因为x用到默认值y时,y还没有声明。

对象的解构赋值

解构不仅可以用于数组,还可以用于对象。

1
2
3
var { foo, bar } = { foo: "aaa", bar: "bbb" };
foo // "aaa"
bar // "bbb"

对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。

1
2
3
4
5
6
var { bar, foo } = { foo: "aaa", bar: "bbb" };
foo // "aaa"
bar // "bbb"

var { baz } = { foo: "aaa", bar: "bbb" };
baz // undefined

上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined。

1
2
3
4
5
6
7
var { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"

let obj = { first: 'hello', last: 'world' };
let { first: f, last: l } = obj;
f // 'hello'
l // 'world'

如果变量名与属性名不一致,必须写成下面这样。

这实际上说明,对象的解构赋值是下面形式的简写。

1
var { foo: foo, bar: bar } = { foo: "aaa", bar: "bbb" };

也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。

1
2
3
var { foo: baz } = { foo: "aaa", bar: "bbb" };
baz // "aaa"
foo // error: foo is not defined

上面代码中,真正被赋值的是变量baz,而不是模式foo。

注意,采用这种写法时,变量的声明和赋值是一体的。对于let和const来说,变量不能重新声明,所以一旦赋值的变量以前声明过,就会报错。

1
2
3
4
5
let foo;
let {foo} = {foo: 1}; // SyntaxError: Duplicate declaration "foo"

let baz;
let {bar: baz} = {bar: 1}; // SyntaxError: Duplicate declaration "baz"

上面代码中,解构赋值的变量都会重新声明,所以报错了。不过,因为var命令允许重新声明,所以这个错误只会在使用let和const命令时出现。如果没有第二个let命令,上面的代码就不会报错。

1
2
3
4
5
let foo;
({foo} = {foo: 1}); // 成功

let baz;
({bar: baz} = {bar: 1}); // 成功

上面代码中,let命令下面一行的圆括号是必须的,否则会报错。因为解析器会将起首的大括号,理解成一个代码块,而不是赋值语句。

和数组一样,解构也可以用于嵌套结构的对象。

1
2
3
4
5
6
7
8
9
10
var obj = {
p: [
'Hello',
{ y: 'World' }
]
};

var { p: [x, { y }] } = obj;
x // "Hello"
y // "World"

注意,这时p是模式,不是变量,因此不会被赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
var node = {
loc: {
start: {
line: 1,
column: 5
}
}
};

var { loc: { start: { line }} } = node;
line // 1
loc // error: loc is undefined
start // error: start is undefined

上面代码中,只有line是变量,loc和start都是模式,不会被赋值。

下面是嵌套赋值的例子。

1
2
3
4
5
6
7
let obj = {};
let arr = [];

({ foo: obj.prop, bar: arr[0] } = { foo: 123, bar: true });

obj // {prop:123}
arr // [true]

对象的解构也可以指定默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var {x = 3} = {};
x // 3

var {x, y = 5} = {x: 1};
x // 1
y // 5

var {x:y = 3} = {};
y // 3

var {x:y = 3} = {x: 5};
y // 5

var { message: msg = 'Something went wrong' } = {};
msg // "Something went wrong"

默认值生效的条件是,对象的属性值严格等于undefined。

1
2
3
4
5
var {x = 3} = {x: undefined};
x // 3

var {x = 3} = {x: null};
x // null

上面代码中,如果x属性等于null,就不严格相等于undefined,导致默认值不会生效。

如果解构失败,变量的值等于undefined。

1
2
var {foo} = {bar: 'baz'};
foo // undefined

如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。

1
2
// 报错
var {foo: {bar}} = {baz: 'baz'};

上面代码中,等号左边对象的foo属性,对应一个子对象。该子对象的bar属性,解构时会报错。原因很简单,因为foo这时等于undefined,再取子属性就会报错,请看下面的代码。

1
2
var _tmp = {baz: 'baz'};
_tmp.foo.bar // 报错

如果要将一个已经声明的变量用于解构赋值,必须非常小心。

1
2
3
4
// 错误的写法
var x;
{x} = {x: 1};
// SyntaxError: syntax error

上面代码的写法会报错,因为JavaScript引擎会将{x}理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免JavaScript将其解释为代码块,才能解决这个问题。

1
2
// 正确的写法
({x} = {x: 1});

上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。关于圆括号与解构赋值的关系,参见下文

解构赋值允许,等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。

1
2
3
({} = [true, false]);
({} = 'abc');
({} = []);

上面的表达式虽然毫无意义,但是语法是合法的,可以执行。

对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。

1
let { log, sin, cos } = Math;

上面代码将Math对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。

由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。

1
2
3
4
var arr = [1, 2, 3];
var {0 : first, [arr.length - 1] : last} = arr;
first // 1
last // 3

上面代码对数组进行对象结构。数组arr的0键对应的值是1,[arr.length - 1] 就是2键,对应的值是3。

字符串的解构赋值

字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。

1
2
3
4
5
6
const [a, b, c, d, e] = 'hello';
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"

类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值。

1
2
let {length : len} = 'hello';
len // 5

数值和布尔值的解构赋值

解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。

1
2
3
4
5
let {toString: s} = 123;
s === Number.prototype.toString // true

let {toString: s} = true;
s === Boolean.prototype.toString // true

上面代码中,数值和布尔值的包装对象都有toString属性,因此变量s都能取到值。

解构赋值的规则是,只要等号右边的值不是对象,就先将其转为对象。由于undefined和null无法转为对象,所以对它们进行解构赋值,都会报错。

1
2
let { prop: x } = undefined; // TypeError
let { prop: y } = null; // TypeError

函数参数的解构赋值

函数的参数也可以使用解构赋值。

1
2
3
4
5
function add([x, y]){
return x + y;
}

add([1, 2]); // 3

上面代码中,函数add的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x和y。对于函数内部的代码来说,它们能感受到的参数就是x和y。

下面是另一个例子。

1
2
[[1, 2], [3, 4]].map(([a, b]) => a + b);
// [ 3, 7 ]

函数参数的解构也可以使用默认值。

1
2
3
4
5
6
7
8
function move({x = 0, y = 0} = {}) {
return [x, y];
}

move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, 0]
move({}); // [0, 0]
move(); // [0, 0]

上面代码中,函数move的参数是一个对象,通过对这个对象进行解构,得到变量x和y的值。如果解构失败,x和y等于默认值。

注意,下面的写法会得到不一样的结果。

1
2
3
4
5
6
7
8
function move({x, y} = { x: 0, y: 0 }) {
return [x, y];
}

move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, undefined]
move({}); // [undefined, undefined]
move(); // [0, 0]

上面代码是为函数move的参数指定默认值,而不是为变量x和y指定默认值,所以会得到与前一种写法不同的结果。

圆括号问题

解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。

由此带来的问题是,如果模式中出现圆括号怎么处理。ES6的规则是,只要有可能导致解构的歧义,就不得使用圆括号。

但是,这条规则实际上不那么容易辨别,处理起来相当麻烦。因此,建议只要有可能,就不要在模式中放置圆括号

不能使用圆括号的情况

以下三种解构赋值不得使用圆括号。

(1)变量声明语句中,不能带有圆括号。

1
2
3
4
5
6
7
8
9
// 全部报错
var [(a)] = [1];

var {x: (c)} = {};
var ({x: c}) = {};
var {(x: c)} = {};
var {(x): c} = {};

var { o: ({ p: p }) } = { o: { p: 2 } };

上面三个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

(2)函数参数中,模式不能带有圆括号。

函数参数也属于变量声明,因此不能带有圆括号。

1
2
// 报错
function f([(z)]) { return z; }

(3)赋值语句中,不能将整个模式,或嵌套模式中的一层,放在圆括号之中。

1
2
3
// 全部报错
({ p: a }) = { p: 42 };
([a]) = [5];

上面代码将整个模式放在圆括号之中,导致报错。

1
2
// 报错
[({ p: a }), { x: c }] = [{}, {}];

上面代码将嵌套模式的一层,放在圆括号之中,导致报错。

可以使用圆括号的情况

可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。

1
2
3
[(b)] = [3]; // 正确
({ p: (d) } = {}); // 正确
[(parseInt.prop)] = [3]; // 正确

上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p,而不是d;第三行语句与第一行语句的性质一致。

用途

变量的解构赋值用途很多。

(1)交换变量的值

1
[x, y] = [y, x];

上面代码交换变量x和y的值,这样的写法不仅简洁,而且易读,语义非常清晰。

(2)从函数返回多个值

函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 返回一个数组

function example() {
return [1, 2, 3];
}
var [a, b, c] = example();

// 返回一个对象

function example() {
return {
foo: 1,
bar: 2
};
}
var { foo, bar } = example();

(3)函数参数的定义

解构赋值可以方便地将一组参数与变量名对应起来。

1
2
3
4
5
6
7
// 参数是一组有次序的值
function f([x, y, z]) { ... }
f([1, 2, 3]);

// 参数是一组无次序的值
function f({x, y, z}) { ... }
f({z: 3, y: 2, x: 1});

(4)提取JSON数据

解构赋值对提取JSON对象中的数据,尤其有用。

1
2
3
4
5
6
7
8
9
10
var jsonData = {
id: 42,
status: "OK",
data: [867, 5309]
};

let { id, status, data: number } = jsonData;

console.log(id, status, number);
// 42, "OK", [867, 5309]

上面代码可以快速提取JSON数据的值。

(5)函数参数的默认值

1
2
3
4
5
6
7
8
9
10
11
jQuery.ajax = function (url, {
async = true,
beforeSend = function () {},
cache = true,
complete = function () {},
crossDomain = false,
global = true,
// ... more config
}) {
// ... do stuff
};

指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || ‘default foo’;这样的语句。

(6)遍历Map结构

任何部署了Iterator接口的对象,都可以用for…of循环遍历。Map结构原生支持Iterator接口,配合变量的解构赋值,获取键名和键值就非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var map = new Map();
map.set('first', 'hello');
map.set('second', 'world');

for (let [key, value] of map) {
console.log(key + " is " + value);
}
// first is hello
// second is world
如果只想获取键名,或者只想获取键值,可以写成下面这样。

// 获取键名
for (let [key] of map) {
// ...
}

// 获取键值
for (let [,value] of map) {
// ...
}

(7)输入模块的指定方法

加载模块时,往往需要指定输入那些方法。解构赋值使得输入语句非常清晰。

1
const { SourceMapConsumer, SourceNode } = require("source-map");

语法扩展

字符串的扩展

字符的Unicode表示法

codePointAt()

String.fromCodePoint()

字符串的遍历器接口

at()

normalize()

includes(), startsWith(), endsWith()

repeat()

padStart(),padEnd()

模板字符串

标签模板

String.raw()

正则的扩展

RegExp构造函数

字符串的正则方法

u修饰符

y修饰符

sticky属性

flags属性

RegExp.escape()

后行断言

数值的扩展

二进制和八进制表示法

Number.isFinite(), Number.isNaN()

Number.parseInt(), Number.parseFloat()

Number.isInteger()

Number.EPSILON

安全整数和Number.isSafeInteger()

Math对象的扩展

指数运算符

数组的扩展

函数的扩展

默认值

ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。

1
2
3
4
5
6
7
function log(x, y = 'World') {
console.log(x, y);
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello
与解构赋值默认值结合使用

参数默认值可以与解构赋值的默认值,结合起来使用。

1
2
3
4
5
6
7
8
function foo({x, y = 5}) {
console.log(x, y);
}

foo({}) // undefined, 5
foo({x: 1}) // 1, 5
foo({x: 1, y: 2}) // 1, 2
foo() // TypeError: Cannot read property 'x' of undefined

上面代码使用了对象的解构赋值默认值,而没有使用函数参数的默认值。只有当函数foo的参数是一个对象时,变量x和y才会通过解构赋值而生成。如果函数foo调用时参数不是对象,变量x和y就不会生成,从而报错。如果参数对象没有y属性,y的默认值5才会生效。

函数的length属性

指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。

1
2
3
(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 5) {}).length // 2

上面代码中,length属性的返回值,等于函数的参数个数减去指定了默认值的参数个数。比如,上面最后一个函数,定义了3个参数,其中有一个参数c指定了默认值,因此length属性等于3减去1,最后得到2。

这是因为length属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest参数也不会计入length属性。

1
(function(...args) {}).length // 0

如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。

1
2
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1
应用

利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。

1
2
3
4
5
6
7
8
9
10
function throwIfMissing() {
throw new Error('Missing parameter');
}

function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}

foo()
// Error: Missing parameter
rest 参数

ES6引入rest参数(形式为“…变量名”),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

1
2
3
4
5
6
7
8
9
10
11
function add(...values) {
let sum = 0;

for (var val of values) {
sum += val;
}

return sum;
}

add(2, 5, 3) // 10

上面代码的add函数是一个求和函数,利用rest参数,可以向该函数传入任意数目的参数。

下面是一个rest参数代替arguments变量的例子。

1
2
3
4
5
6
7
// arguments变量的写法
function sortNumbers() {
return Array.prototype.slice.call(arguments).sort();
}

// rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();

上面代码的两种写法,比较后可以发现,rest参数的写法更自然也更简洁。

rest参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。下面是一个利用rest参数改写数组push方法的例子。

1
2
3
4
5
6
7
8
9
function push(array, ...items) {
items.forEach(function(item) {
array.push(item);
console.log(item);
});
}

var a = [];
push(a, 1, 2, 3)

注意,rest参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。

1
2
3
4
5
6
7
8
9
// 报错
function f(a, ...b, c) {
// ...
}
函数的length属性,不包括rest参数。

(function(a) {}).length // 1
(function(...a) {}).length // 0
(function(a, ...b) {}).length // 1

扩展运算符

含义

扩展运算符(spread)是三个点(…)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。

1
2
3
4
5
6
7
8
console.log(...[1, 2, 3])
// 1 2 3

console.log(1, ...[2, 3, 4], 5)
// 1 2 3 4 5

[...document.querySelectorAll('div')]
// [<div>, <div>, <div>]

该运算符主要用于函数调用。

1
2
3
4
5
6
7
8
9
10
function push(array, ...items) {
array.push(...items);
}

function add(x, y) {
return x + y;
}

var numbers = [4, 38];
add(...numbers) // 42

上面代码中,array.push(…items)和add(…numbers)这两行,都是函数的调用,它们的都使用了扩展运算符。该运算符将一个数组,变为参数序列。

扩展运算符与正常的函数参数可以结合使用,非常灵活。

1
2
3
function f(v, w, x, y, z) { }
var args = [0, 1];
f(-1, ...args, 2, ...[3]);

name 属性

函数的name属性,返回该函数的函数名。

1
2
function foo() {}
foo.name // "foo"

这个属性早就被浏览器广泛支持,但是直到ES6,才将其写入了标准。

需要注意的是,ES6对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5的name属性,会返回空字符串,而ES6的name属性会返回实际的函数名。

1
2
3
4
5
6
7
var func1 = function () {};

// ES5
func1.name // ""

// ES6
func1.name // "func1"

上面代码中,变量func1等于一个匿名函数,ES5和ES6的name属性返回的值不一样。

如果将一个具名函数赋值给一个变量,则ES5和ES6的name属性都返回这个具名函数原本的名字。

1
2
3
4
5
6
7
const bar = function baz() {};

// ES5
bar.name // "baz"

// ES6
bar.name // "baz"

Function构造函数返回的函数实例,name属性的值为“anonymous”。

1
(new Function).name // "anonymous"

bind返回的函数,name属性值会加上“bound ”前缀。

1
2
3
4
function foo() {};
foo.bind({}).name // "bound foo"

(function(){}).bind({}).name // "bound "

箭头函数

ES6允许使用“箭头”(=>)定义函数。

1
var f = v => v;

上面的箭头函数等同于:

1
2
3
var f = function(v) {
return v;
};

如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。

1
2
3
4
5
6
7
8
9
var f = () => 5;
// 等同于
var f = function () { return 5 };

var sum = (num1, num2) => num1 + num2;
// 等同于
var sum = function(num1, num2) {
return num1 + num2;
};

如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return语句返回。

1
var sum = (num1, num2) => { return num1 + num2; }

由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号。

1
var getTempItem = id => ({ id: id, name: "Temp" });

箭头函数可以与变量解构结合使用。

1
2
3
4
5
6
const full = ({ first, last }) => first + ' ' + last;

// 等同于
function full(person) {
return person.first + ' ' + person.last;
}

箭头函数使得表达更加简洁。

1
2
const isEven = n => n % 2 == 0;
const square = n => n * n;

上面代码只用了两行,就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。

箭头函数的一个用处是简化回调函数。

1
2
3
4
5
6
7
// 正常函数写法
[1,2,3].map(function (x) {
return x * x;
});

// 箭头函数写法
[1,2,3].map(x => x * x);

另一个例子是

1
2
3
4
5
6
7
// 正常函数写法
var result = values.sort(function (a, b) {
return a - b;
});

// 箭头函数写法
var result = values.sort((a, b) => a - b);

下面是rest参数与箭头函数结合的例子。

1
2
3
4
5
6
7
8
9
const numbers = (...nums) => nums;

numbers(1, 2, 3, 4, 5)
// [1,2,3,4,5]

const headAndTail = (head, ...tail) => [head, tail];

headAndTail(1, 2, 3, 4, 5)
// [1,[2,3,4,5]]
使用注意点

箭头函数有几个使用注意点。

  1. 函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象。
  2. 不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。
  3. 不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用Rest参数代替。
  4. 不可以使用yield命令,因此箭头函数不能用作Generator函数。

上面四点中,第一点尤其值得注意。this对象的指向是可变的,但是在箭头函数中,它是固定的。

1
2
3
4
5
6
7
8
9
10
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

var id = 21;

foo.call({ id: 42 });
// id: 42

上面代码中,setTimeout的参数是一个箭头函数,这个箭头函数的定义生效是在foo函数生成时,而它的真正执行要等到100毫秒后。如果是普通函数,执行时this应该指向全局对象window,这时应该输出21。但是,箭头函数导致this总是指向函数定义生效时所在的对象(本例是{id: 42}),所以输出的是42。

箭头函数可以让setTimeout里面的this,绑定定义时所在的作用域,而不是指向运行时所在的作用域。下面是另一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Timer() {
this.s1 = 0;
this.s2 = 0;
// 箭头函数
setInterval(() => this.s1++, 1000);
// 普通函数
setInterval(function () {
this.s2++;
}, 1000);
}

var timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);
// s1: 3
// s2: 0

上面代码中,Timer函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this绑定定义时所在的作用域(即Timer函数),后者的this指向运行时所在的作用域(即全局对象)。所以,3100毫秒之后,timer.s1被更新了3次,而timer.s2一次都没更新。

箭头函数可以让this指向固定化,这种特性很有利于封装回调函数。下面是一个例子,DOM事件的回调函数封装在一个对象里面。

1
2
3
4
5
6
7
8
9
10
11
12
var handler = {
id: '123456',

init: function() {
document.addEventListener('click',
event => this.doSomething(event.type), false);
},

doSomething: function(type) {
console.log('Handling ' + type + ' for ' + this.id);
}
};

上面代码的init方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。否则,回调函数运行时,this.doSomething这一行会报错,因为此时this指向document对象。

this指向的固定化,并不是因为箭头函数内部有绑定this的机制,实际原因是箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。正是因为它没有this,所以也就不能用作构造函数。

所以,箭头函数转成ES5的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ES6
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

// ES5
function foo() {
var _this = this;

setTimeout(function () {
console.log('id:', _this.id);
}, 100);
}

上面代码中,转换后的ES5版本清楚地说明了,箭头函数里面根本没有自己的this,而是引用外层的this。

另外,由于箭头函数没有自己的this,所以当然也就不能用call()、apply()、bind()这些方法去改变this的指向。

1
2
3
4
5
6
(function() {
return [
(() => this.x).bind({ x: 'inner' })()
];
}).call({ x: 'outer' });
// ['outer']

上面代码中,箭头函数没有自己的this,所以bind方法无效,内部的this指向外部的this。

长期以来,JavaScript语言的this对象一直是一个令人头痛的问题,在对象方法中使用this,必须非常小心。箭头函数”绑定”this,很大程度上解决了这个困扰。

函数绑定

箭头函数可以绑定this对象,大大减少了显式绑定this对象的写法(call、apply、bind)。但是,箭头函数并不适用于所有场合,所以ES7提出了“函数绑定”(function bind)运算符,用来取代call、apply、bind调用。虽然该语法还是ES7的一个提案,但是Babel转码器已经支持。

函数绑定运算符是并排的两个双冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。

1
2
3
4
5
6
7
8
9
10
11
12
foo::bar;
// 等同于
bar.bind(foo);

foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);

const hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key) {
return obj::hasOwnProperty(key);
}

如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。

1
2
3
4
5
6
7
var method = obj::obj.foo;
// 等同于
var method = ::obj.foo;

let log = ::console.log;
// 等同于
var log = console.log.bind(console);

由于双冒号运算符返回的还是原对象,因此可以采用链式写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 例一
import { map, takeWhile, forEach } from "iterlib";

getPlayers()
::map(x => x.character())
::takeWhile(x => x.strength > 100)
::forEach(x => console.log(x));

// 例二
let { find, html } = jake;

document.querySelectorAll("div.myClass")
::find("p")
::html("hahaha");

尾调用优化

函数参数的尾逗号

对象的扩展

Symbol

Proxy 和 Reflect

二进制数组

Set 和 Map 的数据结构

Iterator 和 for…of 循环

Promise 对象

含义

Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6将其写进了语言标准,统一了用法,原生提供了Promise对象。

所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise是一个对象,从它可以获取异步操作的消息。Promise提供统一的API,各种异步操作都可以用同样的方法进行处理。

Promise对象有以下两个特点。

  1. 对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:Pending(进行中)、Resolved(已完成,又称Fulfilled)和Rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
  2. 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从Pending变为Resolved和从Pending变为Rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。就算改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。

有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。

Promise也有一些缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于Pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

如果某些事件不断地反复发生,一般来说,使用stream模式是比部署Promise更好的选择。

基本用法

ES6规定,Promise对象是一个构造函数,用来生成Promise实例。

下面代码创造了一个Promise实例。

1
2
3
4
5
6
7
8
9
var promise = new Promise(function(resolve, reject) {
// ... some code

if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});

Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不用自己部署。

resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从Pending变为Resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从Pending变为Rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

Promise实例生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。

1
2
3
4
5
promise.then(function(value) {
// success
}, function(error) {
// failure
});

then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为Resolved时调用,第二个回调函数是Promise对象的状态变为Reject时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受Promise对象传出的值作为参数。

下面是一个Promise对象的简单例子。

1
2
3
4
5
6
7
8
9
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, 'done');
});
}

timeout(100).then((value) => {
console.log(value);
});

上面代码中,timeout方法返回一个Promise实例,表示一段时间以后才会发生的结果。过了指定的时间(ms参数)以后,Promise实例的状态变为Resolved,就会触发then方法绑定的回调函数。

Promise新建后就会立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let promise = new Promise(function(resolve, reject) {
console.log('Promise');
resolve();
});

promise.then(function() {
console.log('Resolved.');
});

console.log('Hi!');

// Promise
// Hi!
// Resolved

上面代码中,Promise新建后立即执行,所以首先输出的是“Promise”。然后,then方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行,所以“Resolved”最后输出。

下面是异步加载图片的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function loadImageAsync(url) {
return new Promise(function(resolve, reject) {
var image = new Image();

image.onload = function() {
resolve(image);
};

image.onerror = function() {
reject(new Error('Could not load image at ' + url));
};

image.src = url;
});
}

上面代码中,使用Promise包装了一个图片加载的异步操作。如果加载成功,就调用resolve方法,否则就调用reject方法。

下面是一个用Promise对象实现的Ajax操作的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var getJSON = function(url) {
var promise = new Promise(function(resolve, reject){
var client = new XMLHttpRequest();
client.open("GET", url);
client.onreadystatechange = handler;
client.responseType = "json";
client.setRequestHeader("Accept", "application/json");
client.send();

function handler() {
if (this.readyState !== 4) {
return;
}
if (this.status === 200) {
resolve(this.response);
} else {
reject(new Error(this.statusText));
}
};
});

return promise;
};

getJSON("/posts.json").then(function(json) {
console.log('Contents: ' + json);
}, function(error) {
console.error('出错了', error);
});

上面代码中,getJSON是对XMLHttpRequest对象的封装,用于发出一个针对JSON数据的HTTP请求,并且返回一个Promise对象。需要注意的是,在getJSON内部,resolve函数和reject函数调用时,都带有参数。

如果调用resolve函数和reject函数时带有参数,那么它们的参数会被传递给回调函数。reject函数的参数通常是Error对象的实例,表示抛出的错误;resolve函数的参数除了正常的值以外,还可能是另一个Promise实例,表示异步操作的结果有可能是一个值,也有可能是另一个异步操作,比如像下面这样。

1
2
3
4
5
6
7
8
var p1 = new Promise(function (resolve, reject) {
// ...
});

var p2 = new Promise(function (resolve, reject) {
// ...
resolve(p1);
})

上面代码中,p1和p2都是Promise的实例,但是p2的resolve方法将p1作为参数,即一个异步操作的结果是返回另一个异步操作。

注意,这时p1的状态就会传递给p2,也就是说,p1的状态决定了p2的状态。如果p1的状态是Pending,那么p2的回调函数就会等待p1的状态改变;如果p1的状态已经是Resolved或者Rejected,那么p2的回调函数将会立刻执行。

1
2
3
4
5
6
7
8
9
10
11
12
var p1 = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('fail')), 3000)
})

var p2 = new Promise(function (resolve, reject) {
setTimeout(() => resolve(p1), 1000)
})

p2
.then(result => console.log(result))
.catch(error => console.log(error))
// Error: fail

上面代码中,p1是一个Promise,3秒之后变为rejected。p2的状态在1秒之后改变,resolve方法返回的是p1。此时,由于p2返回的是另一个Promise,所以后面的then语句都变成针对后者(p1)。又过了2秒,p1变为rejected,导致触发catch方法指定的回调函数。

Promise.prototype.then()

Promise实例具有then方法,也就是说,then方法是定义在原型对象Promise.prototype上的。它的作用是为Promise实例添加状态改变时的回调函数。前面说过,then方法的第一个参数是Resolved状态的回调函数,第二个参数(可选)是Rejected状态的回调函数。

then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。

1
2
3
4
5
getJSON("/posts.json").then(function(json) {
return json.post;
}).then(function(post) {
// ...
});

上面的代码使用then方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。

采用链式的then,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise对象(即有异步操作),这时后一个回调函数,就会等待该Promise对象的状态发生变化,才会被调用。

1
2
3
4
5
6
7
getJSON("/post/1.json").then(function(post) {
return getJSON(post.commentURL);
}).then(function funcA(comments) {
console.log("Resolved: ", comments);
}, function funcB(err){
console.log("Rejected: ", err);
});

如果采用箭头函数,上面的代码可以写得更简洁。

1
2
3
4
5
6
getjson("/post/1.json").then(
post => getjson(post.commenturl)
).then(
comments => console.log("resolved: ", comments),
err => console.log("rejected: ", err)
);

Promise.prototype.catch()

1
2
3
4
5
6
7
8
Promise.prototype.catch方法是.then(null, rejection)的别名,用于指定发生错误时的回调函数。

getJSON("/posts.json").then(function(posts) {
// ...
}).catch(function(error) {
// 处理 getJSON 和 前一个回调函数运行时发生的错误
console.log('发生错误!', error);
});

上面代码中,getJSON方法返回一个Promise对象,如果该对象状态变为Resolved,则会调用then方法指定的回调函数;如果异步操作抛出错误,状态就会变为Rejected,就会调用catch方法指定的回调函数,处理这个错误。另外,then方法指定的回调函数,如果运行中抛出错误,也会被catch方法捕获。

一般来说,不要在then方法里面定义Reject状态的回调函数(即then的第二个参数),总是使用catch方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// bad
promise
.then(function(data) {
// success
}, function(err) {
// error
});

// good
promise
.then(function(data) { //cb
// success
})
.catch(function(err) {
// error
});

上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面then方法执行中的错误,也更接近同步的写法(try/catch)。因此,建议总是使用catch方法,而不使用then方法的第二个参数。

跟传统的try/catch代码块不同的是,如果没有使用catch方法指定错误处理的回调函数,Promise对象抛出的错误不会传递到外层代码,即不会有任何反应。

catch方法之中,还能再抛出错误,通过下一个 catch 捕获。

1
2
3
4
5
6
7
8
9
10
11
someAsyncThing().then(function() {
return someOtherAsyncThing();
}).catch(function(error) {
console.log('oh no', error);
// 下面一行会报错,因为y没有声明
y + 2;
}).catch(function(error) {
console.log('carry on', error);
});
// oh no [ReferenceError: x is not defined]
// carry on [ReferenceError: y is not defined]

Promise.all()

Promise.all方法用于将多个Promise实例,包装成一个新的Promise实例。

1
var p = Promise.all([p1, p2, p3]);

上面代码中,Promise.all方法接受一个数组作为参数,p1、p2、p3都是Promise对象的实例,如果不是,就会先调用下面讲到的Promise.resolve方法,将参数转为Promise实例,再进一步处理。(Promise.all方法的参数可以不是数组,但必须具有Iterator接口,且返回的每个成员都是Promise实例。)

p的状态由p1、p2、p3决定,分成两种情况。

  1. 只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
  2. 只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。

下面是一个具体的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const databasePromise = connectDatabase();

const booksPromise = databaseProimse
.then(findAllBooks);

const userPromise = databasePromise
.then(getCurrentUser);

Promise.all([
booksPromise,
userPromise
])
.then(([books, user]) => pickTopRecommentations(books, user));

上面代码中,booksPromise和userPromise是两个异步操作,只有等到它们的结果都返回了,才会触发pickTopRecommentations这个回调函数。

Promise.race()

Promise.race方法同样是将多个Promise实例,包装成一个新的Promise实例。

1
var p = Promise.race([p1,p2,p3]);

上面代码中,只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的Promise实例的返回值,就传递给p的回调函数。

Promise.race方法的参数与Promise.all方法一样,如果不是Promise实例,就会先调用下面讲到的Promise.resolve方法,将参数转为Promise实例,再进一步处理。

下面是一个例子,如果指定时间内没有获得结果,就将Promise的状态变为reject,否则变为resolve。

1
2
3
4
5
6
7
8
var p = Promise.race([
fetch('/resource-that-may-take-a-while'),
new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('request timeout')), 5000)
})
])
p.then(response => console.log(response))
p.catch(error => console.log(error))

上面代码中,如果5秒之内fetch方法无法返回结果,变量p的状态就会变为rejected,从而触发catch方法指定的回调函数。

Promise.resolve()

有时需要将现有对象转为Promise对象,Promise.resolve方法就起到这个作用。

1
var jsPromise = Promise.resolve($.ajax('/whatever.json'));

上面代码将jQuery生成的deferred对象,转为一个新的Promise对象。

Promise.resolve等价于下面的写法。

1
2
3
4
Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))
Promise.resolve方法的参数分成四种情况。

(1)参数是一个Promise实例

如果参数是Promise实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例。

(2)参数是一个thenable对象

thenable对象指的是具有then方法的对象,比如下面这个对象。

1
2
3
4
5
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};

Promise.resolve方法会将这个对象转为Promise对象,然后就立即执行thenable对象的then方法。

1
2
3
4
5
6
7
8
9
10
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};

let p1 = Promise.resolve(thenable);
p1.then(function(value) {
console.log(value); // 42
});

上面代码中,thenable对象的then方法执行后,对象p1的状态就变为resolved,从而立即执行最后那个then方法指定的回调函数,输出42。

(3)参数不是具有then方法的对象,或根本就不是对象

如果参数是一个原始值,或者是一个不具有then方法的对象,则Promise.resolve方法返回一个新的Promise对象,状态为Resolved。

1
2
3
4
5
6
var p = Promise.resolve('Hello');

p.then(function (s){
console.log(s)
});
// Hello

上面代码生成一个新的Promise对象的实例p。由于字符串Hello不属于异步操作(判断方法是它不是具有then方法的对象),返回Promise实例的状态从一生成就是Resolved,所以回调函数会立即执行。Promise.resolve方法的参数,会同时传给回调函数。

(4)不带有任何参数

Promise.resolve方法允许调用时不带参数,直接返回一个Resolved状态的Promise对象。

所以,如果希望得到一个Promise对象,比较方便的方法就是直接调用Promise.resolve方法。

1
2
3
4
5
var p = Promise.resolve();

p.then(function () {
// ...
});

上面代码的变量p就是一个Promise对象。

需要注意的是,立即resolve的Promise对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(function () {
console.log('three');
}, 0);

Promise.resolve().then(function () {
console.log('two');
});

console.log('one');

// one
// two
// three

上面代码中,setTimeout(fn, 0)在下一轮“事件循环”开始时执行,Promise.resolve()在本轮“事件循环”结束时执行,console.log(‘one’)则是立即执行,因此最先输出。

Promise.reject()

Promise.reject(reason)方法也会返回一个新的Promise实例,该实例的状态为rejected。它的参数用法与Promise.resolve方法完全一致。

1
2
3
4
5
6
7
8
var p = Promise.reject('出错了');
// 等同于
var p = new Promise((resolve, reject) => reject('出错了'))

p.then(null, function (s){
console.log(s)
});
// 出错了

上面代码生成一个Promise对象的实例p,状态为rejected,回调函数会立即执行。

两个有用的附加方法

下面介绍如何部署两个不在ES6之中、但很有用的方法。

done()

Promise对象的回调链,不管以then方法或catch方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个done方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。

1
2
3
4
5
asyncFunc()
.then(f1)
.catch(r1)
.then(f2)
.done();

它的实现代码相当简单。

1
2
3
4
5
6
7
Promise.prototype.done = function (onFulfilled, onRejected) {
this.then(onFulfilled, onRejected)
.catch(function (reason) {
// 抛出一个全局错误
setTimeout(() => { throw reason }, 0);
});
};

从上面代码可见,done方法的使用,可以像then方法那样用,提供Fulfilled和Rejected状态的回调函数,也可以不提供任何参数。但不管怎样,done都会捕捉到任何可能出现的错误,并向全局抛出。

finally()

finally方法用于指定不管Promise对象最后状态如何,都会执行的操作。它与done方法的最大区别,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。

下面是一个例子,服务器使用Promise处理请求,然后使用finally方法关掉服务器。

1
2
3
4
5
server.listen(0)
.then(function () {
// run test
})
.finally(server.stop);

它的实现也很简单。

1
2
3
4
5
6
7
Promise.prototype.finally = function (callback) {
let P = this.constructor;
return this.then(
value => P.resolve(callback()).then(() => value),
reason => P.resolve(callback()).then(() => { throw reason })
);
};

上面代码中,不管前面的Promise是fulfilled还是rejected,都会执行回调函数callback。

Generator 函数

协程

传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"(coroutine),意思是多个线程互相协作,完成异步任务。

协程有点像函数,又有点像线程。它的运行流程大致如下。

  1. 第一步,协程A开始执行。
  2. 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
  3. 第三步,(一段时间后)协程B交还执行权。
  4. 第四步,协程A恢复执行。

上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。

举例来说,读取文件的协程写法如下。

1
2
3
4
5
function *asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。

Generator函数的概念

Generator函数是协程在ES6的实现,最大特点就是可以交出函数的执行权(即暂停执行)。

整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。Generator函数的执行方法如下。

1
2
3
4
5
6
7
8
function* gen(x){
var y = yield x + 2;
return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中,调用Generator函数,会返回一个内部指针(即遍历器)g 。这是Generator函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上例是执行到x + 2为止。

换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。

Generator函数的数据交换和错误处理

Generator函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。

next方法返回值的value属性,是Generator函数向外输出数据;next方法还可以接受参数,这是向Generator函数体内输入数据。

上面代码中,第一个next方法的value属性,返回表达式x + 2的值(3)。第二个next方法带有参数2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收。因此,这一步的 value 属性,返回的就是2(变量y的值)。

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}

var g = gen(1);
g.next();
g.throw('出错了');
// 出错了

上面代码的最后一行,Generator函数体外,使用指针对象的throw方法抛出的错误,可以被函数体内的try …catch代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

异步任务的封装

下面看看如何使用 Generator 函数,执行一个真实的异步任务。

1
2
3
4
5
6
7
var fetch = require('node-fetch');

function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}

上面代码中,Generator函数封装了一个异步操作,该操作先读取一个远程接口,然后从JSON格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield命令。

执行这段代码的方法如下。

1
2
3
4
5
6
7
8
var g = gen();
var result = g.next();

result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});

上面代码中,首先执行Generator函数,获取遍历器对象,然后使用next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个Promise对象,因此要用 then 方法调用下一个 next 方法。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

Thunk 函数

Thunk 函数主要用于实现编译器的"传名调用"(call by name)。

1
2
3
4
5
6
7
var x = 1;

function f(m){
return m * 2;
}

f(x + 5)

上面代码先定义函数f,然后向它传入表达式x + 5。请问,这个表达式应该何时求值?

  • 一种意见是"传值调用"(call by value),即在进入函数体之前,就计算x + 5的值(等于6),再将这个值传入函数f 。C语言就采用这种策略。
  • 另一种意见是"传名调用"(call by name),即直接将表达式x + 5传入函数体,只在用到它的时候求值。Haskell语言采用这种策略。

编译器的"传名调用"实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做Thunk函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function f(m){
return m * 2;
}

f(x + 5);

// 等同于

var thunk = function () {
return x + 5;
};

function f(thunk){
return thunk() * 2;
}

JavaScript语言是传值调用,它的Thunk函数含义有所不同。在JavaScript语言中,Thunk函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。

1
2
3
4
5
6
7
8
9
10
11
12
// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);

// Thunk版本的readFile(单参数版本)
var readFileThunk = Thunk(fileName);
readFileThunk(callback);

var Thunk = function (fileName){
return function (callback){
return fs.readFile(fileName, callback);
};
};

使用上面的转换器,生成fs.readFile的Thunk函数。

1
2
var readFileThunk = Thunk(fs.readFile);
readFileThunk(fileA)(callback);

下面是另一个完整的例子。

1
2
3
4
5
6
7
function f(a, cb) {
cb(a);
}
let ft = Thunk(f);

let log = console.log.bind(console);
ft(1)(log) // 1
Thunkify模块

生产环境的转换器,建议使用Thunkify模块。

首先是安装。

1
$ npm install thunkify

使用方式如下。

1
2
3
4
5
6
7
var thunkify = require('thunkify');
var fs = require('fs');

var read = thunkify(fs.readFile);
read('package.json')(function(err, str){
// ...
});

Thunkify的源码与上一节那个简单的转换器非常像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function thunkify(fn){
return function(){
var args = new Array(arguments.length);
var ctx = this;

for(var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}

return function(done){
var called;

args.push(function(){
if (called) return;
called = true;
done.apply(null, arguments);
});

try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};

它的源码主要多了一个检查机制,变量called确保回调函数只运行一次。这样的设计与下文的Generator函数相关。请看下面的例子。

1
2
3
4
5
6
7
8
9
10
function f(a, b, callback){
var sum = a + b;
callback(sum);
callback(sum);
}

var ft = thunkify(f);
var print = console.log.bind(console);
ft(1, 2)(print);
// 3

上面代码中,由于thunkify只允许回调函数执行一次,所以只输出一行结果。

Generator 函数的流程管理

Thunk函数真正的威力,在于可以自动执行Generator函数。下面就是一个基于Thunk函数的Generator执行器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function run(fn) {
var gen = fn();

function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}

next();
}

function* g() {
// ...
}

run(g);

上面代码的run函数,就是一个Generator函数的自动执行器。内部的next函数就是Thunk的回调函数。next函数先将指针移到Generator函数的下一步(gen.next方法),然后判断Generator函数是否结束(result.done属性),如果没结束,就将next函数再传入Thunk函数(result.value属性),否则就直接退出。

有了这个执行器,执行Generator函数方便多了。不管内部有多少个异步操作,直接把Generator函数传入run函数即可。当然,前提是每一个异步操作,都要是Thunk函数,也就是说,跟在yield命令后面的必须是Thunk函数。

1
2
3
4
5
6
7
8
var g = function* (){
var f1 = yield readFile('fileA');
var f2 = yield readFile('fileB');
// ...
var fn = yield readFile('fileN');
};

run(g);

上面代码中,函数g封装了n个异步的读取文件操作,只要执行run函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk函数并不是Generator函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制Generator函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。

异步操作和 Async 函数

ES6诞生以前,异步编程的方法,大概有下面四种。

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise 对象

ES6将JavaScript异步编程带入了一个全新的阶段,ES7的Async函数更是提出了异步编程的终极解决方案。

co 模块

co模块 是著名程序员TJ Holowaychuk于2013年6月发布的一个小工具,用于Generator函数的自动执行。

比如,有一个Generator函数,用于依次读取两个文件。

1
2
3
4
5
6
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

co模块可以让你不用编写Generator函数的执行器。

1
2
var co = require('co');
co(gen);

上面代码中,Generator函数只要传入co函数,就会自动执行。

co函数返回一个Promise对象,因此可以用then方法添加回调函数。

1
2
3
co(gen).then(function (){
console.log('Generator 函数执行完成');
});

上面代码中,等到Generator函数执行结束,就会输出一行提示。

co模块的原理

为什么co可以自动执行Generator函数?

前面说过,Generator就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。

两种方法可以做到这一点。

  1. 回调函数。将异步操作包装成Thunk函数,在回调函数里面交回执行权。
  2. Promise 对象。将异步操作包装成Promise对象,用then方法交回执行权。

co模块其实就是将两种自动执行器(Thunk函数和Promise对象),包装成一个模块。使用co的前提条件是,Generator函数的yield命令后面,只能是Thunk函数或Promise对象。

上一节已经介绍了基于Thunk函数的自动执行器。下面来看,基于Promise对象的自动执行器。这是理解co模块必须的。

基于Promise对象的自动执行

还是沿用上面的例子。首先,把fs模块的readFile方法包装成一个Promise对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fs = require('fs');

var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) return reject(error);
resolve(data);
});
});
};

var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

然后,手动执行上面的Generator函数。

1
2
3
4
5
6
7
var g = gen();

g.next().value.then(function(data){
g.next(data).value.then(function(data){
g.next(data);
});
});

手动执行其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function run(gen){
var g = gen();

function next(data){
var result = g.next(data);
if (result.done) return result.value;
result.value.then(function(data){
next(data);
});
}

next();
}

run(gen);

上面代码中,只要Generator函数还没执行到最后一步,next函数就调用自身,以此实现自动执行。

处理并发的异步操作

co支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。

这时,要把并发的操作都放在数组或对象里面,跟在yield语句后面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 数组的写法
co(function* () {
var res = yield [
Promise.resolve(1),
Promise.resolve(2)
];
console.log(res);
}).catch(onerror);

// 对象的写法
co(function* () {
var res = yield {
1: Promise.resolve(1),
2: Promise.resolve(2),
};
console.log(res);
}).catch(onerror);

下面是另一个例子。

1
2
3
4
5
6
7
8
9
co(function* () {
var values = [n1, n2, n3];
yield values.map(somethingAsync);
});

function* somethingAsync(x) {
// do something async
return y
}

上面的代码允许并发三个somethingAsync异步操作,等到它们全部完成,才会进行下一步。

async 函数

含义

ES7提供了async函数,使得异步操作变得更加方便。async函数是什么?一句话,async函数就是Generator函数的语法糖。

前文有一个Generator函数,依次读取两个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fs = require('fs');

var readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) reject(error);
resolve(data);
});
});
};

var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

写成async函数,就是下面这样。

1
2
3
4
5
6
var asyncReadFile = async function (){
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

一比较就会发现,async函数就是将Generator函数的星号(*)替换成async,将yield替换成await,仅此而已。

async函数对 Generator 函数的改进,体现在以下四点。

(1)内置执行器。Generator函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。

1
var result = asyncReadFile();

上面的代码调用了asyncReadFile函数,然后它就会自动执行,输出最后结果。这完全不像Generator函数,需要调用next方法,或者用co模块,才能得到真正执行,得到最后结果。

(2)更好的语义。async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。

(3)更广的适用性。 co模块约定,yield命令后面只能是Thunk函数或Promise对象,而async函数的await命令后面,可以是Promise对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。

(4)返回值是Promise。async函数的返回值是Promise对象,这比Generator函数的返回值是Iterator对象方便多了。你可以用then方法指定下一步的操作。

进一步说,async函数完全可以看作多个异步操作,包装成的一个Promise对象,而await命令就是内部then命令的语法糖。

async函数的语法规则总体上比较简单,难点是错误处理机制。

(1)async函数返回一个Promise对象。

async函数内部return语句返回的值,会成为then方法回调函数的参数。

1
2
3
4
5
6
async function f() {
return 'hello world';
}

f().then(v => console.log(v))
// "hello world"

上面代码中,函数f内部return命令返回的值,会被then方法回调函数接收到。

async函数内部抛出错误,会导致返回的Promise对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。

1
2
3
4
5
6
7
8
9
async function f() {
throw new Error('出错了');
}

f().then(
v => console.log(v),
e => console.log(e)
)
// Error: 出错了

(2)async函数返回的Promise对象,必须等到内部所有await命令的Promise对象执行完,才会发生状态改变。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。

下面是一个例子。

1
2
3
4
5
6
7
async function getTitle(url) {
let response = await fetch(url);
let html = await response.text();
return html.match(/<title>([\s\S]+)<\/title>/i)[1];
}
getTitle('https://tc39.github.io/ecma262/').then(console.log)
// "ECMAScript 2017 Language Specification"

(3)正常情况下,await命令后面是一个Promise对象。如果不是,会被转成一个立即resolve的Promise对象。

1
2
3
4
5
6
async function f() {
return await 123;
}

f().then(v => console.log(v))
// 123

上面代码中,await命令的参数是数值123,它被转成Promise对象,并立即resolve。

await命令后面的Promise对象如果变为reject状态,则reject的参数会被catch方法的回调函数接收到。

1
2
3
4
5
6
7
8
async function f() {
await Promise.reject('出错了');
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))
// 出错了

注意,上面代码中,await语句前面没有return,但是reject方法的参数依然传入了catch方法的回调函数。这里如果在await前面加上return,效果是一样的。

只要一个await语句后面的Promise变为reject,那么整个async函数都会中断执行。

1
2
3
4
async function f() {
await Promise.reject('出错了');
await Promise.resolve('hello world'); // 不会执行
}

上面代码中,第二个await语句是不会执行的,因为第一个await语句状态变成了reject。

为了避免这个问题,可以将第一个await放在try…catch结构里面,这样第二个await就会执行。

1
2
3
4
5
6
7
8
9
10
11
async function f() {
try {
await Promise.reject('出错了');
} catch(e) {
}
return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))
// hello world

另一种方法是await后面的Promise对象再跟一个catch方面,处理前面可能出现的错误。

1
2
3
4
5
6
7
8
9
10
async function f() {
await Promise.reject('出错了')
.catch(e => console.log(e));
return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))
// 出错了
// hello world

如果有多个await命令,可以统一放在try…catch结构中。

1
2
3
4
5
6
7
8
9
10
11
12
async function main() {
try {
var val1 = await firstStep();
var val2 = await secondStep(val1);
var val3 = await thirdStep(val1, val2);

console.log('Final: ', val3);
}
catch (err) {
console.error(err);
}
}

(4)如果await后面的异步操作出错,那么等同于async函数返回的Promise对象被reject。

1
2
3
4
5
6
7
8
9
10
async function f() {
await new Promise(function (resolve, reject) {
throw new Error('出错了');
});
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))
// Error:出错了

上面代码中,async函数f执行后,await后面的Promise对象会抛出一个错误对象,导致catch方法的回调函数被调用,它的参数就是抛出的错误对象。具体的执行机制,可以参考后文的“async函数的实现”。

防止出错的方法,也是将其放在try…catch代码块之中。

1
2
3
4
5
6
7
8
9
async function f() {
try {
await new Promise(function (resolve, reject) {
throw new Error('出错了');
});
} catch(e) {
}
return await('hello world');
}
await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在 try...catch 代码块中。

Class

基本语法

JavaScript语言的传统方法是通过构造函数,定义并生成新对象。下面是一个例子。

1
2
3
4
5
6
7
8
9
10
function Point(x, y) {
this.x = x;
this.y = y;
}

Point.prototype.toString = function () {
return '(' + this.x + ', ' + this.y + ')';
};

var p = new Point(1, 2);

上面这种写法跟传统的面向对象语言(比如C++和Java)差异很大,很容易让新学习这门语言的程序员感到困惑。

ES6提供了更接近传统语言的写法,引入了Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。基本上,ES6的class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用ES6的“类”改写,就是下面这样。

1
2
3
4
5
6
7
8
9
10
11
//定义类
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}

toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}

上面代码定义了一个“类”,可以看到里面有一个constructor方法,这就是构造方法,而this关键字则代表实例对象。也就是说,ES5的构造函数Point,对应ES6的Point类的构造方法。

Point类除了构造方法,还定义了一个toString方法。注意,定义“类”的方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。另外,方法之间不需要逗号分隔,加了会报错

  • 类方法的定义前面不需要加 function 关键字;
  • 方法之间不需要逗号分隔。

ES6的类,完全可以看作构造函数的另一种写法。

1
2
3
4
5
6
class Point {
// ...
}

typeof Point // "function"
Point === Point.prototype.constructor // true

上面代码表明,类的数据类型就是函数,类本身就指向构造函数。

使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。

1
2
3
4
5
6
7
8
class Bar {
doStuff() {
console.log('stuff');
}
}

var b = new Bar();
b.doStuff() // "stuff"

构造函数的prototype属性,在ES6的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Point {
constructor(){
// ...
}

toString(){
// ...
}

toValue(){
// ...
}
}

// 等同于

Point.prototype = {
toString(){},
toValue(){}
};

在类的实例上面调用方法,其实就是调用原型上的方法。

1
2
3
4
class B {}
let b = new B();

b.constructor === B.prototype.constructor // true

上面代码中,b是B类的实例,它的constructor方法就是B类原型的constructor方法。

由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign方法可以很方便地一次向类添加多个方法。

1
2
3
4
5
6
7
8
9
10
class Point {
constructor(){
// ...
}
}

Object.assign(Point.prototype, {
toString(){},
toValue(){}
});

prototype对象的constructor属性,直接指向“类”的本身,这与ES5的行为是一致的。

1
Point.prototype.constructor === Point // true

另外,类的内部所有定义的方法,都是不可枚举的(non-enumerable)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
constructor(x, y) {
// ...
}

toString() {
// ...
}
}

Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

上面代码中,toString方法是Point类内部定义的方法,它是不可枚举的。这一点与ES5的行为不一致。

1
2
3
4
5
6
7
8
9
10
11
12
var Point = function (x, y) {
// ...
};

Point.prototype.toString = function() {
// ...
};

Object.keys(Point.prototype)
// ["toString"]
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

上面代码采用ES5的写法,toString方法就是可枚举的。

类的属性名,可以采用表达式。

1
2
3
4
5
6
7
8
9
10
let methodName = "getArea";
class Square{
constructor(length) {
// ...
}

[methodName]() {
// ...
}
}

上面代码中,Square类的方法名getArea,是从表达式得到的。

不存在变量提升

Class不存在变量提升(hoist),这一点与ES5完全不同。

1
2
new Foo(); // ReferenceError
class Foo {}

上面代码中,Foo类使用在前,定义在后,这样会报错,因为ES6不会把类的声明提升到代码头部。这种规定的原因与下文要提到的继承有关,必须保证子类在父类之后定义。

1
2
3
4
5
{
let Foo = class {};
class Bar extends Foo {
}
}

上面的代码不会报错,因为class继承Foo的时候,Foo已经有定义了。但是,如果存在class的提升,上面代码就会报错,因为class会被提升到代码头部,而let命令是不提升的,所以导致class继承Foo的时候,Foo还没有定义。

Class 表达式

与函数一样,类也可以使用表达式的形式定义。

1
2
3
4
5
const MyClass = class Me {
getClassName() {
return Me.name;
}
};

上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass而不是Me,Me只在Class的内部代码可用,指代当前类

1
2
3
let inst = new MyClass();
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined

上面代码表示,Me只在Class内部有定义。

如果类的内部没用到的话,可以省略Me,也就是可以写成下面的形式。

1
const MyClass = class { /* ... */ };

采用Class表达式,可以写出立即执行的Class。

1
2
3
4
5
6
7
8
9
10
11
let person = new class {
constructor(name) {
this.name = name;
}

sayName() {
console.log(this.name);
}
}('张三');

person.sayName(); // "张三"

上面代码中,person是一个立即执行的类的实例。

私有方法

私有方法是常见需求,但ES6不提供,只能通过变通方法模拟实现。

  • 一种做法是在命名上加以区别
  • 另一种方法就是索性将私有方法移出模块,因为模块内部的所有方法都是对外可见的。
1
2
3
4
5
6
7
8
9
10
11
class Widget {
foo (baz) {
bar.call(this, baz);
}

// ...
}

function bar(baz) {
return this.snaf = baz;
}
  • 还有一种方法是利用Symbol值的唯一性,将私有方法的名字命名为一个Symbol值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bar = Symbol('bar');
const snaf = Symbol('snaf');

export default class myClass{

// 公有方法
foo(baz) {
this[bar](baz);
}

// 私有方法
[bar](baz) {
return this[snaf] = baz;
}

// ...
};

上面代码中,bar和snaf都是Symbol值,导致第三方无法获取到它们,因此达到了私有方法和私有属性的效果。

This的指向

类的方法内部如果含有this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Logger {
printName(name = 'there') {
this.print(`Hello ${name}`);
}

print(text) {
console.log(text);
}
}

const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

上面代码中,printName方法中的this,默认指向Logger类的实例。但是,如果将这个方法提取出来单独使用,this会指向该方法运行时所在的环境,因为找不到print方法而导致报错。

一个比较简单的解决方法是,在构造方法中绑定this,这样就不会找不到print方法了。

1
2
3
4
5
6
7
class Logger {
constructor() {
this.printName = this.printName.bind(this);
}

// ...
}

另一种解决方法是使用箭头函数。

1
2
3
4
5
6
7
8
9
class Logger {
constructor() {
this.printName = (name = 'there') => {
this.print(`Hello ${name}`);
};
}

// ...
}

还有一种解决方法是使用Proxy,获取方法的时候,自动绑定this。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function selfish (target) {
const cache = new WeakMap();
const handler = {
get (target, key) {
const value = Reflect.get(target, key);
if (typeof value !== 'function') {
return value;
}
if (!cache.has(value)) {
cache.set(value, value.bind(target));
}
return cache.get(value);
}
};
const proxy = new Proxy(target, handler);
return proxy;
}

const logger = selfish(new Logger());

严格模式

类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。只要你的代码写在类或模块之中,就只有严格模式可用

考虑到未来所有的代码,其实都是运行在模块之中,所以ES6实际上把整个语言升级到了严格模式。

Class的继承

基本用法

Class之间可以通过extends关键字实现继承,这比ES5的通过修改原型链实现继承,要清晰和方便很多。

1
class ColorPoint extends Point {}

上面代码定义了一个ColorPoint类,该类通过extends关键字,继承了Point类的所有属性和方法。但是由于没有部署任何代码,所以这两个类完全一样,等于复制了一个Point类。下面,我们在ColorPoint内部加上代码。

1
2
3
4
5
6
7
8
9
10
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的constructor(x, y)
this.color = color;
}

toString() {
return this.color + ' ' + super.toString(); // 调用父类的toString()
}
}

上面代码中,constructor方法和toString方法之中,都出现了super关键字,它在这里表示父类的构造函数,用来新建父类的this对象。

子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类没有自己的this对象,而是继承父类的this对象,然后对其进行加工。如果不调用super方法,子类就得不到this对象。

1
2
3
4
5
6
7
8
class Point { /* ... */ }

class ColorPoint extends Point {
constructor() {
}
}

let cp = new ColorPoint(); // ReferenceError

上面代码中,ColorPoint继承了父类Point,但是它的构造函数没有调用super方法,导致新建实例时报错。

ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。

如果子类没有定义constructor方法,这个方法会被默认添加,代码如下。也就是说,不管有没有显式定义,任何一个子类都有constructor方法。

1
2
3
constructor(...args) {
super(...args);
}

另一个需要注意的地方是,在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,是基于对父类实例加工,只有super方法才能返回父类实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}

class ColorPoint extends Point {
constructor(x, y, color) {
this.color = color; // ReferenceError
super(x, y);
this.color = color; // 正确
}
}

上面代码中,子类的constructor方法没有调用super之前,就使用this关键字,结果报错,而放在super方法之后就是正确的。

下面是生成子类实例的代码。

1
2
3
4
let cp = new ColorPoint(25, 8, 'green');

cp instanceof ColorPoint // true
cp instanceof Point // true

上面代码中,实例对象cp同时是ColorPoint和Point两个类的实例,这与ES5的行为完全一致。

Object.getPrototypeOf()

Object.getPrototypeOf方法可以用来从子类上获取父类。

1
2
Object.getPrototypeOf(ColorPoint) === Point
// true

因此,可以使用这个方法判断,一个类是否继承了另一个类。

super关键字

super这个关键字,有两种用法,含义不同。

(1)作为函数调用时(即super(…args)),super代表父类的构造函数。

(2)作为对象调用时(即super.prop或super.method()),super代表父类。注意,此时super即可以引用父类实例的属性和方法,也可以引用父类的静态方法。

new.target属性

new是从构造函数生成实例的命令。ES6为new命令引入了一个new.target属性,(在构造函数中)返回new命令作用于的那个构造函数。如果构造函数不是通过new命令调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(name) {
if (new.target !== undefined) {
this.name = name;
} else {
throw new Error('必须使用new生成实例');
}
}

// 另一种写法
function Person(name) {
if (new.target === Person) {
this.name = name;
} else {
throw new Error('必须使用new生成实例');
}
}

var person = new Person('张三'); // 正确
var notAPerson = Person.call(person, '张三'); // 报错

上面代码确保构造函数只能通过new命令调用。

Class内部调用new.target,返回当前Class。

1
2
3
4
5
6
7
8
9
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}

var obj = new Rectangle(3, 4); // 输出 true

需要注意的是,子类继承父类时,new.target会返回子类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
// ...
}
}

class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}

var obj = new Square(3); // 输出 false

上面代码中,new.target会返回子类。

利用这个特点,可以写出不能独立使用、必须继承后才能使用的类(类似Java的抽象类)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error('本类不能实例化');
}
}
}

class Rectangle extends Shape {
constructor(length, width) {
super();
// ...
}
}

var x = new Shape(); // 报错
var y = new Rectangle(3, 4); // 正确

上面代码中,Shape类不能被实例化,只能用于继承。

注意,在函数外部,使用new.target会报错。

Mixin 模式

Mixin模式指的是,将多个类的接口“混入”(mix in)另一个类。它在ES6的实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function mix(...mixins) {
class Mix {}

for (let mixin of mixins) {
copyProperties(Mix, mixin);
copyProperties(Mix.prototype, mixin.prototype);
}

return Mix;
}

function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if ( key !== "constructor"
&& key !== "prototype"
&& key !== "name"
) {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}

上面代码的mix函数,可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。

1
2
3
4
class DistributedEdit extends mix(Loggable, Serializable) {
// ...
}

Decorator

Module

ES6 在语言规格的层面上,实现了模块功能。ES6模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

浏览器使用ES6模块的语法如下。

1
<script type="module" src="foo.js"></script>

上面代码在网页中插入一个模块foo.js,由于type属性设为module,所以浏览器知道这是一个ES6模块。

Node的默认模块格式是CommonJS,目前还没决定怎么支持ES6模块。所以,只能通过Babel这样的转码器,在Node里面使用ES6模块。

export命令

模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个JS文件,里面使用export命令输出变量。

1
2
3
4
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

上面代码是profile.js文件,保存了用户信息。ES6将其视为一个模块,里面用export命令对外部输出了三个变量。

export的写法,除了像上面这样,还有另外一种。

1
2
3
4
5
6
// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;

export {firstName, lastName, year};

上面代码在export命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在var语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。

export命令除了输出变量,还可以输出函数或类(class)。

1
2
3
export function multiply(x, y) {
return x * y;
};

上面代码对外输出一个函数multiply。

通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。

1
2
3
4
5
6
7
8
function v1() { ... }
function v2() { ... }

export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};

上面代码使用as关键字,重命名了函数v1和v2的对外接口。重命名后,v2可以用不同的名字输出两次。

需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

1
2
3
4
5
6
// 报错
export 1;

// 报错
var m = 1;
export m;

上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出1,第二种写法通过变量m,还是直接输出1。1只是一个值,不是接口。正确的写法是下面这样。

1
2
3
4
5
6
7
8
9
10
// 写法一
export var m = 1;

// 写法二
var m = 1;
export {m};

// 写法三
var n = 1;
export {n as m};

上面三种写法都是正确的,规定了对外的接口m。其他脚本可以通过这个接口,取到值1。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。

同样的,function和class的输出,也必须遵守这样的写法。

1
2
3
4
5
6
7
8
9
10
// 报错
function f() {}
export f;

// 正确
export function f() {};

// 正确
function f() {}
export {f};

另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

1
2
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

上面代码输出变量foo,值为bar,500毫秒之后变成baz。

这一点与CommonJS规范完全不同。CommonJS模块输出的是值的缓存,不存在动态更新,详见下文《ES6模块加载的实质》一节。

最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了ES6模块的设计初衷。

1
2
3
4
function foo() {
export default 'bar' // SyntaxError
}
foo()

上面代码中,export语句放在函数之中,结果报错。

import命令

使用export命令定义了模块的对外接口以后,其他JS文件就可以通过import命令加载这个模块(文件)。

1
2
3
4
5
6
7
// main.js

import {firstName, lastName, year} from './profile';

function setName(element) {
element.textContent = firstName + ' ' + lastName;
}

上面代码的import命令,就用于加载profile.js文件,并从中输入变量。import命令接受一个对象(用大括号表示),里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。

如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。

1
import { lastName as surname } from './profile';

注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。

1
2
3
foo();

import { foo } from 'my_module';

上面的代码不会报错,因为import的执行早于foo的调用。

如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。

1
2
3
4
5
export { es6 as default } from './someModule';

// 等同于
import { es6 } from './someModule';
export default es6;

上面代码中,export和import语句可以结合在一起,写成一行。但是从可读性考虑,不建议采用这种写法,而应该采用标准写法。

另外,ES7有一个提案,简化先输入后输出的写法,拿掉输出时的大括号。

1
2
3
4
5
// 提案的写法
export v from 'mod';

// 现行的写法
export {v} from 'mod';

import语句会执行所加载的模块,因此可以有下面的写法。

1
import 'lodash';

上面代码仅仅执行lodash模块,但是不输入任何值。

模块的整体加载

1
2
3
4
import * as circle from './circle';

console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));

export default 命令

export default命令可以为模块指定默认输出。

1
2
3
4
// export-default.js
export default function () {
console.log('foo');
}

上面代码是一个模块文件export-default.js,它的默认输出是一个函数。

其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。

1
2
3
// import-default.js
import customName from './export-default';
customName(); // 'foo'

export default命令用在非匿名函数前,也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
// export-default.js
export default function foo() {
console.log('foo');
}

// 或者写成

function foo() {
console.log('foo');
}

export default foo;

下面比较一下默认输出和正常输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 输出
export default function crc32() {
// ...
}
// 输入
import crc32 from 'crc32';

// 输出
export function crc32() {
// ...
};
// 输入
import {crc32} from 'crc32';

上面代码的两组写法,第一组是使用export default时,对应的import语句不需要使用大括号;第二组是不使用export default时,对应的import语句需要使用大括号。

export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export deault命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能对应一个方法。

export default也可以用来输出类。

1
2
3
4
5
6
// MyClass.js
export default class { ... }

// main.js
import MyClass from 'MyClass';
let o = new MyClass();

模块的继承

模块之间也可以继承。

假设有一个circleplus模块,继承了circle模块。

1
2
3
4
5
6
7
// circleplus.js

export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
return Math.exp(x);
}

上面代码中的export *,表示再输出circle模块的所有属性和方法。注意,export *命令会忽略circle模块的default方法。然后,上面代码又输出了自定义的e变量和默认方法。

这时,也可以将circle的属性或方法,改名后再输出。

1
2
3
// circleplus.js

export { area as circleArea } from 'circle';

上面代码表示,只输出circle模块的area方法,且将其改名为circleArea。

加载上面模块的写法如下。

1
2
3
4
5
// main.js

import * as math from 'circleplus';
import exp from 'circleplus';
console.log(exp(math.e));

上面代码中的import exp表示,将circleplus模块的默认方法加载为exp方法。

ES6模块的转码

浏览器目前还不支持ES6模块,为了现在就能使用,可以将转为ES5的写法。除了Babel可以用来转码之外,还有以下两个方法,也可以用来转码。

  1. ES6 module transpiler - square公司开源的一个转码器,可以将ES6模块转为CommonJS模块或AMD模块的写法,从而在浏览器中使用。
  2. SystemJS - 它是一个垫片库(polyfill),可以在浏览器内加载ES6模块、AMD模块和CommonJS模块,将其转为ES5格式。它在后台调用的是Google的Traceur转码器。

本文参考材料

深入阅读

Comments