let 和 const 命令 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/let.md
如果使用let
,声明的变量仅在块级作用域内有效,最后输出的是6。
var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 6
上面代码中,变量i
是let
声明的,当前的i
只在本轮循环有效,所以每一次循环的i
其实都是一个新的变量,所以最后输出的是6
。你可能会问,如果每一轮循环的变量i
都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i
时,就在上一轮循环的基础上进行计算。
另外,for
循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc
上面代码正确运行,输出了3次abc
。这表明函数内部的变量i
与循环变量i
不在同一个作用域,有各自单独的作用域。
不存在变量提升
var
命令会发生”变量提升“现象,即变量可以在声明之前使用,值为undefined
。这种现象多多少少是有些奇怪的,按照一般的逻辑,变量应该在声明语句之后才可以使用。
为了纠正这种现象,let
命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;
// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;
ES6明确规定,如果区块中存在let
和const
命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
“暂时性死区”也意味着typeof
不再是一个百分之百安全的操作。
typeof x; // ReferenceError
let x;
上面代码中,变量x
使用let
命令声明,所以在声明之前,都属于x
的“死区”,只要用到该变量就会报错。因此,typeof
运行时就会抛出一个ReferenceError
。
作为比较,如果一个变量根本没有被声明,使用typeof
反而不会报错。
typeof undeclared_variable // "undefined"
上面代码中,undeclared_variable
是一个不存在的变量名,结果返回“undefined”。所以,在没有let
之前,typeof
运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。
另外,下面的代码也会报错,与var
的行为不同。
// 不报错
var x = x;
// 报错
let x = x;
// ReferenceError: x is not defined
上面代码报错,也是因为暂时性死区。使用let
声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x
的声明语句还没有执行完成前,就去取x
的值,导致报错”x 未定义“。
不允许重复声明:let
不允许在相同作用域内,重复声明同一个变量。
ES5 只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。
ES6 的块级作用域
let
实际上为 JavaScript 新增了块级作用域。
function f1() {
let n = 5;
if (true) {
let n = 10;
}
console.log(n); // 5
}
上面的函数有两个代码块,都声明了变量n
,运行后输出5。这表示外层代码块不受内层代码块的影响。如果两次都使用var
定义变量n
,最后输出的值才是10。
块级作用域的出现,实际上使得获得广泛应用的立即执行函数表达式(IIFE)不再必要了。
// IIFE 写法
(function () {
var tmp = ...;
...
}());
// 块级作用域写法
{
let tmp = ...;
...
}
ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let
,在块级作用域之外不可引用。
do 表达式
本质上,块级作用域是一个语句,将多个操作封装在一起,没有返回值。
{
let t = f();
t = t * t + 1;
}
上面代码中,块级作用域将两个语句封装在一起。但是,在块级作用域以外,没有办法得到t
的值,因为块级作用域不返回值,除非t
是全局变量。
现在有一个提案,使得块级作用域可以变为表达式,也就是说可以返回值,办法就是在块级作用域之前加上do
,使它变为do
表达式。
let x = do {
let t = f();
t * t + 1;
};
上面代码中,变量x
会得到整个块级作用域的返回值。
const 命令
基本用法
const
声明一个只读的常量。一旦声明,常量的值就不能改变。
const PI = 3.1415;
PI // 3.1415
PI = 3;
// TypeError: Assignment to constant variable.
上面代码表明改变常量的值会报错。
const
声明的变量不得改变值,这意味着,const
一旦声明变量,就必须立即初始化,不能留到以后赋值。
const foo;
// SyntaxError: Missing initializer in const declaration
上面代码表示,对于const
来说,只声明不赋值,就会报错。
const
的作用域与let
命令相同:只在声明所在的块级作用域内有效。
if (true) {
const MAX = 5;
}
MAX // Uncaught ReferenceError: MAX is not defined
const
命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
if (true) {
console.log(MAX); // ReferenceError
const MAX = 5;
}
上面代码在常量MAX
声明之前就调用,结果报错。
const
声明的常量,也与let
一样不可重复声明。
var message = "Hello!";
let age = 25;
// 以下两行都会报错
const message = "Goodbye!";
const age = 30;
const
实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指针,const
只能保证这个指针是固定的,至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。
如果真的想将对象冻结,应该使用Object.freeze
方法。
const foo = Object.freeze({});
// 常规模式时,下面一行不起作用;
// 严格模式时,该行会报错
foo.prop = 123;
上面代码中,常量foo
指向一个冻结的对象,所以添加新属性不起作用,严格模式时还会报错。
除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。
var constantize = (obj) => {
Object.freeze(obj);
Object.keys(obj).forEach( (key, i) => {
if ( typeof obj[key] === 'object' ) {
constantize( obj[key] );
}
});
};
ES6 声明变量的六种方法
ES5 只有两种声明变量的方法:var
命令和function
命令。ES6除了添加let
和const
命令,后面章节还会提到,另外两种声明变量的方法:import
命令和class
命令。所以,ES6 一共有6种声明变量的方法。
ES6为了改变这一点,一方面规定,为了保持兼容性,var
命令和function
命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let
命令、const
命令、class
命令声明的全局变量,不属于顶层对象的属性。也就是说,从ES6开始,全局变量将逐步与顶层对象的属性脱钩。
同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用this
变量,但是有局限性。
- 全局环境中,
this
会返回顶层对象。但是,Node模块和ES6模块中,this
返回的是当前模块。 - 函数里面的
this
,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this
会指向顶层对象。但是,严格模式下,这时this
会返回undefined
。 - 不管是严格模式,还是普通模式,
new Function('return this')()
,总是会返回全局对象。但是,如果浏览器用了CSP(Content Security Policy,内容安全政策),那么eval
、new Function
这些方法都可能无法使用。
变量的解构赋值 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/destructuring.md
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
注意,ES6 内部使用严格相等运算符(===
),判断一个位置是否有值。所以,如果一个数组成员不严格等于undefined
,默认值是不会生效的。
由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
let arr = [1, 2, 3];
let {0 : first, [arr.length - 1] : last} = arr;
first // 1
last // 3
上面代码对数组进行对象解构。数组arr
的0
键对应的值是1
,[arr.length - 1]
就是2
键,对应的值是3
。方括号这种写法,属于“属性名表达式”,参见《对象的扩展》一章。
字符串的解构赋值
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
const [a, b, c, d, e] = 'hello';
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"
类似数组的对象都有一个length
属性,因此还可以对这个属性解构赋值。
let {length : len} = 'hello';
len // 5
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined
和null
无法转为对象,所以对它们进行解构赋值,都会报错。
let { prop: x } = undefined; // TypeError
let { prop: y } = null; // TypeError
字符串的扩展 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/string.md
ES6引入了模板字符串解决这个问题。
$('#result').append(`
There are ${basket.count} items
in your basket, ${basket.onSale}
are on sale!
`);
模板字符串(template string)是增强版的字符串,用反引号(`)标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。
// 普通字符串
`In JavaScript '\n' is a line-feed.`
// 多行字符串
`In JavaScript this is
not legal.`
console.log(`string text line 1
string text line 2`);
// 字符串中嵌入变量
var name = "Bob", time = "today";
`Hello ${name}, how are you ${time}?`
正则的扩展 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/regex.md
ES6改变了这种行为。如果RegExp构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。
new RegExp(/abc/ig, 'i').flags
// "i"
另外,只有在使用u
修饰符的情况下,Unicode表达式当中的大括号才会被正确解读,否则会被解读为量词。
/^\u{3}$/.test('uuu') // true
上面代码中,由于正则表达式没有u
修饰符,所以大括号被解读为量词。加上u
修饰符,就会被解读为Unicode表达式。
字符串对象共有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]
RegExp.escape()
字符串必须转义,才能作为正则模式。
function escapeRegExp(str) {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
}
let str = '/path/to/resource.html?search=query';
escapeRegExp(str)
// "\/path\/to\/resource\.html\?search=query"
数值的扩展 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/number.md
二进制和八进制表示法
ES6 提供了二进制和八进制数值的新的写法,分别用前缀0b
(或0B
)和0o
(或0O
)表示。
0b111110111 === 503 // true
0o767 === 503 // true
从 ES5 开始,在严格模式之中,八进制就不再允许使用前缀0
表示,ES6 进一步明确,要使用前缀0o
表示。
// 非严格模式
(function(){
console.log(0o11 === 011);
})() // true
// 严格模式
(function(){
'use strict';
console.log(0o11 === 011);
})() // Uncaught SyntaxError: Octal literals are not allowed in strict mode.
如果要将0b
和0o
前缀的字符串数值转为十进制,要使用Number
方法。
Number('0b111') // 7
Number('0o10') // 8
它们与传统的全局方法isFinite()
和isNaN()
的区别在于,传统方法先调用Number()
将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()
对于非数值一律返回false
, Number.isNaN()
只有对于NaN
才返回true
,非NaN
一律返回false
。
isFinite(25) // true
isFinite("25") // true
Number.isFinite(25) // true
Number.isFinite("25") // false
isNaN(NaN) // true
isNaN("NaN") // true
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
Number.isNaN(1) // false
ES6 将全局方法parseInt()
和parseFloat()
,移植到Number
对象上面,行为完全保持不变。
这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。
Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true
数组的扩展 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/array.md
扩展运算符(...
)也可以将某些数据结构转为数组。
// arguments对象
function foo() {
var args = [...arguments];
}
// NodeList对象
[...document.querySelectorAll('div')]
扩展运算符背后调用的是遍历器接口(Symbol.iterator
),如果一个对象没有部署这个接口,就无法转换。Array.from
方法还支持类似数组的对象。所谓类似数组的对象,本质特征只有一点,即必须有length
属性。因此,任何有length
属性的对象,都可以通过Array.from
方法转为数组,而此时扩展运算符就无法转换。
Array.from({ length: 3 });
// [ undefined, undefined, undefined ]
上面代码中,Array.from
返回了一个具有三个成员的数组,每个位置的值都是undefined
。扩展运算符转换不了这个对象。
ES5 对空位的处理,已经很不一致了,大多数情况下会忽略空位。
-
forEach()
,filter()
,every()
和some()
都会跳过空位。 -
map()
会跳过空位,但会保留这个值 -
join()
和toString()
会将空位视为undefined
,而undefined
和null
会被处理成空字符串。
// forEach方法
[,'a'].forEach((x,i) => console.log(i)); // 1
// filter方法
['a',,'b'].filter(x => true) // ['a','b']
// every方法
[,'a'].every(x => x==='a') // true
// some方法
[,'a'].some(x => x !== 'a') // false
// map方法
[,'a'].map(x => 1) // [,1]
// join方法
[,'a',undefined,null].join('#') // "#a##"
// toString方法
[,'a',undefined,null].toString() // ",a,,"
ES6 则是明确将空位转为undefined
。
Array.from
方法会将数组的空位,转为undefined
,也就是说,这个方法不会忽略空位。
Array.from(['a',,'b'])
// [ "a", undefined, "b" ]
扩展运算符(...
)也会将空位转为undefined
。
[...['a',,'b']]
// [ "a", undefined, "b" ]
copyWithin()
会连空位一起拷贝。
[,'a','b',,].copyWithin(2,0) // [,"a",,"a"]
fill()
会将空位视为正常的数组位置。
new Array(3).fill('a') // ["a","a","a"]
for...of
循环也会遍历空位。
let arr = [, ,];
for (let i of arr) {
console.log(1);
}
// 1
// 1
上面代码中,数组arr
有两个空位,for...of
并没有忽略它们。如果改成map
方法遍历,空位是会跳过的。
entries()
、keys()
、values()
、find()
和findIndex()
会将空位处理成undefined
。
// entries()
[...[,'a'].entries()] // [[0,undefined], [1,"a"]]
// keys()
[...[,'a'].keys()] // [0,1]
// values()
[...[,'a'].values()] // [undefined,"a"]
// find()
[,'a'].find(x => true) // undefined
// findIndex()
[,'a'].findIndex(x => true) // 0
由于空位的处理规则非常不统一,所以建议避免出现空位。
函数的扩展 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/function.md
ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
function log(x, y) {
y = y || 'World';
console.log(x, y);
}
log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello World
上面代码检查函数log
的参数y
有没有赋值,如果没有,则指定默认值为World
。这种写法的缺点在于,如果参数y
赋值了,但是对应的布尔值为false
,则该赋值不起作用。就像上面代码的最后一行,参数y
等于空字符,结果被改为默认值。
为了避免这个问题,通常需要先判断一下参数y
是否被赋值,如果没有,再等于默认值。
if (typeof y === 'undefined') {
y = 'World';
}
ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。
function log(x, y = 'World') {
console.log(x, y);
}
log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello
可以看到,ES6 的写法比 ES5 简洁许多,而且非常自然。下面是另一个例子。
function Point(x = 0, y = 0) {
this.x = x;
this.y = y;
}
var p = new Point();
p // { x: 0, y: 0 }
除了简洁,ES6 的写法还有两个好处:首先,阅读代码的人,可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中,彻底拿掉这个参数,也不会导致以前的代码无法运行。
参数变量是默认声明的,所以不能用let
或const
再次声明。
function foo(x = 5) {
let x = 1; // error
const x = 2; // error
}
上面代码中,参数变量x
是默认声明的,在函数体中,不能用let
或const
再次声明,否则会报错。
使用参数默认值时,函数不能有同名参数。
function foo(x, x, y = 1) {
// ...
}
// SyntaxError: Duplicate parameter name not allowed in this context
另外,一个容易忽略的地方是,参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。
let x = 99;
function foo(p = x + 1) {
console.log(p);
}
foo() // 100
x = 100;
foo() // 101
上面代码中,参数p
的默认值是x + 1
。这时,每次调用函数foo
,都会重新计算x + 1
,而不是默认p
等于 100。
如果传入undefined
,将触发该参数等于默认值,null
则没有这个效果。
function foo(x = 5, y = 6) {
console.log(x, y);
}
foo(undefined, null)
// 5 null
上面代码中,x
参数对应undefined
,结果触发了默认值,y
参数等于null
,就没有触发默认值。
指定了默认值以后,函数的length
属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length
属性将失真。
这是因为length
属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest 参数也不会计入length
属性。
(function(...args) {}).length // 0
如果设置了默认值的参数不是尾参数,那么length
属性也不再计入后面的参数了。
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1
作用域
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为,在不设置参数默认值时,是不会出现的。
下面是一个更复杂的例子。
var x = 1;
function foo(x, y = function() { x = 2; }) {
var x = 3;
y();
console.log(x);
}
foo() // 3
x // 1
上面代码中,函数foo
的参数形成一个单独作用域。这个作用域里面,首先声明了变量x
,然后声明了变量y
,y
的默认值是一个匿名函数。这个匿名函数内部的变量x
,指向同一个作用域的第一个参数x
。函数foo
内部又声明了一个内部变量x
,该变量与第一个参数x
由于不是同一个作用域,所以不是同一个变量,因此执行y
后,内部变量x
和外部全局变量x
的值都没变。
如果将var x = 3
的var
去除,函数foo
的内部变量x
就指向第一个参数x
,与匿名函数内部的x
是一致的,所以最后输出的就是2
,而外层的全局变量x
依然不受影响。
var x = 1;
function foo(x, y = function() { x = 2; }) {
x = 3;
y();
console.log(x);
}
foo() // 2
x // 1
ES6 引入 rest 参数(形式为...变量名
),用于获取函数的多余参数,这样就不需要使用arguments
对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
rest 参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。下面是一个利用 rest 参数改写数组push
方法的例子。
function push(array, ...items) {
items.forEach(function(item) {
array.push(item);
console.log(item);
});
}
var a = [];
push(a, 1, 2, 3)
注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
// 报错
function f(a, ...b, c) {
// ...
}
函数的length
属性,不包括 rest 参数。
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
function throwIfMissing() {
throw new Error('Missing parameter');
}
function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}
foo()
// Error: Missing parameter
另外,可以将参数默认值设为undefined
,表明这个参数是可以省略的。
function foo(optional = undefined) { ··· }
字符串
扩展运算符还可以将字符串转为真正的数组。
[...'hello']
// [ "h", "e", "l", "l", "o" ]
上面的写法,有一个重要的好处,那就是能够正确识别32位的Unicode字符。
'x\uD83D\uDE80y'.length // 4
[...'x\uD83D\uDE80y'].length // 3
上面代码的第一种写法,JavaScript会将32位Unicode字符,识别为2个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数,可以像下面这样写。
function length(str) {
return [...str].length;
}
length('x\uD83D\uDE80y') // 3
凡是涉及到操作32位Unicode字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
let str = 'x\uD83D\uDE80y';
str.split('').reverse().join('')
// 'y\uDE80\uD83Dx'
[...str].reverse().join('')
// 'y\uD83D\uDE80x'
上面代码中,如果不用扩展运算符,字符串的reverse
操作就不正确。
两种方法可以规避这种限制。第一种是设定全局性的严格模式,这是合法的。
'use strict';
function doSomething(a, b = a) {
// code
}
第二种是把函数包在一个无参数的立即执行函数里面。
const doSomething = (function () {
'use strict';
return function(value = 42) {
return value;
};
}());
箭头函数有几个使用注意点。
(1)函数体内的this
对象,就是定义时所在的对象,而不是使用时所在的对象。
(2)不可以当作构造函数,也就是说,不可以使用new
命令,否则会抛出一个错误。
(3)不可以使用arguments
对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
(4)不可以使用yield
命令,因此箭头函数不能用作 Generator 函数。
对象的扩展 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/object.md
Object.assign
拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false
)。
Object.assign
方法实行的是浅拷贝,而不是深拷贝。也就是说,如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用。
ES5 有三个操作会忽略enumerable
为false
的属性。
-
for...in
循环:只遍历对象自身的和继承的可枚举的属性 -
Object.keys()
:返回对象自身的所有可枚举的属性的键名 -
JSON.stringify()
:只串行化对象自身的可枚举的属性
ES6 新增了一个操作Object.assign()
,会忽略enumerable
为false
的属性,只拷贝对象自身的可枚举的属性。
这四个操作之中,只有for...in
会返回继承的属性。实际上,引入enumerable
的最初目的,就是让某些属性可以规避掉for...in
操作。比如,对象原型的toString
方法,以及数组的length
属性,就通过这种手段,不会被for...in
遍历到。
Object.getOwnPropertyDescriptor(Object.prototype, 'toString').enumerable
// false
Object.getOwnPropertyDescriptor([], 'length').enumerable
// false
上面代码中,toString
和length
属性的enumerable
都是false
,因此for...in
不会遍历到这两个继承自原型的属性。
另外,ES6规定,所有Class的原型的方法都是不可枚举的。
Object.getOwnPropertyDescriptor(class {foo() {}}.prototype, 'foo').enumerable
// false
总的来说,操作中引入继承的属性会让问题复杂化,大多数时候,我们只关心对象自身的属性。所以,尽量不要用for...in
循环,而用Object.keys()
代替。
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 或字符串,也不管是否可枚举。
以上的5种方法遍历对象的属性,都遵守同样的属性遍历的次序规则。
- 首先遍历所有属性名为数值的属性,按照数字排序。
- 其次遍历所有属性名为字符串的属性,按照生成时间排序。
- 最后遍历所有属性名为 Symbol 值的属性,按照生成时间排序。
Reflect.ownKeys({ [Symbol()]:0, b:0, 10:0, 2:0, a:0 })
// ['2', '10', 'b', 'a', Symbol()]
上面代码中,Reflect.ownKeys
方法返回一个数组,包含了参数对象的所有属性。这个数组的属性次序是这样的,首先是数值属性2
和10
,其次是字符串属性b
和a
,最后是 Symbol 属性。
__proto__
属性
__proto__
属性(前后各两个下划线),用来读取或设置当前对象的prototype
对象。目前,所有浏览器(包括 IE11)都部署了这个属性。
// es6的写法
var obj = {
method: function() { ... }
};
obj.__proto__ = someOtherObj;
// es5的写法
var obj = Object.create(someOtherObj);
obj.method = function() { ... };
该属性没有写入 ES6 的正文,而是写入了附录,原因是__proto__
前后的双下划线,说明它本质上是一个内部属性,而不是一个正式的对外的 API,只是由于浏览器广泛支持,才被加入了 ES6。标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用下面的Object.setPrototypeOf()
(写操作)、Object.getPrototypeOf()
(读操作)、Object.create()
(生成操作)代替。
在实现上,__proto__
调用的是Object.prototype.__proto__
,具体实现如下。
Object.defineProperty(Object.prototype, '__proto__', {
get() {
let _thisObj = Object(this);
return Object.getPrototypeOf(_thisObj);
},
set(proto) {
if (this === undefined || this === null) {
throw new TypeError();
}
if (!isObject(this)) {
return undefined;
}
if (!isObject(proto)) {
return undefined;
}
let status = Reflect.setPrototypeOf(this, proto);
if (!status) {
throw new TypeError();
}
},
});
function isObject(value) {
return Object(value) === value;
}
如果一个对象本身部署了__proto__
属性,则该属性的值就是对象的原型。
Object.getPrototypeOf({ __proto__: null })
// null
Null 传导运算符
编程实务中,如果读取对象内部的某个属性,往往需要判断一下该对象是否存在。比如,要读取message.body.user.firstName
,安全的写法是写成下面这样。
const firstName = (message
&& message.body
&& message.body.user
&& message.body.user.firstName) || 'default';
这样的层层判断非常麻烦,因此现在有一个提案,引入了“Null 传导运算符”(null propagation operator)?.
,简化上面的写法。
const firstName = message?.body?.user?.firstName || 'default';
上面代码有三个?.
运算符,只要其中一个返回null
或undefined
,就不再往下运算,而是返回undefined
。
“Null 传导运算符”有四种用法。
-
obj?.prop
// 读取对象属性 -
obj?.[expr]
// 同上 -
func?.(...args)
// 函数或对象方法的调用 -
new C?.(...args)
// 构造函数的调用
传导运算符之所以写成obj?.prop
,而不是obj?prop
,是为了方便编译器能够区分三元运算符?:
(比如obj?prop:123
)。
下面是更多的例子。
// 如果 a 是 null 或 undefined, 返回 undefined
// 否则返回 a.b.c().d
a?.b.c().d
// 如果 a 是 null 或 undefined,下面的语句不产生任何效果
// 否则执行 a.b = 42
a?.b = 42
// 如果 a 是 null 或 undefined,下面的语句不产生任何效果
delete a?.b
Symbol https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/symbol.md
ES6 引入了一种新的原始数据类型Symbol
,表示独一无二的值。它是 JavaScript 语言的第七种数据类型,前六种是:undefined
、null
、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。
注意,Symbol
函数前不能使用new
命令,否则会报错。这是因为生成的 Symbol 是一个原始类型的值,不是对象。也就是说,由于 Symbol 值不是对象,所以不能添加属性。基本上,它是一种类似于字符串的数据类型。
注意,Symbol
函数的参数只是表示对当前 Symbol 值的描述,因此相同参数的Symbol
函数的返回值是不相等的。
// 没有参数的情况
var s1 = Symbol();
var s2 = Symbol();
s1 === s2 // false
// 有参数的情况
var s1 = Symbol('foo');
var s2 = Symbol('foo');
s1 === s2 // false
上面代码中,s1
和s2
都是Symbol
函数的返回值,而且参数相同,但是它们是不相等的。
Symbol 值不能与其他类型的值进行运算,会报错。
var sym = Symbol('My symbol');
"your symbol is " + sym
// TypeError: can't convert symbol to string
`your symbol is ${sym}`
// TypeError: can't convert symbol to string
但是,Symbol 值可以显式转为字符串。
var sym = Symbol('My symbol');
String(sym) // 'Symbol(My symbol)'
sym.toString() // 'Symbol(My symbol)'
另外,Symbol 值也可以转为布尔值,但是不能转为数值。
var sym = Symbol();
Boolean(sym) // true
!sym // false
if (sym) {
// ...
}
Number(sym) // TypeError
sym + 2 // TypeError
Symbol 类型还可以用于定义一组常量,保证这组常量的值都是不相等的。
log.levels = {
DEBUG: Symbol('debug'),
INFO: Symbol('info'),
WARN: Symbol('warn')
};
log(log.levels.DEBUG, 'debug message');
log(log.levels.INFO, 'info message');
还有一点需要注意,Symbol 值作为属性名时,该属性还是公开属性,不是私有属性。
Symbol 作为属性名,该属性不会出现在for...in
、for...of
循环中,也不会被Object.keys()
、Object.getOwnPropertyNames()
、JSON.stringify()
返回。但是,它也不是私有属性,有一个Object.getOwnPropertySymbols
方法,可以获取指定对象的所有 Symbol 属性名。
由于以 Symbol 值作为名称的属性,不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有的、但又希望只用于内部的方法。
Set和Map数据结构 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/set-map.md
首先,WeakSet 的成员只能是对象,而不能是其他类型的值。
const ws = new WeakSet();
ws.add(1)
// TypeError: Invalid value used in weak set
ws.add(Symbol())
// TypeError: invalid value used in weak set
上面代码试图向 WeakSet 添加一个数值和Symbol
值,结果报错,因为 WeakSet 只能放置对象。
其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。
这是因为垃圾回收机制依赖引用计数,如果一个值的引用次数不为0
,垃圾回收机制就不会释放这块内存。对于那些不重要的引用,在结束使用之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakMap 里面的引用就会自动消失。
由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历。
Object 结构提供了“字符串—值”的对应,Map结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,Map 比 Object 更合适。
事实上,不仅仅是数组,任何具有 Iterator 接口、且每个成员都是一个双元素的数组的数据结构(详见《Iterator》一章)都可以当作Map
构造函数的参数。这就是说,Set
和Map
都可以用来生成新的 Map。
const set = new Set([
['foo', 1],
['bar', 2]
]);
const m1 = new Map(set);
m1.get('foo') // 1
const m2 = new Map([['baz', 3]]);
const m3 = new Map(m2);
m3.get('baz') // 3
上面代码中,我们分别使用 Set 对象和 Map 对象,当作Map
构造函数的参数,结果都生成了新的 Map 对象。
由上可知,Map 的键实际上是跟内存地址绑定的,只要内存地址不一样,就视为两个键。这就解决了同名属性碰撞(clash)的问题,我们扩展别人的库的时候,如果使用对象作为键名,就不用担心自己的属性与原作者的属性同名。
如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键,包括0
和-0
,布尔值true
和字符串true
则是两个不同的键。另外,undefined
和null
也是两个不同的键。虽然NaN
不严格相等于自身,但 Map 将其视为同一个键。
let map = new Map();
map.set(-0, 123);
map.get(+0) // 123
map.set(true, 1);
map.set('true', 2);
map.get(true) // 1
map.set(undefined, 3);
map.set(null, 4);
map.get(undefined) // 3
map.set(NaN, 123);
map.get(NaN) // 123
首先,WeakMap
只接受对象作为键名(null
除外),不接受其他类型的值作为键名。
const map = new WeakMap();
map.set(1, 2)
// TypeError: 1 is not an object!
map.set(Symbol(), 2)
// TypeError: Invalid value used as weak map key
map.set(null, 2)
// TypeError: Invalid value used as weak map key
上面代码中,如果将数值1
和Symbol
值作为 WeakMap 的键名,都会报错。
其次,WeakMap
的键名所指向的对象,不计入垃圾回收机制。
Proxy https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/proxy.md
Reflect https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/reflect.md
Promise 对象 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/promise.md
Promise
对象有以下两个特点。
(1)对象的状态不受外界影响。Promise
对象代表一个异步操作,有三种状态:Pending
(进行中)、Resolved
(已完成,又称 Fulfilled)和Rejected
(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise
这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise
对象的状态改变,只有两种可能:从Pending
变为Resolved
和从Pending
变为Rejected
。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。如果改变已经发生了,你再对Promise
对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
有了Promise
对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise
对象提供统一的接口,使得控制异步操作更加容易。
Promise
也有一些缺点。首先,无法取消Promise
,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise
内部抛出的错误,不会反应到外部。第三,当处于Pending
状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
如果某些事件不断地反复发生,一般来说,使用 Stream 模式是比部署Promise
更好的选择。
Promise.prototype.then()
Promise 实例具有then
方法,也就是说,then
方法是定义在原型对象Promise.prototype
上的。它的作用是为 Promise 实例添加状态改变时的回调函数。前面说过,then
方法的第一个参数是Resolved
状态的回调函数,第二个参数(可选)是Rejected
状态的回调函数。
then
方法返回的是一个新的Promise
实例(注意,不是原来那个Promise
实例)。因此可以采用链式写法,即then
方法后面再调用另一个then
方法。
Promise.prototype.catch
方法是.then(null, rejection)
的别名,用于指定发生错误时的回调函数。
另外,then
方法指定的回调函数,如果运行中抛出错误,也会被catch
方法捕获。
Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch
语句捕获。
一般来说,不要在then
方法里面定义Reject状态的回调函数(即then
的第二个参数),总是使用catch
方法。
跟传统的try/catch
代码块不同的是,如果没有使用catch
方法指定错误处理的回调函数,Promise对象抛出的错误不会传递到外层代码,即不会有任何反应。
Promise 指定在下一轮“事件循环”再抛出错误。到了那个时候,Promise 的运行已经结束了,所以这个错误是在 Promise 函数体外抛出的,会冒泡到最外层,成了未捕获的错误。
注意,如果作为参数的 Promise 实例,自己定义了catch
方法,那么它一旦被rejected
,并不会触发Promise.all()
的catch
方法。
需要注意的是,立即resolve
的Promise对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。
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')
则是立即执行,因此最先输出。
done()
Promise对象的回调链,不管以then
方法或catch
方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个done
方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。
asyncFunc()
.then(f1)
.catch(r1)
.then(f2)
.done();
它的实现代码相当简单。
Promise.prototype.done = function (onFulfilled, onRejected) {
this.then(onFulfilled, onRejected)
.catch(function (reason) {
// 抛出一个全局错误
setTimeout(() => { throw reason }, 0);
});
};
从上面代码可见,done
方法的使用,可以像then
方法那样用,提供Fulfilled
和Rejected
状态的回调函数,也可以不提供任何参数。但不管怎样,done
都会捕捉到任何可能出现的错误,并向全局抛出。
Iterator 和 for...of 循环 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/iterator.md
Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是ES6创造了一种新的遍历命令for...of
循环,Iterator接口主要供for...of
消费。
如果使用 TypeScript 的写法,遍历器接口(Iterable)、指针对象(Iterator)和next
方法返回值的规格可以描述如下。
interface Iterable {
[Symbol.iterator]() : Iterator,
}
interface Iterator {
next(value?: any) : IterationResult,
}
interface IterationResult {
value: any,
done: boolean,
}
遍历器对象除了具有next
方法,还可以具有return
方法和throw
方法。如果你自己写遍历器对象生成函数,那么next
方法是必须部署的,return
方法和throw
方法是否部署是可选的。
return
方法的使用场合是,如果for...of
循环提前退出(通常是因为出错,或者有break
语句或continue
语句),就会调用return
方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署return
方法。
function readLinesSync(file) {
return {
next() {
if (file.isAtEndOfFile()) {
file.close();
return { done: true };
}
},
return() {
file.close();
return { done: true };
},
};
}
上面代码中,函数readLinesSync
接受一个文件对象作为参数,返回一个遍历器对象,其中除了next
方法,还部署了return
方法。下面,我们让文件的遍历提前返回,这样就会触发执行return
方法。
for (let line of readLinesSync(fileName)) {
console.log(line);
break;
}
注意,return
方法必须返回一个对象,这是Generator规格决定的。
throw
方法主要是配合Generator函数使用,一般的遍历器对象用不到这个方法。请参阅《Generator函数》一章。
JavaScript 原有的for...in
循环,只能获得对象的键名,不能直接获取键值。ES6 提供for...of
循环,允许遍历获得键值。
var arr = ['a', 'b', 'c', 'd'];
for (let a in arr) {
console.log(a); // 0 1 2 3
}
for (let a of arr) {
console.log(a); // a b c d
}
上面代码表明,for...in
循环读取键名,for...of
循环读取键值。如果要通过for...of
循环,获取数组的索引,可以借助数组实例的entries
方法和keys
方法,参见《数组的扩展》章节。
for...of
循环调用遍历器接口,数组的遍历器接口只返回具有数字索引的属性。这一点跟for...in
循环也不一样。
let arr = [3, 5, 7];
arr.foo = 'hello';
for (let i in arr) {
console.log(i); // "0", "1", "2", "foo"
}
for (let i of arr) {
console.log(i); // "3", "5", "7"
}
上面代码中,for...of
循环不会返回数组arr
的foo
属性。
并不是所有类似数组的对象都具有 Iterator 接口,一个简便的解决方法,就是使用Array.from
方法将其转为数组。
let arrayLike = { length: 2, 0: 'a', 1: 'b' };
// 报错
for (let x of arrayLike) {
console.log(x);
}
// 正确
for (let x of Array.from(arrayLike)) {
console.log(x);
}
对象
对于普通的对象,for...of
结构不能直接使用,会报错,必须部署了 Iterator 接口后才能使用。但是,这样情况下,for...in
循环依然可以用来遍历键名。
这种写法比较麻烦,因此数组提供内置的forEach
方法。
myArray.forEach(function (value) {
console.log(value);
});
这种写法的问题在于,无法中途跳出forEach
循环,break
命令或return
命令都不能奏效。
for...in
循环有几个缺点。
- 数组的键名是数字,但是
for...in
循环是以字符串作为键名“0”、“1”、“2”等等。 -
for...in
循环不仅遍历数字键名,还会遍历手动添加的其他键,甚至包括原型链上的键。 - 某些情况下,
for...in
循环会以任意顺序遍历键名。
总之,for...in
循环主要是为遍历对象而设计的,不适用于遍历数组。
for...of
循环相比上面几种做法,有一些显著的优点。
for (let value of myArray) {
console.log(value);
}
- 有着同
for...in
一样的简洁语法,但是没有for...in
那些缺点。 - 不同于
forEach
方法,它可以与break
、continue
和return
配合使用。 - 提供了遍历所有数据结构的统一操作接口。
Generator 函数的语法 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/generator.md
需要注意的是,yield
表达式后面的表达式,只有当调用next
方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。
function* gen() {
yield 123 + 456;
}
上面代码中,yield
后面的表达式123 + 456
,不会立即求值,只会在next
方法将指针移到这一句时,才会求值。
yield
表达式与return
语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到yield
,函数暂停执行,下一次再从该位置继续向后执行,而return
语句不具备位置记忆的功能。一个函数里面,只能执行一次(或者说一个)return
语句,但是可以执行多次(或者说多个)yield
表达式。正常函数只能返回一个值,因为只能执行一次return
;Generator 函数可以返回一系列的值,因为可以有任意多个yield
。从另一个角度看,也可以说 Generator 生成了一系列的值,这也就是它的名称的来历(英语中,generator 这个词是“生成器”的意思)。
Generator 函数可以不用yield
表达式,这时就变成了一个单纯的暂缓执行函数。
function* f() {
console.log('执行了!')
}
var generator = f();
setTimeout(function () {
generator.next()
}, 2000);
上面代码中,函数f
如果是普通函数,在为变量generator
赋值时就会执行。但是,函数f
是一个 Generator 函数,就变成只有调用next
方法时,函数f
才会执行。
另外需要注意,yield
表达式只能用在 Generator 函数里面,用在其他地方都会报错。
上面代码也会产生句法错误,因为forEach
方法的参数是一个普通函数,但是在里面使用了yield
表达式(这个函数里面还使用了yield*
表达式,详细介绍见后文)。一种修改方法是改用for
循环。
另外,yield
表达式如果用在另一个表达式之中,必须放在圆括号里面。
function* demo() {
console.log('Hello' + yield); // SyntaxError
console.log('Hello' + yield 123); // SyntaxError
console.log('Hello' + (yield)); // OK
console.log('Hello' + (yield 123)); // OK
}
yield
表达式用作函数参数或放在赋值表达式的右边,可以不加括号。
function* demo() {
foo(yield 'a', yield 'b'); // OK
let input = yield; // OK
}
由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator
属性,从而使得该对象具有 Iterator 接口。
var myIterable = {};
myIterable[Symbol.iterator] = function* () {
yield 1;
yield 2;
yield 3;
};
[...myIterable] // [1, 2, 3]
Generator 函数执行后,返回一个遍历器对象。该对象本身也具有Symbol.iterator
属性,执行后返回自身。
function* gen(){
// some code
}
var g = gen();
g[Symbol.iterator]() === g
// true
上面代码中,gen
是一个 Generator 函数,调用它会生成一个遍历器对象g
。它的Symbol.iterator
属性,也是一个遍历器对象生成函数,执行后返回它自己。
注意,由于next
方法的参数表示上一个yield
表达式的返回值,所以第一次使用next
方法时,不能带有参数。V8 引擎直接忽略第一次使用next
方法时的参数,只有从第二次使用next
方法开始,参数才是有效的。从语义上讲,第一个next
方法用来启动遍历器对象,所以不用带有参数。
function*foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
for (let v of foo()) {
console.log(v);
}
// 1 2 3 4 5
上面代码使用for...of
循环,依次显示5个yield
表达式的值。这里需要注意,一旦next
方法的返回对象的done
属性为true
,for...of
循环就会中止,且不包含该返回对象,所以上面代码的return
语句返回的6
,不包括在for...of
循环之中。
下面是一个利用 Generator 函数和for...of
循环,实现斐波那契数列的例子。
function* fibonacci() {
let [prev, curr] = [0, 1];
for (;;) {
[prev, curr] = [curr, prev + curr];
yield curr;
}
}
for (let n of fibonacci()) {
if (n > 1000) break;
console.log(n);
}
从上面代码可见,使用for...of
语句时不需要使用next
方法。
利用for...of
循环,可以写出遍历任意对象(object)的方法。原生的 JavaScript 对象没有遍历接口,无法使用for...of
循环,通过 Generator 函数为它加上这个接口,就可以用了。
function* objectEntries(obj) {
let propKeys = Reflect.ownKeys(obj);
for (let propKey of propKeys) {
yield [propKey, obj[propKey]];
}
}
let jane = { first: 'Jane', last: 'Doe' };
for (let [key, value] of objectEntries(jane)) {
console.log(`${key}: ${value}`);
}
// first: Jane
// last: Doe
上面代码中,对象jane
原生不具备 Iterator 接口,无法用for...of
遍历。这时,我们通过 Generator 函数objectEntries
为它加上遍历器接口,就可以用for...of
遍历了。加上遍历器接口的另一种写法是,将 Generator 函数加到对象的Symbol.iterator
属性上面。
function* objectEntries() {
let propKeys = Object.keys(this);
for (let propKey of propKeys) {
yield [propKey, this[propKey]];
}
}
let jane = { first: 'Jane', last: 'Doe' };
jane[Symbol.iterator] = objectEntries;
for (let [key, value] of jane) {
console.log(`${key}: ${value}`);
}
// first: Jane
// last: Doe
除了for...of
循环以外,扩展运算符(...
)、解构赋值和Array.from
方法内部调用的,都是遍历器接口。这意味着,它们都可以将 Generator 函数返回的 Iterator 对象,作为参数。
function* numbers () {
yield 1
yield 2
return 3
yield 4
}
// 扩展运算符
[...numbers()] // [1, 2]
// Array.from 方法
Array.from(numbers()) // [1, 2]
// 解构赋值
let [x, y] = numbers();
x // 1
y // 2
// for...of 循环
for (let n of numbers()) {
console.log(n)
}
// 1
// 2
Generator 函数返回的遍历器对象,都有一个throw
方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。
注意,不要混淆遍历器对象的throw
方法和全局的throw
命令。上面代码的错误,是用遍历器对象的throw
方法抛出的,而不是用throw
命令抛出的。后者只能被函数体外的catch
语句捕获。
如果 Generator 函数内部没有部署try...catch
代码块,那么throw
方法抛出的错误,将被外部try...catch
代码块捕获。
如果 Generator 函数内部和外部,都没有部署try...catch
代码块,那么程序将报错,直接中断执行。
throw
方法被捕获以后,会附带执行下一条yield
表达式。也就是说,会附带执行一次next
方法。
另外,throw
命令与g.throw
方法是无关的,两者互不影响。
这种函数体内捕获错误的机制,大大方便了对错误的处理。多个yield
表达式,可以只用一个try...catch
代码块来捕获错误。如果使用回调函数的写法,想要捕获多个错误,就不得不为每个函数内部写一个错误处理语句,现在只在 Generator 函数内部写一次catch
语句就可以了。
Generator 函数体外抛出的错误,可以在函数体内捕获;反过来,Generator 函数体内抛出的错误,也可以被函数体外的catch
捕获。
一旦 Generator 执行过程中抛出错误,且没有被内部捕获,就不会再执行下去了。如果此后还调用next
方法,将返回一个value
属性等于undefined
、done
属性等于true
的对象,即 JavaScript 引擎认为这个 Generator 已经运行结束了。
实际上,任何数据结构只要有 Iterator 接口,就可以被yield*
遍历。
function*genFuncWithReturn() {
yield 'a';
yield 'b';
return 'The result';
}
function* logReturned(genObj) {
let result = yield* genObj;
console.log(result);
}
[...logReturned(genFuncWithReturn())]
// The result
// 值为 [ 'a', 'b' ]
上面代码中,存在两次遍历。第一次是扩展运算符遍历函数logReturned
返回的遍历器对象,第二次是yield*
语句遍历函数genFuncWithReturn
返回的遍历器对象。这两次遍历的效果是叠加的,最终表现为扩展运算符遍历函数genFuncWithReturn
返回的遍历器对象。所以,最后的数据表达式得到的值等于[ 'a', 'b' ]
。但是,函数genFuncWithReturn
的return
语句的返回值The result
,会返回给函数logReturned
内部的result
变量,因此会有终端输出。
Generator 函数的异步应用 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/generator-async.md
ES6 诞生以前,异步编程的方法,大概有下面四种。
- 回调函数
- 事件监听
- 发布/订阅
- Promise 对象
Generator 函数将 JavaScript 异步编程带入了一个全新的阶段。
async 函数 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/async.md
co
模块约定,yield
命令后面只能是 Thunk 函数或 Promise 对象,而async
函数的await
命令后面,可以是Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
async
函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then
方法指定下一步的操作。
进一步说,async
函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await
命令就是内部then
命令的语法糖。
async
函数返回一个 Promise 对象。
async
函数内部抛出错误,会导致返回的 Promise 对象变为reject
状态。抛出的错误对象会被catch
方法回调函数接收到。
async
函数返回的 Promise 对象,必须等到内部所有await
命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return
语句或者抛出错误。也就是说,只有async
函数内部的异步操作执行完,才会执行then
方法指定的回调函数。
正常情况下,await
命令后面是一个 Promise 对象。如果不是,会被转成一个立即resolve
的 Promise 对象。
第一点,前面已经说过,await
命令后面的Promise
对象,运行结果可能是rejected
,所以最好把await
命令放在try...catch
代码块中。
async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里。
Class 的基本语法 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/class.md
类和模块的内部,默认就是严格模式,所以不需要使用use strict
指定运行模式。只要你的代码写在类或模块之中,就只有严格模式可用。
考虑到未来所有的代码,其实都是运行在模块之中,所以 ES6 实际上把整个语言升级到了严格模式。
类必须使用new
调用,否则会报错。这是它跟普通构造函数的一个主要区别,后者不用new
也可以执行。
与 ES5 一样,实例的属性除非显式定义在其本身(即定义在this
对象上),否则都是定义在原型上(即定义在class
上)。
Class 表达式
与函数一样,类也可以使用表达式的形式定义。
const MyClass = class Me {
getClassName() {
return Me.name;
}
};
上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass
而不是Me
,Me
只在 Class 的内部代码可用,指代当前类。
let inst = new MyClass();
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined
上面代码表示,Me
只在 Class 内部有定义。
如果类的内部没用到的话,可以省略Me
,也就是可以写成下面的形式。
const MyClass = class { /* ... */ };
采用 Class 表达式,可以写出立即执行的 Class。
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}('张三');
person.sayName(); // "张三"
上面代码中,person
是一个立即执行的类的实例。
私有属性可以指定初始值,在构造函数执行时进行初始化。
new
是从构造函数生成实例的命令。ES6 为new
命令引入了一个new.target
属性,该属性一般用在在构造函数之中,返回new
命令作用于的那个构造函数。如果构造函数不是通过new
命令调用的,new.target
会返回undefined
,因此这个属性可以用来确定构造函数是怎么调用的。
Class 的继承 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/class-extends.md
ES5 的继承,实质是先创造子类的实例对象this
,然后再将父类的方法添加到this
上面(Parent.apply(this)
)。ES6 的继承机制完全不同,实质是先创造父类的实例对象this
(所以必须先调用super
方法),然后再用子类的构造函数修改this
。
super
这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。
注意,super
虽然代表了父类A
的构造函数,但是返回的是子类B
的实例,即super
内部的this
指的是B
,因此super()
在这里相当于A.prototype.constructor.call(this)
。
上面代码中,new.target
指向当前正在执行的函数。可以看到,在super()
执行时,它指向的是子类B
的构造函数,而不是父类A
的构造函数。也就是说,super()
内部的this
指向的是B
。
作为函数时,super()
只能用在子类的构造函数之中,用在其他地方就会报错。
子类必须在constructor
方法中调用super
方法,否则新建实例时会报错。这是因为子类没有自己的this
对象,而是继承父类的this
对象,然后对其进行加工。如果不调用super
方法,子类就得不到this
对象。
另一个需要注意的地方是,在子类的构造函数中,只有调用super
之后,才可以使用this
关键字,否则会报错。这是因为子类实例的构建,是基于对父类实例加工,只有super
方法才能返回父类实例。
上面代码中,子类B
当中的super.p()
,就是将super
当作一个对象使用。这时,super
在普通方法之中,指向A.prototype
,所以super.p()
就相当于A.prototype.p()
。
这里需要注意,由于super
指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super
调用的。
上面代码中,super
在静态方法之中指向父类,在普通方法之中指向父类的原型对象。
注意,继承Object
的子类,有一个行为差异。
class NewObj extends Object{
constructor(){
super(...arguments);
}
}
var o = new NewObj({attr: true});
o.attr === true // false
上面代码中,NewObj
继承了Object
,但是无法通过super
方法向父类Object
传参。这是因为 ES6 改变了Object
构造函数的行为,一旦发现Object
方法不是通过new Object()
这种形式调用,ES6 规定Object
构造函数会忽略参数。
修饰器 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/decorator.md
注意,修饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,修饰器能在编译阶段运行代码。也就是说,修饰器本质就是编译时执行的函数。
前面的例子是为类添加一个静态属性,如果想添加实例属性,可以通过目标类的prototype
对象操作。
如果同一个方法有多个修饰器,会像剥洋葱一样,先从外到内进入,然后由内向外执行。
function dec(id){
console.log('evaluated', id);
return (target, property, descriptor) => console.log('executed', id);
}
class Example {
@dec(1)
@dec(2)
method(){}
}
// evaluated 1
// evaluated 2
// executed 2
// executed 1
上面代码中,外层修饰器@dec(1)
先进入,但是内层修饰器@dec(2)
先执行。
除了注释,修饰器还能用来类型检查。所以,对于类来说,这项功能相当有用。从长期来看,它将是JavaScript代码静态分析的重要工具。
修饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。
Module 的语法 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/module.md
ES6 模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";
。
严格模式主要有以下限制。
- 变量必须声明后再使用
- 函数的参数不能有同名属性,否则报错
- 不能使用
with
语句 - 不能对只读属性赋值,否则报错
- 不能使用前缀0表示八进制数,否则报错
- 不能删除不可删除的属性,否则报错
- 不能删除变量
delete prop
,会报错,只能删除属性delete global[prop]
-
eval
不会在它的外层作用域引入变量 -
eval
和arguments
不能被重新赋值 -
arguments
不会自动反映函数参数的变化 - 不能使用
arguments.callee
- 不能使用
arguments.caller
- 禁止
this
指向全局对象 - 不能使用
fn.caller
和fn.arguments
获取函数调用的堆栈 - 增加了保留字(比如
protected
、static
和interface
)
上面这些限制,模块都必须遵守。由于严格模式是 ES5 引入的,不属于 ES6,所以请参阅相关 ES5 书籍,本书不再详细介绍了。
其中,尤其需要注意this
的限制。ES6 模块之中,顶层的this
指向undefined
,即不应该在顶层代码使用this。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export
关键字输出该变量。下面是一个 JS 文件,里面使用export
命令输出变量。
需要特别注意的是,export
命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;
// 报错
var m = 1;
export m;
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出1,第二种写法通过变量m
,还是直接输出1。1
只是一个值,不是接口。正确的写法是下面这样。
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
上面三种写法都是正确的,规定了对外的接口m
。其他脚本可以通过这个接口,取到值1
。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。
另外,export
语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
上面代码输出变量foo
,值为bar
,500毫秒之后变成baz
。
这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新,详见下文《Module 的加载实现》一节。
最后,export
命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import
命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了ES6模块的设计初衷。
function foo() {
export default 'bar' // SyntaxError
}
foo()
上面代码中,export
语句放在函数之中,结果报错。
import
后面的from
指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js
路径可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。
注意,import
命令具有提升效果,会提升到整个模块的头部,首先执行。
由于import
是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
上面三种写法都会报错,因为它们用到了表达式、变量和if
结构。在静态分析阶段,这些语法都是没法得到值的。
最后,import
语句会执行所加载的模块,因此可以有下面的写法。
import 'lodash';
上面代码仅仅执行lodash
模块,但是不输入任何值。
如果多次重复执行同一句import
语句,那么只会执行一次,而不会执行多次。
import 'lodash';
import 'lodash';
上面代码加载了两次lodash
,但是只会执行一次。
import { foo } from 'my_module';
import { bar } from 'my_module';
// 等同于
import { foo, bar } from 'my_module';
上面代码中,虽然foo
和bar
在两个语句中加载,但是它们对应的是同一个my_module
实例。也就是说,import
语句是 Singleton 模式。
目前阶段,通过 Babel 转码,CommonJS 模块的require
命令和 ES6 模块的import
命令,可以写在同一个模块里面,但是最好不要这样做。因为import
在静态解析阶段执行,所以它是一个模块之中最早执行的。下面的代码可能不会得到预期结果。
require('core-js/modules/es6.symbol');
require('core-js/modules/es6.promise');
import React from 'React';
本质上,export default
就是输出一个叫做default
的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。
// modules.js
function add(x, y) {
return x * y;
}
export {add as default};
// 等同于
// export default add;
// app.js
import { default as xxx } from 'modules';
// 等同于
// import xxx from 'modules';
正是因为export default
命令其实只是输出一个叫做default
的变量,所以它后面不能跟变量声明语句。
模块的继承
模块之间也可以继承。
假设有一个circleplus
模块,继承了circle
模块。
// 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
的属性或方法,改名后再输出。
// circleplus.js
export { area as circleArea } from 'circle';
上面代码表示,只输出circle
模块的area
方法,且将其改名为circleArea
。
加载上面模块的写法如下。
// main.js
import * as math from 'circleplus';
import exp from 'circleplus';
console.log(exp(math.e));
上面代码中的import exp
表示,将circleplus
模块的默认方法加载为exp
方法。
import()
函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,也会加载指定的模块。另外,import()
函数与所加载的模块没有静态连接关系,这点也是与import
语句不相同。
import()
类似于 Node 的require
方法,区别主要是前者是异步加载,后者是同步加载。
Module 的加载实现 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/module-loader.md
对于外部的模块脚本(上例是foo.js
),有几点需要注意。
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
- 模块脚本自动采用严格模式,不管有没有声明
use strict
。 - 模块之中,可以使用
import
命令加载其他模块(.js
后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export
命令输出对外接口。 - 模块之中,顶层的
this
关键字返回undefined
,而不是指向window
。也就是说,在模块顶层使用this
关键字,是无意义的。 - 同一个模块如果加载多次,将只执行一次。
利用顶层的this
等于undefined
这个语法点,可以侦测当前代码是否在 ES6 模块之中。
const isNotModuleScript = this !== undefined;
讨论 Node 加载 ES6 模块之前,必须了解 ES6 模块与 CommonJS 模块完全不同。
它们有两个重大差异。
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
第二个差异是因为 CommonJS 加载的是一个对象(即module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。
// lib.js
export let obj = {};
// main.js
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError
上面代码中,main.js
从lib.js
输入变量obj
,可以对obj
添加属性,但是重新赋值就会报错。因为变量obj
指向的地址是只读的,不能重新赋值,这就好比main.js
创造了一个名为obj
的const
变量。
最后,export
通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。
Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。
在静态分析阶段,一个模块脚本只要有一行import
或export
语句,Node 就会认为该脚本为 ES6 模块,否则就为 CommonJS 模块。如果不输出任何接口,但是希望被 Node 认为是 ES6 模块,可以在脚本中加一行语句。
export {};
上面的命令并不是输出一个空对象,而是不输出任何接口的 ES6 标准写法。
如果不指定绝对路径,Node 加载 ES6 模块会依次寻找以下脚本,与require()
的规则一致。
import './foo';
// 依次寻找
// ./foo.js
// ./foo/package.json
// ./foo/index.js
import 'baz';
// 依次寻找
// ./node_modules/baz.js
// ./node_modules/baz/package.json
// ./node_modules/baz/index.js
// 寻找上一级目录
// ../node_modules/baz.js
// ../node_modules/baz/package.json
// ../node_modules/baz/index.js
// 再上一级目录
ES6 模块之中,顶层的this
指向undefined
;CommonJS 模块的顶层this
指向当前模块,这是两者的一个重大差异。
如果采用整体输入的写法(import * as xxx from someModule
),default
会取代module.exports
,作为输入的接口。
import * as baz from './a';
// baz = {
// get default() {return module.exports;},
// get foo() {return this.default.foo}.bind(baz),
// get bar() {return this.default.bar}.bind(baz)
// }
上面代码中,this.default
取代了module.exports
。需要注意的是,Node 会自动为baz
添加default
属性,通过baz.default
拿到module.exports
。
// b.js
module.exports = null;
// es.js
import foo from './b';
// foo = null;
import * as bar from './b';
// bar = {default:null};
上面代码中,es.js
采用第二种写法时,要通过bar.default
这样的写法,才能拿到module.exports
。
下面是另一个例子。
// c.js
module.exports = function two() {
return 2;
};
// es.js
import foo from './c';
foo(); // 2
import * as bar from './c';
bar.default(); // 2
bar(); // throws, bar is not a function
上面代码中,bar
本身是一个对象,不能当作函数调用,只能通过bar.default
调用。
编程风格 https://github.com/ruanyf/es6tutorial/blob/gh-pages/docs/style.md