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 | node --v8-options | grep harmony |
Babel是一个广泛使用的ES6转码器,可以将ES6代码转为ES5代码,从而在现有环境执行。
Facebook 的 React Native 同样使用 babel ,以支持 ES5、ES6、ES7 的部分语法特性。见 React Native - JavaScript Runtime。
ES6新增了let命令,用来声明变量。它的用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效。
1 | { |
上面代码在代码块之中,分别用let和var声明了两个变量。然后在代码块之外调用这两个变量,结果let声明的变量报错,var声明的变量返回了正确的值。这表明,let声明的变量只在它所在的代码块有效。
for循环的计数器,就很合适使用let命令。
1 | for (let i = 0; i < arr.length; i++) {} |
上面代码的计数器i,只在for循环体内有效。
下面的代码如果使用var,最后输出的是10。
1 | var a = []; |
上面代码中,变量i是var声明的,在全局范围内都有效。所以每一次循环,新的i值都会覆盖旧值,导致最后输出的是最后一轮的i的值。
如果使用let,声明的变量仅在块级作用域内有效,最后输出的是6。
1 | var a = []; |
上面代码中,变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。
let不像var那样会发生“变量提升”现象。所以,变量一定要在声明后使用,否则报错。
1 | console.log(foo); // 输出undefined |
上面代码中,变量foo用var命令声明,会发生变量提升,即脚本开始运行时,变量foo已经存在了,但是没有值,所以会输出undefined。变量bar用let命令声明,不会发生变量提升。这表示在声明它之前,变量bar是不存在的,这时如果用到它,就会抛出一个错误。
只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
1 | var tmp = 123; |
上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。
ES6明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称TDZ)。
1 | if (true) { |
上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。
“暂时性死区”也意味着typeof
不再是一个百分之百安全的操作。
1 | typeof x; // ReferenceError |
上面代码中,变量x使用let命令声明,所以在声明之前,都属于x的“死区”,只要用到该变量就会报错。因此,typeof
运行时就会抛出一个ReferenceError。
作为比较,如果一个变量根本没有被声明,使用typeof
反而不会报错。
1 | typeof undeclared_variable // "undefined" |
上面代码中,undeclared_variable是一个不存在的变量名,结果返回“undefined”。所以,在没有let之前,typeof
运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。
有些“死区”比较隐蔽,不太容易发现。
1 | function bar(x = y, y = 2) { |
上面代码中,调用bar函数之所以报错(某些实现可能不报错),是因为参数x默认值等于另一个参数y,而此时y还没有声明,属于”死区“。如果y的默认值是x,就不会报错,因为此时x已经声明了。
1 | function bar(x = 2, y = x) { |
ES6规定暂时性死区和let、const语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在ES5是很常见的,现在有了这种规定,避免此类错误就很容易了。 总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。
let不允许在相同作用域内,重复声明同一个变量。
1 | // 报错 |
因此,不能在函数内部重新声明参数。
1 | function func(arg) { |
let实际上为JavaScript新增了块级作用域。
1 | function f1() { |
上面的函数有两个代码块,都声明了变量n,运行后输出5。这表示外层代码块不受内层代码块的影响。如果使用var定义变量n,最后输出的值就是10。
ES6允许块级作用域的任意嵌套。
1 | {{{{{let insane = 'Hello World'}}}}}; |
上面代码使用了一个五层的块级作用域。外层作用域无法读取内层作用域的变量。
1 | {{{{ |
内层作用域可以定义外层作用域的同名变量。
1 | {{{{ |
块级作用域的出现,实际上使得获得广泛应用的立即执行匿名函数(IIFE)不再必要了。
1 | // IIFE写法 |
const声明一个只读的常量。一旦声明,常量的值就不能改变。
1 | const PI = 3.1415; |
上面代码表明改变常量的值会报错。
const声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。
1 | const foo; |
上面代码表示,对于const来说,只声明不赋值,就会报错。
const的作用域与let命令相同:只在声明所在的块级作用域内有效。
1 | if (true) { |
const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
1 | if (true) { |
上面代码在常量MAX声明之前就调用,结果报错。
const声明的常量,也与let一样不可重复声明。
1 | var message = "Hello!"; |
对于复合类型的变量,变量名不指向数据,而是指向数据所在的地址。const命令只是保证变量名指向的地址不变,并不保证该地址的数据不变,所以将一个对象声明为常量必须非常小心。
1 | const foo = {}; |
上面代码中,常量foo储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。
下面是另一个例子。
1 | const a = []; |
上面代码中,常量a是一个数组,这个数组本身是可写的,但是如果将另一个数组赋值给a,就会报错。
Object.freeze
方法。
1 | const foo = Object.freeze({}); |
上面代码中,常量foo指向一个冻结的对象,所以添加新属性不起作用,严格模式时还会报错。
除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。
1 | var constantize = (obj) => { |
ES5之前只有两种声明变量的方法:var命令和function命令。ES6除了添加let和const命令,后面章节还会提到,另外两种声明变量的方法:import命令和class命令。所以,ES6一共有6种声明变量的方法。
全局对象是最顶层的对象,在浏览器环境指的是window对象,在Node.js指的是global对象。ES5之中,全局对象的属性与全局变量是等价的。
1 | window.a = 1; |
上面代码中,全局对象的属性赋值与全局变量的赋值,是同一件事。(对于Node来说,这一条只对REPL环境适用,模块环境之中,全局变量必须显式声明成global对象的属性。)
未声明的全局变量,自动成为全局对象window的属性,这被认为是JavaScript语言最大的设计败笔之一。这样的设计带来了两个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道,其次程序员很容易不知不觉地就创建了全局变量(比如打字出错)。另一方面,从语义上讲,语言的顶层对象是一个有实体含义的对象,也是不合适的。
ES6为了改变这一点,一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是全局对象的属性;另一方面规定,let命令、const命令、class命令声明的全局变量,不属于全局对象的属性。也就是说,从ES6开始,全局变量将逐步与全局对象的属性脱钩。
1 | var a = 1; |
上面代码中,全局变量a由var命令声明,所以它是全局对象的属性;全局变量b由let命令声明,所以它不是全局对象的属性,返回undefined。
ES6允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
以前,为变量赋值,只能直接指定值。
1 | var a = 1; |
ES6允许写成下面这样。
1 | var [a, b, c] = [1, 2, 3]; |
上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。
1 | let [foo, [[bar], baz]] = [1, [[2], 3]]; |
如果解构不成功,变量的值就等于undefined。
1 | var [foo] = []; |
以上两种情况都属于解构不成功,foo的值都会等于undefined。
另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。
1 | let [x, y] = [1, 2, 3]; |
上面两个例子,都属于不完全解构,但是可以成功。
如果等号的右边不是数组(或者严格地说,不是可遍历的结构),那么将会报错。
1 | // 报错 |
上面的表达式都会报错,因为等号右边的值,要么转为对象以后不具备Iterator接口(前五个表达式),要么本身就不具备Iterator接口(最后一个表达式)。
解构赋值不仅适用于var命令,也适用于let和const命令。
1 | var [v1, v2, ..., vN ] = array; |
对于Set结构,也可以使用数组的解构赋值。
1 | let [x, y, z] = new Set(["a", "b", "c"]); |
事实上,只要某种数据结构具有Iterator接口,都可以采用数组形式的解构赋值。
1 | function* fibs() { |
上面代码中,fibs是一个Generator函数,原生具有Iterator接口。解构赋值会依次从这个接口获取值。
注意,ES6内部使用严格相等运算符(===
),判断一个位置是否有值。所以,如果一个数组成员不严格等于undefined,默认值是不会生效的。
1 | var [x = 1] = [undefined]; |
上面代码中,如果一个数组成员是null,默认值就不会生效,因为null不严格等于undefined。
如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。
1 | function f() { |
上面代码中,因为x能取到值,所以函数f根本不会执行。上面的代码其实等价于下面的代码。
1 | let x; |
默认值可以引用解构赋值的其他变量,但该变量必须已经声明。
1 | let [x = 1, y = x] = []; // x=1; y=1 |
上面最后一个表达式之所以会报错,是因为x用到默认值y时,y还没有声明。
解构不仅可以用于数组,还可以用于对象。
1 | var { foo, bar } = { foo: "aaa", bar: "bbb" }; |
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
1 | var { bar, foo } = { foo: "aaa", bar: "bbb" }; |
上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined。
1 | var { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
如果变量名与属性名不一致,必须写成下面这样。
这实际上说明,对象的解构赋值是下面形式的简写。
1 | var { foo: foo, bar: bar } = { foo: "aaa", bar: "bbb" }; |
也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
1 | var { foo: baz } = { foo: "aaa", bar: "bbb" }; |
上面代码中,真正被赋值的是变量baz,而不是模式foo。
注意,采用这种写法时,变量的声明和赋值是一体的。对于let和const来说,变量不能重新声明,所以一旦赋值的变量以前声明过,就会报错。
1 | let foo; |
上面代码中,解构赋值的变量都会重新声明,所以报错了。不过,因为var命令允许重新声明,所以这个错误只会在使用let和const命令时出现。如果没有第二个let命令,上面的代码就不会报错。
1 | let foo; |
上面代码中,let命令下面一行的圆括号是必须的,否则会报错。因为解析器会将起首的大括号,理解成一个代码块,而不是赋值语句。
和数组一样,解构也可以用于嵌套结构的对象。
1 | var obj = { |
注意,这时p是模式,不是变量,因此不会被赋值。
1 | var node = { |
上面代码中,只有line是变量,loc和start都是模式,不会被赋值。
下面是嵌套赋值的例子。
1 | let obj = {}; |
对象的解构也可以指定默认值。
1 | var {x = 3} = {}; |
默认值生效的条件是,对象的属性值严格等于undefined。
1 | var {x = 3} = {x: undefined}; |
上面代码中,如果x属性等于null,就不严格相等于undefined,导致默认值不会生效。
如果解构失败,变量的值等于undefined。
1 | var {foo} = {bar: 'baz'}; |
如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。
1 | // 报错 |
上面代码中,等号左边对象的foo属性,对应一个子对象。该子对象的bar属性,解构时会报错。原因很简单,因为foo这时等于undefined,再取子属性就会报错,请看下面的代码。
1 | var _tmp = {baz: 'baz'}; |
如果要将一个已经声明的变量用于解构赋值,必须非常小心。
1 | // 错误的写法 |
上面代码的写法会报错,因为JavaScript引擎会将{x}理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免JavaScript将其解释为代码块,才能解决这个问题。
1 | // 正确的写法 |
上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。关于圆括号与解构赋值的关系,参见下文。
解构赋值允许,等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。
1 | ({} = [true, false]); |
上面的表达式虽然毫无意义,但是语法是合法的,可以执行。
对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。
1 | let { log, sin, cos } = Math; |
上面代码将Math对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。
由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
1 | var arr = [1, 2, 3]; |
上面代码对数组进行对象结构。数组arr的0键对应的值是1,[arr.length - 1]
就是2键,对应的值是3。
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
1 | const [a, b, c, d, e] = 'hello'; |
类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值。
1 | let {length : len} = 'hello'; |
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
1 | let {toString: s} = 123; |
上面代码中,数值和布尔值的包装对象都有toString属性,因此变量s都能取到值。
解构赋值的规则是,只要等号右边的值不是对象,就先将其转为对象。由于undefined和null无法转为对象,所以对它们进行解构赋值,都会报错。
1 | let { prop: x } = undefined; // TypeError |
函数的参数也可以使用解构赋值。
1 | function add([x, y]){ |
上面代码中,函数add的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x和y。对于函数内部的代码来说,它们能感受到的参数就是x和y。
下面是另一个例子。
1 | [[1, 2], [3, 4]].map(([a, b]) => a + b); |
函数参数的解构也可以使用默认值。
1 | function move({x = 0, y = 0} = {}) { |
上面代码中,函数move的参数是一个对象,通过对这个对象进行解构,得到变量x和y的值。如果解构失败,x和y等于默认值。
注意,下面的写法会得到不一样的结果。
1 | function move({x, y} = { x: 0, y: 0 }) { |
上面代码是为函数move的参数指定默认值,而不是为变量x和y指定默认值,所以会得到与前一种写法不同的结果。
解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。
由此带来的问题是,如果模式中出现圆括号怎么处理。ES6的规则是,只要有可能导致解构的歧义,就不得使用圆括号。
但是,这条规则实际上不那么容易辨别,处理起来相当麻烦。因此,建议只要有可能,就不要在模式中放置圆括号。
以下三种解构赋值不得使用圆括号。
(1)变量声明语句中,不能带有圆括号。
1 | // 全部报错 |
上面三个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。
(2)函数参数中,模式不能带有圆括号。
函数参数也属于变量声明,因此不能带有圆括号。
1 | // 报错 |
(3)赋值语句中,不能将整个模式,或嵌套模式中的一层,放在圆括号之中。
1 | // 全部报错 |
上面代码将整个模式放在圆括号之中,导致报错。
1 | // 报错 |
上面代码将嵌套模式的一层,放在圆括号之中,导致报错。
可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。
1 | [(b)] = [3]; // 正确 |
上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p,而不是d;第三行语句与第一行语句的性质一致。
变量的解构赋值用途很多。
(1)交换变量的值
1 | [x, y] = [y, x]; |
上面代码交换变量x和y的值,这样的写法不仅简洁,而且易读,语义非常清晰。
(2)从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
1 | // 返回一个数组 |
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。
1 | // 参数是一组有次序的值 |
(4)提取JSON数据
解构赋值对提取JSON对象中的数据,尤其有用。
1 | var jsonData = { |
上面代码可以快速提取JSON数据的值。
(5)函数参数的默认值
1 | jQuery.ajax = function (url, { |
指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || ‘default foo’;这样的语句。
(6)遍历Map结构
任何部署了Iterator接口的对象,都可以用for…of循环遍历。Map结构原生支持Iterator接口,配合变量的解构赋值,获取键名和键值就非常方便。
1 | var map = new Map(); |
(7)输入模块的指定方法
加载模块时,往往需要指定输入那些方法。解构赋值使得输入语句非常清晰。
1 | const { SourceMapConsumer, SourceNode } = require("source-map"); |
ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。
1 | function log(x, y = 'World') { |
参数默认值可以与解构赋值的默认值,结合起来使用。
1 | function foo({x, y = 5}) { |
上面代码使用了对象的解构赋值默认值,而没有使用函数参数的默认值。只有当函数foo的参数是一个对象时,变量x和y才会通过解构赋值而生成。如果函数foo调用时参数不是对象,变量x和y就不会生成,从而报错。如果参数对象没有y属性,y的默认值5才会生效。
指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。
1 | (function (a) {}).length // 1 |
上面代码中,length属性的返回值,等于函数的参数个数减去指定了默认值的参数个数。比如,上面最后一个函数,定义了3个参数,其中有一个参数c指定了默认值,因此length属性等于3减去1,最后得到2。
这是因为length属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest参数也不会计入length属性。
1 | (function(...args) {}).length // 0 |
如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。
1 | (function (a = 0, b, c) {}).length // 0 |
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
1 | function throwIfMissing() { |
ES6引入rest参数(形式为“…变量名”),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
1 | function add(...values) { |
上面代码的add函数是一个求和函数,利用rest参数,可以向该函数传入任意数目的参数。
下面是一个rest参数代替arguments变量的例子。
1 | // arguments变量的写法 |
上面代码的两种写法,比较后可以发现,rest参数的写法更自然也更简洁。
rest参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。下面是一个利用rest参数改写数组push方法的例子。
1 | function push(array, ...items) { |
注意,rest参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
1 | // 报错 |
扩展运算符(spread)是三个点(…)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。
1 | console.log(...[1, 2, 3]) |
该运算符主要用于函数调用。
1 | function push(array, ...items) { |
上面代码中,array.push(…items)和add(…numbers)这两行,都是函数的调用,它们的都使用了扩展运算符。该运算符将一个数组,变为参数序列。
扩展运算符与正常的函数参数可以结合使用,非常灵活。
1 | function f(v, w, x, y, z) { } |
函数的name属性,返回该函数的函数名。
1 | function foo() {} |
这个属性早就被浏览器广泛支持,但是直到ES6,才将其写入了标准。
需要注意的是,ES6对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5的name属性,会返回空字符串,而ES6的name属性会返回实际的函数名。
1 | var func1 = function () {}; |
上面代码中,变量func1等于一个匿名函数,ES5和ES6的name属性返回的值不一样。
如果将一个具名函数赋值给一个变量,则ES5和ES6的name属性都返回这个具名函数原本的名字。
1 | const bar = function baz() {}; |
Function构造函数返回的函数实例,name属性的值为“anonymous”。
1 | (new Function).name // "anonymous" |
bind返回的函数,name属性值会加上“bound ”前缀。
1 | function foo() {}; |
ES6允许使用“箭头”(=>)定义函数。
1 | var f = v => v; |
上面的箭头函数等同于:
1 | var f = function(v) { |
如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
1 | var f = () => 5; |
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return语句返回。
1 | var sum = (num1, num2) => { return num1 + num2; } |
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号。
1 | var getTempItem = id => ({ id: id, name: "Temp" }); |
箭头函数可以与变量解构结合使用。
1 | const full = ({ first, last }) => first + ' ' + last; |
箭头函数使得表达更加简洁。
1 | const isEven = n => n % 2 == 0; |
上面代码只用了两行,就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。
箭头函数的一个用处是简化回调函数。
1 | // 正常函数写法 |
另一个例子是
1 | // 正常函数写法 |
下面是rest参数与箭头函数结合的例子。
1 | const numbers = (...nums) => nums; |
箭头函数有几个使用注意点。
上面四点中,第一点尤其值得注意。this对象的指向是可变的,但是在箭头函数中,它是固定的。
1 | function foo() { |
上面代码中,setTimeout的参数是一个箭头函数,这个箭头函数的定义生效是在foo函数生成时,而它的真正执行要等到100毫秒后。如果是普通函数,执行时this应该指向全局对象window,这时应该输出21。但是,箭头函数导致this总是指向函数定义生效时所在的对象(本例是{id: 42}),所以输出的是42。
箭头函数可以让setTimeout里面的this,绑定定义时所在的作用域,而不是指向运行时所在的作用域。下面是另一个例子。
1 | function Timer() { |
上面代码中,Timer函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this绑定定义时所在的作用域(即Timer函数),后者的this指向运行时所在的作用域(即全局对象)。所以,3100毫秒之后,timer.s1被更新了3次,而timer.s2一次都没更新。
箭头函数可以让this指向固定化,这种特性很有利于封装回调函数。下面是一个例子,DOM事件的回调函数封装在一个对象里面。
1 | var handler = { |
上面代码的init方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。否则,回调函数运行时,this.doSomething这一行会报错,因为此时this指向document对象。
this指向的固定化,并不是因为箭头函数内部有绑定this的机制,实际原因是箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。正是因为它没有this,所以也就不能用作构造函数。
所以,箭头函数转成ES5的代码如下。
1 | // ES6 |
上面代码中,转换后的ES5版本清楚地说明了,箭头函数里面根本没有自己的this,而是引用外层的this。
另外,由于箭头函数没有自己的this,所以当然也就不能用call()、apply()、bind()这些方法去改变this的指向。
1 | (function() { |
上面代码中,箭头函数没有自己的this,所以bind方法无效,内部的this指向外部的this。
箭头函数可以绑定this对象,大大减少了显式绑定this对象的写法(call、apply、bind)。但是,箭头函数并不适用于所有场合,所以ES7提出了“函数绑定”(function bind)运算符,用来取代call、apply、bind调用。虽然该语法还是ES7的一个提案,但是Babel转码器已经支持。
函数绑定运算符是并排的两个双冒号(::
),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。
1 | foo::bar; |
如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。
1 | var method = obj::obj.foo; |
由于双冒号运算符返回的还是原对象,因此可以采用链式写法。
1 | // 例一 |
Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6将其写进了语言标准,统一了用法,原生提供了Promise对象。
所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise是一个对象,从它可以获取异步操作的消息。Promise提供统一的API,各种异步操作都可以用同样的方法进行处理。
Promise对象有以下两个特点。
有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。
Promise也有一些缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于Pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
如果某些事件不断地反复发生,一般来说,使用stream模式是比部署Promise更好的选择。
ES6规定,Promise对象是一个构造函数,用来生成Promise实例。
下面代码创造了一个Promise实例。
1 | var promise = new Promise(function(resolve, reject) { |
Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不用自己部署。
resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从Pending变为Resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从Pending变为Rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
Promise实例生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。
1 | promise.then(function(value) { |
then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为Resolved时调用,第二个回调函数是Promise对象的状态变为Reject时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受Promise对象传出的值作为参数。
下面是一个Promise对象的简单例子。
1 | function timeout(ms) { |
上面代码中,timeout方法返回一个Promise实例,表示一段时间以后才会发生的结果。过了指定的时间(ms参数)以后,Promise实例的状态变为Resolved,就会触发then方法绑定的回调函数。
Promise新建后就会立即执行。
1 | let promise = new Promise(function(resolve, reject) { |
上面代码中,Promise新建后立即执行,所以首先输出的是“Promise”。然后,then方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行,所以“Resolved”最后输出。
下面是异步加载图片的例子。
1 | function loadImageAsync(url) { |
上面代码中,使用Promise包装了一个图片加载的异步操作。如果加载成功,就调用resolve方法,否则就调用reject方法。
下面是一个用Promise对象实现的Ajax操作的例子。
1 | var getJSON = function(url) { |
上面代码中,getJSON是对XMLHttpRequest对象的封装,用于发出一个针对JSON数据的HTTP请求,并且返回一个Promise对象。需要注意的是,在getJSON内部,resolve函数和reject函数调用时,都带有参数。
如果调用resolve函数和reject函数时带有参数,那么它们的参数会被传递给回调函数。reject函数的参数通常是Error对象的实例,表示抛出的错误;resolve函数的参数除了正常的值以外,还可能是另一个Promise实例,表示异步操作的结果有可能是一个值,也有可能是另一个异步操作,比如像下面这样。
1 | var p1 = new Promise(function (resolve, reject) { |
上面代码中,p1和p2都是Promise的实例,但是p2的resolve方法将p1作为参数,即一个异步操作的结果是返回另一个异步操作。
注意,这时p1的状态就会传递给p2,也就是说,p1的状态决定了p2的状态。如果p1的状态是Pending,那么p2的回调函数就会等待p1的状态改变;如果p1的状态已经是Resolved或者Rejected,那么p2的回调函数将会立刻执行。
1 | var p1 = new Promise(function (resolve, reject) { |
上面代码中,p1是一个Promise,3秒之后变为rejected。p2的状态在1秒之后改变,resolve方法返回的是p1。此时,由于p2返回的是另一个Promise,所以后面的then语句都变成针对后者(p1)。又过了2秒,p1变为rejected,导致触发catch方法指定的回调函数。
Promise实例具有then方法,也就是说,then方法是定义在原型对象Promise.prototype上的。它的作用是为Promise实例添加状态改变时的回调函数。前面说过,then方法的第一个参数是Resolved状态的回调函数,第二个参数(可选)是Rejected状态的回调函数。
then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。
1 | getJSON("/posts.json").then(function(json) { |
上面的代码使用then方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。
采用链式的then,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise对象(即有异步操作),这时后一个回调函数,就会等待该Promise对象的状态发生变化,才会被调用。
1 | getJSON("/post/1.json").then(function(post) { |
如果采用箭头函数,上面的代码可以写得更简洁。
1 | getjson("/post/1.json").then( |
1 | Promise.prototype.catch方法是.then(null, rejection)的别名,用于指定发生错误时的回调函数。 |
上面代码中,getJSON方法返回一个Promise对象,如果该对象状态变为Resolved,则会调用then方法指定的回调函数;如果异步操作抛出错误,状态就会变为Rejected,就会调用catch方法指定的回调函数,处理这个错误。另外,then方法指定的回调函数,如果运行中抛出错误,也会被catch方法捕获。
一般来说,不要在then方法里面定义Reject状态的回调函数(即then的第二个参数),总是使用catch方法。
1 | // bad |
上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面then方法执行中的错误,也更接近同步的写法(try/catch)。因此,建议总是使用catch方法,而不使用then方法的第二个参数。
跟传统的try/catch代码块不同的是,如果没有使用catch方法指定错误处理的回调函数,Promise对象抛出的错误不会传递到外层代码,即不会有任何反应。
catch方法之中,还能再抛出错误,通过下一个 catch 捕获。
1 | someAsyncThing().then(function() { |
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 | const databasePromise = connectDatabase(); |
上面代码中,booksPromise和userPromise是两个异步操作,只有等到它们的结果都返回了,才会触发pickTopRecommentations这个回调函数。
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 | var p = Promise.race([ |
上面代码中,如果5秒之内fetch方法无法返回结果,变量p的状态就会变为rejected,从而触发catch方法指定的回调函数。
有时需要将现有对象转为Promise对象,Promise.resolve方法就起到这个作用。
1 | var jsPromise = Promise.resolve($.ajax('/whatever.json')); |
上面代码将jQuery生成的deferred对象,转为一个新的Promise对象。
Promise.resolve等价于下面的写法。
1 | Promise.resolve('foo') |
(1)参数是一个Promise实例
如果参数是Promise实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例。
(2)参数是一个thenable对象
thenable对象指的是具有then方法的对象,比如下面这个对象。
1 | let thenable = { |
Promise.resolve方法会将这个对象转为Promise对象,然后就立即执行thenable对象的then方法。
1 | let thenable = { |
上面代码中,thenable对象的then方法执行后,对象p1的状态就变为resolved,从而立即执行最后那个then方法指定的回调函数,输出42。
(3)参数不是具有then方法的对象,或根本就不是对象
如果参数是一个原始值,或者是一个不具有then方法的对象,则Promise.resolve方法返回一个新的Promise对象,状态为Resolved。
1 | var p = Promise.resolve('Hello'); |
上面代码生成一个新的Promise对象的实例p。由于字符串Hello不属于异步操作(判断方法是它不是具有then方法的对象),返回Promise实例的状态从一生成就是Resolved,所以回调函数会立即执行。Promise.resolve方法的参数,会同时传给回调函数。
(4)不带有任何参数
Promise.resolve方法允许调用时不带参数,直接返回一个Resolved状态的Promise对象。
所以,如果希望得到一个Promise对象,比较方便的方法就是直接调用Promise.resolve方法。
1 | var p = Promise.resolve(); |
上面代码的变量p就是一个Promise对象。
需要注意的是,立即resolve的Promise对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。
1 | setTimeout(function () { |
上面代码中,setTimeout(fn, 0)在下一轮“事件循环”开始时执行,Promise.resolve()在本轮“事件循环”结束时执行,console.log(‘one’)则是立即执行,因此最先输出。
Promise.reject(reason)方法也会返回一个新的Promise实例,该实例的状态为rejected。它的参数用法与Promise.resolve方法完全一致。
1 | var p = Promise.reject('出错了'); |
上面代码生成一个Promise对象的实例p,状态为rejected,回调函数会立即执行。
下面介绍如何部署两个不在ES6之中、但很有用的方法。
Promise对象的回调链,不管以then方法或catch方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个done方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。
1 | asyncFunc() |
它的实现代码相当简单。
1 | Promise.prototype.done = function (onFulfilled, onRejected) { |
从上面代码可见,done方法的使用,可以像then方法那样用,提供Fulfilled和Rejected状态的回调函数,也可以不提供任何参数。但不管怎样,done都会捕捉到任何可能出现的错误,并向全局抛出。
finally方法用于指定不管Promise对象最后状态如何,都会执行的操作。它与done方法的最大区别,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。
下面是一个例子,服务器使用Promise处理请求,然后使用finally方法关掉服务器。
1 | server.listen(0) |
它的实现也很简单。
1 | Promise.prototype.finally = function (callback) { |
上面代码中,不管前面的Promise是fulfilled还是rejected,都会执行回调函数callback。
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"(coroutine),意思是多个线程互相协作,完成异步任务。
协程有点像函数,又有点像线程。它的运行流程大致如下。
上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。
举例来说,读取文件的协程写法如下。
1 | function *asyncJob() { |
上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。
协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。
Generator函数是协程在ES6的实现,最大特点就是可以交出函数的执行权(即暂停执行)。
整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。Generator函数的执行方法如下。
1 | function* gen(x){ |
上面代码中,调用Generator函数,会返回一个内部指针(即遍历器)g 。这是Generator函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上例是执行到x + 2为止。
换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。
Generator函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。
next方法返回值的value属性,是Generator函数向外输出数据;next方法还可以接受参数,这是向Generator函数体内输入数据。
上面代码中,第一个next方法的value属性,返回表达式x + 2的值(3)。第二个next方法带有参数2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收。因此,这一步的 value 属性,返回的就是2(变量y的值)。
Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。
1 | function* gen(x){ |
上面代码的最后一行,Generator函数体外,使用指针对象的throw方法抛出的错误,可以被函数体内的try …catch代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。
下面看看如何使用 Generator 函数,执行一个真实的异步任务。
1 | var fetch = require('node-fetch'); |
上面代码中,Generator函数封装了一个异步操作,该操作先读取一个远程接口,然后从JSON格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield命令。
执行这段代码的方法如下。
1 | var g = gen(); |
上面代码中,首先执行Generator函数,获取遍历器对象,然后使用next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个Promise对象,因此要用 then 方法调用下一个 next 方法。
可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。
Thunk 函数主要用于实现编译器的"传名调用"(call by name)。
1 | var x = 1; |
上面代码先定义函数f,然后向它传入表达式x + 5。请问,这个表达式应该何时求值?
编译器的"传名调用"实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做Thunk函数。
1 | function f(m){ |
JavaScript语言是传值调用,它的Thunk函数含义有所不同。在JavaScript语言中,Thunk函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。
1 | // 正常版本的readFile(多参数版本) |
使用上面的转换器,生成fs.readFile的Thunk函数。
1 | var readFileThunk = Thunk(fs.readFile); |
下面是另一个完整的例子。
1 | function f(a, cb) { |
生产环境的转换器,建议使用Thunkify模块。
首先是安装。
1 | $ npm install thunkify |
使用方式如下。
1 | var thunkify = require('thunkify'); |
Thunkify的源码与上一节那个简单的转换器非常像。
1 | function thunkify(fn){ |
它的源码主要多了一个检查机制,变量called确保回调函数只运行一次。这样的设计与下文的Generator函数相关。请看下面的例子。
1 | function f(a, b, callback){ |
上面代码中,由于thunkify只允许回调函数执行一次,所以只输出一行结果。
Thunk函数真正的威力,在于可以自动执行Generator函数。下面就是一个基于Thunk函数的Generator执行器。
1 | function run(fn) { |
上面代码的run函数,就是一个Generator函数的自动执行器。内部的next函数就是Thunk的回调函数。next函数先将指针移到Generator函数的下一步(gen.next方法),然后判断Generator函数是否结束(result.done属性),如果没结束,就将next函数再传入Thunk函数(result.value属性),否则就直接退出。
有了这个执行器,执行Generator函数方便多了。不管内部有多少个异步操作,直接把Generator函数传入run函数即可。当然,前提是每一个异步操作,都要是Thunk函数,也就是说,跟在yield命令后面的必须是Thunk函数。
1 | var g = function* (){ |
上面代码中,函数g封装了n个异步的读取文件操作,只要执行run函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
Thunk函数并不是Generator函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制Generator函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
ES6诞生以前,异步编程的方法,大概有下面四种。
ES6将JavaScript异步编程带入了一个全新的阶段,ES7的Async函数更是提出了异步编程的终极解决方案。
co模块 是著名程序员TJ Holowaychuk于2013年6月发布的一个小工具,用于Generator函数的自动执行。
比如,有一个Generator函数,用于依次读取两个文件。
1 | var gen = function* (){ |
co模块可以让你不用编写Generator函数的执行器。
1 | var co = require('co'); |
上面代码中,Generator函数只要传入co函数,就会自动执行。
co函数返回一个Promise对象,因此可以用then方法添加回调函数。
1 | co(gen).then(function (){ |
上面代码中,等到Generator函数执行结束,就会输出一行提示。
为什么co可以自动执行Generator函数?
前面说过,Generator就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。
两种方法可以做到这一点。
co模块其实就是将两种自动执行器(Thunk函数和Promise对象),包装成一个模块。使用co的前提条件是,Generator函数的yield命令后面,只能是Thunk函数或Promise对象。
上一节已经介绍了基于Thunk函数的自动执行器。下面来看,基于Promise对象的自动执行器。这是理解co模块必须的。
还是沿用上面的例子。首先,把fs模块的readFile方法包装成一个Promise对象。
1 | var fs = require('fs'); |
然后,手动执行上面的Generator函数。
1 | var g = gen(); |
手动执行其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。
1 | function run(gen){ |
上面代码中,只要Generator函数还没执行到最后一步,next函数就调用自身,以此实现自动执行。
co支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。
这时,要把并发的操作都放在数组或对象里面,跟在yield语句后面。
1 | // 数组的写法 |
下面是另一个例子。
1 | co(function* () { |
上面的代码允许并发三个somethingAsync异步操作,等到它们全部完成,才会进行下一步。
ES7提供了async函数,使得异步操作变得更加方便。async函数是什么?一句话,async函数就是Generator函数的语法糖。
前文有一个Generator函数,依次读取两个文件。
1 | var fs = require('fs'); |
写成async函数,就是下面这样。
1 | var asyncReadFile = async function (){ |
一比较就会发现,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 | async function f() { |
上面代码中,函数f内部return命令返回的值,会被then方法回调函数接收到。
async函数内部抛出错误,会导致返回的Promise对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。
1 | async function f() { |
(2)async函数返回的Promise对象,必须等到内部所有await命令的Promise对象执行完,才会发生状态改变。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。
下面是一个例子。
1 | async function getTitle(url) { |
(3)正常情况下,await命令后面是一个Promise对象。如果不是,会被转成一个立即resolve的Promise对象。
1 | async function f() { |
上面代码中,await命令的参数是数值123,它被转成Promise对象,并立即resolve。
await命令后面的Promise对象如果变为reject状态,则reject的参数会被catch方法的回调函数接收到。
1 | async function f() { |
注意,上面代码中,await语句前面没有return,但是reject方法的参数依然传入了catch方法的回调函数。这里如果在await前面加上return,效果是一样的。
只要一个await语句后面的Promise变为reject,那么整个async函数都会中断执行。
1 | async function f() { |
上面代码中,第二个await语句是不会执行的,因为第一个await语句状态变成了reject。
为了避免这个问题,可以将第一个await放在try…catch结构里面,这样第二个await就会执行。
1 | async function f() { |
另一种方法是await后面的Promise对象再跟一个catch方面,处理前面可能出现的错误。
1 | async function f() { |
如果有多个await命令,可以统一放在try…catch结构中。
1 | async function main() { |
(4)如果await后面的异步操作出错,那么等同于async函数返回的Promise对象被reject。
1 | async function f() { |
上面代码中,async函数f执行后,await后面的Promise对象会抛出一个错误对象,导致catch方法的回调函数被调用,它的参数就是抛出的错误对象。具体的执行机制,可以参考后文的“async函数的实现”。
防止出错的方法,也是将其放在try…catch代码块之中。
1 | async function f() { |
try...catch
代码块中。
JavaScript语言的传统方法是通过构造函数,定义并生成新对象。下面是一个例子。
1 | function Point(x, y) { |
上面这种写法跟传统的面向对象语言(比如C++和Java)差异很大,很容易让新学习这门语言的程序员感到困惑。
ES6提供了更接近传统语言的写法,引入了Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。基本上,ES6的class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用ES6的“类”改写,就是下面这样。
1 | //定义类 |
上面代码定义了一个“类”,可以看到里面有一个constructor方法,这就是构造方法,而this关键字则代表实例对象。也就是说,ES5的构造函数Point,对应ES6的Point类的构造方法。
Point类除了构造方法,还定义了一个toString方法。注意,定义“类”的方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。另外,方法之间不需要逗号分隔,加了会报错。
function
关键字;ES6的类,完全可以看作构造函数的另一种写法。
1 | class Point { |
上面代码表明,类的数据类型就是函数,类本身就指向构造函数。
使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。
1 | class Bar { |
构造函数的prototype属性,在ES6的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。
1 | class Point { |
在类的实例上面调用方法,其实就是调用原型上的方法。
1 | class B {} |
上面代码中,b是B类的实例,它的constructor方法就是B类原型的constructor方法。
由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign方法可以很方便地一次向类添加多个方法。
1 | class Point { |
prototype对象的constructor属性,直接指向“类”的本身,这与ES5的行为是一致的。
1 | Point.prototype.constructor === Point // true |
另外,类的内部所有定义的方法,都是不可枚举的(non-enumerable)。
1 | class Point { |
上面代码中,toString方法是Point类内部定义的方法,它是不可枚举的。这一点与ES5的行为不一致。
1 | var Point = function (x, y) { |
上面代码采用ES5的写法,toString方法就是可枚举的。
类的属性名,可以采用表达式。
1 | let methodName = "getArea"; |
上面代码中,Square类的方法名getArea,是从表达式得到的。
Class不存在变量提升(hoist),这一点与ES5完全不同。
1 | new Foo(); // ReferenceError |
上面代码中,Foo类使用在前,定义在后,这样会报错,因为ES6不会把类的声明提升到代码头部。这种规定的原因与下文要提到的继承有关,必须保证子类在父类之后定义。
1 | { |
上面的代码不会报错,因为class继承Foo的时候,Foo已经有定义了。但是,如果存在class的提升,上面代码就会报错,因为class会被提升到代码头部,而let命令是不提升的,所以导致class继承Foo的时候,Foo还没有定义。
与函数一样,类也可以使用表达式的形式定义。
1 | const MyClass = class Me { |
上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass而不是Me,Me只在Class的内部代码可用,指代当前类。
1 | let inst = new MyClass(); |
上面代码表示,Me只在Class内部有定义。
如果类的内部没用到的话,可以省略Me,也就是可以写成下面的形式。
1 | const MyClass = class { /* ... */ }; |
采用Class表达式,可以写出立即执行的Class。
1 | let person = new class { |
上面代码中,person是一个立即执行的类的实例。
私有方法是常见需求,但ES6不提供,只能通过变通方法模拟实现。
1 | class Widget { |
1 | const bar = Symbol('bar'); |
上面代码中,bar和snaf都是Symbol值,导致第三方无法获取到它们,因此达到了私有方法和私有属性的效果。
类的方法内部如果含有this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能报错。
1 | class Logger { |
上面代码中,printName方法中的this,默认指向Logger类的实例。但是,如果将这个方法提取出来单独使用,this会指向该方法运行时所在的环境,因为找不到print方法而导致报错。
一个比较简单的解决方法是,在构造方法中绑定this,这样就不会找不到print方法了。
1 | class Logger { |
另一种解决方法是使用箭头函数。
1 | class Logger { |
还有一种解决方法是使用Proxy,获取方法的时候,自动绑定this。
1 | function selfish (target) { |
类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。只要你的代码写在类或模块之中,就只有严格模式可用。
考虑到未来所有的代码,其实都是运行在模块之中,所以ES6实际上把整个语言升级到了严格模式。
Class之间可以通过extends关键字实现继承,这比ES5的通过修改原型链实现继承,要清晰和方便很多。
1 | class ColorPoint extends Point {} |
上面代码定义了一个ColorPoint类,该类通过extends关键字,继承了Point类的所有属性和方法。但是由于没有部署任何代码,所以这两个类完全一样,等于复制了一个Point类。下面,我们在ColorPoint内部加上代码。
1 | class ColorPoint extends Point { |
上面代码中,constructor方法和toString方法之中,都出现了super关键字,它在这里表示父类的构造函数,用来新建父类的this对象。
子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类没有自己的this对象,而是继承父类的this对象,然后对其进行加工。如果不调用super方法,子类就得不到this对象。
1 | class Point { /* ... */ } |
上面代码中,ColorPoint继承了父类Point,但是它的构造函数没有调用super方法,导致新建实例时报错。
ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。
如果子类没有定义constructor方法,这个方法会被默认添加,代码如下。也就是说,不管有没有显式定义,任何一个子类都有constructor方法。
1 | constructor(...args) { |
另一个需要注意的地方是,在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,是基于对父类实例加工,只有super方法才能返回父类实例。
1 | class Point { |
上面代码中,子类的constructor方法没有调用super之前,就使用this关键字,结果报错,而放在super方法之后就是正确的。
下面是生成子类实例的代码。
1 | let cp = new ColorPoint(25, 8, 'green'); |
上面代码中,实例对象cp同时是ColorPoint和Point两个类的实例,这与ES5的行为完全一致。
Object.getPrototypeOf方法可以用来从子类上获取父类。
1 | Object.getPrototypeOf(ColorPoint) === Point |
因此,可以使用这个方法判断,一个类是否继承了另一个类。
super这个关键字,有两种用法,含义不同。
(1)作为函数调用时(即super(…args)),super代表父类的构造函数。
(2)作为对象调用时(即super.prop或super.method()),super代表父类。注意,此时super即可以引用父类实例的属性和方法,也可以引用父类的静态方法。
new是从构造函数生成实例的命令。ES6为new命令引入了一个new.target属性,(在构造函数中)返回new命令作用于的那个构造函数。如果构造函数不是通过new命令调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。
1 | function Person(name) { |
上面代码确保构造函数只能通过new命令调用。
Class内部调用new.target,返回当前Class。
1 | class Rectangle { |
需要注意的是,子类继承父类时,new.target会返回子类。
1 | class Rectangle { |
上面代码中,new.target会返回子类。
利用这个特点,可以写出不能独立使用、必须继承后才能使用的类(类似Java的抽象类)。
1 | class Shape { |
上面代码中,Shape类不能被实例化,只能用于继承。
注意,在函数外部,使用new.target会报错。
Mixin模式指的是,将多个类的接口“混入”(mix in)另一个类。它在ES6的实现如下。
1 | function mix(...mixins) { |
上面代码的mix函数,可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。
1 | class DistributedEdit extends mix(Loggable, Serializable) { |
ES6 在语言规格的层面上,实现了模块功能。ES6模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
浏览器使用ES6模块的语法如下。
1 | <script type="module" src="foo.js"></script> |
上面代码在网页中插入一个模块foo.js,由于type属性设为module,所以浏览器知道这是一个ES6模块。
Node的默认模块格式是CommonJS,目前还没决定怎么支持ES6模块。所以,只能通过Babel这样的转码器,在Node里面使用ES6模块。
模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个JS文件,里面使用export命令输出变量。
1 | // profile.js |
上面代码是profile.js文件,保存了用户信息。ES6将其视为一个模块,里面用export命令对外部输出了三个变量。
export的写法,除了像上面这样,还有另外一种。
1 | // profile.js |
上面代码在export命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在var语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。
export命令除了输出变量,还可以输出函数或类(class)。
1 | export function multiply(x, y) { |
上面代码对外输出一个函数multiply。
通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
1 | function v1() { ... } |
上面代码使用as关键字,重命名了函数v1和v2的对外接口。重命名后,v2可以用不同的名字输出两次。
需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
1 | // 报错 |
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出1,第二种写法通过变量m,还是直接输出1。1只是一个值,不是接口。正确的写法是下面这样。
1 | // 写法一 |
上面三种写法都是正确的,规定了对外的接口m。其他脚本可以通过这个接口,取到值1。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。
同样的,function和class的输出,也必须遵守这样的写法。
1 | // 报错 |
另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
1 | export var foo = 'bar'; |
上面代码输出变量foo,值为bar,500毫秒之后变成baz。
这一点与CommonJS规范完全不同。CommonJS模块输出的是值的缓存,不存在动态更新,详见下文《ES6模块加载的实质》一节。
最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了ES6模块的设计初衷。
1 | function foo() { |
上面代码中,export语句放在函数之中,结果报错。
使用export命令定义了模块的对外接口以后,其他JS文件就可以通过import命令加载这个模块(文件)。
1 | // main.js |
上面代码的import命令,就用于加载profile.js文件,并从中输入变量。import命令接受一个对象(用大括号表示),里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。
如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
1 | import { lastName as surname } from './profile'; |
注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。
1 | foo(); |
上面的代码不会报错,因为import的执行早于foo的调用。
如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
1 | export { es6 as default } from './someModule'; |
上面代码中,export和import语句可以结合在一起,写成一行。但是从可读性考虑,不建议采用这种写法,而应该采用标准写法。
另外,ES7有一个提案,简化先输入后输出的写法,拿掉输出时的大括号。
1 | // 提案的写法 |
import语句会执行所加载的模块,因此可以有下面的写法。
1 | import 'lodash'; |
上面代码仅仅执行lodash模块,但是不输入任何值。
1 | import * as circle from './circle'; |
export default命令可以为模块指定默认输出。
1 | // export-default.js |
上面代码是一个模块文件export-default.js,它的默认输出是一个函数。
其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。
1 | // import-default.js |
export default命令用在非匿名函数前,也是可以的。
1 | // export-default.js |
下面比较一下默认输出和正常输出。
1 | // 输出 |
上面代码的两组写法,第一组是使用export default时,对应的import语句不需要使用大括号;第二组是不使用export default时,对应的import语句需要使用大括号。
export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export deault命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能对应一个方法。
export default也可以用来输出类。
1 | // MyClass.js |
模块之间也可以继承。
假设有一个circleplus模块,继承了circle模块。
1 | // circleplus.js |
上面代码中的export *,表示再输出circle模块的所有属性和方法。注意,export *命令会忽略circle模块的default方法。然后,上面代码又输出了自定义的e变量和默认方法。
这时,也可以将circle的属性或方法,改名后再输出。
1 | // circleplus.js |
上面代码表示,只输出circle模块的area方法,且将其改名为circleArea。
加载上面模块的写法如下。
1 | // main.js |
上面代码中的import exp表示,将circleplus模块的默认方法加载为exp方法。
浏览器目前还不支持ES6模块,为了现在就能使用,可以将转为ES5的写法。除了Babel可以用来转码之外,还有以下两个方法,也可以用来转码。