本文是我在学习 ECMAScript 6 时的记录,用于个人查询总结。在学习的过程中参考过很多别人的文章,如有需要,可以根据链接详细学习:
ECMAScript 6简介
ECMAScript和JavaScript的关系
要讲清楚这个问题,需要回顾历史。1996年11月,JavaScript的创造者Netscape公司,决定将JavaScript提交给国际标准化组织ECMA,希望这种语言能够成为国际标准。次年,ECMA发布262号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为ECMAScript,这个版本就是1.0版。
该标准从一开始就是针对JavaScript语言制定的,但是之所以不叫JavaScript,有两个原因。一是商标,Java是Sun公司的商标,根据授权协议,只有Netscape公司可以合法地使用JavaScript这个名字,且JavaScript本身也已经被Netscape公司注册为商标。二是想体现这门语言的制定者是ECMA,不是Netscape,这样有利于保证这门语言的开放性和中立性。
因此,ECMAScript和JavaScript的关系是,前者是后者的规格,后者是前者的一种实现(另外的ECMAScript方言还有Jscript和ActionScript)。日常场合,这两个词是可以互换的。
部署进度
各大浏览器的最新版本,对ES6的支持可以查看 kangax.github.io/es5-compat-table/es6/。随着时间的推移,支持度已经越来越高了,ES6的大部分特性都实现了
ES6 和 ES5 的对比
let 和 const
let 命令
let
命令相比于 var
命令,有一下区别:
- 不存在变量提升
- 暂时性死区
- 不允许重复声明
块级作用域
ES5只有全局作用域和函数作用域,没有块级作用域。let
实际上为JavaScript新增了块级作用域。
块级作用域与函数声明:
ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。
ES6 改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6在附录B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。
- 允许在块级作用域内声明函数。
- 函数声明类似于
var
,即会提升到全局作用域或函数作用域的头部。 - 同时,函数声明还会提升到所在的块级作用域的头部。
注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let处理。
考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
const命令
const
声明一个只读的常量。一旦声明,常量的值就不能改变。
const
声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。
1 | const foo; |
const
的作用域与let
命令相同:只在声明所在的块级作用域内有效。
const
命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
const
声明的常量,也与let
一样不可重复声明。
对于复合类型的变量,变量名不指向数据,而是指向数据所在的地址。const
命令只是保证变量名指向的地址不变,并不保证该地址的数据不变,所以将一个对象声明为常量必须非常小心。
如果真的想将对象冻结,应该使用Object.freeze
方法。除了将对象本身冻结,对象的属性也应该冻结
1 | var constantize = (obj) => { |
ES5只有两种声明变量的方法:var
命令和function
命令。ES6除了添加let
和const
命令,后面章节还会提到,另外两种声明变量的方法:import
命令和class
命令。所以,ES6一共有6种声明变量的方法。
顶层对象的属性
顶层对象,在浏览器环境指的是window
对象,在Node指的是global
对象。ES5之中,顶层对象的属性与全局变量是等价的。
ES6为了改变这一点,一方面规定,为了保持兼容性,var
命令和function
命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let
命令、const
命令、class
命令声明的全局变量,不属于顶层对象的属性。也就是说,从ES6开始,全局变量将逐步与顶层对象的属性脱钩。
global 对象
ES5的顶层对象,本身也是一个问题,因为它在各种实现里面是不统一的。
- 浏览器里面,顶层对象是
window
,但 Node 和 Web Worker 没有window
。 - 浏览器和 Web Worker 里面,
self
也指向顶层对象,但是Node没有self
。 Node 里面,顶层对象是
global
,但其他环境都不支持。全局环境中,this会返回顶层对象。但是,Node模块和ES6模块中,this返回的是当前模块。
- 函数里面的this,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this会指向顶层对象。但是,严格模式下,这时this会返回undefined。
- 不管是严格模式,还是普通模式,new Function(‘return this’)(),总是会返回全局对象。但是,如果浏览器用了CSP(Content Security Policy,内容安全政策),那么eval、new Function这些方法都可能无法使用。
现在有一个提案,在语言标准的层面,引入global
作为顶层对象。也就是说,在所有环境下,global
都是存在的,都可以从它拿到顶层对象。
垫片库system.global
模拟了这个提案,可以在所有环境拿到global
。
1 | // CommonJS的写法 |
上面代码将顶层对象放入变量global
。
变量的解构赋值
数组的解构赋值
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
1 | let [a, b, c] = [1, 2, 3]; |
默认值
解构赋值允许指定默认值。
1 | let [foo = true] = []; |
注意,ES6 内部使用严格相等运算符(===
),判断一个位置是否有值。所以,如果一个数组成员不严格等于undefined
,默认值是不会生效的。
对象的解构赋值
解构不仅可以用于数组,还可以用于对象。
1 | let { foo, bar } = { foo: "aaa", bar: "bbb" }; |
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
如果变量名与属性名不一致,必须写成下面这样。
1 | var { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
字符串的解构赋值
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
1 | const [a, b, c, d, e] = 'hello'; |
数值和布尔值的解构赋值
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
1 | let {toString: s} = 123; |
上面代码中,数值和布尔值的包装对象都有toString
属性,因此变量s都能取到值。
解构赋值的规则是,只要等号右边的值不是对象,就先将其转为对象。由于undefined
和null
无法转为对象,所以对它们进行解构赋值,都会报错。
函数参数的解构赋值
函数的参数也可以使用解构赋值。
1 | function add([x, y]){ |
1 | [[1, 2], [3, 4]].map(([a, b]) => a + b); |
用途
交换变量的值
1 | let x = 1; |
从函数返回多个值
1 | // 返回一个数组 |
函数参数的定义
1 | // 参数是一组有次序的值 |
提取JSON数据
解构赋值对提取JSON对象中的数据,尤其有用。
1 | let jsonData = { |
函数参数的默认值
1 | jQuery.ajax = function (url, { |
指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || 'default foo';
这样的语句。
遍历Map结构
任何部署了Iterator
接口的对象,都可以用for...of
循环遍历。Map
结构原生支持Iterator
接口,配合变量的解构赋值,获取键名和键值就非常方便。
1 | var map = new Map(); |
如果只想获取键名,或者只想获取键值,可以写成下面这样。
1 | // 获取键名 |
输入模块的指定方法
加载模块时,往往需要指定输入那些方法。解构赋值使得输入语句非常清晰。
1 | const { SourceMapConsumer, SourceNode } = require("source-map"); |
字符串的扩展
ES6加强了对Unicode的支持,并且扩展了字符串对象。
字符的Unicode表示法
JavaScript允许采用\uxxxx形式表示一个字符,其中“xxxx”表示字符的码点。但是,这种表示法只限于\u0000——\uFFFF之间的字符。超出这个范围的字符,必须用两个双字节的形式表达。
1 | "\uD842\uDFB7" |
上面代码表示,如果直接在\u后面跟上超过0xFFFF的数值(比如\u20BB7),JavaScript会理解成\u20BB+7。由于\u20BB是一个不可打印字符,所以只会显示一个空格,后面跟着一个7。
ES6 对这一点做出了改进,只要将码点放入大括号,就能正确解读该字符。
1 | "\u{20BB7}" |
上面代码中,最后一个例子表明,大括号表示法与四字节的UTF-16编码是等价的。
有了这种表示法之后,JavaScript共有6种方法可以表示一个字符。
1 | '\z' === 'z' // true |
codePointAt()
JavaScript内部,字符以UTF-16的格式储存,每个字符固定为2
个字节。对于那些需要4个字节储存的字符(Unicode码点大于0xFFFF
的字符),JavaScript会认为它们是两个字符。
1 | var s = "𠮷"; |
ES6提供了codePointAt
方法,能够正确处理4个字节储存的字符,返回一个字符的码点。
1 | var s = '𠮷a'; |
codePointAt
方法会正确返回32位的UTF-16字符的码点。对于那些两个字节储存的常规字符,它的返回结果与charCodeAt
方法相同。
String.fromCodePoint()
ES5提供String.fromCharCode
方法,用于从码点返回对应字符,但是这个方法不能识别32位的UTF-16字符(Unicode编号大于0xFFFF)。
ES6提供了String.fromCodePoint方法,可以识别0xFFFF的字符,弥补了String.fromCharCode方法的不足。在作用上,正好与codePointAt方法相反。
1 | String.fromCodePoint(0x20BB7) |
上面代码中,如果String.fromCodePoint方法有多个参数,则它们会被合并成一个字符串返回。
注意,fromCodePoint方法定义在String对象上,而codePointAt方法定义在字符串的实例对象上。
字符串的遍历器接口
ES6为字符串添加了遍历器接口(详见《Iterator》一章),使得字符串可以被for…of循环遍历。
1 | for (let codePoint of 'foo') { |
除了遍历字符串,这个遍历器最大的优点是可以识别大于0xFFFF的码点,传统的for循环无法识别这样的码点。
includes(), startsWith(), endsWith()
传统上,JavaScript只有indexOf方法,可以用来确定一个字符串是否包含在另一个字符串中。ES6又提供了三种新方法。
- includes():返回布尔值,表示是否找到了参数字符串。
- startsWith():返回布尔值,表示参数字符串是否在源字符串的头部。
- endsWith():返回布尔值,表示参数字符串是否在源字符串的尾部。
repeat()
repeat
方法返回一个新字符串,表示将原字符串重复n
次。
1 | 'x'.repeat(3) // "xxx" |
padStart(),padEnd()
ES2017 引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部或尾部补全。padStart()
用于头部补全,padEnd()
用于尾部补全。
模板字符串
传统的JavaScript语言,输出模板通常是这样写的。
1 | $('#result').append( |
上面这种写法相当繁琐不方便,ES6引入了模板字符串解决这个问题。
1 | $('#result').append(` |
正则的扩展
RegExp构造函数
ES6改变了这种行为。如果RegExp构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。
1 | new RegExp(/abc/ig, 'i').flags |
上面代码中,原有正则对象的修饰符是ig,它会被第二个参数i覆盖。
字符串的正则方法
字符串对象共有4个方法,可以使用正则表达式:match()
、replace()
、search()
和split()
。
ES6将这4个方法,在语言内部全部调用RegExp的实例方法,从而做到所有与正则相关的方法,全都定义在RegExp对象上。
- String.prototype.match 调用 RegExp.prototype[Symbol.match]
- String.prototype.replace 调用 RegExp.prototype[Symbol.replace]
- String.prototype.search 调用 RegExp.prototype[Symbol.search]
- String.prototype.split 调用 RegExp.prototype[Symbol.split]
u修饰符
S6对正则表达式添加了u修饰符,含义为“Unicode模式”,用来正确处理大于\uFFFF
的Unicode字符。也就是说,会正确处理四个字节的UTF-16编码。
1 | /^\uD83D/u.test('\uD83D\uDC2A') |
上面代码中,\uD83D\uDC2A
是一个四个字节的UTF-16编码,代表一个字符。但是,ES5不支持四个字节的UTF-16编码,会将其识别为两个字符,导致第二行代码结果为true
。加了u
修饰符以后,ES6就会识别其为一个字符,所以第一行代码结果为false
。
一旦加上u
修饰符号,就会修改一些正则表达式的行为。
y 修饰符
除了u
修饰符,ES6还为正则表达式添加了y
修饰符,叫做“粘连”(sticky)修饰符。
y
修饰符的作用与g
修饰符类似,也是全局匹配,后一次匹配都从上一次匹配成功的下一个位置开始。不同之处在于,g
修饰符只要剩余位置中存在匹配就可,而y修饰符确保匹配必须从剩余的第一个位置开始,这也就是“粘连”的涵义。
进一步说,y
修饰符号隐含了头部匹配的标志^
。
1 | var s = 'aaa_aa_a'; |
与y
修饰符相匹配,ES6的正则对象多了sticky
属性,表示是否设置了y
修饰符。
1 | var r = /hello\d/y; |
flags属性
ES6为正则表达式新增了flags属性,会返回正则表达式的修饰符。
1 | // ES5的source属性 |
RegExp.escape()
字符串必须转义,才能作为正则模式
1 | function escapeRegExp(str) { |
用字符串生成正则匹配模式:
1 | RegExp.escape('The Quick Brown Fox'); |
s 修饰符:dotAll 模式
正则表达式中,点(.
)是一个特殊字符,代表任意的单个字符,但是行终止符(line terminator character)除外。
以下四个字符属于”行终止符“。
- U+000A 换行符(\n)
- U+000D 回车符(\r)
- U+2028 行分隔符(line separator)
- U+2029 段分隔符(paragraph separator)
1 | /foo.bar/.test('foo\nbar') |
现在有一个提案,引入/s
修饰符,使得.
可以匹配任意单个字符。
1 | /foo.bar/s.test('foo\nbar') // true |
数值的扩展
二进制和八进制表示法
ES6 提供了二进制和八进制数值的新的写法,分别用前缀0b
(或0B
)和0o
(或0O
)表示。
1 | 0b111110111 === 503 // true |
如果要将0b
和0o
前缀的字符串数值转为十进制,要使用Number
方法。
1 | Number('0b111') // 7 |
Number.isFinite(), Number.isNaN()
ES6在Number
对象上,新提供了Number.isFinite()
和Number.isNaN()
两个方法。
Number.isFinite()
用来检查一个数值是否为有限的(finite
)。
Number.isNaN()
用来检查一个值是否为NaN
。
它们与传统的全局方法isFinite()
和isNaN()
的区别在于,传统方法先调用Number()
将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,非数值一律返回false
。
Number.parseInt(), Number.parseFloat()
ES6将全局方法parseInt()
和parseFloat()
,移植到Number对象上面,行为完全保持不变。
1 | // ES5的写法 |
Number.isInteger()
Number.isInteger()
用来判断一个值是否为整数。需要注意的是,在JavaScript内部,整数和浮点数是同样的储存方法,所以3和3.0被视为同一个值。
1 | Number.isInteger(25) // true |
Number.EPSILON
ES6在Number对象上面,新增一个极小的常量Number.EPSILON
。
引入一个这么小的量的目的,在于为浮点数计算,设置一个误差范围。
但是如果这个误差能够小于Number.EPSILON
,我们就可以认为得到了正确结果。
因此,Number.EPSILON
的实质是一个可以接受的误差范围。
1 | Number.EPSILON |
安全整数和Number.isSafeInteger()
JavaScript能够准确表示的整数范围在-2^53
到2^53
之间(不含两个端点),超过这个范围,无法精确表示这个值。
1 | Math.pow(2, 53) // 9007199254740992 |
ES6引入了Number.MAX_SAFE_INTEGER
和Number.MIN_SAFE_INTEGER
这两个常量,用来表示这个范围的上下限。Number.isSafeInteger()
则是用来判断一个整数是否落在这个范围之内。
1 | Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1 |
Math对象的扩展
Math.trunc()
Math.trunc
方法用于去除一个数的小数部分,返回整数部分。
1 | Math.trunc(4.1) // 4 |
Math.sign()
Math.sign
方法用来判断一个数到底是正数、负数、还是零。
它会返回五种值。
- 参数为正数,返回+1;
- 参数为负数,返回-1;
- 参数为0,返回0;
- 参数为-0,返回-0;
- 其他值,返回NaN。
Math.cbrt()
Math.cbrt
方法用于计算一个数的立方根。
Math.clz32()
JavaScript的整数使用32位二进制形式表示,Math.clz32
方法返回一个数的32位无符号整数形式有多少个前导0。
Math.imul()
Math.imul
方法返回两个数以32位带符号整数形式相乘的结果,返回的也是一个32位的带符号整数。
大多数情况下,Math.imul(a, b)
与a * b
的结果是相同的,之所以需要部署这个方法,是因为JavaScript有精度限制,超过2的53次方的值无法精确表示。这就是说,对于那些很大的数的乘法,低位数值往往都是不精确的,Math.imul
方法可以返回正确的低位数值。
Math.signbit()
Math.sign()
用来判断一个值的正负,但是如果参数是-0,它会返回-0。
引入了Math.signbit()
方法判断一个数的符号位是否设置了。
1 | Math.signbit(2) //false |
数组的扩展
Array.from()
Array.from
方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括ES6新增的数据结构Set和Map)。
实际应用中,常见的类似数组的对象是DOM操作返回的NodeList集合,以及函数内部的arguments对象。Array.from
都可以将它们转为真正的数组。
1 | // NodeList对象 |
上面代码中,querySelectorAll
方法返回的是一个类似数组的对象,只有将这个对象转为真正的数组,才能使用forEach
方法
Array.of()
Array.of
方法用于将一组值,转换为数组。
1 | Array.of(3, 11, 8) // [3,11,8] |
这个方法的主要目的,是弥补数组构造函数Array()
的不足。因为参数个数的不同,会导致Array()
的行为有差异。
1 | Array() // [] |
数组实例的copyWithin()
数组实例的copyWithin
方法,在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法,会修改当前数组。
数组实例的find()和findIndex()
数组实例的find
方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true
的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined
。
数组实例的findIndex
方法的用法与find方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1
。
这两个方法都可以发现NaN
,弥补了数组的IndexOf
方法的不足。
数组实例的entries(),keys()和values()
ES6提供三个新的方法——entries()
,keys()
和values()
——用于遍历数组。它们都返回一个遍历器对象(详见《Iterator》一章),可以用for...of
循环进行遍历,唯一的区别是keys()
是对键名的遍历、values()
是对键值的遍历,entries()
是对键值对的遍历。
1 | for (let index of ['a', 'b'].keys()) { |
函数的扩展
函数参数的默认值
在ES6之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
1 | function log(x, y) { |
ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。
1 | function log(x, y = 'World') { |
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
函数的 length 属性
指定了默认值以后,函数的length
属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length
属性将失真。
rest参数
ES6 引入 rest 参数(形式为“…变量名”),用于获取函数的多余参数,这样就不需要使用arguments
对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
1 | function add(...values) { |
扩展运算符
扩展运算符(spread)是三个点(...
)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。
1 | console.log(...[1, 2, 3]) |
扩展运算符的应用
合并数组
1 | // ES5 |
与解构赋值结合
1 | // ES5 |
字符串
扩展运算符还可以将字符串转为真正的数组。
1 | [...'hello'] |
上面代码的第一种写法,JavaScript会将32位Unicode字符,识别为2个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数
凡是涉及到操作32位Unicode字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
1 | let str = 'x\uD83D\uDE80y'; |
实现了Iterator接口的对象
任何Iterator接口的对象,都可以用扩展运算符转为真正的数组。
1 | var nodeList = document.querySelectorAll('div'); |
箭头函数
ES6允许使用“箭头”(=>
)定义函数
1 | var f = v => v; |
上面的箭头函数等同于:
1 | var f = function(v) { |
如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return
语句返回。
1 | var sum = (num1, num2) => { return num1 + num2; } |
如果箭头函数直接返回一个对象,必须在对象外面加上括号。
1 | var getTempItem = id => ({ id: id, name: "Temp" }); |
箭头函数有几个使用注意点。
(1)函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象。
(2)不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。
(3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用Rest参数代替。
(4)不可以使用yield命令,因此箭头函数不能用作Generator函数。
箭头函数转成ES5的代码如下。
1 | // ES6 |
绑定 this
箭头函数可以绑定this
对象,大大减少了显式绑定this
对象的写法(call、apply、bind
)。但是,箭头函数并不适用于所有场合,所以ES7提出了“函数绑定”(function bind)运算符,用来取代call、apply、bind
调用。虽然该语法还是ES7的一个提案,但是Babel转码器已经支持。
1 | foo::bar; |
尾调用优化
什么是尾调用?
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
1 | function f(x){ |
尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
还有一个比较著名的例子,就是计算fibonacci 数列,也能充分说明尾递归优化的重要性
如果是非尾递归的fibonacci 递归方法
1 | function Fibonacci (n) { |
如果我们使用尾递归优化过的fibonacci 递归算法
1 | function Fibonacci2 (n , ac1 = 1 , ac2 = 1) { |
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。
函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。
1 | function currying(fn, n) { |
ES6的尾调用优化只在严格模式下开启,正常模式是无效的。
对象的扩展
属性的简洁表示法
ES6允许直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。
1 | var foo = 'bar'; |
除了属性简写,方法也可以简写。
1 | var o = { |
属性名表达式
ES6 允许字面量定义对象时,用方法二(表达式)作为对象的属性名,即把表达式放在方括号内。
1 | var lastWord = 'last word'; |
Object.is()
ES5比较两个值是否相等,只有两个运算符:相等运算符(==
)和严格相等运算符(===
)。它们都有缺点,前者会自动转换数据类型,后者的NaN
不等于自身,以及+0
等于-0
。JavaScript缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。
ES6提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is
就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===
)的行为基本一致。
1 | +0 === -0 //true |
Object.assign()
Object.assign
方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。
1 | var target = { a: 1 }; |
Object.assign
方法实行的是浅拷贝,而不是深拷贝。也就是说,如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用。
属性的遍历
ES6一共有5种方法可以遍历对象的属性。
(1)for…in
for…in循环遍历对象自身的和继承的可枚举属性(不含Symbol属性)。
(2)Object.keys(obj)
Object.keys返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含Symbol属性)。
(3)Object.getOwnPropertyNames(obj)
Object.getOwnPropertyNames返回一个数组,包含对象自身的所有属性(不含Symbol属性,但是包括不可枚举属性)。
(4)Object.getOwnPropertySymbols(obj)
Object.getOwnPropertySymbols返回一个数组,包含对象自身的所有Symbol属性。
(5)Reflect.ownKeys(obj)
Reflect.ownKeys返回一个数组,包含对象自身的所有属性,不管是属性名是Symbol或字符串,也不管是否可枚举。
__proto__
属性,Object.setPrototypeOf(),Object.getPrototypeOf()
__proto__
前后的双下划线,说明它本质上是一个内部属性,而不是一个正式的对外的 API,只是由于浏览器广泛支持,才被加入了 ES6。标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用下面的Object.setPrototypeOf()
(写操作)、Object.getPrototypeOf()
(读操作)、Object.create()
(生成操作)代替。
在实现上,__proto__
调用的是Object.prototype.__proto__
Object.keys(),Object.values(),Object.entries()
ES5 引入了Object.keys
方法,返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键名
ES2017 引入了跟Object.keys
配套的Object.values
和Object.entries
,作为遍历一个对象的补充手段,供for...of
循环使用。
Object.getOwnPropertyDescriptors()
ES5有一个Object.getOwnPropertyDescriptor
方法,返回某个对象属性的描述对象(descriptor)。
ES2017 引入了Object.getOwnPropertyDescriptors
方法,返回指定对象所有自身属性(非继承属性)的描述对象。
1 | const obj = { |
Symbol
ES5的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的方法(mixin模式),新方法的名字就有可能与现有方法产生冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是ES6引入Symbol的原因。
ES6引入了一种新的原始数据类型Symbol,表示独一无二的值。它是JavaScript语言的第七种数据类型,前六种是:Undefined、Null、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。
Symbol值通过Symbol
函数生成。这就是说,对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的Symbol类型。凡是属性名属于Symbol类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。
1 | let s = Symbol(); |
注意,Symbol
函数前不能使用new
命令,否则会报错。这是因为生成的Symbol是一个原始类型的值,不是对象。
作为属性名的Symbol
1 | var mySymbol = Symbol(); |
注意,Symbol值作为对象属性名时,不能用点运算符。
1 | var mySymbol = Symbol(); |
属性名的遍历
Symbol 作为属性名,该属性不会出现在for...in
、for...of
循环中,也不会被Object.keys()
、Object.getOwnPropertyNames()
、JSON.stringify()
返回。但是,它也不是私有属性,有一个Object.getOwnPropertySymbols
方法,可以获取指定对象的所有 Symbol 属性名。
另一个新的API,Reflect.ownKeys
方法可以返回所有类型的键名,包括常规键名和 Symbol 键名。
1 | let obj = { |
由于以 Symbol 值作为名称的属性,不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有的、但又希望只用于内部的方法。
Symbol.for(),Symbol.keyFor()
有时,我们希望重新使用同一个Symbol值,Symbol.for
方法可以做到这一点。它接受一个字符串作为参数,然后搜索有没有以该参数作为名称的Symbol值。如果有,就返回这个Symbol值,否则就新建并返回一个以该字符串为名称的Symbol值。
模块的 Singleton 模式
Singleton模式指的是调用一个类,任何时候返回的都是同一个实例。使用 Symbol.for
可以实现。
Set和Map数据结构
Set
ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。
Set结构的实例有四个遍历方法,可以用于遍历成员。
- keys():返回键名的遍历器
- values():返回键值的遍历器
- entries():返回键值对的遍历器
- forEach():使用回调函数遍历每个成员
扩展运算符和Set结构相结合,就可以去除数组的重复成员。
1 | let arr = [3, 5, 2, 2, 5, 5]; |
WeakSet
WeakSet结构与Set类似,也是不重复的值的集合。但是,它与Set有两个区别。
首先,WeakSet的成员只能是对象,而不能是其他类型的值。
其次,WeakSet中的对象都是弱引用,即垃圾回收机制不考虑WeakSet对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于WeakSet之中。这个特点意味着,无法引用WeakSet的成员,因此WeakSet是不可遍历的。
WeakSet不能遍历,是因为成员都是弱引用,随时可能消失,遍历机制无法保证成员的存在,很可能刚刚遍历结束,成员就取不到了。WeakSet的一个用处,是储存DOM节点,而不用担心这些节点从文档移除时,会引发内存泄漏。
Map
JavaScript的对象(Object),本质上是键值对的集合(Hash结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。
为了解决这个问题,ES6提供了Map数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object结构提供了“字符串—值”的对应,Map结构提供了“值—值”的对应,是一种更完善的Hash结构实现。如果你需要“键值对”的数据结构,Map比Object更合适。
Map原生提供三个遍历器生成函数和一个遍历方法。
- keys():返回键名的遍历器。
- values():返回键值的遍历器。
- entries():返回所有成员的遍历器。
- forEach():遍历Map的所有成员。
需要特别注意的是,Map的遍历顺序就是插入顺序。
Map转为数组
前面已经提过,Map转为数组最方便的方法,就是使用扩展运算符(…)
1 | let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']); |
将数组转入Map构造函数,就可以转为Map。
1 | new Map([[true, 7], [{foo: 3}, ['abc']]]) |
WeakMap
WeakMap
结构与Map
结构基本类似,唯一的区别是它只接受对象作为键名(null
除外),不接受其他类型的值作为键名,而且键名所指向的对象,不计入垃圾回收机制。
Proxy
Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。
Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
1 | var obj = new Proxy({}, { |
上面代码对一个空对象架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。这里暂时先不解释具体的语法,只看运行结果。对设置了拦截行为的对象obj,去读写它的属性,就会得到下面的结果。
1 | obj.count = 1 |
Reflect
Reflect
对象与Proxy
对象一样,也是 ES6 为了操作对象而提供的新 API。Reflect
对象的设计目的有这样几个。
(1) 将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。现阶段,某些方法同时在Object和Reflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。
(2) 修改某些Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false。
(3) 让Object操作都变成函数行为。某些Object操作是命令式,比如name in obj和delete obj[name],而Reflect.has(obj, name)和Reflect.deleteProperty(obj, name)让它们变成了函数行为。
(4)Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。
Promise 对象
Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6将其写进了语言标准,统一了用法,原生提供了Promise对象。
如果某些事件不断地反复发生,一般来说,使用 stream 模式是比部署Promise更好的选择。
基本用法
1 | var promise = new Promise(function(resolve, reject) { |
Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve
和reject
。它们是两个函数,由JavaScript引擎提供,不用自己部署。
Promise实例生成以后,可以用then
方法分别指定Resolved
状态和Reject
状态的回调函数。
1 | promise.then(function(value) { |
可以使用 Promise
异步加载图片,进行 Ajax 处理等。
下面是异步加载图片的例子。
1 | function loadImageAsync(url) { |
下面是一个用Promise对象实现的Ajax操作的例子。
1 | var getJSON = function(url) { |
Promise.prototype.then()
then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。
Promise.prototype.catch()
Promise.prototype.catch
方法是.then(null, rejection)
的别名,用于指定发生错误时的回调函数。
一般来说,不要在then
方法里面定义Reject
状态的回调函数(即then的第二个参数),总是使用catch
方法。
1 | // bad |
上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面then
方法执行中的错误,也更接近同步的写法(try/catch
)。因此,建议总是使用catch
方法,而不使用then
方法的第二个参数。
缺点:跟传统的try/catch
代码块不同的是,如果没有使用catch
方法指定错误处理的回调函数,Promise
对象抛出的错误不会传递到外层代码,即不会有任何反应。
Promise.all()
Promise.all
方法用于将多个Promise实例,包装成一个新的Promise实例。
1 | var p = Promise.all([p1, p2, p3]); |
(1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
(2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。
Promise.race()
Promise.race
方法同样是将多个Promise实例,包装成一个新的Promise实例。
上面代码中,只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。
Promise.resolve()
有时需要将现有对象转为Promise对象,Promise.resolve
方法就起到这个作用。
done()
Promise对象的回调链,不管以then方法或catch方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个done方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。
finally()
finally方法用于指定不管Promise对象最后状态如何,都会执行的操作。它与done方法的最大区别,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。
Iterator和for…of循环
Iterator(遍历器)的概念
遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署Iterator接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
Iterator的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是ES6创造了一种新的遍历命令for...of
循环,Iterator接口主要供for...of
消费。
数据结构的默认Iterator接口
Iterator接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即for...of
循环(详见下文)。当使用for...of
循环遍历某种数据结构时,该循环会自动去寻找Iterator接口。
在ES6中,有三类数据结构原生具备Iterator接口:数组、某些类似数组的对象(字符串,rguments
对象、DOM NodeList 对象,Generator 对象)、Set和Map结构。
对于字符串来说,for...of
循环还有一个特点,就是会正确识别32位UTF-16字符。
数组
for...of
循环可以代替数组实例的forEach
方法。
JavaScript原有的for…in循环,只能获得对象的键名,不能直接获取键值。ES6提供for…of循环,允许遍历获得键值。
1 | var arr = ['a', 'b', 'c', 'd']; |
Set和Map结构
遍历的顺序是按照各个成员被添加进数据结构的顺序。其次,Set结构遍历时,返回的是一个值,而Map结构遍历时,返回的是一个数组,该数组的两个成员分别为当前Map成员的键名和键值。
Generator 函数的语法
Generator 函数是 ES6 提供的一种异步编程解决方案
Generator 函数有多种理解角度。从语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。
执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。
形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function
关键字与函数名之间有一个星号(*
);二是,函数体内部使用yield
语句,定义不同的内部状态(yield在英语里的意思就是“产出”)。
yield语句不能用在普通函数中,否则会报错。
1 | function* helloWorldGenerator() { |
上面代码定义了一个Generator函数helloWorldGenerator,它内部有两个yield语句“hello”和“world”,即该函数有三个状态:hello,world和return语句(结束执行)。
每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield语句(或return语句)为止。换言之,Generator函数是分段执行的,yield语句是暂停执行的标记,而next方法可以恢复执行。
与Iterator接口的关系
由于Generator函数就是遍历器生成函数,因此可以把Generator赋值给对象的Symbol.iterator属性,从而使得该对象具有Iterator接口。
Generator.prototype.return()
Generator函数返回的遍历器对象,还有一个return
方法,可以返回给定的值,并且终结遍历Generator函数。
第一次使用 next()
,就从函数头运行到第一个 yield
1 | function* gen() { |
yield* 语句
用到yield*
语句,用来在一个 Generator 函数里面执行另一个 Generator 函数。
应用
异步操作的同步化表达
Generator函数的暂停执行的效果,意味着可以把异步操作写在yield语句里面,等到调用next方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield语句下面,反正要等到调用next方法时再执行。
通过Generator函数逐行读取文本文件
1 | function* numbers() { |
控制流管理
利用for...of
循环会自动依次执行yield
命令的特性,提供一种更一般的控制流管理的方法。
1 | let steps = [step1Func, step2Func, step3Func]; |
部署Iterator接口
利用Generator函数,可以在任意对象上部署Iterator接口。
1 | function* iterEntries(obj) { |
Generator 函数的异步应用
基本概念
异步
所谓”异步”,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。
回调函数
JavaScript 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字callback
,直译过来就是”重新调用”。
Promise
回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件,代码如下。
1 | fs.readFile(fileA, 'utf-8', function (err, data) { |
不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。这种情况就称为”回调函数地狱”(callback hell)。
Promise 对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用 Promise,连续读取多个文件,写法如下。
1 | var readFile = require('fs-readfile-promise'); |
Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆then
,原来的语义变得很不清楚。
Generator 函数
协程
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做”协程”(coroutine),意思是多个线程互相协作,完成异步任务。
1 | function *asyncJob() { |
上面代码的函数asyncJob
是一个协程,它的奥妙就在其中的yield
命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield
命令是异步两个阶段的分界线。
协程遇到yield
命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield
命令,简直一模一样。
协程的 Generator 函数实现
Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。
1 | function* gen(x) { |
上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上例是执行到x + 2为止。
Thunk 函数的自动流程管理
Thunk 函数真正的威力,在于可以自动执行 Generator 函数。
co 模块
co 模块是著名程序员 TJ Holowaychuk 于2013年6月发布的一个小工具,用于 Generator 函数的自动执行
async 函数
含义
async 函数是什么?一句话,它就是 Generator 函数的语法糖。
前文有一个 Generator 函数,依次读取两个文件。
1 | var fs = require('fs'); |
写成async
函数,就是下面这样。
1 | var asyncReadFile = async function () { |
一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async
,将yield
替换成await
,仅此而已。
async函数对 Generator 函数的改进,体现在以下四点。
(1)内置执行器。
(2)更好的语义。
(3)更广的适用性。
(4)返回值是 Promise。
async 函数有多种使用形式。
1 | // 函数声明 |
Promise 对象的状态变
async函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或者抛出错误。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。
1 | async function getTitle(url) { |
上面代码中,函数getTitle
内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行then方法里面的console.log
。
Class
ES6提供了更接近传统语言的写法,引入了Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。基本上,ES6的class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。
1 | //定义类 |
构造函数的prototype
属性,在ES6的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype
属性上面。
类的内部所有定义的方法,都是不可枚举的(non-enumerable)。
类的实例对象
1 | //定义类 |
上面代码中,x和y都是实例对象point自身的属性(因为定义在this变量上),所以hasOwnProperty方法返回true,而toString是原型对象的属性(因为定义在Point类上),所以hasOwnProperty方法返回false。这些都与ES5的行为保持一致。
私有方法
私有方法是常见需求,但 ES6 不提供,只能通过变通方法模拟实现。
this的指向
类的方法内部如果含有this,它默认指向类的实例。
Class的继承
Class之间可以通过extends
关键字实现继承,这比ES5的通过修改原型链实现继承,要清晰和方便很多。
1 | class ColorPoint extends Point { |
子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类没有自己的this对象,而是继承父类的this对象,然后对其进行加工。如果不调用super方法,子类就得不到this对象。
ES5的继承,实质是先创造子类的实例对象this
,然后再将父类的方法添加到this
上面(Parent.apply(this)
)。ES6的继承机制完全不同,实质是先创造父类的实例对象this
(所以必须先调用super
方法),然后再用子类的构造函数修改this
。
另一个需要注意的地方是,在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,是基于对父类实例加工,只有super方法才能返回父类实例。
类的prototype属性和proto属性
大多数浏览器的ES5实现之中,每一个对象都有__proto__
属性,指向对应的构造函数的prototype属性。Class作为构造函数的语法糖,同时有prototype属性和__proto__
属性,因此同时存在两条继承链。
(1)子类的__proto__
属性,表示构造函数的继承,总是指向父类。
(2)子类prototype
属性的__proto__
属性,表示方法的继承,总是指向父类的prototype
属性。
1 | class A { |
Extends 的继承目标
extends关键字后面可以跟多种类型的值。
super 关键字
super这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。
第一种情况,super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。
1 | class A {} |
第二种情况,super作为对象时,指向父类的原型对象。
实例的proto属性
子类实例的proto属性的proto属性,指向父类实例的proto属性。也就是说,子类的原型的原型,是父类的原型。
1 | var p1 = new Point(2, 3); |
原生构造函数的继承
原生构造函数是指语言内置的构造函数,通常用来生成数据结构。ECMAScript的原生构造函数大致有下面这些。
- Boolean()
- Number()
- String()
- Array()
- Date()
- Function()
- RegExp()
- Error()
- Object()
以前,这些原生构造函数是无法继承的,比如,不能自己定义一个Array的子类。
ES5是先新建子类的实例对象this,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。
ES6允许继承原生构造函数定义子类,因为ES6是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可以继承。下面是一个继承Array的例子。
1 | class MyArray extends Array { |
Class的取值函数(getter)和存值函数(setter)
与ES5一样,在Class内部可以使用get和set关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
1 | class MyClass { |
Class 的静态方法
类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static
关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。
Class的静态属性和实例属性
静态属性指的是Class本身的属性,即Class.propname,而不是定义在实例对象(this)上的属性。
1 | class Foo { |
上面的写法为Foo类定义了一个静态属性prop。
目前,只有这种写法可行,因为ES6明确规定,Class内部只有静态方法,没有静态属性。
new.target属性
new是从构造函数生成实例的命令。ES6为new命令引入了一个new.target属性,(在构造函数中)返回new命令作用于的那个构造函数。如果构造函数不是通过new命令调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。
Mixin模式的实现
将多个对象合成为一个类。使用的时候,只要继承这个类即可。
1 | class DistributedEdit extends mix(Loggable, Serializable) { |
修饰器(Decorator)
类的修饰
修饰器(Decorator)是一个函数,用来修改类的行为。这是ES7的一个提案,目前Babel转码器已经支持。
1 | function testable(target) { |
基本上,修饰器的行为就是下面这样。
1 | @decorator |
方法的修饰
修饰器不仅可以修饰类,还可以修饰类的属性。
1 | class Person { |
上面代码中,修饰器readonly用来修饰“类”的name方法。
此时,修饰器函数一共可以接受三个参数,第一个参数是所要修饰的目标对象,第二个参数是所要修饰的属性名,第三个参数是该属性的描述对象。
1 | function readonly(target, name, descriptor){ |
为什么修饰器不能用于函数?
修饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。
core-decorators.js
core-decorators.js是一个第三方模块,提供了几个常见的修饰器,通过它可以更好地理解修饰器。
Module 的语法
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES6 模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
1 | // CommonJS模块 |
ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。
1 | // ES6模块 |
上面代码的实质是从fs模块加载3个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。
export 命令
模块功能主要由两个命令构成:export
和import
。export
命令用于规定模块的对外接口,import
命令用于输入其他模块提供的功能。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。
1 | // profile.js |
export
命令除了输出变量,还可以输出函数或类(class)
1 | export function multiply(x, y) { |
通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
1 | function v1() { ... } |
1 | // 报错 |
export
语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新
export
命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import
命令也是如此。
import 命令
使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。
1 | // main.js |
上面代码的import命令,用于加载profile.js文件,并从中输入变量。import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。
由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。
模块的整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
1 | import * as circle from './circle'; |
export default 命令
从前面的例子可以看出,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。
为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。
1 | // export-default.js |
其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。
1 | // import-default.js |
1 | // 第一组 |
上面代码的两组写法,第一组是使用export default时,对应的import语句不需要使用大括号;第二组是不使用export default时,对应的import语句需要使用大括号。
export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能对应一个方法。
export 与 import 的复合写法
如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
1 | export { foo, bar } from 'my_module'; |
跨模块常量
本书介绍const
命令的时候说过,const
声明的常量只在当前代码块有效。如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。
1 | // constants.js 模块 |
import()
前面介绍过,import命令会被 JavaScript 引擎静态分析,先于模块内的其他模块执行(叫做”连接“更合适)。
1 | // 报错 |
这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。从语法上,条件加载就不可能实现。如果import
命令要取代 Node 的require
方法,这就形成了一个障碍。因为require
是运行时加载模块,import
命令无法取代require
的动态加载功能。
因此,有一个提案,建议引入import()
函数,完成动态加载。
import()
返回一个 Promise 对象。
编程风格
块级作用域
(1)let 取代 var
在let
和const
之间,建议优先使用const
,尤其是在全局环境,不应该设置变量,只应设置常量。
字符串
静态字符串一律使用单引号或反引号,不使用双引号。动态字符串使用反引号。
1 | // bad |
解构赋值
使用数组成员对变量赋值时,优先使用解构赋值。
函数的参数如果是对象的成员,优先使用解构赋值。
如果函数返回多个值,优先使用对象的解构赋值,而不是数组的解构赋值。这样便于以后添加返回值,以及更改返回值的顺序。
对象
单行定义的对象,最后一个成员不以逗号结尾。多行定义的对象,最后一个成员以逗号结尾。
对象尽量静态化,一旦定义,就不得随意添加新的属性。如果添加属性不可避免,要使用Object.assign
方法。
数组
使用扩展运算符(...
)拷贝数组。
1 | // bad |
使用Array.from
方法,将类似数组的对象转为数组。
1 | const foo = document.querySelectorAll('.foo'); |
函数
立即执行函数可以写成箭头函数的形式。
那些需要使用函数表达式的场合,尽量用箭头函数代替。因为这样更简洁,而且绑定了this。
箭头函数取代Function.prototype.bind
,不应再用self/_this/that
绑定 this
。
1 | // bad |
不要在函数体内使用arguments变量,使用rest运算符(…)代替。因为rest运算符显式表明你想要获取参数,而且arguments是一个类似数组的对象,而rest运算符可以提供一个真正的数组。
Class
总是用Class,取代需要prototype的操作。因为Class的写法更简洁,更易于理解。
模块
首先,Module语法是JavaScript模块的标准写法,坚持使用这种写法。使用import
取代require
。
使用export
取代module.exports
。
SIMD
SIMD 通常用于矢量运算。
1 | var a = [1, 2, 3, 4]; |
如果采用 SIMD 模式,只要运算一次就够了。
1 | var a = SIMD.Float32x4(1, 2, 3, 4); |
ES6 转码
Babel转码器
Babel 是一个广泛使用的ES6转码器,可以将ES6代码转为ES5代码,从而在现有环境执行。这意味着,你可以用ES6的方式编写程序,又不用担心现有环境是否支持。下面是一个例子。
1 | // 转码前 |
上面的原始代码用了箭头函数,这个特性还没有得到广泛支持,Babel将其转为普通函数,就能在现有的JavaScript环境执行了。
配置文件.babelrc
Babel的配置文件是.babelrc
,存放在项目的根目录下。使用Babel的第一步,就是配置这个文件。
该文件用来设置转码规则和插件,基本格式如下
1 | { |
presets
字段设定转码规则,官方提供以下的规则集,你可以根据需要安装。
1 | # ES2015转码规则 |
然后,将这些规则加入.babelrc
。
1 | { |
注意,以下所有Babel工具和模块的使用,都必须先写好.babelrc
。
命令行转码babel-cli
Babel提供babel-cli
工具,用于命令行转码。
它的安装命令如下。
1 | $ npm install --global babel-cli |
1 | # 转码结果输出到标准输出 |
上面代码是在全局环境下,进行Babel转码。这意味着,如果项目要运行,全局环境必须有Babel,也就是说项目产生了对环境的依赖。另一方面,这样做也无法支持不同项目使用不同版本的Babel。
一个解决办法是将babel-cli
安装在项目之中。
1 | # 安装 |
然后,改写package.json
。
1 | { |
转码的时候,就执行下面的命令。
1 | $ npm run build |
babel-node
babel-cli
工具自带一个babel-node
命令,提供一个支持ES6的REPL环境。它支持Node的REPL环境的所有功能,而且可以直接运行ES6代码。
它不用单独安装,而是随babel-cli
一起安装。然后,执行babel-node`就进入REPL环境。
1 | $ babel-node |
babel-node
命令可以直接运行ES6脚本。将上面的代码放入脚本文件es6.js,然后直接运行。
1 | $ babel-node es6.js |
babel-node
也可以安装在项目中。
1 | $ npm install --save-dev babel-cli |
然后,改写package.json
1 | { |
上面代码中,使用babel-node
替代node
,这样script.js
本身就不用做任何转码处理。
babel-core
如果某些代码(Webpack 代码)需要调用Babel的API进行转码,就要使用babel-core
模块。
安装命令如下。
1 | npm install babel-core --save |
然后,在项目中就可以调用babel-core。
1 | var babel = require('babel-core'); |
配置对象options
,可以参看官方文档http://babeljs.io/docs/usage/options/。
下面是一个例子。
1 | var es6Code = 'let x = n => n + 1'; |
上面代码中,transform
方法的第一个参数是一个字符串,表示需要被转换的ES6代码,第二个参数是转换的配置对象。
babel-polyfill
Babel默认只转换新的JavaScript句法(syntax),而不转换新的API,比如Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol、Promise等全局对象,以及一些定义在全局对象上的方法(比如Object.assign
)都不会转码。
举例来说,ES6在Array
对象上新增了Array.from
方法。Babel就不会转码这个方法。如果想让这个方法运行,必须使用babel-polyfill
,为当前环境提供一个垫片。
安装命令如下。
1 | $ npm install --save babel-polyfill |
然后,在脚本头部,加入如下一行代码。
1 | import 'babel-polyfill'; |
Babel默认不转码的API非常多,详细清单可以查看babel-plugin-transform-runtime模块的definitions.js文件。
在线转换
Babel提供一个REPL在线编译器,可以在线将ES6代码转为ES5代码。转换后的代码,可以直接作为ES5代码插入网页运行。