ES6详解(持续更)

前言

之前对ES6也有过学习使用,但是都没有深入系统去看看文档书籍,这里开始对一些ES6语法进行整理总结。
本文基于阮一峰ES6入门,线上地址:阮一峰ES6入门



let和const命令

1、let命令

  • 如果使用let,声明的变量仅在块级作用域内有效
var a = [];
for (let i = 0; i < 10; i++) {    //如果这里用var,则下面的输出为10
  a[i] = function () {
    console.log(i);
  };
}
a[6](); // 6
  • for循环有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
for (let i = 0; i < 3; i++) {
  let i = 'abc';
  console.log(i);
}
// abc
// abc
// abc
  • 不存在变量提升
  • 暂时性死区,相当于
var tmp = 123;
if (true) {
  tmp = 'abc'; // ReferenceError
  let tmp;
}
function bar(x = y, y = 2) {
  return [x, y];
}
bar(); // 报错

// 不报错
var x = x;
// 报错
let x = x;
// ReferenceError: x is not defined

暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

  • 不允许重复声明

2、块级作用域

块级作用域与函数声明

  • ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
  • ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。
function f() { console.log('I am outside!'); }
(function () {
  if (false) {
    // 重复声明一次函数f
    function f() { console.log('I am inside!'); }
  }
  f();
}());

上面代码在 ES5 中运行,会得到“I am inside!”,因为在if内声明的函数f会被提升到函数头部
ES6 就完全不一样了,理论上会得到“I am outside!”。因为块级作用域内声明的函数类似于let,对作用域之外没有影响。但是,如果你真的在 ES6 浏览器中运行一下上面的代码,是会报错的,因为如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。
1、 允许在块级作用域内声明函数。
2、函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
3、同时,函数声明还会提升到所在的块级作用域的头部。
考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。

  • 另外,还有一个需要注意的地方。ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。

3、const命令

  • const声明一个只读的常量。一旦声明,常量的值就不能改变。
  • const的作用域与let命令相同:只在声明所在的块级作用域内有效。
  • const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
  • const声明的常量,也与let一样不可重复声明。

本质

const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。如果真的想将对象冻结,应该使用Object.freeze方法。

var constantize = (obj) => {
  Object.freeze(obj);
  Object.keys(obj).forEach( (key, i) => {
    if ( typeof obj[key] === 'object' ) {
      constantize( obj[key] );
    }
  });
};
  • 顶层对象的属性
var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1

let b = 1;
window.b // undefined
  • globalThis对象


变量的解构赋值

数组的解构赋值

  • 对于 Set 结构,也可以使用数组的解构赋值。
let [x, y, z] = new Set(['a', 'b', 'c']);
x // "a"
  • 事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。
  • 默认值
let [foo = true] = [];  //foo = true
let [x, y = 'b'] = ['a']; // x='a', y='b'
let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'

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

function f() {
  console.log('aaa');
}
let [x = f()] = [1];

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

let [x = 1, y = x] = [];     // x=1; y=1
let [x = 1, y = x] = [2];    // x=2; y=2
let [x = 1, y = x] = [1, 2]; // x=1; y=2
let [x = y, y = 1] = [];     // ReferenceError: y is not defined

对象的解构赋值

  • 对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
let { baz } = { foo: 'aaa', bar: 'bbb' };
baz // undefined
  • 如果变量名与属性名不一致,必须写成下面这样。
let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"
  • 对象的解构赋值是下面形式的简写。也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' };
  • 与数组一样,解构也可以用于嵌套结构的对象。
const node = {
  loc: {
    start: {
      line: 1,
      column: 5
    }
  }
};

let { loc, loc: { start }, loc: { start: { line }} } = node;
line // 1
loc  // Object {start: Object}
start // Object {line: 1, column: 5}

上面代码有三次解构赋值,分别是对loc、start、line三个属性的解构赋值。注意,最后一次对line属性的解构赋值之中,只有line是变量,loc和start都是模式,不是变量。

// 报错
let {foo: {bar}} = {baz: 'baz'};

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

  • 注意,对象的解构赋值可以取到继承的属性。
  • 默认值
  • 注意点
    (1)如果要将一个已经声明的变量用于解构赋值,必须非常小心。
// 错误的写法
let x;
{x} = {x: 1};  //不能加var或let,因为x已经声明过了
// SyntaxError: syntax error

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

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

(2)解构赋值允许等号左边的模式之中,不放置任何变量名。
(3)由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。

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

字符串的解构赋值

  • 字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
const [a, b] = 'hello';
a // "h"
b // "e"
  • 类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值。
let {length : len} = 'hello';
len // 5

数值和布尔值的解构赋值

  • 解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
let {toString: s} = 123;
s === Number.prototype.toString // true

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

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

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

函数参数的解构赋值

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

[[1, 2], [3, 4]].map(([a, b]) => a + b);
// [ 3, 7 ]
  • 函数参数的解构也可以使用默认值。
function move({x = 0, y = 0} = {}) {
  return [x, y];
}
move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, 0]
move({}); // [0, 0]
move(); // [0, 0]

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

undefined就会触发函数参数的默认值。

[1, undefined, 3].map((x = 'yes') => x);
// [ 1, 'yes', 3 ]

圆括号问题

不能使用圆括号情况

(1)变量声明语句

// 全部报错
let [(a)] = [1];
let {x: (c)} = {};
let ({x: c}) = {};
let {(x: c)} = {};
let {(x): c} = {};
let { o: ({ p: p }) } = { o: { p: 2 } };

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

// 报错
function f([(z)]) { return z; }
// 报错
function f([z,(x)]) { return x; }

(3)赋值语句的模式

// 全部报错
({ p: a }) = { p: 42 };
([a]) = [5];
上面代码将整个模式放在圆括号之中,导致报错。
// 报错
[({ p: a }), { x: c }] = [{}, {}];

可以使用圆括号的情况

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

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

用途

(1)交换变量的值

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

(2)从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。

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

(4)提取 JSON 数据
(5)函数参数的默认值
(6)遍历 Map 结构

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

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

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


字符串的拓展

字符串的Unicode表示法

  • ES6 加强了对 Unicode 的支持,允许采用\uxxxx形式表示一个字符,其中xxxx表示字符的 Unicode 码点。但是,这种表示法只限于码点在\u0000~\uFFFF之间的字符。超出这个范围的字符,必须用两个双字节的形式表示。
  • ES6 对这一点做出了改进,只要将码点放入大括号,就能正确解读该字符。
"\u{20BB7}"
// "𠮷"
'\u{1F680}' === '\uD83D\uDE80'
// true
  • 有了这种表示法之后,JavaScript 共有 6 种方法可以表示一个字符。
'\z' === 'z'  // true
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true
'\u{7A}' === 'z' // true

字符串的遍历器接口

ES6 为字符串添加了遍历器接口(详见《Iterator》一章),使得字符串可以被for…of循环遍历。

for (let codePoint of 'foo') {
  console.log(codePoint)
}
// "f"
// "o"
// "o"
  • 除了遍历字符串,这个遍历器最大的优点是可以识别大于0xFFFF的码点,传统的for循环无法识别这样的码点。
let text = String.fromCodePoint(0x20BB7);
for (let i = 0; i < text.length; i++) {
  console.log(text[i]);
}
// " "
// " "
for (let i of text) {
  console.log(i);
}
// "𠮷"

上面代码中,字符串text只有一个字符,但是for循环会认为它包含两个字符(都不可打印),而for…of循环会正确识别出这一个字符。


直接输入 U+2028 和 U+2029

JavaScript 规定有5个字符,不能在字符串里面直接使用,只能使用转义形式。

  • U+005C:反斜杠(reverse solidus)
  • U+000D:回车(carriage return)
  • U+2028:行分隔符(line separator)
  • U+2029:段分隔符(paragraph separator)
  • U+000A:换行符(line feed)

举例来说,字符串里面不能直接包含反斜杠,一定要转义写成\或者\u005c。

问题

JSON 格式允许字符串里面直接使用 U+2028(行分隔符)和 U+2029(段分隔符)。这样一来,服务器输出的 JSON 被JSON.parse解析,就有可能直接报错。为了消除这个报错,ES2019 允许 JavaScript 字符串直接输入 U+2028(行分隔符)和 U+2029(段分隔符)。注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为 JSON 本来就不允许直接包含正则表达式。


JSON.stringify() 的改造


模板字符串

  • 传统的 JavaScript 语言,输出模板通常是这样写的(下面使用了 jQuery 的方法)。
$('#result').append(
  'There are <b>' + basket.count + '</b> ' +
  'items in your basket, '
);
  • 如果在模板字符串中需要使用反引号,则前面要用反斜杠转义。
  • 如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中。如果你不想要这个换行,可以使用trim方法消除它。
  • 模板字符串中嵌入变量,需要将变量名写在${}之中。大括号内部可以放入任意的 JavaScript 表达式,可以进行运算,以及引用对象属性。
  • 模板字符串之中还能调用函数。
  • 如果大括号中的值不是字符串,将按照一般的规则转为字符串。比如,大括号中是一个对象,将默认调用对象的toString方法。
  • 模板字符串甚至还能嵌套。


字符串的新增方法

String.fromCodePoint()

  • ES5 提供String.fromCharCode()方法,用于从 Unicode 码点返回对应字符,但是这个方法不能识别码点大于0xFFFF的字符。
  • ES6 提供了String.fromCodePoint()方法,可以识别大于0xFFFF的字符,弥补了String.fromCharCode()方法的不足。
  • 如果String.fromCodePoint方法有多个参数,则它们会被合并成一个字符串返回。
  • 注意,fromCodePoint方法定义在String对象上,而codePointAt方法定义在字符串的实例对象上。

String.raw()


实例方法

1、codePointAt()
2、normalize()
3、includes(), startsWith(), endsWith()

  • 这三个方法都支持第二个参数,表示开始搜索的位置。
  • 使用第二个参数n时,endsWith的行为与其他两个方法有所不同。它针对前n个字符,而其他两个方法针对从第n个位置直到字符串结束。

4、repeat()

  • repeat方法返回一个新字符串,表示将原字符串重复n次。

5、padStart(),padEnd()

  • ES2017 引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部或尾部补全。padStart()用于头部补全,padEnd()用于尾部补全。

6、trimStart(),trimEnd()

  • 它们返回的都是新字符串,不会修改原始字符串。
  • 浏览器还部署了额外的两个方法,trimLeft()是trimStart()的别名,trimRight()是trimEnd()的别名。

7、matchAll()



正则的扩展

正则的扩展

  • ES5 不允许此时使用第二个参数添加修饰符,否则会报错。
var regex = new RegExp(/xyz/, 'i');
// Uncaught TypeError: Cannot supply flags when constructing one RegExp from another
  • ES6 改变了这种行为。如果RegExp构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。
new RegExp(/abc/ig, 'i').flags
// "i"

后行断言

  • “先行断言”: /x(?=y)/ /x(?!y)/
  • “后行断言”:/(?<=\$)\d+/ /(?<!y)x/
  • “后行断言”的实现,需要先匹配/(?<=y)x/的x,然后再回到左边,匹配y的部分。这种“先右后左”的执行顺序,与所有其他正则操作相反,导致了一些不符合预期的行为
/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]
/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]
  • 其次,“后行断言”的反斜杠引用,也与通常的顺序相反,必须放在对应的那个括号之前。

String.prototype.matchAll()

它返回的是一个遍历器(Iterator),而不是数组。

正则匹配索引

exec()方法的返回结果加上indices属性,在这个属性上面可以拿到匹配的开始位置和结束位置。

修饰符

1、u 修饰符,RegExp.prototype.unicode 属性
2、y修饰符,RegExp.prototype.sticky 属性
3、RegExp.prototype.flags 属性
4、s 修饰符:dotAll 模式
5、Unicode 属性类
6、具名组匹配

const RE_DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;

可以在exec方法返回结果的groups属性上引用该组名。
7、解构赋值和替换

let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');
one  // foo
two  // bar
  • 字符串替换时,使用$<组名>引用具名组。
let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
'2015-01-02'.replace(re, '$<day>/$<month>/$<year>')
// '02/01/2015'
  • replace方法的第二个参数也可以是函数,该函数的参数序列如下。
'2015-01-02'.replace(re, (
   matched, // 整个匹配结果 2015-01-02
   capture1, // 第一个组匹配 2015
   capture2, // 第二个组匹配 01
   capture3, // 第三个组匹配 02
   position, // 匹配开始的位置 0
   S, // 原字符串 2015-01-02
   groups // 具名组构成的一个对象 {year, month, day}
 ) => {
 let {day, month, year} = groups;
 return `${day}/${month}/${year}`;
});

8、引用
如果要在正则表达式内部引用某个“具名组匹配”,可以使用\k<组名>的写法。



数值的拓展

二进制和八进制表示法

  • ES6 提供了二进制和八进制数值的新的写法,分别用前缀0b(或0B)和0o(或0O)表示。
  • 从 ES5 开始,在严格模式之中,八进制就不再允许使用前缀0表示,ES6 进一步明确,要使用前缀0o表示。
  • 如果要将0b和0o前缀的字符串数值转为十进制,要使用Number方法。

Number.isFinite(), Number.isNaN()

  • 如果参数类型不是数值,Number.isFinite一律返回false。
  • 它们与传统的全局方法isFinite()和isNaN()的区别在于,传统方法先调用Number()将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效

其他方法

1、Number.parseInt(), Number.parseFloat()
2、Number.isInteger()

  • JavaScript 内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。
  • 如果参数不是数值,Number.isInteger返回false。
  • 小数的精度达到了小数点后16个十进制位,转成二进制位超过了53个二进制位,导致最后的那个2被丢弃
Number.isInteger(3.0000000000000002) // true
  • 如果一个数值的绝对值小于Number.MIN_VALUE(5E-324),即小于 JavaScript 能够分辨的最小值,会被自动转为 0。
    3、Number.EPSILON
  • 等于 2 的 -52 次方
  • Number.EPSILON实际上是 JavaScript 能够表示的最小精度。
    4、安全整数和 Number.isSafeInteger()

Math对象的拓展

1、Math.trunc()

  • Math.trunc方法用于去除一个数的小数部分,返回整数部分。
  • 对于非数值,Math.trunc内部使用Number方法将其先转为数值。
  • 对于空值和无法截取整数的值,返回NaN。

2、Math.sign()

  • 它会返回五种值。
    参数为正数,返回+1;
    参数为负数,返回-1;
    参数为 0,返回0;
    参数为-0,返回-0;
    其他值,返回NaN。
  • 如果参数是非数值,会自动转为数值。对于那些无法转为数值的值,会返回NaN。

3、Math.cbrt()

  • Math.cbrt()方法用于计算一个数的立方根。对于非数值,Math.cbrt()方法内部也是先使用Number()方法将其转为数值。

4、Math.clz32()

  • Math.clz32()方法将参数转为 32 位无符号整数的形式,然后返回这个 32 位值里面有多少个前导 0。
  • 对于小数,Math.clz32方法只考虑整数部分。
  • 对于空值或其他类型的值,Math.clz32方法会将它们先转为数值,然后再计算。

5、Math.imul()

  • Math.imul方法返回两个数以 32 位带符号整数形式相乘的结果,返回的也是一个 32 位的带符号整数。

6、Math.fround()

  • Math.fround方法返回一个数的32位单精度浮点数形式。

7、Math.hypot()

  • Math.hypot方法返回所有参数的平方和的平方根。

8、Math.expm1()

  • Math.expm1(x)返回 ex - 1,即Math.exp(x) - 1

9、Math.log1p()

  • Math.log1p(x)方法返回1 + x的自然对数,即Math.log(1 + x)。如果x小于-1,返回NaN。

10、Math.log10()

  • Math.log10(x)返回以 10 为底的x的对数。如果x小于 0,则返回 NaN。

11、Math.log2()

  • Math.log2(x)返回以 2 为底的x的对数。如果x小于 0,则返回 NaN。

12、双曲函数方法
ES6 新增了 6 个双曲函数方法。

  • Math.sinh(x) 返回x的双曲正弦(hyperbolic sine)
  • Math.cosh(x) 返回x的双曲余弦(hyperbolic cosine)
  • Math.tanh(x) 返回x的双曲正切(hyperbolic tangent)
  • Math.asinh(x) 返回x的反双曲正弦(inverse hyperbolic sine)
  • Math.acosh(x) 返回x的反双曲余弦(inverse hyperbolic cosine)
  • Math.atanh(x) 返回x的反双曲正切(inverse hyperbolic tangent)

13、指数运算符

  • ES2016 新增了一个指数运算符(**)。这个运算符的一个特点是右结合,而不是常见的左结合。多个指数运算符连用时,是从最右边开始计算的。
// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2
// 512

指数运算符可以与等号结合,形成一个新的赋值运算符(**=)。


BigInt 数据类型

简介

JavaScript 所有数字都保存成 64 位浮点数,这给数值的表示带来了两大限制。一是数值的精度只能到 53 个二进制位(相当于 16 个十进制位),大于这个范围的整数,JavaScript 是无法精确表示的,这使得 JavaScript 不适合进行科学和金融方面的精确计算。二是大于或等于2的1024次方的数值,JavaScript 无法表示,会返回Infinity

  • 为了与 Number 类型区别,BigInt 类型的数据必须添加后缀n。
  • BigInt 与普通整数是两种值,它们之间并不相等。
  • typeof运算符对于 BigInt 类型的数据返回bigint。
  • BigInt 可以使用负号(-),但是不能使用正号(+),因为会与 asm.js 冲突。

BigInt 对象

  • BigInt()构造函数必须有参数,而且参数必须可以正常转为数值,下面的用法都会报错。
new BigInt() // TypeError
BigInt(undefined) //TypeError
BigInt(null) // TypeError
BigInt('123n') // SyntaxError
BigInt('abc') // SyntaxError
  • 参数如果是小数,也会报错。
  • 方法
BigInt.prototype.toString()
BigInt.prototype.valueOf()
  • 转换规则
    可以使用Boolean()、Number()和String()这三个方法,将 BigInt 可以转为布尔值、数值和字符串类型。
Boolean(0n) // false
Boolean(1n) // true
Number(1n)  // 1
String(1n)  // "1"

上面代码中,注意最后一个例子,转为字符串时后缀n会消失。
另外,取反运算符(!)也可以将 BigInt 转为布尔值。

  • 数学运算
    几乎所有的数值运算符都可以用在 BigInt,但是有两个例外。
    不带符号的右移位运算符>>>
    一元的求正运算符+
  • BigInt 不能与普通数值进行混合运算。
  • 比较运算符(比如>)和相等运算符(==)允许 BigInt 与其他类型的值混合计算,因为这样做不会损失精度。


函数的拓展

函数参数的默认值

基本用法

  • 参数变量是默认声明的,所以不能用let或const再次声明。
function foo(x = 5) {
  let x = 1; // error
  const x = 2; // error
}
  • 使用参数默认值时,函数不能有同名参数。
// 不报错
function foo(x, x, y) {
  // ...
}

// 报错
function foo(x, x, y = 1) {
  // ...
}
  • 另外,一个容易忽略的地方是,参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。

参数默认值的位置

  • 通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。

函数的 length 属性

  • 指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。
  • (function(...args) {}).length // 0
  • 如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1

作用域

  • 一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为,在不设置参数默认值时,是不会出现的。
let x = 1;
function f(y = x) {
  let x = 2;
  console.log(y);
}
f() // 1

上面代码中,函数f调用时,参数y = x形成一个单独的作用域。这个作用域里面,变量x本身没有定义,所以指向外层的全局变量x。函数调用时,函数体内部的局部变量x影响不到默认值变量x。

  • 参数x = x形成一个单独作用域。实际执行的是let x = x,由于暂时性死区的原因,这行代码会报错”x 未定义“。
var x = 1;
function foo(x = x) {
  // ...
}
foo() // ReferenceError: x is not defined
  • 如果参数的默认值是一个函数,该函数的作用域也遵守这个规则。
var x = 1;
function foo(x, y = function() { x = 2; }) {
  var x = 3;
  y();
  console.log(x);
}
foo() // 3
x // 1

应用

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


rest 参数

  • ES6 引入 rest 参数(形式为…变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
  • arguments对象不是数组,而是一个类似数组的对象。所以为了使用数组的方法,必须使用Array.prototype.slice.call先将其转为数组。
  • 注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
  • 函数的length属性,不包括 rest 参数。

严格模式

  • ES2016规定只要函数参数使用了默认值、解构赋值、或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。

name 属性

  • 函数的name属性,返回该函数的函数名。
  • 如果将一个匿名函数赋值给一个变量,ES5 的name属性,会返回空字符串,而 ES6 的name属性会返回实际的函数名。
var f = function () {};
// ES5
f.name // ""
// ES6
f.name // "f"
  • 如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的name属性都返回这个具名函数原本的名字。
const bar = function baz() {};
// ES5
bar.name // "baz"
// ES6
bar.name // "baz"
  • Function构造函数返回的函数实例,name属性的值为anonymous。
(new Function).name // "anonymous"
  • bind返回的函数,name属性值会加上bound前缀。

箭头函数

基本用法

  • 由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
// 报错
let getTempItem = id => { id: id, name: "Temp" };
// 不报错
let getTempItem = id => ({ id: id, name: "Temp" });
  • 下面是一种特殊情况,虽然可以运行,但会得到错误的结果。
let foo = () => { a: 1 };
foo() // undefined

使用注意点

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

  • 上面四点中,第一点尤其值得注意。this对象的指向是可变的,但是在箭头函数中,它是固定的。
function foo() {
  return () => {
    return () => {
      return () => {
        console.log('id:', this.id);
      };
    };
  };
}
var f = foo.call({id: 1});
var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1
  • 除了this,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:arguments、super、new.target。
  • 由于箭头函数没有自己的this,所以当然也就不能用call()、apply()、bind()这些方法去改变this的指向

不适用场合

const cat = {
  lives: 9,
  jumps: () => {
    this.lives--;
  }
}

var button = document.getElementById('press');
button.addEventListener('click', () => {
  this.classList.toggle('on');
});

尾调用优化

  • 注意,目前只有 Safari 浏览器支持尾调用优化,Chrome 和 Firefox 都不支持。
  • 尾递归

严格模式

  • ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
    这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
func.arguments:返回调用时函数的参数。
func.caller:返回调用当前函数的那个函数。

尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。

  • 正常模式下实现尾递归优化。
    蹦床函数(trampoline)可以将递归执行转为循环执行。
function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。

function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1);
  } else {
    return x;
  }
}
  • Function.prototype.toString()


数组的扩展

扩展运算符

含义

  • 扩展运算符(spread)是三个点(…)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。
  • 扩展运算符后面还可以放置表达式。
const arr = [
  ...(x > 0 ? ['a'] : []),
  'b',
];
  • 如果扩展运算符后面是一个空数组,则不产生任何效果。

作用

  • 字符串
    扩展运算符还可以将字符串转为真正的数组。
[...'hello']
// [ "h", "e", "l", "l", "o" ]

上面的写法,有一个重要的好处,那就是能够正确识别四个字节的 Unicode 字符。

  • 实现了 Iterator 接口的对象
    任何定义了遍历器(Iterator)接口的对象(参阅 Iterator 一章),都可以用扩展运算符转为真正的数组。

方法

Array.from()

  • Array.from方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。实际应用中,常见的类似数组的对象是 DOM 操作返回的 NodeList 集合,以及函数内部的arguments对象。所谓类似数组的对象,本质特征只有一点,即必须有length属性。
  • Array.from还可以接受第二个参数,作用类似于数组的map方法,用来对每个元素进行处理,将处理后的值放入返回的数组。

Array.of()

  • Array.of方法用于将一组值,转换为数组。
Array() // []
Array(3) // [, , ,]
Array(3, 11, 8) // [3, 11, 8]
  • Array.of方法可以用下面的代码模拟实现。
function ArrayOf(){
  return [].slice.call(arguments);
}

数组实例的 copyWithin()

数组实例的 find() 和 findIndex()

  • 如果没有符合条件的成员,则返回undefined。
  • 如果所有成员都不符合条件,则返回-1。
  • 这两个方法都可以接受第二个参数,用来绑定回调函数的this对象。
  • 另外,这两个方法都可以发现NaN,弥补了数组的indexOf方法的不足。
    findIndex方法可以借助Object.is方法做到。
[NaN].indexOf(NaN)
// -1
[NaN].findIndex(y => Object.is(NaN, y))
// 0

数组实例的 fill()

  • 如果填充的类型为对象,那么被赋值的是同一个内存地址的对象,而不是深拷贝对象。

数组实例

entries(),keys() 和 values()

  • 它们都返回一个遍历器对象
for (let [index, elem] of ['a', 'b'].entries()) {
  console.log(index, elem);
}
// 0 "a"
// 1 "b"

//如果不使用for...of循环,可以手动调用遍历器对象的next方法,进行遍历。
let letter = ['a', 'b', 'c'];
let entries = letter.entries();
console.log(entries.next().value); // [0, 'a']
console.log(entries.next().value); // [1, 'b']
console.log(entries.next().value); // [2, 'c']

includes()

  • 该方法的第二个参数表示搜索的起始位置,默认为0。如果第二个参数为负数,则表示倒数的位置,如果这时它大于数组长度(比如第二个参数为-4,但数组长度为3),则会重置为从0开始。
  • indexOf
[NaN].indexOf(NaN)
// -1
//includes使用的是不一样的判断算法,就没有这个问题。
[NaN].includes(NaN)
// true
  • 另外,Map 和 Set 数据结构有一个has方法,需要注意与includes区分。
    Map 结构的has方法,是用来查找键名的
    Set 结构的has方法,是用来查找值的

flat(),flatMap()

  • 该方法返回一个新数组,对原数据没有影响。
  • 如果原数组有空位,flat()方法会跳过空位。
[1, 2, , 4, 5].flat()
// [1, 2, 4, 5]
  • flatMap()方法对原数组的每个成员执行一个函数(相当于执行Array.prototype.map()),然后对返回值组成的数组执行flat()方法。该方法返回一个新数组,不改变原数组。
// 相当于 [[2, 4], [3, 6], [4, 8]].flat()
[2, 3, 4].flatMap((x) => [x, x * 2])
// [2, 4, 3, 6, 4, 8]
  • flatMap()只能展开一层数组。
// 相当于 [[[2]], [[4]], [[6]], [[8]]].flat()
[1, 2, 3, 4].flatMap(x => [[x * 2]])
// [[2], [4], [6], [8]]
  • flatMap()方法的参数是一个遍历函数,该函数可以接受三个参数,分别是当前数组成员、当前数组成员的位置(从零开始)、原数组。
arr.flatMap(function callback(currentValue[, index[, array]]) {
  // ...
}[, thisArg])
  • flatMap()方法还可以有第二个参数,用来绑定遍历函数里面的this。

数组的空位

  • 空位不是undefined,一个位置的值等于undefined,依然是有值的。空位是没有任何值,in运算符可以说明这一点。
0 in [undefined, undefined, undefined] // true
0 in [, , ,] // false
  • ES5 对空位的处理,已经很不一致了,大多数情况下会忽略空位。
    forEach(), filter(), reduce(), every()some()都会跳过空位。
    map()会跳过空位,但会保留这个值
    join()toString()会将空位视为undefined,而undefined和null会被处理成空字符串。
  • ES6 则是明确将空位转为undefined。
    Array.from方法会将数组的空位,转为undefined,也就是说,这个方法不会忽略空位。


对象的拓展

属性的简洁表示法

//属性
const foo = 'bar';
const baz = {foo};

//方法
const o = {
  method() {
    return "Hello!";
  }
};

//属性的赋值器(setter)和取值器(getter),事实上也是采用这种写法。
const cart = {
  _wheels: 4,
  get wheels () {
    return this._wheels;
  },
  set wheels (value) {
    if (value < this._wheels) {
      throw new Error('数值太小了!');
    }
    this._wheels = value;
  }
}
  • 注意,简写的对象方法不能用作构造函数,会报错。不简写可以
const obj = {
  f() {
    this.foo = 'bar';
  }
};
new obj.f() // 报错

属性名表达式

  • ES6 允许字面量定义对象时,用方法二(表达式)作为对象的属性名,即把表达式放在方括号内。
let lastWord = 'last word';
const a = {
  'first word': 'hello',
  [lastWord]: 'world'
};
a['first word'] // "hello"
a[lastWord] // "world"
a['last word'] // "world"
  • 表达式还可以用于定义方法名。
let obj = {
  ['h' + 'ello']() {
    return 'hi';
  }
};
obj.hello() // hi
  • 注意,属性名表达式与简洁表示法,不能同时使用,会报错。
  • 注意,属性名表达式如果是一个对象,默认情况下会自动将对象转为字符串[object Object],这一点要特别小心。

方法的 name 属性

  • 函数的name属性,返回函数名。对象方法也是函数,因此也有name属性。
const person = {
  sayName() {
    console.log('hello!');
  },
};
person.sayName.name   // "sayName"
  • 如果对象的方法使用了取值函数(getter)和存值函数(setter),则name属性不是在该方法上面,而是该方法的属性的描述对象的get和set属性上面,返回值是方法名前加上get和set。
const obj = {
  get foo() {},
  set foo(x) {}
};

obj.foo.name
// TypeError: Cannot read property 'name' of undefined

const descriptor = Object.getOwnPropertyDescriptor(obj, 'foo');

descriptor.get.name // "get foo"
descriptor.set.name // "set foo"
  • 有两种特殊情况:bind方法创造的函数,name属性返回bound加上原函数的名字;Function构造函数创造的函数,name属性返回anonymous。
  • 如果对象的方法是一个 Symbol 值,那么name属性返回的是这个 Symbol 值的描述。
const key1 = Symbol('description');
const key2 = Symbol();
let obj = {
  [key1]() {},
  [key2]() {},
};
obj[key1].name // "[description]"
obj[key2].name // ""

属性的可枚举性和遍历

可枚举性

  • 有四个操作会忽略enumerable为false的属性。
    1、for...in循环:只遍历对象自身的和继承的可枚举的属性。
    2、Object.keys():返回对象自身的所有可枚举的属性的键名。
    3、JSON.stringify():只串行化对象自身的可枚举的属性。
    4、Object.assign(): 忽略enumerable为false的属性,只拷贝对象自身的可枚举的属性。
    只有for…in会返回继承的属性,其他三个方法都会忽略继承的属性
  • 总的来说,操作中引入继承的属性会让问题复杂化,大多数时候,我们只关心对象自身的属性。所以,尽量不要用for…in循环,而用Object.keys()代替
  • 另外,ES6 规定,所有 Class 的原型的方法都是不可枚举的。

属性的遍历

  • 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()]

super 关键字

  • this关键字总是指向函数所在的当前对象,ES6 又新增了另一个类似的关键字super,指向当前对象的原型对象。
  • 注意,super关键字表示原型对象时,只能用在对象的方法之中,用在其他地方都会报错。
// 报错
const obj = {
  foo: super.foo
}
// 报错
const obj = {
  foo: () => super.foo
}
// 报错
const obj = {
  foo: function () {
    return super.foo
  }
}

上面三种super的用法都会报错,因为对于 JavaScript 引擎来说,这里的super都没有用在对象的方法之中。第一种写法是super用在属性里面,第二种和第三种写法是super用在一个函数里面,然后赋值给foo属性。目前,只有对象方法的简写法可以让 JavaScript 引擎确认,定义的是对象的方法。

  • JavaScript 引擎内部,super.foo等同于Object.getPrototypeOf(this).foo(属性)或Object.getPrototypeOf(this).foo.call(this)(方法)。
  • 下面代码中,super.foo指向原型对象proto的foo方法,但是绑定的this却还是当前对象obj,因此输出的就是world。
const proto = {
  x: 'hello',
  foo() {
    console.log(this.x);
  },
};
const obj = {
  x: 'world',
  foo() {
    super.foo();
  }
}
Object.setPrototypeOf(obj, proto);
obj.foo() // "world"

对象的扩展运算符

解构赋值

  • 对象的解构赋值用于从一个对象取值,相当于将目标对象自身的所有可遍历的(enumerable)、但尚未被读取的属性,分配到指定的对象上面。
  • 由于解构赋值要求等号右边是一个对象,所以如果等号右边是undefined或null,就会报错,因为它们无法转为对象。
  • 解构赋值必须是最后一个参数,否则会报错。
  • 注意,解构赋值的拷贝是浅拷贝,即如果一个键的值是复合类型的值(数组、对象、函数)、那么解构赋值拷贝的是这个值的引用,而不是这个值的副本。
  • 另外,扩展运算符的解构赋值,不能复制继承自原型对象的属性。
  • 下面代码中,变量x是单纯的解构赋值,所以可以读取对象o继承的属性;变量y和z是扩展运算符的解构赋值,只能读取对象o自身的属性
const o = Object.create({ x: 1, y: 2 });
o.z = 3;
let { x, ...newObj } = o;
let { y, z } = newObj;
x // 1
y // undefined
z // 3

ES6 规定,变量声明语句之中,如果使用解构赋值,扩展运算符后面必须是一个变量名,而不能是一个解构赋值表达式,所以上面代码引入了中间变量newObj,如果写成下面这样会报错。

let { x, ...{ y, z } } = o;
// SyntaxError:

扩展运算符

let z = { a: 3, b: 4 };
let n = { ...z };
n // { a: 3, b: 4 }
  • 由于数组是特殊的对象,所以对象的扩展运算符也可以用于数组。
let foo = { ...['a', 'b', 'c'] };
foo
// {0: "a", 1: "b", 2: "c"}
  • 如果扩展运算符后面是一个空对象,则没有任何效果。
{...{}, a: 1}
// { a: 1 }
  • 如果扩展运算符后面不是对象,则会自动将其转为对象。
// 等同于 {...Object(1)}
{...1} // {}

上面代码中,扩展运算符后面是整数1,会自动转为数值的包装对象Number{1}。由于该对象没有自身属性,所以返回一个空对象。
下面的例子都是类似的道理。

// 等同于 {...Object(true)}
{...true} // {}

// 等同于 {...Object(undefined)}
{...undefined} // {}

// 等同于 {...Object(null)}
{...null} // {}
  • 但是,如果扩展运算符后面是字符串,它会自动转成一个类似数组的对象,因此返回的不是空对象。
{...'hello'}
// {0: "h", 1: "e", 2: "l", 3: "l", 4: "o"}
  • 对象的扩展运算符等同于使用Object.assign()方法。
let aClone = { ...a };
// 等同于
let aClone = Object.assign({}, a);
  • 如果想完整克隆一个对象,还拷贝对象原型的属性,可以采用下面的写法。
const clone2 = Object.assign(
  Object.create(Object.getPrototypeOf(obj)),
  obj
);
// 写法三
const clone3 = Object.create(
  Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj)
)
  • 扩展运算符的参数对象之中,如果有取值函数get,这个函数是会执行的。
let a = {
  get x() {
    throw new Error('not throw yet');
  }
}
let aWithXGetter = { ...a }; // 报错

上面例子中,取值函数get在扩展a对象时会自动执行,导致报错。


链判断运算符

const fooInput = myForm.querySelector('input[name=foo]')
  • 上面例子中,必须先判断fooInput是否存在,才能读取fooInput.value。
    这样的层层判断非常麻烦,因此 ES2020 引入了“链判断运算符”(optional chaining operator)?.,简化上面的写法。
const firstName = message?.body?.user?.firstName || 'default';
  • 链判断运算符有三种用法。
obj?.prop // 对象属性
obj?.[expr] // 同上
func?.(...args) // 函数或对象方法的调用
  • 使用这个运算符,有几个注意点。
    (1)短路机制
    ?.运算符相当于一种短路机制,只要不满足条件,就不再往下执行。
    (2)delete 运算符
delete a?.b
// 等同于
a == null ? undefined : delete a.b

上面代码中,如果a是undefined或null,会直接返回undefined,而不会进行delete运算。
(3)括号的影响
如果属性链有圆括号,链判断运算符对圆括号外部没有影响,只对圆括号内部有影响。

(a?.b).c
// 等价于
(a == null ? undefined : a.b).c

上面代码中,?.对圆括号外部没有影响,不管a对象是否存在,圆括号后面的.c总是会执行。一般来说,使用?.运算符的场合,不应该使用圆括号。
(4)报错场合
以下写法是禁止的,会报错。

// 构造函数
new a?.()
new a?.b()
// 链判断运算符的右侧有模板字符串
a?.`{b}`
a?.b`{c}`
// 链判断运算符的左侧是 super
super?.()
super?.foo
// 链运算符用于赋值运算符左侧
a?.b = c

(5)右侧不得为十进制数值


Null 判断运算符

  • 它的行为类似||,但是只有运算符左侧的值为null或undefined时,才会返回右侧的值。
const headerText = response.settings.headerText ?? 'Hello, world!';

??有一个运算优先级问题,它与&&和||的优先级孰高孰低。现在的规则是,如果多个逻辑运算符一起使用,必须用括号表明优先级,否则会报错。



对象的新增方法

Object.is()

  • 它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。不同之处只有两个:一是+0不等于-0,二是NaN等于自身。
Object.is('foo', 'foo')
// true
Object.is({}, {})
// false

Object.assign()

基本用法

  • Object.assign()方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。
  • 如果只有一个参数,Object.assign()会直接返回该参数。
  • 如果该参数不是对象,则会先转成对象,然后返回。由于undefined和null无法转成对象,所以如果它们作为参数,就会报错。
  • 如果非对象参数出现在源对象的位置(即非首参数),那么处理规则有所不同。首先,这些参数都会转成对象,如果无法转成对象,就会跳过。这意味着,如果undefined和null不在首参数,就不会报错。
  • 其他类型的值(即数值、字符串和布尔值)不在首参数,也不会报错。但是,除了字符串会以数组形式,拷贝入目标对象,其他值都不会产生效果。
Object(true) // {[[PrimitiveValue]]: true}
Object(10)  //  {[[PrimitiveValue]]: 10}
Object('abc') // {0: "a", 1: "b", 2: "c", length: 3, [[PrimitiveValue]]: "abc"}
  • Object.assign()拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false)。
  • 属性名为 Symbol 值的属性,也会被Object.assign()拷贝。

注意点

(1)浅拷贝
(2)同名属性的替换
(3)数组的处理
Object.assign()可以用来处理数组,但是会把数组视为对象。

Object.assign([1, 2, 3], [4, 5])
// [4, 5, 3]

(4)取值函数的处理
Object.assign()只能进行值的复制,如果要复制的值是一个取值函数,那么将求值后再复制。

const source = {
  get foo() { return 1 }
};
const target = {};
Object.assign(target, source)
// { foo: 1 }

常见用途

(1)为对象添加属性
(2)为对象添加方法
(3)克隆对象
不过,采用这种方法克隆,只能克隆原始对象自身的值,不能克隆它继承的值。如果想要保持继承链,可以采用下面的代码。

function clone(origin) {
  let originProto = Object.getPrototypeOf(origin);
  return Object.assign(Object.create(originProto), origin);
}

(4)合并多个对象
(5)为属性指定默认值


Object.getOwnPropertyDescriptors()

  • Object.getOwnPropertyDescriptors()方法,返回指定对象所有自身属性(非继承属性)的描述对象。
  • 该方法的实现非常容易。
function getOwnPropertyDescriptors(obj) {
  const result = {};
  for (let key of Reflect.ownKeys(obj)) {
    result[key] = Object.getOwnPropertyDescriptor(obj, key);
  }
  return result;
}
  • 该方法的引入目的,主要是为了解决Object.assign()无法正确拷贝get属性和set属性的问题。Object.getOwnPropertyDescriptors()方法配合Object.defineProperties()方法,就可以实现正确拷贝。
const source = {
  set foo(value) {
    console.log(value);
  }
};
const target2 = {};
Object.defineProperties(target2, Object.getOwnPropertyDescriptors(source));
Object.getOwnPropertyDescriptor(target2, 'foo')
// { get: undefined,
//   set: [Function: set foo],
//   enumerable: true,
//   configurable: true }
  • Object.getOwnPropertyDescriptors()方法的另一个用处,是配合Object.create()方法,将对象属性克隆到一个新对象。这属于浅拷贝。
const clone = Object.create(Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj));
  • 另外,Object.getOwnPropertyDescriptors()方法可以实现一个对象继承另一个对象。
const obj = Object.create(
  prot,
  Object.getOwnPropertyDescriptors({
    foo: 123,
  })
);
  • Object.getOwnPropertyDescriptors()也可以用来实现 Mixin(混入)模式。

__proto__属性,Object.setPrototypeOf(),Object.getPrototypeOf()

__proto__属性

  • 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;
}

Object.setPrototypeOf()

  • Object.setPrototypeOf方法的作用与__proto__相同,用来设置一个对象的原型对象(prototype),返回参数对象本身。
  • 如果第一个参数不是对象,会自动转为对象。但是由于返回的还是第一个参数,所以这个操作不会产生任何效果。
  • 由于undefined和null无法转为对象,所以如果第一个参数是undefined或null,就会报错。

Object.getPrototypeOf()

  • 如果参数不是对象,会被自动转为对象。
  • 如果参数是undefined或null,它们无法转为对象,所以会报错。


Object.keys(),Object.values(),Object.entries()

Object.keys()

  • 返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键名。

Object.values()

  • Object.values方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值。
  • 返回数组的成员顺序,与本章的《属性的遍历》部分介绍的排列规则一致。
  • Object.values只返回对象自身的可遍历属性。
const obj = Object.create({}, {p: {value: 42}});
Object.values(obj) // []
  • Object.values会过滤属性名为 Symbol 值的属性。
  • 如果Object.values方法的参数是一个字符串,会返回各个字符组成的一个数组。
  • 如果参数不是对象,Object.values会先将其转为对象。由于数值和布尔值的包装对象,都不会为实例添加非继承的属性。所以,Object.values会返回空数组。

Object.entries()

  • 除了返回值不一样,该方法的行为与Object.values基本一致。
  • 如果原对象的属性名是一个 Symbol 值,该属性会被忽略。
  • Object.entries方法的另一个用处是,将对象转为真正的Map结构。
const obj = { foo: 'bar', baz: 42 };
const map = new Map(Object.entries(obj));
map // Map { foo: "bar", baz: 42 }
  • 自己实现Object.entries方法,非常简单。
// Generator函数的版本
function* entries(obj) {
  for (let key of Object.keys(obj)) {
    yield [key, obj[key]];
  }
}

// 非Generator函数的版本
function entries(obj) {
  let arr = [];
  for (let key of Object.keys(obj)) {
    arr.push([key, obj[key]]);
  }
  return arr;
}

Object.fromEntries()

  • Object.fromEntries()方法是Object.entries()的逆操作,用于将一个键值对数组转为对象。
Object.fromEntries([
  ['foo', 'bar'],
  ['baz', 42]
])
// { foo: "bar", baz: 42 }
  • 该方法的主要目的,是将键值对的数据结构还原为对象,因此特别适合将 Map 结构转为对象。
const entries = new Map([
  ['foo', 'bar'],
  ['baz', 42]
]);
Object.fromEntries(entries)
// { foo: "bar", baz: 42 }
  • 该方法的一个用处是配合URLSearchParams对象,将查询字符串转为对象。
Object.fromEntries(new URLSearchParams('foo=bar&baz=qux'))
// { foo: "bar", baz: "qux" }


Symbol

概述

  • 注意,Symbol函数前不能使用new命令,否则会报错。这是因为生成的 Symbol 是一个原始类型的值,不是对象。
  • 如果 Symbol 的参数是一个对象,就会调用该对象的toString方法,将其转为字符串,然后才生成一个 Symbol 值。
  • Symbol 值不能与其他类型的值进行运算,会报错。
  • 但是,Symbol 值可以显式转为字符串。另外,Symbol 值也可以转为布尔值,但是不能转为数值。
let sym = Symbol('My symbol');
String(sym) // 'Symbol(My symbol)'
sym.toString() // 'Symbol(My symbol)'
let sym = Symbol();
Boolean(sym) // true
!sym  // false
if (sym) {
  // ...
}
Number(sym) // TypeError
sym + 2 // TypeError

Symbol.prototype.description

  • ES2019 提供了一个实例属性description,直接返回 Symbol 的描述。

作为属性名的 Symbol

  • 注意,Symbol 值作为对象属性名时,不能用点运算符。
  • 因为点运算符后面总是字符串,所以不会读取mySymbol作为标识名所指代的那个值,导致a的属性名实际上是一个字符串,而不是一个 Symbol 值。同理,在对象的内部,使用 Symbol 值定义属性时,Symbol 值必须放在方括号之中。
let s = Symbol();
let obj = {
  [s]: function (arg) { ... }
};
obj[s](123);

属性名的遍历

  • Symbol 作为属性名,遍历对象的时候,该属性不会出现在for...in、for...of循环中,也不会被Object.keys()、Object.getOwnPropertyNames()、JSON.stringify()返回。
  • 有一个Object.getOwnPropertySymbols()方法,可以获取指定对象的所有 Symbol 属性名。该方法返回一个数组,成员是当前对象的所有用作属性名的 Symbol 值。
  • Reflect.ownKeys()方法可以返回所有类型的键名,包括常规键名和 Symbol 键名。

Symbol.for(),Symbol.keyFor()

  • Symbol.for()方法接受一个字符串作为参数,然后搜索有没有以该参数作为名称的 Symbol 值。如果有,就返回这个 Symbol 值,否则就新建一个以该字符串为名称的 Symbol 值,并将其注册到全局。
  • Symbol.for()Symbol()这两种写法,都会生成新的 Symbol。它们的区别是,前者会被登记在全局环境中供搜索,后者不会。
  • Symbol.keyFor()方法返回一个已登记的 Symbol 类型值的key。
  • 注意,Symbol.for()为 Symbol 值登记的名字,是全局环境的,不管有没有在全局环境运行。
  • Symbol.for()的这个全局登记特性,可以用在不同的 iframe 或 service worker 中取到同一个值。

内置的 Symbol 值

Symbol.hasInstance

  • 对象的Symbol.hasInstance属性,指向一个内部方法。当其他对象使用instanceof运算符,判断是否为该对象的实例时,会调用这个方法。比如,foo instanceof Foo在语言内部,实际调用的是Foo[Symbol.hasInstance](foo)
class MyClass {
  [Symbol.hasInstance](foo) {
    return foo instanceof Array;
  }
}
[1, 2, 3] instanceof new MyClass() // true

Symbol.isConcatSpreadable

  • 对象的Symbol.isConcatSpreadable属性等于一个布尔值,表示该对象用于Array.prototype.concat()时,是否可以展开。
  • 数组的默认行为是可以展开,Symbol.isConcatSpreadable默认等于undefined。该属性等于true时,也有展开的效果。类似数组的对象正好相反,默认不展开。
let arr1 = ['c', 'd'];
['a', 'b'].concat(arr1, 'e') // ['a', 'b', 'c', 'd', 'e']
arr1[Symbol.isConcatSpreadable] // undefined
obj[Symbol.isConcatSpreadable] = true;
['a', 'b'].concat(obj, 'e') // ['a', 'b', 'c', 'd', 'e']
  • Symbol.isConcatSpreadable属性也可以定义在类里面。
class A1 extends Array {
  constructor(args) {
    super(args);
    this[Symbol.isConcatSpreadable] = true;
  }
}
class A2 extends Array {
  constructor(args) {
    super(args);
  }
  get [Symbol.isConcatSpreadable] () {   //必须有get,不然没有效果
    return false;
  }
}
let a1 = new A1();
a1[0] = 3;
a1[1] = 4;
let a2 = new A2();
a2[0] = 5;
a2[1] = 6;
[1, 2].concat(a1).concat(a2)
// [1, 2, 3, 4, [5, 6]]
  • 注意,Symbol.isConcatSpreadable的位置差异,A1是定义在实例上,A2是定义在类本身,效果相同。

Symbol.species

  • 对象的Symbol.species属性,指向一个构造函数。创建衍生对象时,会使用该属性。
class MyArray extends Array {
  static get [Symbol.species]() { return Array; }
}
  • 上面代码中,由于定义了Symbol.species属性,创建衍生对象时就会使用这个属性返回的函数,作为构造函数。这个例子也说明,定义Symbol.species属性要采用get取值器。默认的Symbol.species属性等同于下面的写法。
static get [Symbol.species]() {
  return this;
}

Symbol.match

  • 对象的Symbol.match属性,指向一个函数。当执行str.match(myObject)时,如果该属性存在,会调用它,返回该方法的返回值。
String.prototype.match(regexp)
// 等同于
regexp[Symbol.match](this)
class MyMatcher {
  [Symbol.match](string) {
    return 'hello world'.indexOf(string);
  }
}
'e'.match(new MyMatcher()) // 1

Symbol.replace

Symbol.search

Symbol.split

Symbol.iterator

Symbol.toPrimitive

Symbol.toStringTag

Symbol.unscopables



Set 和 Map 数据结构

Set

基本用法

  • Set函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。
  • 一种去除数组重复成员的方法。
// 去除数组的重复成员
[...new Set(array)]
  • 上面的方法也可以用于,去除字符串里面的重复字符。
[...new Set('ababbc')].join('')
// "abc"
  • 向 Set 加入值的时候,不会发生类型转换,所以5和"5"是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(===),主要的区别是向 Set 加入值时认为NaN等于自身,而精确相等运算符认为NaN不等于自身。

Set 实例的属性和方法

  • Set 结构的实例有以下属性。
    Set.prototype.constructor:构造函数,默认就是Set函数。
    Set.prototype.size:返回Set实例的成员总数。

  • Set 实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。

  • 下面先介绍四个操作方法。
    Set.prototype.add(value):添加某个值,返回 Set 结构本身。
    Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
    Set.prototype.has(value):返回一个布尔值,表示该值是否为Set的成员。
    Set.prototype.clear():清除所有成员,没有返回值。

  • Array.from方法可以将 Set 结构转为数组。

遍历操作

  • Set 结构的实例有四个遍历方法,可以用于遍历成员。
    Set.prototype.keys():返回键名的遍历器
    Set.prototype.values():返回键值的遍历器
    Set.prototype.entries():返回键值对的遍历器
    Set.prototype.forEach():使用回调函数遍历每个成员
  • 需要特别指出的是,Set的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用 Set 保存一个回调函数列表,调用时就能保证按照添加顺序调用。

(1)keys(),values(),entries()

  • keys方法、values方法、entries方法返回的都是遍历器对象(详见《Iterator 对象》一章)。由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以keys方法和values方法的行为完全一致。
  • Set 结构的实例默认可遍历,它的默认遍历器生成函数就是它的values方法。
Set.prototype[Symbol.iterator] === Set.prototype.values
// true
  • 这意味着,可以省略values方法,直接用for…of循环遍历 Set。
let set = new Set(['red', 'green', 'blue']);
for (let x of set) {
  console.log(x);
}
// red
// green
// blue

(2)forEach()

  • Set 结构的实例与数组一样,也拥有forEach方法,用于对每个成员执行某种操作,没有返回值。
  • 该函数的参数与数组的forEach一致,依次为键值、键名、集合本身
  • 另外,forEach方法还可以有第二个参数,表示绑定处理函数内部的this对象。

(3)遍历的应用

  • 扩展运算符(…)内部使用for…of循环,所以也可以用于 Set 结构。
  • 数组的map和filter方法也可以间接用于 Set 。因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。
  • 如果想在遍历操作中,同步改变原来的 Set 结构,目前没有直接的方法,但有两种变通方法。一种是利用原 Set 结构映射出一个新的结构,然后赋值给原来的 Set 结构;另一种是利用Array.from方法。
// 方法一
let set = new Set([1, 2, 3]);
set = new Set([...set].map(val => val * 2));
// set的值是2, 4, 6
// 方法二
let set = new Set([1, 2, 3]);
set = new Set(Array.from(set, val => val * 2));  //!!!
// set的值是2, 4, 6

WeakSet

含义

  • 它与 Set 有两个区别。
    1、首先,WeakSet 的成员只能是对象,而不能是其他类型的值。
    2、其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。
  • ES6 规定 WeakSet 不可遍历。

语法

  • 作为构造函数,WeakSet 可以接受一个数组或类似数组的对象作为参数。(实际上,任何具有 Iterable 接口的对象,都可以作为 WeakSet 的参数。)该数组的所有成员,都会自动成为 WeakSet 实例对象的成员。
const a = [[1, 2], [3, 4]];
const ws = new WeakSet(a);
// WeakSet {[1, 2], [3, 4]}
  • 数组的成员只能是对象。
const b = [3, 4];
const ws = new WeakSet(b);
// Uncaught TypeError: Invalid value used in weak set(…)
  • WeakSet 结构有以下三个方法。
    WeakSet.prototype.add(value):向 WeakSet 实例添加一个新成员。
    WeakSet.prototype.delete(value):清除 WeakSet 实例的指定成员。
    WeakSet.prototype.has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例之中。
  • WeakSet 没有size属性,没有办法遍历它的成员。

Map

含义和基本用法

  • 对象只接受字符串作为键名
  • 作为构造函数,Map 也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。
const map = new Map([
  ['name', '张三'],
  ['title', 'Author']
]);
map.size // 2
map.has('name') // true
map.get('name') // "张三"
map.has('title') // true
map.get('title') // "Author"
  • Map构造函数接受数组作为参数,实际上执行的是下面的算法。
const items = [
  ['name', '张三'],
  ['title', 'Author']
];
const map = new Map();
items.forEach(
  ([key, value]) => map.set(key, value)
);
  • 不仅仅是数组,任何具有 Iterator 接口、且每个成员都是一个双元素的数组的数据结构(详见《Iterator》一章)都可以当作Map构造函数的参数。这就是说,Set和Map都可以用来生成新的 Map。
  • 如果对同一个键多次赋值,后面的值将覆盖前面的值。
  • 如果读取一个未知的键,则返回undefined。
  • 注意,只有对同一个对象的引用,Map 结构才将其视为同一个键。这一点要非常小心。
const map = new Map();
map.set(['a'], 555);
map.get(['a']) // undefined
  • 如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键,比如0和-0就是一个键(+0也是),布尔值true和字符串true则是两个不同的键。另外,undefined和null也是两个不同的键。虽然NaN不严格相等于自身,但 Map 将其视为同一个键。

实例的属性和操作方法

(1)size 属性
(2)Map.prototype.set(key, value)
set方法返回的是当前的Map对象,因此可以采用链式写法。
(3)Map.prototype.get(key)
get方法读取key对应的键值,如果找不到key,返回undefined。
(4)Map.prototype.has(key)
(5)Map.prototype.delete(key)
delete方法删除某个键,返回true。如果删除失败,返回false。
(6)Map.prototype.clear()
clear方法清除所有成员,没有返回值。

遍历方法

  • Map 结构原生提供三个遍历器生成函数和一个遍历方法。
    Map.prototype.keys():返回键名的遍历器。
    Map.prototype.values():返回键值的遍历器。
    Map.prototype.entries():返回所有成员的遍历器。
    Map.prototype.forEach():遍历 Map 的所有成员。
  • 需要特别注意的是,Map 的遍历顺序就是插入顺序。
// 等同于使用map.entries()
for (let [key, value] of map) {
  console.log(key, value);
}
// "F" "no"
// "T" "yes"
  • 表示 Map 结构的默认遍历器接口(Symbol.iterator属性),就是entries方法。
map[Symbol.iterator] === map.entries
// true
  • Map 结构转为数组结构,比较快速的方法是使用扩展运算符(…)。
[...map.keys()]
// [1, 2, 3]
[...map.values()]
// ['one', 'two', 'three']
[...map.entries()]
// [[1,'one'], [2, 'two'], [3, 'three']]
[...map]
// [[1,'one'], [2, 'two'], [3, 'three']]
  • 结合数组的map方法、filter方法,可以实现 Map 的遍历和过滤(Map 本身没有map和filter方法)。
const map1 = new Map(
  [...map0].filter(([k, v]) => k < 3)
);
  • 此外,Map 还有一个forEach方法,与数组的forEach方法类似,也可以实现遍历。
map.forEach(function(value, key, map) {
  console.log("Key: %s, Value: %s", key, value);
});

forEach方法还可以接受第二个参数,用来绑定this

与其他数据结构的互相转换

(1)Map 转为数组
前面已经提过,Map 转为数组最方便的方法,就是使用扩展运算符(...)。
(2)数组 转为 Map
将数组传入 Map 构造函数,就可以转为 Map。
(3)Map 转为对象
如果所有 Map 的键都是字符串,它可以无损地转为对象。
如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。

function strMapToObj(strMap) {
  let obj = Object.create(null);
  for (let [k,v] of strMap) {
    obj[k] = v;
  }
  return obj;
}
const myMap = new Map()
  .set('yes', true)
  .set('no', false);
strMapToObj(myMap)
// { yes: true, no: false }

如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。
(4)对象转为 Map
对象转为 Map 可以通过Object.entries()。

let obj = {"a":1, "b":2};
let map = new Map(Object.entries(obj));

(5)Map 转为 JSON

  • Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名都是字符串,这时可以选择转为对象 JSON。
function strMapToJson(strMap) {
  return JSON.stringify(strMapToObj(strMap));
}
let myMap = new Map().set('yes', true).set('no', false);
strMapToJson(myMap)
// '{"yes":true,"no":false}'
  • 另一种情况是,Map 的键名有非字符串,这时可以选择转为数组 JSON。
function mapToArrayJson(map) {
  return JSON.stringify([...map]);
}
let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']);
mapToArrayJson(myMap)
// '[[true,7],[{"foo":3},["abc"]]]'

(6)JSON 转为 Map

JSON 转为 Map,正常情况下,所有键名都是字符串。

function jsonToStrMap(jsonStr) {
  return objToStrMap(JSON.parse(jsonStr));     //先转成对象,再转换为Map
}
jsonToStrMap('{"yes": true, "no": false}')
// Map {'yes' => true, 'no' => false}

但是,有一种特殊情况,整个 JSON 就是一个数组,且每个数组成员本身,又是一个有两个成员的数组。这时,它可以一一对应地转为 Map。这往往是 Map 转为数组 JSON 的逆操作。

function jsonToMap(jsonStr) {
  return new Map(JSON.parse(jsonStr));         //如果是数组就直接new Map()
}
jsonToMap('[[true,7],[{"foo":3},["abc"]]]')
// Map {true => 7, Object {foo: 3} => ['abc']}

WeakMap

含义

  • WeakMap与Map的区别有两点。
    1、首先,WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。
    2、其次,WeakMap的键名所指向的对象,不计入垃圾回收机制。
  • WeakMap的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。
  • 基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用WeakMap结构。当该 DOM 元素被清除,其所对应的WeakMap记录就会自动被移除。
    注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。
const wm = new WeakMap();
let key = {};
let obj = {foo: 1};
wm.set(key, obj);
obj = null;
wm.get(key)
// Object {foo: 1}

上面代码中,键值obj是正常引用。所以,即使在 WeakMap 外部消除了obj的引用,WeakMap 内部的引用依然存在。

WeakMap 的语法

  • WeakMap 与 Map 在 API 上的区别主要是两个,一是没有遍历操作(即没有keys()、values()和entries()方法),也没有size属性。
  • 二是无法清空,即不支持clear方法。因此,WeakMap只有四个方法可用:get()、set()、has()、delete()。

WeakMap 的用途

  • WeakMap 应用的典型场合就是 DOM 节点作为键名。
  • WeakMap 的另一个用处是部署私有属性。


Proxy(建议先看Reflect)

概述

  • ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。
var proxy = new Proxy(target, handler);
  • Proxy 对象的所有用法,都是上面这种形式,不同的只是handler参数的写法。其中,new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。如果handler没有设置任何拦截,那就等同于直接通向原对象。
var target = {};
var handler = {};
var proxy = new Proxy(target, handler);
proxy.a = 'b';
target.a // "b"
  • 一个技巧是将 Proxy 对象,设置到object.proxy属性,从而可以在object对象上调用。
var object = { proxy: new Proxy(target, handler) };
  • Proxy 实例也可以作为其他对象的原型对象。
var proxy = new Proxy({}, {
  get: function(target, propKey) {
    return 35;
  }
});
let obj = Object.create(proxy);
obj.time // 35

上面代码中,proxy对象是obj对象的原型,obj对象本身并没有time属性,所以根据原型链,会在proxy对象上读取该属性,导致被拦截。

var handler = {
  get: function(target, name) {
    if (name === 'prototype') {
      return Object.prototype;
    }
    return 'Hello, ' + name;
  },
   apply: function(target, thisBinding, args) {
    return args[0];
  },
  construct: function(target, args) {
    return {value: args[1]};
  }
};
var fproxy = new Proxy(function(x, y) {
  return x + y;
}, handler);
fproxy(1, 2) // 1
new fproxy(1, 2) // {value: 2}
fproxy.prototype === Object.prototype // true
fproxy.foo === "Hello, foo" // true
  • 下面是 Proxy 支持的拦截操作一览,一共 13 种。
    get(target, propKey, receiver):拦截对象属性的读取,比如proxy.foo和proxy['foo']
    set(target, propKey, value, receiver):拦截对象属性的设置,比如proxy.foo = vproxy['foo'] = v,返回一个布尔值。
    has(target, propKey):拦截propKey in proxy的操作,返回一个布尔值。
    deleteProperty(target, propKey):拦截delete proxy[propKey]的操作,返回一个布尔值。
    ownKeys(target):拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
    getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
    defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
    preventExtensions(target):拦截Object.preventExtensions(proxy),返回一个布尔值。
    getPrototypeOf(target):拦截Object.getPrototypeOf(proxy),返回一个对象。
    isExtensible(target):拦截Object.isExtensible(proxy),返回一个布尔值。
    setPrototypeOf(target, proto):拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。
  • 如果目标对象是函数,那么还有两种额外操作可以拦截。
    apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。
    construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)

Proxy 实例的方法

get()

  • get方法可以继承。
let proto = new Proxy({}, {
  get(target, propertyKey, receiver) {
    console.log('GET ' + propertyKey);
    return target[propertyKey];
  }
});
let obj = Object.create(proto);
obj.foo // "GET foo"
  • 利用 Proxy,可以将读取属性的操作(get),转变为执行某个函数,从而实现属性的链式操作。
var pipe = function (value) {
  var funcStack = [];
  var oproxy = new Proxy({} , {
    get : function (pipeObject, fnName) {
      if (fnName === 'get') {
        return funcStack.reduce(function (val, fn) {
          return fn(val);
        },value);
      }
      funcStack.push(window[fnName]);
      return oproxy;
    }
  });
  return oproxy;
}
var double = n => n * 2;
var pow    = n => n * n;
var reverseInt = n => n.toString().split("").reverse().join("") | 0;
pipe(3).double.pow.reverseInt.get; // 63
  • 下面是一个get方法的第三个参数的例子,它总是指向原始的读操作所在的那个对象,一般情况下就是 Proxy 实例。
const proxy = new Proxy({}, {
  get: function(target, key, receiver) {
    return receiver;
  }
});
proxy.getReceiver === proxy // true

上面代码中,proxy对象的getReceiver属性是由proxy对象提供的,所以receiver指向proxy对象。

const proxy = new Proxy({}, {
  get: function(target, key, receiver) {
    return receiver;
  }
});
const d = Object.create(proxy);
d.a === d // true

上面代码中,d对象本身没有a属性,所以读取d.a的时候,会去d的原型proxy对象找。这时,receiver就指向d,代表原始的读操作所在的那个对象。

  • 如果一个属性不可配置(configurable)且不可写(writable),则 Proxy 不能修改该属性,否则通过 Proxy 对象访问该属性会报错。
const target = Object.defineProperties({}, {
  foo: {
    value: 123,
    writable: false,
    configurable: false
  },
});
const handler = {
  get(target, propKey) {
    return 'abc';       //这里如果是console.log,则会输出再报错
  }
};
const proxy = new Proxy(target, handler);
proxy.foo
// TypeError: Invariant check failed

set()

  • set方法用来拦截某个属性的赋值操作,可以接受四个参数,依次为目标对象、属性名、属性值和 Proxy 实例本身,其中最后一个参数可选。
  • set方法的第四个参数receiver,指的是原始的操作行为所在的那个对象,一般情况下是proxy实例本身
const handler = {
  set: function(obj, prop, value, receiver) {
    obj[prop] = receiver;
  }
};
const proxy = new Proxy({}, handler);
const myObj = {};
Object.setPrototypeOf(myObj, proxy);
myObj.foo = 'bar';
myObj.foo === myObj // true

上面代码中,设置myObj.foo属性的值时,myObj并没有foo属性,因此引擎会到myObj的原型链去找foo属性。myObj的原型对象proxy是一个 Proxy 实例,设置它的foo属性会触发set方法。这时,第四个参数receiver就指向原始赋值行为所在的对象myObj。

  • 注意,如果目标对象自身的某个属性,不可写且不可配置,那么set方法将不起作用。
const obj = {};
Object.defineProperty(obj, 'foo', {
  value: 'bar',
  writable: false,
});
const handler = {
  set: function(obj, prop, value, receiver) {
    obj[prop] = 'baz';
  }
};
const proxy = new Proxy(obj, handler);
proxy.foo = 'baz';
proxy.foo // "bar"
  • 注意,严格模式下,set代理如果没有返回true,就会报错。严格模式下,set代理返回false或者undefined,都会报错。

apply()

  • apply方法拦截函数的调用、call和apply操作。
  • apply方法可以接受三个参数,分别是目标对象、目标对象的上下文对象(this)和目标对象的参数数组。
var twice = {
  apply (target, ctx, args) {
    return Reflect.apply(...arguments) * 2;
  }
};
function sum (left, right) {
  return left + right;
};
var proxy = new Proxy(sum, twice);
proxy(1, 2) // 6
proxy.call(null, 5, 6) // 22
proxy.apply(null, [7, 8]) // 30

上面代码中,每当执行proxy函数(直接调用或call和apply调用),就会被apply方法拦截。

  • 另外,直接调用Reflect.apply方法,也会被拦截。(上面的不会)
Reflect.apply(proxy, null, [9, 10]) // 38

has()

  • has()方法用来拦截HasProperty操作,即判断对象是否具有某个属性时,这个方法会生效。典型的操作就是in运算符。
  • has()方法可以接受两个参数,分别是目标对象、需查询的属性名。
  • 如果原对象不可配置或者禁止扩展,这时has()拦截会报错。
var obj = { a: 10 };
Object.preventExtensions(obj);
var p = new Proxy(obj, {
  has: function(target, prop) {
    return false;
  }
});
'a' in p // TypeError is thrown
  • 值得注意的是,has()方法拦截的是HasProperty操作,而不是HasOwnProperty操作,即has()方法不判断一个属性是对象自身的属性,还是继承的属性。
  • 另外,虽然for...in循环也用到了in运算符,但是has()拦截对for...in循环不生效

construct()

  • construct()方法用于拦截new命令,下面是拦截对象的写法。
const handler = {
  construct (target, args, newTarget) {
    return new target(...args);
  }
};
  • construct()方法可以接受三个参数。
    target:目标对象。
    args:构造函数的参数数组。
    newTarget:创造实例对象时,new命令作用的构造函数(下面例子的p)。
const p = new Proxy(function () {}, {
  construct: function(target, args) {
    console.log('called: ' + args.join(', '));
    return { value: args[0] * 10 };
  }
});
(new p(1)).value
// "called: 1"
// 10
  • construct()方法返回的必须是一个对象,否则会报错。
  • 另外,由于construct()拦截的是构造函数,所以它的目标对象必须是函数,否则就会报错。
  • 注意,construct()方法中的this指向的是handler,而不是实例对象。
const handler = {
  construct: function(target, args) {
    console.log(this ===  handler);
    return new target(...args);
  }
}
let p = new Proxy(function () {}, handler);
new p() // true

deleteProperty()

  • deleteProperty方法用于拦截delete操作,如果这个方法抛出错误或者返回false,当前属性就无法被delete命令删除。
  • 注意,目标对象自身的不可配置(configurable)的属性,不能被deleteProperty方法删除,否则报错。

defineProperty()

  • defineProperty()方法拦截了Object.defineProperty()操作。
var handler = {
  defineProperty (target, key, descriptor) {
    return false;
  }
};
var target = {};
var proxy = new Proxy(target, handler);
proxy.foo = 'bar' // 不会生效
  • 上面代码中,defineProperty()方法内部没有任何操作,只返回false,导致添加新属性总是无效。注意,这里的false只是用来提示操作失败,本身并不能阻止添加新属性。
  • 注意,如果目标对象不可扩展(non-extensible),则defineProperty()不能增加目标对象上不存在的属性,否则会报错。
  • 另外,如果目标对象的某个属性不可写(writable)或不可配置(configurable),则defineProperty()方法不得改变这两个设置。

getOwnPropertyDescriptor()

  • getOwnPropertyDescriptor()方法拦截Object.getOwnPropertyDescriptor(),返回一个属性描述对象或者undefined。

getPrototypeOf()

  • getPrototypeOf()方法主要用来拦截获取对象原型。具体来说,拦截下面这些操作。
Object.prototype.__proto__
Object.prototype.isPrototypeOf()
Object.getPrototypeOf()
Reflect.getPrototypeOf()
instanceof
  • 注意,getPrototypeOf()方法的返回值必须是对象或者null,否则报错。另外,如果目标对象不可扩展(non-extensible), getPrototypeOf()方法必须返回目标对象的原型对象。

isExtensible()

  • 注意,该方法只能返回布尔值,否则返回值会被自动转为布尔值。
  • 这个方法有一个强限制,它的返回值必须与目标对象的isExtensible属性保持一致,否则就会抛出错误。

ownKeys()

  • ownKeys()方法用来拦截对象自身属性的读取操作。具体来说,拦截以下操作。
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols()
Object.keys()
for...in循环
  • 注意,使用Object.keys()方法时,有三类属性会被ownKeys()方法自动过滤,不会返回。
    1、目标对象上不存在的属性
    2、属性名为 Symbol 值
    3、不可遍历(enumerable)的属性
let target = {
  a: 1,
  b: 2,
  c: 3,
  [Symbol.for('secret')]: '4',
};
Object.defineProperty(target, 'key', {
  enumerable: false,
  configurable: true,
  writable: true,
  value: 'static'
});
let handler = {
  ownKeys(target) {
    return ['a', 'd', Symbol.for('secret'), 'key'];
  }
};
let proxy = new Proxy(target, handler);
Object.keys(proxy)
// ['a']
  • ownKeys()方法还可以拦截Object.getOwnPropertyNames()
  • for…in循环也受到ownKeys()方法的拦截。
const obj = { hello: 'world' };
const proxy = new Proxy(obj, {
  ownKeys: function () {
    return ['a', 'b'];
  }
});
for (let key in proxy) {
  console.log(key); // 没有任何输出
}

上面代码中,ownkeys()指定只返回a和b属性,由于obj没有这两个属性,因此for...in循环不会有任何输出。

  • ownKeys()方法返回的数组成员,只能是字符串或 Symbol 值。如果有其他类型的值,或者返回的根本不是数组,就会报错。
var obj = {};
var p = new Proxy(obj, {
  ownKeys: function(target) {
    return [123, true, undefined, null, {}, []];
  }
});
Object.getOwnPropertyNames(p)
// Uncaught TypeError: 123 is not a valid property name
  • 如果目标对象自身包含不可配置的属性,则该属性必须被ownKeys()方法返回,否则报错。
  • 另外,如果目标对象是不可扩展的(non-extensible),这时ownKeys()方法返回的数组之中,必须包含原对象的所有属性,且不能包含多余的属性,否则报错。

preventExtensions()

  • preventExtensions()方法拦截Object.preventExtensions()。该方法必须返回一个布尔值,否则会被自动转为布尔值。
  • 这个方法有一个限制,只有目标对象不可扩展时(即Object.isExtensible(proxy)false),proxy.preventExtensions才能返回true,否则会报错。
    为了防止出现这个问题,通常要在proxy.preventExtensions()方法里面,调用一次Object.preventExtensions()。
var proxy = new Proxy({}, {
  preventExtensions: function(target) {
    console.log('called');
    Object.preventExtensions(target);
    return true;
  }
});
Object.preventExtensions(proxy)
// "called"
// Proxy {}

setPrototypeOf()

  • setPrototypeOf()方法主要用来拦截Object.setPrototypeOf()方法。
  • 注意,该方法只能返回布尔值,否则会被自动转为布尔值。另外,如果目标对象不可扩展(non-extensible),setPrototypeOf()方法不得改变目标对象的原型。

Proxy.revocable()

  • Proxy.revocable()方法返回一个可取消的 Proxy 实例。
let target = {};
let handler = {};
let {proxy, revoke} = Proxy.revocable(target, handler);
proxy.foo = 123;
proxy.foo // 123
revoke();
proxy.foo // TypeError: Revoked
  • Proxy.revocable()方法返回一个对象,该对象的proxy属性是Proxy实例,revoke属性是一个函数,可以取消Proxy实例。
  • Proxy.revocable()的一个使用场景是,目标对象不允许直接访问,必须通过代理访问,一旦访问结束,就收回代理权,不允许再次访问。

this 问题

  • 正常情况下,Proxy代理的钩子函数中的this指向的是Proxy代理实例(construct钩子函数除外,该钩子函数中this指向的是handler)
const target = {
  m: function () {
    console.log(this === proxy);
  }
};
const handler = {};
const proxy = new Proxy(target, handler);
target.m() // false
proxy.m()  // true
  • 下面是一个例子,由于this指向的变化,导致 Proxy 无法代理目标对象。
const _name = new WeakMap();
class Person {
  constructor(name) {
    _name.set(this, name);
  }
  get name() {
    return _name.get(this);
  }
}
const jane = new Person('Jane');
jane.name // 'Jane'
const proxy = new Proxy(jane, {});
proxy.name // undefined

上面代码中,目标对象jane的name属性,实际保存在外部WeakMap对象_name上面,通过this键区分。由于通过proxy.name访问时,this指向proxy,导致无法取到值,所以返回undefined。

  • 此外,有些原生对象的内部属性,只有通过正确的this才能拿到,所以 Proxy 也无法代理这些原生对象的属性。
const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);
proxy.getDate();
// TypeError: this is not a Date object.

上面代码中,getDate()方法只能在Date对象实例上面拿到,如果this不是Date对象实例就会报错。这时,this绑定原始对象,就可以解决这个问题。

const target = new Date('2015-01-01');
const handler = {
  get(target, prop) {
    if (prop === 'getDate') {
      return target.getDate.bind(target);    //必须要bind,否则内部this是Proxy
    }
    return Reflect.get(target, prop);
  }
};
const proxy = new Proxy(target, handler);
proxy.getDate() // 1


Reflect

概述

  • Reflect对象的设计目的有这样几个。

(1) 将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。现阶段,某些方法同时在Object和Reflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。
(2) 修改某些Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false。

// 老写法
try {
  Object.defineProperty(target, property, attributes);
  // success
} catch (e) {
  // failure
}
// 新写法
if (Reflect.defineProperty(target, property, attributes)) {
  // success
} else {
  // failure
}

(3) 让Object操作都变成函数行为。某些Object操作是命令式,比如name in objdelete obj[name],而Reflect.has(obj, name)Reflect.deleteProperty(obj, name)让它们变成了函数行为。

// 老写法
'assign' in Object // true
// 新写法
Reflect.has(Object, 'assign') // true

(4)Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。

Proxy(target, {
  set: function(target, name, value, receiver) {
    var success = Reflect.set(target, name, value, receiver);
    if (success) {
      console.log('property ' + name + ' on ' + target + ' set to ' + value);
    }
    return success;
  }
});

上面代码中,Proxy方法拦截target对象的属性赋值行为。它采用Reflect.set方法将值赋值给对象的属性,确保完成原有的行为,然后再部署额外的功能。
下面是另一个例子。

var loggedObj = new Proxy(obj, {
  get(target, name) {
    console.log('get', target, name);
    return Reflect.get(target, name);
  },
  deleteProperty(target, name) {
    console.log('delete' + name);
    return Reflect.deleteProperty(target, name);
  },
  has(target, name) {
    console.log('has' + name);
    return Reflect.has(target, name);
  }
});

上面代码中,每一个Proxy对象的拦截操作(get、delete、has),内部都调用对应的Reflect方法,保证原生行为能够正常执行。添加的工作,就是将每一个操作输出一行日志。

  • 有了Reflect对象以后,很多操作会更易读。
// 老写法
Function.prototype.apply.call(Math.floor, undefined, [1.75]) // 1
// 新写法
Reflect.apply(Math.floor, undefined, [1.75]) // 1

静态方法

  • Reflect对象一共有 13 个静态方法。
Reflect.apply(target, thisArg, args)
Reflect.construct(target, args)
Reflect.get(target, name, receiver)
Reflect.set(target, name, value, receiver)
Reflect.defineProperty(target, name, desc)
Reflect.deleteProperty(target, name)
Reflect.has(target, name)
Reflect.ownKeys(target)
Reflect.isExtensible(target)
Reflect.preventExtensions(target)
Reflect.getOwnPropertyDescriptor(target, name)
Reflect.getPrototypeOf(target)
Reflect.setPrototypeOf(target, prototype)

Reflect.get(target, name, receiver)

  • Reflect.get方法查找并返回target对象的name属性,如果没有该属性,则返回undefined
  • 如果name属性部署了读取函数(getter),则读取函数的this绑定receiver。
  • 如果第一个参数不是对象,Reflect.get方法会报错。

Reflect.set(target, name, value, receiver)

  • Reflect.set方法设置target对象的name属性等于value。
  • 如果name属性设置了赋值函数,则赋值函数的this绑定receiver。
  • 注意,如果 Proxy对象和 Reflect对象联合使用,前者拦截赋值操作,后者完成赋值的默认行为,而且传入了receiver,那么Reflect.set会触发Proxy.defineProperty拦截。
let p = {
  a: 'a'
};
let handler = {
  set(target, key, value, receiver) {
    console.log('set');
    Reflect.set(target, key, value, receiver)
  },
  defineProperty(target, key, attribute) {
    console.log('defineProperty');
    Reflect.defineProperty(target, key, attribute);
  }
};
let obj = new Proxy(p, handler);
obj.a = 'A';
// set
// defineProperty

上面代码中,Proxy.set拦截里面使用了Reflect.set,而且传入了receiver,导致触发Proxy.defineProperty拦截。这是因为Proxy.setreceiver参数总是指向当前的 Proxy实例(即上例的obj),而Reflect.set一旦传入receiver,就会将属性赋值到receiver上面(即obj),导致触发defineProperty拦截。如果Reflect.set没有传入receiver,那么就不会触发defineProperty拦截。

  • 如果第一个参数不是对象,Reflect.set会报错。

Reflect.has(obj, name)

  • Reflect.has方法对应name in obj里面的in运算符。
  • 如果Reflect.has()方法的第一个参数不是对象,会报错。

Reflect.deleteProperty(obj, name)

  • Reflect.deleteProperty方法等同于delete obj[name],用于删除对象的属性。
  • 该方法返回一个布尔值。如果删除成功,或者被删除的属性不存在,返回true;删除失败,被删除的属性依然存在,返回false。
  • 如果Reflect.deleteProperty()方法的第一个参数不是对象,会报错。

Reflect.construct(target, args)

  • Reflect.construct方法等同于new target(...args),这提供了一种不使用new,来调用构造函数的方法。
function Greeting(name) {
  this.name = name;
}
// new 的写法
const instance = new Greeting('张三');
// Reflect.construct 的写法
const instance = Reflect.construct(Greeting, ['张三']);
  • 如果Reflect.construct()方法的第一个参数不是函数,会报错。

Reflect.getPrototypeOf(obj)

  • Reflect.getPrototypeOf方法用于读取对象的__proto__属性,对应Object.getPrototypeOf(obj)。
  • Reflect.getPrototypeOfObject.getPrototypeOf的一个区别是,如果参数不是对象,Object.getPrototypeOf会将这个参数转为对象,然后再运行,而Reflect.getPrototypeOf会报错。

Reflect.setPrototypeOf(obj, newProto)

  • Reflect.setPrototypeOf方法用于设置目标对象的原型(prototype),对应Object.setPrototypeOf(obj, newProto)方法。它返回一个布尔值,表示是否设置成功。
  • 如果无法设置目标对象的原型(比如,目标对象禁止扩展),Reflect.setPrototypeOf方法返回false
Reflect.setPrototypeOf({}, null)
// true
Reflect.setPrototypeOf(Object.freeze({}), null)
// false

如果第一个参数不是对象,Object.setPrototypeOf会返回第一个参数本身,而Reflect.setPrototypeOf会报错。

  • 如果第一个参数是undefined或null,Object.setPrototypeOfReflect.setPrototypeOf都会报错。

Reflect.apply(func, thisArg, args)

  • Reflect.apply方法等同于Function.prototype.apply.call(func, thisArg, args),用于绑定this对象后执行给定函数。
const ages = [11, 33, 12, 54, 18, 96];
// 旧写法
const youngest = Math.min.apply(Math, ages);
const oldest = Math.max.apply(Math, ages);
const type = Object.prototype.toString.call(youngest);
// 新写法
const youngest = Reflect.apply(Math.min, Math, ages);
const oldest = Reflect.apply(Math.max, Math, ages);
const type = Reflect.apply(Object.prototype.toString, youngest, []);

Reflect.defineProperty(target, propertyKey, attributes)

  • Reflect.defineProperty方法基本等同于Object.defineProperty,用来为对象定义属性。未来,后者会被逐渐废除,请从现在开始就使用Reflect.defineProperty代替它。
    这个方法可以与Proxy.defineProperty配合使用。
const p = new Proxy({}, {
  defineProperty(target, prop, descriptor) {
    console.log(descriptor);
    return Reflect.defineProperty(target, prop, descriptor);
  }
});
p.foo = 'bar';
// {value: "bar", writable: true, enumerable: true, configurable: true}
p.foo // "bar"
//PS:如果Proxy里又set,那么除非Reflect传入reciever,否则不会触发defineProperty

Reflect.getOwnPropertyDescriptor(target, propertyKey)

  • Reflect.getOwnPropertyDescriptorObject.getOwnPropertyDescriptor的一个区别是,如果第一个参数不是对象,Object.getOwnPropertyDescriptor(1, 'foo')不报错,返回undefined,而Reflect.getOwnPropertyDescriptor(1, 'foo')会抛出错误,表示参数非法。

Reflect.isExtensible (target)

  • Reflect.isExtensible方法对应Object.isExtensible,返回一个布尔值,表示当前对象是否可扩展。
  • 如果参数不是对象,Object.isExtensible会返回false,因为非对象本来就是不可扩展的,而Reflect.isExtensible会报错。

Reflect.preventExtensions(target)

  • Reflect.preventExtensions对应Object.preventExtensions方法,用于让一个对象变为不可扩展。它返回一个布尔值,表示是否操作成功。
  • 如果参数不是对象,Object.preventExtensions在 ES5 环境报错,在 ES6 环境返回传入的参数,而Reflect.preventExtensions会报错。

Reflect.ownKeys (target)

  • Reflect.ownKeys方法用于返回对象的所有属性,基本等同于Object.getOwnPropertyNamesObject.getOwnPropertySymbols之和。
  • 如果Reflect.ownKeys()方法的第一个参数不是对象,会报错。

简单观察者实例

const queuedObservers = new Set();
const observe = fn => queuedObservers.add(fn);
const observable = obj => new Proxy(obj, {set});
function set(target, key, value, receiver) {
  const result = Reflect.set(target, key, value, receiver);
  queuedObservers.forEach(observer => observer());
  return result;
}


Promise

Promise 的含义

  • Promise对象有以下两个特点。
    (1)对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。
    (2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。
  • Promise也有一些缺点。
    1、无法取消Promise,一旦新建它就会立即执行,无法中途取消。
    2、如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。
    3、当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
    如果某些事件不断地反复发生,一般来说,使用 Stream 模式是比部署Promise更好的选择。

基本用法

  • Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。
  • Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。
  • Promise 新建后就会立即执行。
let promise = new Promise(function(resolve, reject) {
  console.log('Promise');
  resolve();
});
promise.then(function() {
  console.log('resolved.');
});
console.log('Hi!');
// Promise
// Hi!
// resolved
  • reject函数的参数通常是Error对象的实例,表示抛出的错误;resolve函数的参数除了正常的值以外,还可能是另一个 Promise 实例,比如像下面这样。
 const p1 = new Promise(function (resolve, reject) {
  // ...
});
const p2 = new Promise(function (resolve, reject) {
  // ...
  resolve(p1);
})

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

const p1 = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('fail')), 3000)
})
const p2 = new Promise(function (resolve, reject) {
  setTimeout(() => resolve(p1), 1000)
})
p2
  .then(result => console.log(result))
  .catch(error => console.log(error))
// Error: fail
  • 上面代码中,p1是一个 Promise,3 秒之后变为rejected。p2的状态在 1 秒之后改变,resolve方法返回的是p1。由于p2返回的是另一个 Promise,导致p2自己的状态无效了,由p1的状态决定p2的状态。所以,后面的then语句都变成针对后者(p1)。又过了 2 秒,p1变为rejected,导致触发catch方法指定的回调函数。最后p1,p2状态都是reject
  • 如果p2是reject,p1是resolve,会直接进入回调再等p1
  • 注意,调用resolvereject并不会终结 Promise 的参数函数的执行。
new Promise((resolve, reject) => {
  resolve(1);
  console.log(2);
}).then(r => {
  console.log(r);
});
// 2
// 1

上面代码中,调用resolve(1)以后,后面的console.log(2)还是会执行,并且会首先打印出来。这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。

  • 一般来说,调用resolvereject以后,Promise 的使命就完成了,后继操作应该放到then方法里面,而不应该直接写在resolvereject的后面。所以,最好在它们前面加上return语句,这样就不会有意外。
new Promise((resolve, reject) => {
  return resolve(1);
  // 后面的语句不会执行
  console.log(2);
})

Promise.prototype.then()

  • Promise 实例具有then方法,也就是说,then方法是定义在原型对象Promise.prototype上的。
  • then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。

Promise.prototype.catch()

  • Promise.prototype.catch()方法是.then(null, rejection).then(undefined, rejection)的别名,用于指定发生错误时的回调函数。
  • 另外,then()方法指定的回调函数,如果运行中抛出错误,也会被catch()方法捕获。
 const promise = new Promise(function(resolve, reject) {
  throw new Error('test');
});
promise.catch(function(error) {
  console.log(error);
});
// Error: test

上面代码中,promise抛出一个错误,就被catch()方法指定的回调函数捕获。注意,上面的写法与下面两种写法是等价的。

// 写法一
const promise = new Promise(function(resolve, reject) {
  try {
    throw new Error('test');
  } catch(e) {
    reject(e);
  }
});
promise.catch(function(error) {
  console.log(error);
});
// 写法二
const promise = new Promise(function(resolve, reject) {
  reject(new Error('test'));
});
promise.catch(function(error) {
  console.log(error);
});

比较上面两种写法,可以发现reject()方法的作用,等同于抛出错误。
如果 Promise 状态已经变成resolved,再抛出错误是无效的。

const promise = new Promise(function(resolve, reject) {
  resolve('ok');
  throw new Error('test');
});
promise
  .then(function(value) { console.log(value) })
  .catch(function(error) { console.log(error) });
// ok

上面代码中,Promise 在resolve语句后面,再抛出错误,不会被捕获,等于没有抛出。因为 Promise 的状态一旦改变,就永久保持该状态,不会再变了。

  • Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch语句捕获。
getJSON('/post/1.json').then(function(post) {
  return getJSON(post.commentURL);
}).then(function(comments) {
  // some code
}).catch(function(error) {
  // 处理前面三个Promise产生的错误
});

上面代码中,一共有三个 Promise 对象:一个由getJSON()产生,两个由then()产生。它们之中任何一个抛出的错误,都会被最后一个catch()捕获。

  • 一般来说,不要在then()方法里面定义 Reject 状态的回调函数(即then的第二个参数),总是使用catch方法。
// bad
promise
  .then(function(data) {
    // success
  }, function(err) {
    // error
  });
// good
promise
  .then(function(data) { //cb
    // success
  })
  .catch(function(err) {
    // error
  });

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

  • 跟传统的try/catch代码块不同的是,如果没有使用catch()方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。
const someAsyncThing = function() {
  return new Promise(function(resolve, reject) {
    // 下面一行会报错,因为x没有声明
    resolve(x + 2);    //会进入rejected状态
  });
};
someAsyncThing().then(function() {
  console.log('everything is great');
});
setTimeout(() => { console.log(123) }, 2000);
// Uncaught (in promise) ReferenceError: x is not defined
// 123

上面代码中,someAsyncThing()函数产生的 Promise 对象,内部有语法错误。浏览器运行到这一行,会打印出错误提示ReferenceError: x is not defined,但是不会退出进程、终止脚本执行,2 秒之后还是会输出123。这就是说,Promise 内部的错误不会影响到 Promise 外部的代码,通俗的说法就是“Promise 会吃掉错误”。

  • 这个脚本放在服务器执行,退出码就是0(即表示执行成功)。不过,Node.js 有一个unhandledRejection事件,专门监听未捕获的reject错误,上面的脚本会触发这个事件的监听函数,可以在监听函数里面抛出错误。
process.on('unhandledRejection', function (err, p) {
  throw err;
});

上面代码中,unhandledRejection事件的监听函数有两个参数,第一个是错误对象,第二个是报错的 Promise 实例,它可以用来了解发生错误的环境信息。

  • 注意,Node 有计划在未来废除unhandledRejection事件。如果 Promise 内部有未捕获的错误,会直接终止进程,并且进程的退出码不为 0。
const promise = new Promise(function (resolve, reject) {
  resolve('ok');
  setTimeout(function () { throw new Error('test') }, 0)
});
promise.then(function (value) { console.log(value) });
// ok
// Uncaught Error: test
  • 上面代码中,Promise 指定在下一轮“事件循环”再抛出错误。到了那个时候,Promise 的运行已经结束了,所以这个错误是在 Promise 函数体外抛出的,会冒泡到最外层,成了未捕获的错误。
  • 一般总是建议,Promise 对象后面要跟catch()方法,这样可以处理 Promise 内部发生的错误。catch()方法返回的还是一个 Promise 对象,因此后面还可以接着调用then()方法。
const someAsyncThing = function() {
  return new Promise(function(resolve, reject) {
    // 下面一行会报错,因为x没有声明
    resolve(x + 2);
  });
};
someAsyncThing()
.catch(function(error) {
  console.log('oh no', error);
})
.then(function() {
  console.log('carry on');
});
// oh no [ReferenceError: x is not defined]
// carry on

上面代码运行完catch()方法指定的回调函数,会接着运行后面那个then()方法指定的回调函数。如果没有报错,则会跳过catch()方法。

  • catch()方法之中,还能再抛出错误。
const someAsyncThing = function() {
  return new Promise(function(resolve, reject) {
    // 下面一行会报错,因为x没有声明
    resolve(x + 2);
  });
};
someAsyncThing().then(function() {
  return someOtherAsyncThing();
}).catch(function(error) {
  console.log('oh no', error);
  // 下面一行会报错,因为 y 没有声明
  y + 2;
}).then(function() {
  console.log('carry on');
});
// oh no [ReferenceError: x is not defined]

上面代码中,catch()方法抛出一个错误,因为后面没有别的catch()方法了,导致这个错误不会被捕获,也不会传递到外层。如果改写一下,结果就不一样了。

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

上面代码中,第二个catch()方法用来捕获前一个catch()方法抛出的错误。


Promise.prototype.finally()

  • finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。该方法是 ES2018 引入标准的。
  • finally方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是fulfilled还是rejected。这表明,finally方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。
  • finally本质上是then方法的特例。
promise
.finally(() => {
  // 语句
});
// 等同于
promise
.then(
  result => {
    // 语句
    return result;
  },
  error => {
    // 语句
    throw error;
  }
);
  • 它的实现也很简单。(没有参数传进来)
Promise.prototype.finally = function (callback) {
  let P = this.constructor;
  return this.then(
    value  => P.resolve(callback()).then(() => value),
    reason => P.resolve(callback()).then(() => { throw reason })
  );
};
  • 从上面的实现还可以看到,finally方法总是会返回原来的值。

Promise.all()

  • Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。
  • Promise.all()方法接受一个数组作为参数,p1、p2、p3都是 Promise 实例,如果不是,就会先调用下面讲到的Promise.resolve方法,将参数转为 Promise 实例,再进一步处理。另外,Promise.all()方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例。
  • p的状态由p1、p2、p3决定,分成两种情况。
    (1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
    (2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。
  • 注意,如果作为参数的 Promise 实例,自己定义了catch方法,那么它一旦被rejected,并不会触发Promise.all()catch方法。
const p1 = new Promise((resolve, reject) => {
  resolve('hello');
})
.then(result => result)
.catch(e => e);
const p2 = new Promise((resolve, reject) => {
  throw new Error('报错了');
})
.then(result => result)
.catch(e => e);
Promise.all([p1, p2])
.then(result => console.log(result))
.catch(e => console.log(e));
// ["hello", Error: 报错了]

上面代码中,p1会resolved,p2首先会rejected,但是p2有自己的catch方法,该方法返回的是一个新的 Promise 实例,p2指向的实际上是这个实例。该实例执行完catch方法后,也会变成resolved,导致Promise.all()方法参数里面的两个实例都会resolved,因此会调用then方法指定的回调函数,而不会调用catch方法指定的回调函数。


Promise.race()

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

Promise.allSettled()

  • Promise.allSettled()方法接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例。只有等到所有这些参数实例都返回结果,不管是fulfilled还是rejected,包装实例才会结束。该方法由 ES2020 引入。
  • 该方法返回的新的 Promise 实例,一旦结束,状态总是fulfilled,不会变成rejected。状态变成fulfilled后,Promise 的监听函数接收到的参数是一个数组,每个成员对应一个传入Promise.allSettled()的 Promise 实例。
const resolved = Promise.resolve(42);
const rejected = Promise.reject(-1);
const allSettledPromise = Promise.allSettled([resolved, rejected]);
allSettledPromise.then(function (results) {
  console.log(results);
});
// [
//    { status: 'fulfilled', value: 42 },
//    { status: 'rejected', reason: -1 }
// ]
  • 有时候,我们不关心异步操作的结果,只关心这些操作有没有结束。这时,Promise.allSettled()方法就很有用。如果没有这个方法,想要确保所有操作都结束,就很麻烦。Promise.all()方法无法做到这一点。(一个reject,直接到后面)

Promise.any()

  • Promise.any()方法接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例。只要参数实例有一个变成fulfilled状态,包装实例就会变成fulfilled状态;如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。该方法目前是一个第三阶段的提案 。

Promise.resolve()

  • 有时需要将现有对象转为 Promise 对象,Promise.resolve()方法就起到这个作用。
  • Promise.resolve()等价于下面的写法。
Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))
ps:如果第一种方法传入x+1会报错,不会被捕获,第二种方法传入x+1可以被then里捕获

Promise.resolve()方法的参数分成四种情况。
(1)参数是一个 Promise 实例
如果参数是 Promise 实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例。
(2)参数是一个thenable对象
thenable对象指的是具有then方法的对象,比如下面这个对象。

let thenable = {
  then: function(resolve, reject) {
    resolve(42);
  }
};

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

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

上面代码中,thenable对象的then()方法执行后,对象p1的状态就变为resolved,从而立即执行最后那个then()方法指定的回调函数,输出42。
(3)参数不是具有then()方法的对象,或根本就不是对。如果参数是一个原始值,或者是一个不具有then()方法的对象,则Promise.resolve()方法返回一个新的 Promise 对象,状态为resolved

const p = Promise.resolve('Hello');
p.then(function (s) {
  console.log(s)
});
// Hello

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

  • Promise.resolve()方法允许调用时不带参数,直接返回一个resolved状态的 Promise 对象。
  • 所以,如果希望得到一个 Promise 对象,比较方便的方法就是直接调用Promise.resolve()方法。

Promise.reject()

  • Promise.reject(reason)方法也会返回一个新的 Promise 实例,该实例的状态为rejected

应用

加载图片

const preloadImage = function (path) {
  return new Promise(function (resolve, reject) {
    const image = new Image();
    image.onload  = resolve;
    image.onerror = reject;
    image.src = path;
  });
};

Generator 函数与 Promise 的结合

function getFoo () {
  return new Promise(function (resolve, reject){
    resolve('foo');
  });
}
const g = function* () {
  try {
    const foo = yield getFoo();
    console.log(foo);
  } catch (e) {
    console.log(e);
  }
};
function run (generator) {
  const it = generator();
  function go(result) {
    if (result.done) return result.value;
    return result.value.then(function (value) {
      return go(it.next(value));
    }, function (error) {
      return go(it.throw(error));
    });
  }
  go(it.next());
}
run(g);

Promise.try()

  • 有没有一种方法,让同步函数同步执行,异步函数异步执行,并且让它们具有统一的 API 呢?回答是可以的,并且还有两种写法。第一种写法是用async函数来写。
const f = () => console.log('now');
(async () => f())();
console.log('next');
// now
// next

上面代码中,第二行是一个立即执行的匿名函数,会立即执行里面的async函数,因此如果f是同步的,就会得到同步的结果;如果f是异步的,就可以用then指定下一步,就像下面的写法。

(async () => f())()
.then(...)
  • 需要注意的是,async () => f()会吃掉f()抛出的错误。所以,如果想捕获错误,要使用promise.catch方法。
(async () => f())()
.then(...)
.catch(...)

第二种写法是使用new Promise()

const f = () => console.log('now');
(
  () => new Promise(
    resolve => resolve(f())
  )
)();
console.log('next');
// now
// next

上面代码也是使用立即执行的匿名函数,执行new Promise()。这种情况下,同步函数也是同步执行的。

  • 鉴于这是一个很常见的需求,所以现在有一个提案,提供Promise.try方法替代上面的写法。
const f = () => console.log('now');
Promise.try(f);
console.log('next');


Iterator 和 for…of 循环

Iterator(遍历器)的概念

  • Iterator 的作用有三个:
    一、是为各种数据结构,提供一个统一的、简便的访问接口;
    二、是使得数据结构的成员能够按某种次序排列;
    三、是 ES6 创造了一种新的遍历命令for…of循环,Iterator 接口主要供for…of消费。
  • Iterator 的遍历过程是这样的。
    (1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
    (2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
    (3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
    (4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。
  • 每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含valuedone两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。
  • 对于遍历器对象来说,done: falsevalue: undefined属性都是可以省略的

默认 Iterator 接口

  • 当使用for...of循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。
  • ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。
  • Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内
  • 原生具备 Iterator 接口的数据结构如下。
Array
Map
Set
String
TypedArray
函数的 arguments 对象
NodeList 对象
  • 对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。
  • 一个对象如果要具备可被for...of循环调用的 Iterator 接口,就必须在Symbol.iterator的属性上部署遍历器生成方法(原型链上的对象具有该方法也可)。
class RangeIterator {
  constructor(start, stop) {
    this.value = start;
    this.stop = stop;
  }
  [Symbol.iterator]() { return this; }
  next() {
    var value = this.value;
    if (value < this.stop) {
      this.value++;
      return {done: false, value: value};
    }
    return {done: true, value: undefined};
  }
}
function range(start, stop) {
  return new RangeIterator(start, stop);
}
for (var value of range(0, 3)) {
  console.log(value); // 0, 1, 2
}
  • 下面是通过遍历器实现指针结构的例子。
function Obj(value) {
  this.value = value;
  this.next = null;
}
Obj.prototype[Symbol.iterator] = function() {
  var iterator = { next: next };
  var current = this;
  function next() {
    if (current) {
      var value = current.value;
      current = current.next;
      return { done: false, value: value };
    } else {
      return { done: true };
    }
  }
  return iterator;
}
var one = new Obj(1);
var two = new Obj(2);
var three = new Obj(3);
one.next = two;
two.next = three;
for (var i of one){
  console.log(i); // 1, 2, 3
}
  • 下面是另一个为对象添加 Iterator 接口的例子。
let obj = {
  data: [ 'hello', 'world' ],
  [Symbol.iterator]() {
    const self = this;
    let index = 0;
    return {
      next() {
        if (index < self.data.length) {
          return {
            value: self.data[index++],
            done: false
          };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};
  • 对于类似数组的对象(存在数值键名和length属性),部署 Iterator 接口,有一个简便方法,就是Symbol.iterator方法直接引用数组的 Iterator 接口。
NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];

// 或者

NodeList.prototype[Symbol.iterator] = [][Symbol.iterator];
[...document.querySelectorAll('div')] // 可以执行了
  • 下面是另一个类似数组的对象调用数组的Symbol.iterator方法的例子。
let iterable = {
  0: 'a',
  1: 'b',
  2: 'c',
  length: 3,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
};
for (let item of iterable) {
  console.log(item); // 'a', 'b', 'c'
}
  • 注意,普通对象部署数组的Symbol.iterator方法,并无效果。
let iterable = {
  a: 'a',
  b: 'b',
  c: 'c',
  length: 3,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
};
for (let item of iterable) {
  console.log(item); // undefined, undefined, undefined
}
  • 如果Symbol.iterator方法对应的不是遍历器生成函数(即会返回一个遍历器对象),解释引擎将会报错。
var obj = {};
obj[Symbol.iterator] = () => 1;
[...obj] // TypeError: [] is not a function

调用 Iterator 接口的场合

  • 有一些场合会默认调用 Iterator 接口(即Symbol.iterator方法),除了下文会介绍的for...of循环,还有几个别的场合。

(1)解构赋值
对数组和 Set 结构进行解构赋值时,会默认调用Symbol.iterator方法。

let set = new Set().add('a').add('b').add('c');
let [x,y] = set;
// x='a'; y='b'
let [first, ...rest] = set;
// first='a'; rest=['b','c'];

(2)扩展运算符(...)也会调用默认的 Iterator 接口。

// 例一
var str = 'hello';
[...str] //  ['h','e','l','l','o']
// 例二
let arr = ['b', 'c'];
['a', ...arr, 'd']
// ['a', 'b', 'c', 'd']

上面代码的扩展运算符内部就调用 Iterator 接口。

  • 实际上,这提供了一种简便机制,可以将任何部署了 Iterator 接口的数据结构,转为数组。也就是说,只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组。
let arr = [...iterable];

(3)yield*
yield*后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口。

let generator = function* () {
  yield 1;
  yield* [2,3,4];
  yield 5;
};
var iterator = generator();
iterator.next() // { value: 1, done: false }
iterator.next() // { value: 2, done: false }
iterator.next() // { value: 3, done: false }
iterator.next() // { value: 4, done: false }
iterator.next() // { value: 5, done: false }
iterator.next() // { value: undefined, done: true }

(4)其他场合

  • 由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。
for...of
Array.from()
Map(), Set(), WeakMap(), WeakSet()(比如new Map([['a',1],['b',2]]))
Promise.all()
Promise.race()

字符串的 Iterator 接口


Iterator 接口与 Generator 函数

  • Symbol.iterator()方法的最简单实现,还是使用Generator 函数。
let myIterable = {
  [Symbol.iterator]: function* () {
    yield 1;
    yield 2;
    yield 3;
  }
};
[...myIterable] // [1, 2, 3]
// 或者采用下面的简洁写法
let obj = {
  * [Symbol.iterator]() {
    yield 'hello';
    yield 'world';
  }
};
for (let x of obj) {
  console.log(x);
}
// "hello"
// "world"

遍历器对象的 return(),throw()

  • 遍历器对象除了具有next()方法,还可以具有return()方法和throw()方法。如果你自己写遍历器对象生成函数,那么next()方法是必须部署的,return()方法和throw()方法是否部署是可选的。
  • return()方法的使用场合是,如果for...of循环提前退出(通常是因为出错,或者有break语句),就会调用return()方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署return()方法。
function readLinesSync(file) {
  return {
    [Symbol.iterator]() {
      return {
        next() {
          return { done: false };
        },
        return() {
          file.close();
          return { done: true };
        }
      };
    },
  };
}
  • 下面的两种情况,都会触发执行return()方法。
// 情况一
for (let line of readLinesSync(fileName)) {
  console.log(line);
  break;
}
// 情况二
for (let line of readLinesSync(fileName)) {
  console.log(line);
  throw new Error();
}
  • 注意,return()方法必须返回一个对象,这是 Generator 语法决定的。
  • throw()方法主要是配合 Generator 函数使用,一般的遍历器对象用不到这个方法。

for…of 循环

  • for...of循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如arguments对象、DOM NodeList 对象)、后文的 Generator 对象,以及字符串。

数组

数组原生具备iterator接口(即默认部署了Symbol.iterator属性),for...of循环本质上就是调用这个接口产生的遍历器,可以用下面的代码证明。

const arr = ['red', 'green', 'blue'];
for(let v of arr) {
  console.log(v); // red green blue
}
const obj = {};
obj[Symbol.iterator] = arr[Symbol.iterator].bind(arr);
for(let v of obj) {
  console.log(v); // red green blue
}
  • 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"
}

Set 和 Map 结构

  • Set 和 Map 结构也原生具有 Iterator 接口,可以直接使用for...of循环。
var engines = new Set(["Gecko", "Trident", "Webkit", "Webkit"]);
for (var e of engines) {
  console.log(e);
}
// Gecko
// Trident
// Webkit
var es6 = new Map();
es6.set("edition", 6);
es6.set("committee", "TC39");
es6.set("standard", "ECMA-262");
for (var [name, value] of es6) {
  console.log(name + ": " + value);
}
// edition: 6
// committee: TC39
// standard: ECMA-262
  • 值得注意的地方有两个,首先,遍历的顺序是按照各个成员被添加进数据结构的顺序。其次,Set 结构遍历时,返回的是一个值,而 Map 结构遍历时,返回的是一个数组,该数组的两个成员分别为当前 Map 成员的键名和键值。

计算生成的数据结构

  • 有些数据结构是在现有数据结构的基础上,计算生成的。比如,ES6 的数组、Set、Map 都部署了以下三个方法,调用后都返回遍历器对象。
  • entries() 返回一个遍历器对象,用来遍历[键名, 键值]组成的数组。对于数组,键名就是索引值;对于 Set,键名与键值相同。Map 结构的 Iterator 接口,默认就是调用entries方法。
  • keys() 返回一个遍历器对象,用来遍历所有的键名。
  • values() 返回一个遍历器对象,用来遍历所有的键值。

类似数组的对象

  • 类似数组的对象包括好几类。下面是for...of循环用于字符串、DOM NodeList 对象、arguments对象的例子。
  • 对于字符串来说,for...of循环还有一个特点,就是会正确识别 32 位 UTF-16 字符。
for (let x of 'a\uD83D\uDC0A') {
  console.log(x);
}
// 'a'
// '\uD83D\uDC0A'
  • 并不是所有类似数组的对象都具有 Iterator 接口,一个简便的解决方法,就是使用Array.from方法将其转为数组。

对象

  • 对于普通的对象,for...of结构不能直接使用,会报错,必须部署了 Iterator 接口后才能使用。
  • 一种解决方法是,使用Object.keys方法将对象的键名生成一个数组,然后遍历这个数组。
  • 另一个方法是使用 Generator 函数将对象重新包装一下。
function* entries(obj) {
  for (let key of Object.keys(obj)) {
    yield [key, obj[key]];
  }
}
for (let [key, value] of entries(obj)) {
  console.log(key, '->', value);
}
// a -> 1
// b -> 2
// c -> 3

与其他遍历语法的比较

  • 以数组为例,JavaScript 提供多种遍历语法。最原始的写法就是for循环。这种写法比较麻烦,因此数组提供内置的forEach方法。这种写法的问题在于,无法中途跳出forEach循环,break命令或return命令都不能奏效。
  • for...in循环有几个缺点。
    1、数组的键名是数字,但是for...in循环是以字符串作为键名“0”、“1”、“2”等等。
    2、for...in循环不仅遍历数字键名,还会遍历手动添加的其他键,甚至包括原型链上的键。
    3、某些情况下,for...in循环会以任意顺序遍历键名。
  • 总之,for...in循环主要是为遍历对象而设计的,不适用于遍历数组。
  • for...of循环相比上面几种做法,有一些显著的优点。
for (let value of myArray) {
  console.log(value);
}
  • 不同于forEach方法,它可以与break、continue和return配合使用。


Generator 函数的语法

简介

基本概念

  • 调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。
  • ES6 没有规定,function关键字与函数名之间的星号,写在哪个位置。这导致下面的写法都能通过。
function * foo(x, y) { ··· }
function *foo(x, y) { ··· }
function* foo(x, y) { ··· }
function*foo(x, y) { ··· }

一般的写法是上面的第三种

yield 表达式

  • 遍历器对象的next方法的运行逻辑如下。
(1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。
(2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。
(3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。
(4)如果该函数没有return语句,则返回的对象的value属性值为undefined。
  • 需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行
  • Generator 函数可以不用yield表达式,这时就变成了一个单纯的暂缓执行函数。
  • 另外需要注意,yield表达式只能用在 Generator 函数里面,用在其他地方都会报错。嵌套函数里也不行
  • 另外,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
}

与 Iterator 接口的关系

  • Generator 函数执行后,返回一个遍历器对象。该对象本身也具有Symbol.iterator属性,执行后返回自身。

next 方法的参数

  • yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。
function* f() {
  for(var i = 0; true; i++) {
    var reset = yield i;
    if(reset) { i = -1; }
  }
}
var g = f();
g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }
function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
}
var a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}
var b = foo(5);
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }
  • 注意,由于next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。V8 引擎直接忽略第一次使用next方法时的参数,只有从第二次使用next方法开始,参数才是有效的。从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。
function* dataConsumer() {
  console.log('Started');
  console.log(`1. ${yield}`);
  console.log(`2. ${yield}`);
  return 'result';
}
let genObj = dataConsumer();
genObj.next();
// Started
genObj.next('a')
// 1. a
genObj.next('b')
// 2. b

for…of 循环

  • for...of循环可以自动遍历 Generator 函数运行时生成的Iterator对象,且此时不再需要调用next方法。
function* foo() {
  yield 1;
  yield 2;
  return 6;
}
for (let v of foo()) {
  console.log(v);
}
// 1 2 
  • 一旦next方法的返回对象的done属性为true,for...of循环就会中止,且不包含该返回对象
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 对象,作为参数。


Generator.prototype.throw()

  • Generator 函数返回的遍历器对象,都有一个throw方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。
var g = function* () {
  try {
    yield;
  } catch (e) {
    console.log('内部捕获', e);
  }
};
var i = g();
i.next();
try {
  i.throw('a');
  i.throw('b');
} catch (e) {
  console.log('外部捕获', e);
}
// 内部捕获 a
// 外部捕获 b

上面代码中,遍历器对象i连续抛出两个错误。第一个错误被 Generator 函数体内的catch语句捕获。i第二次抛出错误,由于 Generator 函数内部的catch语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被抛出了 Generator 函数体,被函数体外的catch语句捕获。

  • throw方法可以接受一个参数,该参数会被catch语句接收,建议抛出Error对象的实例。
try {
  throw new Error('a');
  throw new Error('b');
} catch (e) {
  console.log('外部捕获', e);
}
// 外部捕获 [Error: a]
  • 上面代码之所以只捕获了a,是因为函数体外的catch语句块,捕获了抛出的a错误以后,就不会再继续try代码块里面剩余的语句了。
  • 如果 Generator 函数内部没有部署try...catch代码块,那么throw方法抛出的错误,将被外部try...catch代码块捕获。
var g = function* () {
  while (true) {
    yield;
    console.log('内部捕获', e);
  }
};
var i = g();
i.next();
try {
  i.throw('a');
  i.throw('b');
} catch (e) {
  console.log('外部捕获', e);
}
// 外部捕获 a
  • 如果 Generator 函数内部和外部,都没有部署try...catch代码块,那么程序将报错,直接中断执行。
  • throw方法抛出的错误要被内部捕获,前提是必须至少执行过一次next方法。
function* gen() {
  try {
    yield 1;
  } catch (e) {
    console.log('内部捕获');
  }
}
var g = gen();
g.throw(1);
// Uncaught 1
  • 上面代码中,g.throw(1)执行时,next方法一次都没有执行过。这时,抛出的错误不会被内部捕获,而是直接在外部抛出,导致程序出错。这种行为其实很好理解,因为第一次执行next方法,等同于启动执行 Generator 函数的内部代码,否则 Generator 函数还没有开始执行,这时throw方法抛错只可能抛出在函数外部。
  • throw方法被捕获以后,会附带执行下一条yield表达式。也就是说,会附带执行一次next方法。
var gen = function* gen(){
  try {
    yield console.log('a');
  } catch (e) {
    // ...
  }
  yield console.log('b');
  yield console.log('c');
}
var g = gen();
g.next() // a
g.throw() // b
g.next() // c

上面代码中,可以看到,只要 Generator 函数内部部署了try...catch代码块,那么遍历器的throw方法抛出的错误,不影响下一次遍历。

  • Generator 函数体内抛出的错误,可以被函数体外的catch捕获。
function* foo() {
  var x = yield 3;
  var y = x.toUpperCase();
  yield y;
}
var it = foo();
it.next(); // { value:3, done:false }
try {
  it.next(42);
} catch (err) {
  console.log(err);
}
  • 一旦 Generator 执行过程中抛出错误,且没有被内部捕获,就不会再执行下去了。如果此后还调用next方法,将返回一个value属性等于undefined、done属性等于true的对象,即 JavaScript 引擎认为这个 Generator 已经运行结束了。

Generator.prototype.return()

  • Generator 函数返回的遍历器对象,还有一个return方法,可以返回给定的值,并且终结遍历 Generator 函数。
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}
var g = gen();
g.next()        // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next()        // { value: undefined, done: true }

如果return方法调用时,不提供参数,则返回值的value属性为undefined。

  • 如果 Generator 函数内部有try...finally代码块,且正在执行try代码块,那么return方法会导致立刻进入finally代码块,执行完以后,整个函数才会结束。
function* numbers () {
  yield 1;
  try {
    yield 2;
    yield 3;
  } finally {
    yield 4;
    yield 5;
  }
  yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }

next()、throw()、return() 的共同点

next()是将yield表达式替换成一个值。
throw()是将yield表达式替换成一个throw语句。
return()是将yield表达式替换成一个return语句。


yield* 表达式

  • 如果在 Generator 函数内部,调用另一个 Generator 函数。需要在前者的函数体内部,自己手动完成遍历。
function* foo() {
  yield 'a';
  yield 'b';
}
function* bar() {
  yield 'x';
  // 手动遍历 foo()
  for (let i of foo()) {
    console.log(i);
  }
  yield 'y';
}
for (let v of bar()){
  console.log(v);
}
// x
// a
// b
// y
  • ES6 提供了yield*表达式,作为解决办法,用来在一个 Generator 函数里面执行另一个 Generator 函数。
function* bar() {
  yield 'x';
  yield* foo();
  yield 'y';
}
// 等同于
function* bar() {
  yield 'x';
  yield 'a';
  yield 'b';
  yield 'y';
}
// 等同于
function* bar() {
  yield 'x';
  for (let v of foo()) {
    yield v;
  }
  yield 'y';
}
for (let v of bar()){
  console.log(v);
}
// "x"
// "a"
// "b"
// "y"
function* inner() {
  yield 'hello!';
}
function* outer1() {
  yield 'open';
  yield inner();
  yield 'close';
}
var gen = outer1()
gen.next().value // "open"
gen.next().value // 返回一个遍历器对象
gen.next().value // "close"
function* outer2() {
  yield 'open'
  yield* inner()
  yield 'close'
}
var gen = outer2()
gen.next().value // "open"
gen.next().value // "hello!"
gen.next().value // "close"
  • yield*后面的 Generator 函数(没有return语句时),不过是for...of的一种简写形式,完全可以用后者替代前者。反之,在有return语句时,则需要用var value = yield* iterator的形式获取return语句的值。
  • 任何数据结构只要有 Iterator 接口,就可以被yield*遍历。
  • 如果被代理的 Generator 函数有return语句,那么就可以向代理它的 Generator 函数返回数据。
 function* foo() {
  yield 2;
  yield 3;
  return "foo";
}
function* bar() {
  yield 1;
  var v = yield* foo();
  console.log("v: " + v);
  yield 4;
}
var it = bar();
it.next()
// {value: 1, done: false}
it.next()
// {value: 2, done: false}
it.next()
// {value: 3, done: false}
it.next();
// "v: foo"
// {value: 4, done: false}
it.next()
// {value: undefined, done: true}
  • 下面是一个稍微复杂的例子,使用yield*语句遍历完全二叉树。
// 下面是二叉树的构造函数,
// 三个参数分别是左树、当前节点和右树
function Tree(left, label, right) {
  this.left = left;
  this.label = label;
  this.right = right;
}
// 下面是中序(inorder)遍历函数。
// 由于返回的是一个遍历器,所以要用generator函数。
// 函数体内采用递归算法,所以左树和右树要用yield*遍历
function* inorder(t) {
  if (t) {
    yield* inorder(t.left);
    yield t.label;
    yield* inorder(t.right);
  }
}
// 下面生成二叉树
function make(array) {
  // 判断是否为叶节点
  if (array.length == 1) return new Tree(null, array[0], null);
  return new Tree(make(array[0]), array[1], make(array[2]));
}
let tree = make([[['a'], 'b', ['c']], 'd', [['e'], 'f', ['g']]]);
// 遍历二叉树
var result = [];
for (let node of inorder(tree)) {
  result.push(node);
}
result
// ['a', 'b', 'c', 'd', 'e', 'f', 'g']

作为对象属性的 Generator 函数

如果一个对象的属性是 Generator 函数,可以简写成下面的形式。

let obj = {
  * myGeneratorMethod() {
    ···
  }
};

Generator 函数的this

  • Generator 函数总是返回一个遍历器,ES6 规定这个遍历器是 Generator 函数的实例,也继承了 Generator 函数的prototype对象上的方法。
function* g() {}
g.prototype.hello = function () {
  return 'hi!';
};
let obj = g();
obj instanceof g // true
obj.hello() // 'hi!' 
  • 如果把g当作普通的构造函数,并不会生效,因为g返回的总是遍历器对象,而不是this对象。
function* g() {
  this.a = 11;
}
let obj = g();
obj.next();
obj.a // undefined
  • Generator 函数也不能跟new命令一起用,会报错。
  • 那么,有没有办法让 Generator 函数返回一个正常的对象实例,既可以用next方法,又可以获得正常的this
  • 下面是一个变通方法。首先,生成一个空对象,使用call方法绑定 Generator 函数内部的this。这样,构造函数调用以后,这个空对象就是 Generator 函数的实例对象了。
function* F() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}
var obj = {};
var f = F.call(obj);
f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}
obj.a // 1
obj.b // 2
obj.c // 3
  • 上面代码中,执行的是遍历器对象f,但是生成的对象实例是obj,有没有办法将这两个对象统一呢?一个办法就是将obj换成F.prototype。
function* F() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}
var f = F.call(F.prototype);
f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}
f.a // 1
f.b // 2
f.c // 3
  • 再将F改成构造函数,就可以对它执行new命令了。
function* gen() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}
function F() {
  return gen.call(gen.prototype);
}
var f = new F();
f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}
f.a // 1
f.b // 2
f.c // 3

含义

Generator 与状态机

var clock = function* () {
  while (true) {
    console.log('Tick!');
    yield;
    console.log('Tock!');
    yield;
  }
};

Generator 与协程

(1)协程与子例程的差异
(2)协程与普通线程的差异

Generator 与上下文


应用

(1)异步操作的同步化表达
(2)控制流管理
(3)部署 Iterator 接口
(4)作为数据结构



Generator 函数的异步应用

传统方法

  • ES6 诞生以前,异步编程的方法,大概有下面四种。
    1、回调函数
    2、事件监听
    3、发布/订阅
    4、Promise 对象

基本概念

异步

回调函数

Promise


Generator 函数

协程

  • 协程有点像函数,又有点像线程。它的运行流程大致如下。
    第一步,协程A开始执行。
    第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
    第三步,(一段时间后)协程B交还执行权。
    第四步,协程A恢复执行。

协程的 Generator 函数实现

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

异步任务的封装

var fetch = require('node-fetch');
function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}
var g = gen();
var result = g.next();
result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

Thunk 函数

  • Thunk 函数是自动执行 Generator 函数的一种方法。

参数的求值策略

  • “传值调用”
  • “传名调用”

Thunk 函数的含义

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

JavaScript 语言的 Thunk 函数

  • JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。
// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);
// Thunk版本的readFile(单参数版本)
var Thunk = function (fileName) {
  return function (callback) {
    return fs.readFile(fileName, callback);
  };
};
var readFileThunk = Thunk(fileName);
readFileThunk(callback);

上面代码中,fs模块的readFile方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。

Thunkify 模块

Generator 函数的流程管理

  • Thunk 函数现在可以用于 Generator 函数的自动流程管理。

co 模块

基本用法

  • co 模块是著名程序员 TJ Holowaychuk 于 2013 年 6 月发布的一个小工具,用于 Generator 函数的自动执行。
  • co 模块可以让你不用编写 Generator 函数的执行器。
var co = require('co');
co(gen);
上面代码中,Generator 函数只要传入co函数,就会自动执行。
co函数返回一个Promise对象,因此可以用then方法添加回调函数。
co(gen).then(function (){
  console.log('Generator 函数执行完成');
});

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

co 模块的原理

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

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

(1)回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。

(2)Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。

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

基于 Promise 对象的自动执行

co 模块的源码

处理并发的异步操作



async 函数

含义

  • async 函数是什么?一句话,它就是 Generator 函数的语法糖。
  • async函数对 Generator 函数的改进,体现在以下四点。
    (1)内置执行器。
    Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。
    (2)更好的语义。
    (3)更广的适用性。
    async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
    (4)返回值是 Promise。

基本用法

  • async函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

语法

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

返回 Promise 对象

  • async函数返回一个 Promise 对象。
  • async函数内部return语句返回的值,会成为then方法回调函数的参数。
  • async函数内部抛出错误,会导致返回的 Promise 对象变为reject状态。

Promise 对象的状态变化

  • async函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或者抛出错误。

await 命令

  • 正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。
async function f() {
  // 等同于
  // return 123;
  return await 123;
}
f().then(v => console.log(v))
// 123
  • 另一种情况是,await命令后面是一个thenable对象(即定义了then方法的对象),那么await会将其等同于 Promise 对象。(会进入then方法,传入resolvereject,如果reject,需要catch,需要resolve才能赋值到sleepTime,后面的语句才能执行)
class Sleep {
  constructor(timeout) {
    this.timeout = timeout;
  }
  then(resolve, reject) {
    const startTime = Date.now();
    setTimeout(
      () => resolve(Date.now() - startTime),
      this.timeout
    );
  }
}
(async () => {
  const sleepTime = await new Sleep(1000);
  console.log(sleepTime);
})();
// 1000
  • 简化的sleep实现。
function sleep(interval) {
  return new Promise(resolve => {
    setTimeout(resolve, interval);
  })
}
// 用法
async function one2FiveInAsync() {
  for(let i = 1; i <= 5; i++) {
    console.log(i);
    await sleep(1000);
  }
}
one2FiveInAsync();
  • await命令后面的 Promise 对象如果变为reject状态,则reject的参数会被catch方法的回调函数接收到。
  • await语句前面没有return,但是reject方法的参数依然传入了catch方法的回调函数。这里如果在await前面加上return,效果是一样的。
  • 任何一个await语句后面的 Promise 对象变为reject状态,那么整个async函数都会中断执行。
async function f() {
  await Promise.reject('出错了');
  await Promise.resolve('hello world'); // 不会执行
}
  • 有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个await放在try...catch结构里面,这样不管这个异步操作是否成功,第二个await都会执行。
  • 另一种方法是await后面的 Promise 对象再跟一个catch方法,处理前面可能出现的错误。

错误处理

  • 如果await后面的异步操作出错,那么等同于async函数返回的 Promise 对象被reject。防止出错的方法,也是将其放在try...catch代码块之中。如果有多个await命令,可以统一放在try...catch结构中。
  • 下面的例子使用try...catch结构,实现多次重复尝试。
const superagent = require('superagent');
const NUM_RETRIES = 3;
async function test() {
  let i;
  for (i = 0; i < NUM_RETRIES; ++i) {
    try {
      await superagent.get('http://google.com/this-throws-an-error');
      break;
    } catch(err) {}
  }
  console.log(i); // 3
}
test();

上面代码中,如果await操作成功,就会使用break语句退出循环;如果失败,会被catch语句捕捉,然后进入下一轮循环。

使用注意点

  • 第一点,前面已经说过,await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try...catch代码块中。
async function myFunction() {
  try {
    await somethingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
}
// 另一种写法
async function myFunction() {
  await somethingThatReturnsAPromise()
  .catch(function (err) {
    console.log(err);
  });
}
  • 第二点,多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。
let foo = await getFoo();
let bar = await getBar();

上面代码中,getFoo和getBar是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo完成以后,才会执行getBar,完全可以让它们同时触发。

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

上面两种写法,getFoo和getBar都是同时触发,这样就会缩短程序的执行时间。

  • 第三点,await命令只能用在async函数之中,如果用在普通函数,就会报错。
  • 第四点,async 函数可以保留运行堆栈。
const a = () => {
  b().then(() => c());
};

上面代码中,函数a内部运行了一个异步任务b()。当b()运行的时候,函数a()不会中断,而是继续执行。等到b()运行结束,可能a()早就运行结束了,b()所在的上下文环境已经消失了。如果b()或c()报错,错误堆栈将不包括a()。
现在将这个例子改成async函数。

const a = async () => {
  await b();
  c();
};

上面代码中,b()运行的时候,a()是暂停执行,上下文环境都保存着。一旦b()或c()报错,错误堆栈将包括a()。


async 函数的实现原理

  • async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里。
async function fn(args) {
  // ...
}
// 等同于
function fn(args) {
  return spawn(function* () {
    // ...
  });
}
  • 所有的async函数都可以写成上面的第二种形式,其中的spawn函数就是自动执行器。
  • 下面给出spawn函数的实现,基本就是前文自动执行器的翻版。
function spawn(genF) {
  return new Promise(function(resolve, reject) {
    const gen = genF();
    function step(nextF) {
      let next;
      try {
        next = nextF();
      } catch(e) {
        return reject(e);
      }
      if(next.done) {
        return resolve(next.value);
      }
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v); });
      }, function(e) {
        step(function() { return gen.throw(e); });
      });
    }
    step(function() { return gen.next(undefined); });
  });
}

顶层 await



Class 的基本语法

简介

类的由来

  • ES6 的类,完全可以看作构造函数的另一种写法。
class Point {
  // ...
}
typeof Point // "function"
Point === Point.prototype.constructor // true

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

  • 使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。
  • 构造函数的prototype属性,在 ES6 的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign方法可以很方便地一次向类添加多个方法。
class Point {
  constructor(){
    // ...
  }
}
Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
});
  • prototype对象的constructor属性,直接指向“类”的本身,这与 ES5 的行为是一致的。
Point.prototype.constructor === Point // true
  • 另外,类的内部所有定义的方法,都是不可枚举的(non-enumerable)。
class Point {
  constructor(x, y) {
    // ...
  }
  toString() {
    // ...
  }
}
Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

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

constructor 方法

  • constructor方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加。
  • constructor方法默认返回实例对象(即this),完全可以指定返回另外一个对象。
class Foo {
  constructor() {
    return Object.create(null);
  }
}
new Foo() instanceof Foo
// false
  • 类必须使用new调用,否则会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行。

类的实例

  • 与 ES5 一样,类的所有实例共享一个原型对象。
var p1 = new Point(2,3);
var p2 = new Point(3,2);
p1.__proto__ === p2.__proto__
//true
  • __proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。生产环境中,我们可以使用 Object.getPrototypeOf 方法来获取实例对象的原型,然后再来为原型添加方法/属性。
var p1 = new Point(2,3);
var p2 = new Point(3,2);
p1.__proto__.printName = function () { return 'Oops' };
p1.printName() // "Oops"
p2.printName() // "Oops"
var p3 = new Point(4,2);
p3.printName() // "Oops"

取值函数(getter)和存值函数(setter)

  • 与 ES5 一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
class MyClass {
  constructor() {
    // ...
  }
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}
let inst = new MyClass();
inst.prop = 123;
// setter: 123
inst.prop
// 'getter'
  • 存值函数和取值函数是设置在属性的 Descriptor 对象上的。
class CustomHTMLElement {
  constructor(element) {
    this.element = element;
  }
  get html() {
    return this.element.innerHTML;
  }
  set html(value) {
    this.element.innerHTML = value;
  }
}
var descriptor = Object.getOwnPropertyDescriptor(
  CustomHTMLElement.prototype, "html"
);
"get" in descriptor  // true
"set" in descriptor  // true
  • 属性表达式
let methodName = 'getArea';
class Square {
  constructor(length) {
    // ...
  }
  [methodName]() {
    // ...
  }
}

Class 表达式

  • 与函数一样,类也可以使用表达式的形式定义。
const MyClass = class Me {
  getClassName() {
    return Me.name;
  }
};

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

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

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

  • 如果类的内部没用到的话,可以省略Me,也就是可以写成下面的形式。
const MyClass = class { /* ... */ };
let person = new class {
  constructor(name) {
    this.name = name;
  }
  sayName() {
    console.log(this.name);
  }
}('张三');
person.sayName(); // "张三"

注意点

(1)严格模式
类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。
(2)不存在提升
类不存在变量提升(hoist),这一点与 ES5 完全不同。

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

(3)name 属性
由于本质上,ES6 的类只是 ES5 的构造函数的一层包装,所以函数的许多特性都被Class继承,包括name属性。

class Point {}
Point.name // "Point"

(4)Generator 方法
如果某个方法之前加上星号(*),就表示该方法是一个 Generator 函数。

class Foo {
  constructor(...args) {
    this.args = args;
  }
  * [Symbol.iterator]() {
    for (let arg of this.args) {
      yield arg;
    }
  }
}
for (let x of new Foo('hello', 'world')) {
  console.log(x);
}
// hello
// world

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

class Logger {
  printName(name = 'there') {
    this.print(`Hello ${name}`);
  }
  print(text) {
    console.log(text);
  }
}
const logger = new Logger();
const { printName } = logger;  //可以取到
printName(); // TypeError: Cannot read property 'print' of undefined

上面代码中,printName方法中的this,默认指向Logger类的实例。但是,如果将这个方法提取出来单独使用,this会指向该方法运行时所在的环境(由于 class 内部是严格模式,所以 this 实际指向的是undefined),从而导致找不到print方法而报错。

  • 一个比较简单的解决方法是,在构造方法中绑定this,这样就不会找不到print方法了。
class Logger {
  constructor() {
    this.printName = this.printName.bind(this);
  }
  // ...
}
  • 另一种解决方法是使用箭头函数。
class Obj {
  constructor() {
    this.getThis = () => this;
  }
}
const myObj = new Obj();
myObj.getThis() === myObj // true
  • 还有一种解决方法是使用Proxy,获取方法的时候,自动绑定this
function selfish (target) {
  const cache = new WeakMap();
  const handler = {
    get (target, key) {
      const value = Reflect.get(target, key);
      if (typeof value !== 'function') {
        return value;
      }
      if (!cache.has(value)) {
        cache.set(value, value.bind(target));
      }
      return cache.get(value);
    }
  };
  const proxy = new Proxy(target, handler);
  return proxy;
}
const logger = selfish(new Logger());

静态方法

  • 类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。
class Foo {
  static classMethod() {
    return 'hello';
  }
}
Foo.classMethod() // 'hello'
var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function
  • 注意,如果静态方法包含this关键字,这个this指的是类,而不是实例。
class Foo {
  static bar() {
    this.baz();
  }
  static baz() {
    console.log('hello');
  }
  baz() {
    console.log('world');
  }
}
Foo.bar() // hello
  • 从这个例子还可以看出,静态方法可以与非静态方法重名。
父类的静态方法,可以被子类继承。
class Foo {
  static classMethod() {
    return 'hello';
  }
}
class Bar extends Foo {
}
Bar.classMethod() // 'hello'
  • 静态方法也是可以从super对象上调用的。
class Foo {
  static classMethod() {
    return 'hello';
  }
}
class Bar extends Foo {
  static classMethod() {
    return super.classMethod() + ', too';
  }
}
Bar.classMethod() // "hello, too"

实例属性的新写法

class IncreasingCounter {
  _count = 0;
  get value() {
    console.log('Getting the current value!');
    return this._count;
  }
  increment() {
    this._count++;
  }
}
  • 上面代码中,实例属性_count与取值函数value()increment()方法,处于同一个层级。这时,不需要在实例属性前面加上this。这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。

静态属性

静态属性指的是 Class 本身的属性,即Class.propName,而不是定义在实例对象(this)上的属性。

class Foo {
}
Foo.prop = 1;
Foo.prop // 1

上面的写法为Foo类定义了一个静态属性prop。

  • 目前,只有这种写法可行,因为 ES6 明确规定,Class 内部只有静态方法,没有静态属性。现在有一个提案提供了类的静态属性,写法是在实例属性的前面,加上static关键字。
class MyClass {
  static myStaticProp = 42;
  constructor() {
    console.log(MyClass.myStaticProp); // 42
  }
}

私有方法和私有属性

现有的解决方案

  • 一种做法是在命名上加以区别。
class Widget {
  // 公有方法
  foo (baz) {
    this._bar(baz);
  }
  // 私有方法
  _bar(baz) {
    return this.snaf = baz;
  }
  // ...
}
  • 另一种方法就是索性将私有方法移出模块,因为模块内部的所有方法都是对外可见的。
class Widget {
  foo (baz) {
    bar.call(this, baz);
  }
  // ...
}
function bar(baz) {
  return this.snaf = baz;
}
  • 还有一种方法是利用Symbol值的唯一性,将私有方法的名字命名为一个Symbol值。
const bar = Symbol('bar');
const snaf = Symbol('snaf');
export default class myClass{
  // 公有方法
  foo(baz) {
    this[bar](baz);
  }
  // 私有方法
  [bar](baz) {
    return this[snaf] = baz;
  }
  // ...
};

私有属性的提案

目前,有一个提案,为class加了私有属性。方法是在属性名之前,使用#表示。


new.target 属性

  • new是从构造函数生成实例对象的命令。ES6 为new命令引入了一个new.target属性,该属性一般用在构造函数之中,返回new命令作用于的那个构造函数。如果构造函数不是通过new命令或Reflect.construct()调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。
function Person(name) {
  if (new.target !== undefined) {
    this.name = name;
  } else {
    throw new Error('必须使用 new 命令生成实例');
  }
}
// 另一种写法
function Person(name) {
  if (new.target === Person) {
    this.name = name;
  } else {
    throw new Error('必须使用 new 命令生成实例');
  }
}
var person = new Person('张三'); // 正确
var notAPerson = Person.call(person, '张三');  // 报错

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

  • Class 内部调用new.target,返回当前 Class。
  • 需要注意的是,子类继承父类时,new.target会返回子类。
class Rectangle {
  constructor(length, width) {
    console.log(new.target === Rectangle);
    // ...
  }
}
class Square extends Rectangle {
  constructor(length, width) {
    super(length, width);
  }
}
var obj = new Square(3); // 输出 false
  • 利用这个特点,可以写出不能独立使用、必须继承后才能使用的类。
class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error('本类不能实例化');
    }
  }
}
class Rectangle extends Shape {
  constructor(length, width) {
    super();
    // ...
  }
}
var x = new Shape();  // 报错
var y = new Rectangle(3, 4);  // 正确

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

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


Class 的继承

简介

  • Class 可以通过extends关键字实现继承
  • super关键字,它在这里表示父类的构造函数,用来新建父类的this对象。
  • 子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。
class Point { /* ... */ }
class ColorPoint extends Point {
  constructor() {
  }
}
let cp = new ColorPoint(); // ReferenceError
  • ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this
  • 如果子类没有定义constructor方法,这个方法会被默认添加,代码如下。也就是说,不管有没有显式定义,任何一个子类都有constructor方法。
class ColorPoint extends Point {
}
// 等同于
class ColorPoint extends Point {
  constructor(...args) {
    super(...args);
  }
}
  • 另一个需要注意的地方是,在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,基于父类实例,只有super方法才能调用父类实例。
let cp = new ColorPoint(25, 8, 'green');
cp instanceof ColorPoint // true
cp instanceof Point // true
  • 最后,父类的静态方法,也会被子类继承。
class A {
  static hello() {
    console.log('hello world');
  }
}
class B extends A {
}
B.hello()  // hello world

Object.getPrototypeOf()

  • Object.getPrototypeOf方法可以用来从子类上获取父类。
Object.getPrototypeOf(ColorPoint) === Point
// true

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


super 关键字

  • super这个关键字,既可以当作函数使用,也可以当作对象使用。
  • 第一种情况,super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。
class A {}
class B extends A {
  constructor() {
    super();
  }
}
  • 注意,super虽然代表了父类A的构造函数,但是返回的是子类B的实例,即super内部的this指的是B的实例,因此super()在这里相当于A.prototype.constructor.call(this)
  • 作为函数时,super()只能用在子类的构造函数之中,用在其他地方就会报错。
  • 第二种情况,super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。
  • 第二种情况,super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。
class A {
  p() {
    return 2;
  }
}
class B extends A {
  constructor() {
    super();
    console.log(super.p()); // 2
  }
}
let b = new B();

这里需要注意,由于super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的。

class A {
  constructor() {
    this.p = 2;
  }
}
class B extends A {
  get m() {
    return super.p;
  }
}
let b = new B();
b.m // undefined

如果属性定义在父类的原型对象上,super就可以取到。

class A {}
A.prototype.x = 2;
class B extends A {
  constructor() {
    super();
    console.log(super.x) // 2
  }
}
let b = new B();
  • ES6 规定,在子类普通方法中通过super调用父类的方法时,方法内部的this指向当前的子类实例。
  • 由于this指向子类实例,所以如果通过super对某个属性赋值,这时super就是this,赋值的属性会变成子类实例的属性。
class A {
  constructor() {
    this.x = 1;
  }
}
class B extends A {
  constructor() {
    super();
    this.x = 2;
    super.x = 3;
    console.log(super.x); // undefined
    console.log(this.x); // 3
  }
}
let b = new B();

上面代码中,super.x赋值为3,这时等同于对this.x赋值为3。而当读取super.x的时候,读的是A.prototype.x,所以返回undefined。

  • 如果super作为对象,用在静态方法之中,这时super将指向父类,而不是父类的原型对象。
    class Parent {
    static myMethod(msg) {
    console.log(‘static’, msg);
    }
    myMethod(msg) {
    console.log(‘instance’, msg);
    }
    }
    class Child extends Parent {
    static myMethod(msg) {
    super.myMethod(msg);
    }

    myMethod(msg) {
    super.myMethod(msg);
    }
    }

Child.myMethod(1); // static 1

var child = new Child();
child.myMethod(2); // instance 2
另外,在子类的静态方法中通过super调用父类的方法时,方法内部的this指向当前的子类,而不是子类的实例。

class A {
  constructor() {
    this.x = 1;
  }
  static print() {
    console.log(this.x);
  }
}
class B extends A {
  constructor() {
    super();
    this.x = 2;
  }
  static m() {
    super.print();
  }
}
B.x = 3;
B.m() // 3
  • 注意,使用super的时候,必须显式指定是作为函数、还是作为对象使用,否则会报错。
class A {}
class B extends A {
  constructor() {
    super();
    console.log(super); // 报错
  }
}
class A {}
class B extends A {
  constructor() {
    super();
    console.log(super.valueOf() instanceof B); // true
  }
}
let b = new B();

上面代码中,super.valueOf()表明super是一个对象,因此就不会报错。同时,由于super使得this指向B的实例,所以**super.valueOf()**返回的是一个B的实例。

  • 最后,由于对象总是继承其他对象的,所以可以在任意一个对象中,使用super关键字。
var obj = {
  toString() {
    return "MyObject: " + super.toString();
  }
};
obj.toString(); // MyObject: [object Object]

类的 prototype 属性和__proto__属性

  • Class 作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。
    (1)子类的__proto__属性,表示构造函数的继承,总是指向父类。
    (2)子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性。
    这样的结果是因为,类的继承是按照下面的模式实现的。
class A {
}
class B {
}
// B 的实例继承 A 的实例
Object.setPrototypeOf(B.prototype, A.prototype);
// B 继承 A 的静态属性
Object.setPrototypeOf(B, A);
const b = new B();
  • Object.setPrototypeOf方法的实现。
Object.setPrototypeOf = function (obj, proto) {
  obj.__proto__ = proto;
  return obj;
}
  • 这两条继承链,可以这样理解:作为一个对象,子类(B)的原型(__proto__属性)是父类(A);作为一个构造函数,子类(B)的原型对象(prototype属性)是父类的原型对象(prototype属性)的实例。
  • 下面,讨论两种情况。第一种,子类继承Object类。
class A extends Object {
}
A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true

这种情况下,A其实就是构造函数Object的复制,A的实例就是Object的实例。

  • 第二种情况,不存在任何继承。
class A {
}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true

这种情况下,A作为一个基类(即不存在任何继承),就是一个普通函数,所以直接继承Function.prototype。但是,A调用后返回一个空对象(即Object实例),所以A.prototype.__proto__指向构造函数(Object)的prototype属性。

实例的 proto 属性

  • 子类实例的__proto__属性的__proto__属性,指向父类实例的__proto__属性。也就是说,子类的原型的原型,是父类的原型。
var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');
p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true

上面代码中,ColorPoint继承了Point,导致前者原型的原型是后者的原型。

  • 因此,通过子类实例的__proto__.__proto__属性,可以修改父类实例的行为。
p2.__proto__.__proto__.printName = function () {
  console.log('Ha');
};
p1.printName() // "Ha"

原生构造函数的继承

  • 原生构造函数是指语言内置的构造函数,通常用来生成数据结构。ECMAScript 的原生构造函数大致有下面这些。
Boolean()
Number()
String()
Array()
Date()
Function()
RegExp()
Error()
Object()
  • 以前,这些原生构造函数是无法继承的,比如,不能自己定义一个Array的子类。
function MyArray() {
  Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
  constructor: {
    value: MyArray,
    writable: true,
    configurable: true,
    enumerable: true
  }
});

上面代码定义了一个继承 Array 的MyArray类。但是,这个类的行为与Array完全不一致。

var colors = new MyArray();
colors[0] = "red";
colors.length  // 0
colors.length = 0;
colors[0]  // "red"
  • 之所以会发生这种情况,是因为子类无法获得原生构造函数的内部属性,通过Array.apply()或者分配给原型对象都不行。原生构造函数会忽略apply方法传入的this,也就是说,原生构造函数的this无法绑定,导致拿不到内部属性。
  • ES5 是先新建子类的实例对象this,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。比如,Array构造函数有一个内部属性[[DefineOwnProperty]],用来定义新属性时,更新length属性,这个内部属性无法在子类获取,导致子类的length属性行为不正常。
  • ES6 允许继承原生构造函数定义子类,因为 ES6 是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可以继承。下面是一个继承Array的例子。
class MyArray extends Array {
  constructor(...args) {
    super(...args);
  }
}
var arr = new MyArray();
arr[0] = 12;
arr.length // 1
arr.length = 0;
arr[0] // undefined

上面代码定义了一个MyArray类,继承了Array构造函数,因此就可以从MyArray生成数组的实例。这意味着,ES6 可以自定义原生数据结构(比如Array、String等)的子类,这是 ES5 无法做到的。

  • 注意,继承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构造函数会忽略参数。


Mixin 模式的实现

Mixin 指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。它的最简单实现如下。

const a = {
  a: 'a'
};
const b = {
  b: 'b'
};
const c = {...a, ...b}; // {a: 'a', b: 'b'}


Module 的语法

概述

  • ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。
// ES6模块
import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 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)

export 命令

  • 模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
  • 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

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

  • export的写法,除了像上面这样,还有另外一种。
// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export { firstName, lastName, year };
  • 上面代码在export命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在var语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。
  • export命令除了输出变量,还可以输出函数或类(class)。
export function multiply(x, y) {
  return x * y;
};

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

  • 通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
function v1() { ... }
function v2() { ... }
export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

上面代码使用as关键字,重命名了函数v1和v2的对外接口。重命名后,v2可以用不同的名字输出两次。

  • 需要特别注意的是,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};
  • 另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

上面代码输出变量foo,值为bar,500 毫秒之后变成baz

  • 这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。
  • 最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。
function foo() {
  export default 'bar' // SyntaxError
}
foo()

import 命令

  • 使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。
// main.js
import { firstName, lastName, year } from './profile.js';
  • 如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import { lastName as surname } from './profile.js';
  • import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。
  • 但是,如果a是一个对象,改写a的属性是允许的。
import {a} from './xxx.js'
a.foo = 'hello'; // 合法操作

上面代码中,a的属性可以成功改写,并且其他模块也可以读到改写后的值。

  • import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。
  • 注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。
foo();
import { foo } from 'my_module';

上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,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';
}
  • 最后,import语句会执行所加载的模块,因此可以有下面的写法。
import 'lodash';
  • 上面代码仅仅执行lodash模块,但是不输入任何值。
  • 如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

模块的整体加载

  • 除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
  • 注意,模块整体加载所在的那个对象(上例是circle),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。
import * as circle from './circle';
// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};

export default 命令

  • 这时就不需要知道原模块输出的函数名。需要注意的是,这时import命令后面,不使用大括号。
  • export default命令用在非匿名函数前,也是可以的。
// export-default.js
export default function foo() {
  console.log('foo');
}
// 或者写成
function foo() {
  console.log('foo');
}
export default foo;

上面代码中,foo函数的函数名foo,在模块外部是无效的。加载的时候,视同匿名函数加载。

// 第一组
export default function crc32() { // 输出
  // ...
}
import crc32 from 'crc32'; // 输入
// 第二组
export function crc32() { // 输出
  // ...
};
import {crc32} from 'crc32'; // 输入
  • 上面代码的两组写法,第一组是使用export default时,对应的import语句不需要使用大括号;第二组是不使用export default时,对应的import语句需要使用大括号。
  • export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能唯一对应export default命令。
  • 本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。
// modules.js
function add(x, y) {
  return x * y;
}
export {add as default};
// 等同于
// export default add;
// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';
  • 正是因为export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。
// 正确
export var a = 1;
// 正确
var a = 1;
export default a;
// 错误
export default var a = 1;

上面代码中,export default a的含义是将变量a的值赋给变量default。所以,最后一种写法会报错。

  • 同样地,因为export default命令的本质是将后面的值,赋给default变量,所以可以直接将一个值写在export default之后。
// 正确
export default 42;
// 报错
export 42;
  • 如果想在一条import语句中,同时输入默认方法和其他接口,可以写成下面这样。
import _, { each, forEach } from 'lodash';
export default function (obj) {
  // ···
}
export function each(obj, iterator, context) {
  // ···
}
export { each as forEach };
  • 上面代码的最后一行的意思是,暴露出forEach接口,默认指向each接口,即forEacheach指向同一个方法。
    * export default也可以用来输出类。
// MyClass.js
export default class { ... }
// main.js
import MyClass from 'MyClass';
let o = new MyClass();

export 与 import 的复合写法

  • 如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
export { foo, bar } from 'my_module';
// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };
  • 上面代码中,exportimport语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foobar实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foobar
  • 默认接口的写法如下。
export { default } from 'foo';
  • 具名接口改为默认接口的写法如下。
export { es6 as default } from './someModule';
// 等同于
import { es6 } from './someModule';
export default es6;
  • 同样地,默认接口也可以改名为具名接口。
export { default as es6 } from './someModule';
  • ES2020 之前,有一种import语句,没有对应的复合写法。
import * as someIdentifier from "someModule";
  • ES2020补上了这个写法。
export * as ns from "mod";
// 等同于
import * as ns from "mod";
export {ns};

模块的继承


跨模块常量

如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。

// constants.js 模块
export const A = 1;
export const B = 3;
export const C = 4;

import()

简介

  • ES2020提案 引入import()函数,支持动态加载模块。
import(specifier)

上面代码中,import函数的参数specifier,指定所要加载的模块的位置。import命令能够接受什么参数,import()函数就能接受什么参数,两者区别主要是后者为动态加载。

  • import()返回一个 Promise 对象。

适用场合

(1)按需加载。
(2)条件加载
import()可以放在if代码块,根据不同的情况,加载不同的模块。
(3)动态的模块路径
import()允许模块路径动态生成。

import(f())
.then(...);

注意点

  • import()加载模块成功以后,这个模块会作为一个对象,当作then方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。
  • 如果模块有default输出接口,可以用参数直接获得。
import('./myModule.js')
.then(myModule => {
  console.log(myModule.default);
});
  • 上面的代码也可以使用具名输入的形式。
import('./myModule.js')
.then(({default: theDefault}) => {
  console.log(theDefault);
});

如果想同时加载多个模块,可以采用下面的写法。

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});
  • import()也可以用在 async 函数之中。
async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();


Module 的加载实现

浏览器加载

传统方法

  • HTML 网页中,浏览器通过<script>标签加载 JavaScript 脚本。
  • deferasync的区别是:defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

加载规则

  • 浏览器加载 ES6 模块,也使用<script>标签,但是要加入type="module"属性。
  • 浏览器对于带有type="module"<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。
  • 如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行。
  • 一旦使用了async属性,<script type="module">就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块。
  • 对于外部的模块脚本,有几点需要注意。
    1、代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
    2、模块脚本自动采用严格模式,不管有没有声明use strict
    3、模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
    4、模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
    5、同一个模块如果加载多次,将只执行一次。

ES6 模块与 CommonJS 模块的差异

  • 它们有三个重大差异。
    1、CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
    2、CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    3、CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。
  • 第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
  • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
  • export通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

Node.js 的模块加载方法

概述

package.json 的 main 字段

  • package.json文件有两个字段可以指定模块的入口文件:mainexports。比较简单的模块,可以只使用main字段,指定模块加载的入口文件。

package.json 的 exports 字段

  • exports字段的优先级高于main字段。它有多种用法。
    (1)子目录别名
    package.json文件的exports字段可以指定脚本或子目录的别名。
    如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。
    (2)main 的别名
    exports字段的别名如果是.,就代表模块的主入口,优先级高于main字段,并且可以直接简写成exports字段的值。
    (3)条件加载

CommonJS 模块加载 ES6 模块

  • CommonJS 的require()命令不能加载 ES6 模块,会报错,只能使用import()这个方法加载。

ES6 模块加载 CommonJS 模块

  • ES6 模块的import命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。

同时支持两种格式的模块

Node.js 的内置模块

加载路径

  • ES6 模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。import命令和package.json文件的main字段如果省略脚本的后缀名,会报错。

内部变量

  • ES6 模块之中,顶层的this指向undefined;CommonJS 模块的顶层this指向当前模块,这是两者的一个重大差异。
  • 以下这些顶层变量在 ES6 模块之中都是不存在的。
arguments
require
module
exports
__filename
__dirname

循环加载

  • “循环加载”(circular dependency)指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。

CommonJS 模块的加载原理

CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

CommonJS 模块的循环加载

ES6 模块的循环加载



编程风格

块级作用域

(1)let 取代 var

(2)全局常量和线程安全

  • 在let和const之间,建议优先使用const,尤其是在全局环境,不应该设置变量,只应设置常量。

字符串

  • 静态字符串一律使用单引号或反引号,不使用双引号。动态字符串使用反引号。

解构赋值

  • 使用数组成员对变量赋值时,优先使用解构赋值。
  • 函数的参数如果是对象的成员,优先使用解构赋值。
  • 如果函数返回多个值,优先使用对象的解构赋值,而不是数组的解构赋值。这样便于以后添加返回值,以及更改返回值的顺序。

对象

  • 单行定义的对象,最后一个成员不以逗号结尾。多行定义的对象,最后一个成员以逗号结尾。
  • 对象尽量静态化,一旦定义,就不得随意添加新的属性。如果添加属性不可避免,要使用Object.assign方法。
  • 如果对象的属性名是动态的,可以在创造对象的时候,使用属性表达式定义。
  • 对象的属性和方法,尽量采用简洁表达法,这样易于描述和书写。

数组

  • 使用扩展运算符(…)拷贝数组。
  • 使用 Array.from 方法,将类似数组的对象转为数组。

函数

  • 立即执行函数可以写成箭头函数的形式。
  • 那些使用匿名函数当作参数的场合,尽量用箭头函数代替。因为这样更简洁,而且绑定了 this。
  • 箭头函数取代Function.prototype.bind,不应再用 self/_this/that 绑定 this。
  • 简单的、单行的、不会复用的函数,建议采用箭头函数。如果函数体较为复杂,行数较多,还是应该采用传统的函数写法。
  • 所有配置项都应该集中在一个对象,放在最后一个参数,布尔值不可以直接作为参数。
  • 不要在函数体内使用 arguments 变量,使用 rest 运算符(…)代替。因为 rest 运算符显式表明你想要获取参数,而且 arguments 是一个类似数组的对象,而 rest 运算符可以提供一个真正的数组。
  • 使用默认值语法设置函数参数的默认值。

Map 结构

  • 注意区分 Object 和 Map,只有模拟现实世界的实体对象时,才使用 Object。如果只是需要key: value的数据结构,使用 Map 结构。因为 Map 有内建的遍历机制。

Class

  • 总是用 Class,取代需要 prototype 的操作。因为 Class 的写法更简洁,更易于理解。
  • 使用extends实现继承,因为这样更简单,不会有破坏instanceof运算的危险。

模块


ESLint 的使用



读懂 ECMAScript 规格

概述


术语

抽象操作

Record 和 field

[[Notation]]

Completion Record


抽象操作的标准流程

  • 抽象操作的运行流程,一般是下面这样。
    1、Let result be AbstractOp().
    2、If result is an abrupt completion, return result.
    3、Set result to result.[[Value]].
    4、return result.
    上面的第一步调用了抽象操作AbstractOp(),得到result,这是一个 Completion Record。第二步,如果result属于 abrupt completion,就直接返回。如果此处没有返回,表示result属于 normal completion。第三步,将result的值设置为resultCompletionRecord.[[Value]]。第四步,返回result

相等运算符

1、如果x不是正常值(比如抛出一个错误),中断执行。
2、如果y不是正常值,中断执行。
3、如果Type(x)Type(y)相同,执行严格相等运算x === y
4、如果x是null,y是undefined,返回true
5、如果x是undefined,y是null,返回true
6、如果Type(x)是数值,Type(y)是字符串,返回x == ToNumber(y)的结果。
7、如果Type(x)是字符串,Type(y)是数值,返回ToNumber(x) == y的结果。
8、如果Type(x)是布尔值,返回ToNumber(x) == y的结果。
9、如果Type(y)是布尔值,返回x == ToNumber(y)的结果。
10、如果Type(x)是字符串或数值或Symbol值,Type(y)是对象,返回x == ToPrimitive(y)的结果。
11、如果Type(x)是对象,Type(y)是字符串或数值或Symbol值,返回ToPrimitive(x) == y的结果。
12、返回false

0 == null // false

数组的空位

const a1 = [undefined, undefined, undefined];
const a2 = [, , ,];
a1.length // 3
a2.length // 3
a1[0] // undefined
a2[0] // undefined
a1[0] === a2[0] // true
  • 但是,它们实际上存在重大差异。
0 in a1 // true
0 in a2 // false
a1.hasOwnProperty(0) // true
a2.hasOwnProperty(0) // false
Object.keys(a1) // ["0", "1", "2"]
Object.keys(a2) // []
a1.map(n => 1) // [1, 1, 1]
a2.map(n => 1) // [, , ,]
  • "数组成员可以省略。只要逗号前面没有任何表达式,数组的length属性就会加 1,并且相应增加其后成员的位置索引。被省略的成员不会被定义。如果被省略的成员是数组最后一个成员,则不会导致数组length属性增加。”
    数组的空位会反映在length属性,也就是说空位有自己的位置,但是这个位置的值是未定义,即这个值是不存在的。如果一定要读取,结果就是undefined
  • 这就解释了为什么in运算符、数组的hasOwnProperty方法、Object.keys方法,都取不到空位的属性名。因为这个属性名根本就不存在,规格里面没说要为空位分配属性名(位置索引),只说要为下一个元素的位置索引加 1。

数组的 map 方法

  • 当处理一个数组时,map会跳过空位


异步遍历器

同步遍历器的问题

  • Iterator 协议里面next()方法只能包含同步操作。
  • 目前的解决方法是,将异步操作包装成 Thunk 函数或者 Promise 对象,即next()方法返回值的value属性是一个 Thunk 函数或者 Promise 对象,等待以后返回真正的值,而done属性则还是同步产生的。
function idMaker() {
  let index = 0;
  return {
    next: function() {
      return {
        value: new Promise(resolve => setTimeout(() => resolve(index++), 1000)),
        done: false
      };
    }
  };
}
const it = idMaker();
it.next().value.then(o => console.log(o)) // 1
it.next().value.then(o => console.log(o)) // 2

异步遍历的接口

  • 异步遍历器的最大的语法特点,就是调用遍历器的next方法,返回的是一个 Promise 对象。
asyncIterator
  .next()
  .then(
    ({ value, done }) => /* ... */
  );
  • 一个对象的同步遍历器的接口,部署在Symbol.iterator属性上面。同样地,对象的异步遍历器接口,部署在Symbol.asyncIterator属性上面。不管是什么样的对象,只要它的Symbol.asyncIterator属性有值,就表示应该对它进行异步遍历。
  • 注意,异步遍历器的next方法是可以连续调用的,不必等到上一步产生的 Promise 对象resolve以后再调用。这种情况下,next方法会累积起来,自动按照每一步的顺序运行下去。

## for await...of * `for...of`循环用于遍历同步的 Iterator 接口。新引入的`for await...of`循环,则是用于遍历异步的 Iterator 接口。 ``` async function f() { for await (const x of createAsyncIterable(['a', 'b'])) { console.log(x); } } // a // b ``` 上面代码中,`createAsyncIterable()`返回一个拥有异步遍历器接口的对象,`for...of`循环自动调用这个对象的异步遍历器的`next`方法,会得到一个 Promise 对象。`await`用来处理这个 Promise 对象,一旦`resolve`,就把得到的值(x)传入`for...of`的循环体。 如果`next`方法返回的 Promise 对象被`reject`,`for await...of`就会报错,要用`try...catch`捕捉。 * 注意,`for await...of`循环也可以用于同步遍历器。

异步 Generator 函数

  • 在语法上,异步 Generator 函数就是async函数与 Generator 函数的结合。
async function* gen() {
  yield 'hello';
}
const genObj = gen();
genObj.next().then(x => console.log(x));
// { value: 'hello', done: false }

上面代码中,gen是一个异步 Generator 函数,执行后返回一个异步 Iterator 对象。对该对象调用next方法,返回一个 Promise 对象。

// 同步 Generator 函数
function* map(iterable, func) {
  const iter = iterable[Symbol.iterator]();
  while (true) {
    const {value, done} = iter.next();
    if (done) break;
    yield func(value);
  }
}
// 异步 Generator 函数
async function* map(iterable, func) {
  const iter = iterable[Symbol.asyncIterator]();
  while (true) {
    const {value, done} = await iter.next();
    if (done) break;
    yield func(value);
  }
}

yield* 语句



ArrayBuffer

  • 简单说,ArrayBuffer对象代表原始的二进制数据,TypedArray视图用来读写简单类型的二进制数据,DataView视图用来读写复杂类型的二进制数据。
  • 注意,二进制数组并不是真正的数组,而是类似数组的对象。
  • 很多浏览器操作的 API,用到了二进制数组操作二进制数据,下面是其中的几个。

Canvas
Fetch API
File API
WebSockets
XMLHttpRequest

ArrayBuffer 对象

概述

  • ArrayBuffer对象代表储存二进制数据的一段内存,它不能直接读写,只能通过视图(TypedArray视图和DataView视图)来读写,视图的作用是以指定格式解读二进制数据。
  • ArrayBuffer也是一个构造函数,可以分配一段可以存放数据的连续内存区域。
const buf = new ArrayBuffer(32);

上面代码生成了一段 32 字节的内存区域,每个字节的值默认都是 0。可以看到,ArrayBuffer构造函数的参数是所需要的内存大小(单位字节)。

  • 为了读写这段内容,需要为它指定视图。DataView视图的创建,需要提供ArrayBuffer对象实例作为参数。
const buf = new ArrayBuffer(32);
const dataView = new DataView(buf);
dataView.getUint8(0) // 0

上面代码对一段 32 字节的内存,建立DataView视图,然后以不带符号的 8 位整数格式,从头读取 8 位二进制数据,结果得到 0,因为原始内存的ArrayBuffer对象,默认所有位都是 0。

  • 另一种TypedArray视图,与DataView视图的一个区别是,它不是一个构造函数,而是一组构造函数,代表不同的数据格式。
const buffer = new ArrayBuffer(12);
const x1 = new Int32Array(buffer);
x1[0] = 1;
const x2 = new Uint8Array(buffer);
x2[0]  = 2;
x1[0] // 2

上面代码对同一段内存,分别建立两种视图:32 位带符号整数(Int32Array构造函数)和 8 位不带符号整数(Uint8Array构造函数)。由于两个视图对应的是同一段内存,一个视图修改底层内存,会影响到另一个视图。

  • TypedArray视图的构造函数,除了接受ArrayBuffer实例作为参数,还可以接受普通数组作为参数,直接分配内存生成底层的ArrayBuffer实例,并同时完成对这段内存的赋值。
const typedArray = new Uint8Array([0,1,2]);
typedArray.length // 3
typedArray[0] = 5;
typedArray // [5, 1, 2]

ArrayBuffer.prototype.byteLength

  • ArrayBuffer实例的byteLength属性,返回所分配的内存区域的字节长度。
const buffer = new ArrayBuffer(32);
buffer.byteLength
// 32
  • 如果要分配的内存区域很大,有可能分配失败(因为没有那么多的连续空余内存),所以有必要检查是否分配成功。
if (buffer.byteLength === n) {
  // 成功
} else {
  // 失败
}

ArrayBuffer.prototype.slice()

  • ArrayBuffer实例有一个slice方法,允许将内存区域的一部分,拷贝生成一个新的ArrayBuffer对象。
const buffer = new ArrayBuffer(8);
const newBuffer = buffer.slice(0, 3);
  • 上面代码拷贝buffer对象的前 3 个字节(从 0 开始,到第 3 个字节前面结束),生成一个新的ArrayBuffer对象。slice方法其实包含两步,第一步是先分配一段新内存,第二步是将原来那个ArrayBuffer对象拷贝过去。
  • 除了slice方法,ArrayBuffer对象不提供任何直接读写内存的方法,只允许在其上方建立视图,然后通过视图读写。

ArrayBuffer.isView()

  • ArrayBuffer有一个静态方法isView,返回一个布尔值,表示参数是否为ArrayBuffer的视图实例。这个方法大致相当于判断参数,是否为TypedArray实例或DataView实例。
const buffer = new ArrayBuffer(8);
ArrayBuffer.isView(buffer) // false
const v = new Int32Array(buffer);
ArrayBuffer.isView(v) // true

TypedArray 视图

概述

  • 目前,TypedArray视图一共包括 9 种类型,每一种视图都是一种构造函数。
  • 普通数组与 TypedArray 数组的差异主要在以下方面。
    1、TypedArray 数组的所有成员,都是同一种类型。
    2、TypedArray 数组的成员是连续的,不会有空位。
    3、TypedArray 数组成员的默认值为 0。比如,new Array(10)返回一个普通数组,里面没有任何成员,只是 10 个空位;new Uint8Array(10)返回一个 TypedArray 数组,里面 10 个成员都是 0。
    4、TypedArray 数组只是一层视图,本身不储存数据,它的数据都储存在底层的ArrayBuffer对象之中,要获取底层对象必须使用buffer属性。

构造函数

(1)TypedArray(buffer, byteOffset=0, length?)

  • 同一个ArrayBuffer对象之上,可以根据不同的数据类型,建立多个视图。
// 创建一个8字节的ArrayBuffer
const b = new ArrayBuffer(8);
// 创建一个指向b的Int32视图,开始于字节0,直到缓冲区的末尾
const v1 = new Int32Array(b);
// 创建一个指向b的Uint8视图,开始于字节2,直到缓冲区的末尾
const v2 = new Uint8Array(b, 2);
// 创建一个指向b的Int16视图,开始于字节2,长度为2
const v3 = new Int16Array(b, 2, 2);
  • 视图的构造函数可以接受三个参数:
    第一个参数(必需):视图对应的底层ArrayBuffer对象。
    第二个参数(可选):视图开始的字节序号,默认从 0 开始。
    第三个参数(可选):视图包含的数据个数,默认直到本段内存区域结束。
  • 注意,byteOffset必须与所要建立的数据类型一致,否则会报错。
const buffer = new ArrayBuffer(8);
const i16 = new Int16Array(buffer, 1);
// Uncaught RangeError: start offset of Int16Array should be a multiple of 2

上面代码中,新生成一个 8 个字节的ArrayBuffer对象,然后在这个对象的第一个字节,建立带符号的 16 位整数视图,结果报错。因为,带符号的 16 位整数需要两个字节,所以byteOffset参数必须能够被 2 整除。
(2)TypedArray(length)

  • 视图还可以不通过ArrayBuffer对象,直接分配内存而生成。
const f64a = new Float64Array(8);
f64a[0] = 10;
f64a[1] = 20;
f64a[2] = f64a[0] + f64a[1];

上面代码生成一个 8 个成员的Float64Array数组(共 64 字节),然后依次对每个成员赋值。这时,视图构造函数的参数就是成员的个数。
(3)TypedArray(typedArray)
TypedArray 数组的构造函数,可以接受另一个TypedArray实例作为参数。

const typedArray = new Int8Array(new Uint8Array(4));

上面代码中,Int8Array构造函数接受一个Uint8Array实例作为参数。

  • 注意,此时生成的新数组,只是复制了参数数组的值,对应的底层内存是不一样的。新数组会开辟一段新的内存储存数据,不会在原数组的内存之上建立视图。
    如果想基于同一段内存,构造不同的视图,可以采用下面的写法。
const x = new Int8Array([1, 1]);
const y = new Int8Array(x.buffer);
x[0] // 1
y[0] // 1
x[0] = 2;
y[0] // 2

(4)TypedArray(arrayLikeObject)
构造函数的参数也可以是一个普通数组,然后直接生成TypedArray实例。

const typedArray = new Uint8Array([1, 2, 3, 4]);
  • 注意,这时TypedArray视图会重新开辟内存,不会在原数组的内存上建立视图。上面代码从一个普通的数组,生成一个 8 位无符号整数的TypedArray实例。
  • TypedArray 数组也可以转换回普通数组。
const normalArray = [...typedArray];
// or
const normalArray = Array.from(typedArray);
// or
const normalArray = Array.prototype.slice.call(typedArray);

数组方法

  • 注意,TypedArray 数组没有concat方法。
  • 另外,TypedArray 数组与普通数组一样,部署了 Iterator 接口,所以可以被遍历。

字节序

  • 字节序指的是数值在内存中的表示方式。
const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
for (let i = 0; i < int32View.length; i++) {
  int32View[i] = i * 2;
}

上面代码生成一个 16 字节的ArrayBuffer对象,然后在它的基础上,建立了一个 32 位整数的视图。由于每个 32 位整数占据 4 个字节,所以一共可以写入 4 个整数,依次为 0,2,4,6。
如果在这段数据上接着建立一个 16 位整数的视图,则可以读出完全不一样的结果。

const int16View = new Int16Array(buffer);
for (let i = 0; i < int16View.length; i++) {
  console.log("Entry " + i + ": " + int16View[i]);
}
// Entry 0: 0
// Entry 1: 0
// Entry 2: 2
// Entry 3: 0
// Entry 4: 4
// Entry 5: 0
// Entry 6: 6
// Entry 7: 0
  • 由于每个 16 位整数占据 2 个字节,所以整个ArrayBuffer对象现在分成 8 段。然后,由于 x86 体系的计算机都采用小端字节序(little endian),相对重要的字节排在后面的内存地址,相对不重要字节排在前面的内存地址,所以就得到了上面的结果。

BYTES_PER_ELEMENT 属性

  • 每一种视图的构造函数,都有一个BYTES_PER_ELEMENT属性,表示这种数据类型占据的字节数。
  • 这个属性在TypedArray实例上也能获取,即TypedArray.prototype.BYTES_PER_ELEMENT

ArrayBuffer 与字符串的互相转换

溢出

TypedArray.prototype.buffer

  • TypedArray实例的buffer属性,返回整段内存区域对应的ArrayBuffer对象。该属性为只读属性。

TypedArray.prototype.byteLength,TypedArray.prototype.byteOffset

  • byteLength属性返回 TypedArray 数组占据的内存长度,单位为字节。byteOffset属性返回 TypedArray 数组从底层ArrayBuffer对象的哪个字节开始。这两个属性都是只读属性。

TypedArray.prototype.length

  • length属性表示 TypedArray 数组含有多少个成员。注意将 length 属性和 byteLength 属性区分,前者是成员长度,后者是字节长度。

TypedArray.prototype.set()

  • TypedArray 数组的set方法用于复制数组(普通数组或 TypedArray 数组),也就是将一段内容完全复制到另一段内存。
const a = new Uint8Array(8);
const b = new Uint8Array(8);
b.set(a);

上面代码复制a数组的内容到b数组,它是整段内存的复制,比一个个拷贝成员的那种复制快得多。

  • set方法还可以接受第二个参数,表示从b对象的哪一个成员开始复制a对象。
const a = new Uint16Array(8);
const b = new Uint16Array(10);
b.set(a, 2)

上面代码的b数组比a数组多两个成员,所以从b[2]开始复制。

TypedArray.prototype.subarray()

  • subarray方法是对于 TypedArray 数组的一部分,再建立一个新的视图。
const a = new Uint16Array(8);
const b = a.subarray(2,3);
a.byteLength // 16
b.byteLength // 2
  • subarray方法的第一个参数是起始的成员序号,第二个参数是结束的成员序号(不含该成员),如果省略则包含剩余的全部成员。所以,上面代码的a.subarray(2,3),意味着 b 只包含a[2]一个成员,字节长度为 2。

TypedArray.prototype.slice()

  • TypeArray 实例的slice方法,可以返回一个指定位置的新的TypedArray实例。
let ui8 = Uint8Array.of(0, 1, 2);
ui8.slice(-1)
// Uint8Array [ 2 ]

上面代码中,ui8是 8 位无符号整数数组视图的一个实例。它的slice方法可以从当前视图之中,返回一个新的视图实例。

  • slice方法的参数,表示原数组的具体位置,开始生成新数组。负值表示逆向的位置,即-1 为倒数第一个位置,-2 表示倒数第二个位置,以此类推。

TypedArray.of()

  • TypedArray 数组的所有构造函数,都有一个静态方法of,用于将参数转为一个TypedArray实例。
Float32Array.of(0.151, -8, 3.7)
// Float32Array [ 0.151, -8, 3.7 ]
  • 下面三种方法都会生成同样一个 TypedArray 数组。
// 方法一
let tarr = new Uint8Array([1,2,3]);
// 方法二
let tarr = Uint8Array.of(1,2,3);
// 方法三
let tarr = new Uint8Array(3);
tarr[0] = 1;
tarr[1] = 2;
tarr[2] = 3;

TypedArray.from()

  • 静态方法from接受一个可遍历的数据结构(比如数组)作为参数,返回一个基于这个结构的TypedArray实例。
  • from方法还可以接受一个函数,作为第二个参数,用来对每个元素进行遍历,功能类似map方法。

复合视图

由于视图的构造函数可以指定起始位置和长度,所以在同一段内存之中,可以依次存放不同类型的数据,这叫做“复合视图”。

const buffer = new ArrayBuffer(24);
const idView = new Uint32Array(buffer, 0, 1);
const usernameView = new Uint8Array(buffer, 4, 16);
const amountDueView = new Float32Array(buffer, 20, 1);

上面代码将一个 24 字节长度的ArrayBuffer对象,分成三个部分:
1、字节 0 到字节 3:1 个 32 位无符号整数
2、字节 4 到字节 19:16 个 8 位整数
3、字节 20 到字节 23:1 个 32 位浮点数


DataView 视图


二进制数组的应用

AJAX

  • 传统上,服务器通过 AJAX 操作只能返回文本数据,即responseType属性默认为text。* * XMLHttpRequest第二版XHR2允许服务器返回二进制数据,这时分成两种情况。如果明确知道返回的二进制数据类型,可以把返回类型(responseType)设为arraybuffer;如果不知道,就设为blob

Canvas

  • Canvas
    网页Canvas元素输出的二进制像素数据,就是 TypedArray 数组。
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const uint8ClampedArray = imageData.data;

WebSocket

  • WebSocket可以通过ArrayBuffer,发送或接收二进制数据。
let socket = new WebSocket('ws://127.0.0.1:8081');
socket.binaryType = 'arraybuffer';
// Wait until socket is open
socket.addEventListener('open', function (event) {
  // Send binary data
  const typedArray = new Uint8Array(4);
  socket.send(typedArray.buffer);
});
// Receive binary data
socket.addEventListener('message', function (event) {
  const arrayBuffer = event.data;
  // ···
});

Fetch API

  • Fetch API 取回的数据,就是ArrayBuffer对象。

File API

如果知道一个文件的二进制数据类型,也可以将这个文件读取为ArrayBuffer对象。


SharedArrayBuffer

  • ES2017 引入SharedArrayBuffer,允许 Worker 线程与主线程共享同一块内存。SharedArrayBuffer的 API 与ArrayBuffer一模一样,唯一的区别是后者无法共享数据。
// 主线程
// 新建 1KB 共享内存
const sharedBuffer = new SharedArrayBuffer(1024);
// 主线程将共享内存的地址发送出去
w.postMessage(sharedBuffer);
// 在共享内存上建立视图,供写入数据
const sharedArray = new Int32Array(sharedBuffer);

Atomics 对象

(1)Atomics.store(),Atomics.load()

  • store()方法用来向共享内存写入数据,load()方法用来从共享内存读出数据。比起直接的读写操作,它们的好处是保证了读写操作的原子性。

(2)Atomics.exchange()

  • Worker 线程如果要写入数据,可以使用上面的Atomics.store()方法,也可以使用Atomics.exchange()方法。它们的区别是,Atomics.store()返回写入的值,而Atomics.exchange()返回被替换的值。

(3)Atomics.wait(),Atomics.wake()

  • 使用while循环等待主线程的通知,不是很高效,如果用在主线程,就会造成卡顿,Atomics对象提供了wait()wake()两个方法用于等待通知。这两个方法相当于锁内存,即在一个线程进行操作时,让其他线程休眠(建立锁),等到操作结束,再唤醒那些休眠的线程(解除锁)。

(4)运算方法
(5)其他方法



最新提案

do 表达式

  • 本质上,块级作用域是一个语句,将多个操作封装在一起,没有返回值。
  • 现在有一个提案,使得块级作用域可以变为表达式,也就是说可以返回值,办法就是在块级作用域之前加上do,使它变为do表达式,然后就会返回内部最后执行的表达式的值。
let x = do {
  let t = f();
  t * t + 1;
};
  • 上面代码中,变量x会得到整个块级作用域的返回值(t * t + 1)
  • do表达式的逻辑非常简单:封装的是什么,就会返回什么。
// 等同于 <表达式>
do { <表达式>; }
// 等同于 <语句>
do { <语句> }
  • do表达式的好处是可以封装多个语句,让程序更加模块化,就像乐高积木那样一块块拼装起来。
  • do块级作用域提供了单独的作用域,内部操作可以与全局作用域隔绝。

throw 表达式

  • JavaScript 语法规定throw是一个命令,用来抛出错误,不能用于表达式之中。
  • 现在有一个提案,允许throw用于表达式。

函数的部分执行

语法

  • 多参数的函数有时需要绑定其中的一个或多个参数,然后返回一个新函数。
function add(x, y) { return x + y; }
function add7(x) { return x + 7; }
  • 上面代码中,add7函数其实是add函数的一个特殊版本,通过将一个参数绑定为7,就可以从add得到add7。
// bind 方法
const add7 = add.bind(null, 7);
// 箭头函数
const add7 = x => add(x, 7);
  • 现在有一个提案,使得绑定参数并返回一个新函数更加容易。这叫做函数的部分执行(partial application)。
const add = (x, y) => x + y;
const addOne = add(1, ?);
const maxGreaterThanZero = Math.max(0, ...);
  • 根据新提案,?是单个参数的占位符,…是多个参数的占位符。?和…只能出现在函数的调用之中,并且会返回一个新函数。

注意点

(1)函数的部分执行是基于原函数的。如果原函数发生变化,部分执行生成的新函数也会立即反映这种变化。

let f = (x, y) => x + y;
const g = f(?, 3);
g(1); // 4
// 替换函数 f
f = (x, y) => x * y;
g(1); // 3

(2)如果预先提供的那个值是一个表达式,那么这个表达式并不会在定义时求值,而是在每次调用时求值。

let a = 3;
const f = (x, y) => x + y;
const g = f(?, a);
g(1); // 4
// 改变 a 的值
a = 10;
g(1); // 11

(3)如果新函数的参数多于占位符的数量,那么多余的参数将被忽略。
(4)…只会被采集一次,如果函数的部分执行使用了多个…,那么每个…的值都将相同。


管道运算符

  • JavaScript 的管道是一个运算符,写作|>。它的左边是一个表达式,右边是一个函数。管道运算符把左边表达式的值,传入右边的函数进行求值。
x |> f
// 等同于
f(x)
  • 管道运算符最大的好处,就是可以把嵌套的函数,写成从左到右的链式表达式。
function doubleSay (str) {
  return str + ", " + str;
}
function capitalize (str) {
  return str[0].toUpperCase() + str.substring(1);
}
function exclaim (str) {
  return str + '!';
}
  • 上面是三个简单的函数。如果要嵌套执行,传统的写法和管道的写法分别如下。
// 传统的写法
exclaim(capitalize(doubleSay('hello')))
// "Hello, hello!"
// 管道的写法
'hello'
  |> doubleSay
  |> capitalize
  |> exclaim
// "Hello, hello!"
  • 管道运算符只能传递一个值,这意味着它右边的函数必须是一个单参数函数。如果是多参数函数,就必须进行柯里化,改成单参数的版本。
  • 管道运算符对于await函数也适用。
x |> await f
// 等同于
await f(x)

数值分隔符


Math.signbit()

  • Math.sign()用来判断一个值的正负,但是如果参数是-0,它会返回-0。这导致对于判断符号位的正负,Math.sign()不是很有用。
  • 目前,有一个提案,引入了Math.signbit()方法判断一个数的符号位是否设置了。
    该方法的算法如下。
    如果参数是NaN,返回false
    如果参数是-0,返回true
    如果参数是负值,返回true
    其他情况返回false

双冒号运算符

  • 函数绑定运算符是并排的两个冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。
foo::bar;
// 等同于
bar.bind(foo);
foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);
  • 如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。
var method = obj::obj.foo;
// 等同于
var method = ::obj.foo;
let log = ::console.log;
// 等同于
var log = console.log.bind(console);
  • 如果双冒号运算符的运算结果,还是一个对象,就可以采用链式写法。

Realm API

  • Realm API 提供沙箱功能(sandbox),允许隔离代码,防止那些被隔离的代码拿到全局对象。

#!命令


import.meta


装饰器

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值