ECMAScript 6 入门 - 《阮一峰 ECMAScript 6 (ES6) 标准入门教程 第三版》 - 书栈网 · BookStackECMAScript 6 入门目录其他 《ECMAScript 6入门》是一本开源的 JavaScript 语言教程,全面介绍 ECMAScript 6 新增的语法特性。https://www.bookstack.cn/read/es6-3rd/sidebar.md
Babel 是一个广泛使用的 ES6 转码器,可以将 ES6 代码转为 ES5 代码,从而在老版本的浏览器执行。这意味着,你可以用 ES6 的方式编写程序,又不用担心现有环境是否支持。
var会变量提升,而let只在命令所在的代码块内有效。需要先声明后使用
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;
// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;
只要块级作用域内存在let命令,它所声明的变量就绑定这个区域,不再受外部的影响。
var tmp = 123;
if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}
//上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp
//导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错
在代码块内,使用let命令声明变量之前,该变量都是不可用的,这在语法上,称为暂时性死区。ES6明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
if (true) {
// TDZ开始
tmp = 'abc'; // ReferenceError
console.log(tmp); // ReferenceError
let tmp; // TDZ结束
console.log(tmp); // undefined
tmp = 123;
console.log(tmp); // 123
}
let不允许在相同作用域内重复声明同一个变量。因此,不能在函数内部重新声明参数。
function func(arg) {
let arg;
}
func() // 报错
//在这个例子中,arg是一个函数参数,因此在函数体内它已经被声明了
//当你尝试再次使用let声明一个同名的变量时,会抛出一个语法错误,因为arg已经被声明过了
function func(arg) {
{
let arg;
}
}
func() // 不报错
//在这个例子中,尽管函数参数arg已被声明,但在新的块级作用域{}内部,可以使用let重新声明一个名为arg的变量
//这个新的arg变量只在这个块级作用域内可见,不会与外部的arg冲突。因此,这个函数不会报错
ES5只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景:
let实际上为JavaScript新增了块级作用域。外层代码块不受内层代码块的影响。允许块级作用域的任意嵌套,每一层都是一个单独的作用域,内层作用域可以定义外层作用域的同名变量,块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。
考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
// 块级作用域内部的函数声明语句,建议不要使用
{
let a = 'secret';
function f() {
return a;
}
}
// 块级作用域内部,优先使用函数表达式
{
let a = 'secret';
let f = function () {
return a;
};
}
ES6的块级作用域必须有大括号,如果没有大括号,JavaScript引擎就认为不存在块级作用域。函数声明也是如此,严格模式下,函数只能声明在当前作用域的顶层。
// 第一种写法,没有大括号,所以不存在块级作用域,而let只能出现在当前作用域的顶层,所以报错。
if (true) let x = 1;
// 第二种写法,有大括号,所以块级作用域成立,不报错。
if (true) {
let x = 1;
}
// 不报错,函数声明必须总是出现在其所在作用域的顶部,或者被包含在一个块级作用域中。
'use strict';
if (true) {
function f() {}
}
// 报错,试图在一个非块级作用域(即if语句后面的单行)中声明一个函数,没有被包含在任何块级作用域内
'use strict';
if (true)
function f() {}
const声明一个只读的常量。一旦声明,常量的值就不能改变,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。对于const来说,只声明不赋值,就会报错。
const foo;
// SyntaxError: Missing initializer in const declaration 只声明不赋值,就会报错
const PI = 3.1415;
PI // 3.1415
PI = 3;
// TypeError: Assignment to constant variable. 改变常量的值会报错
const的作用域与let命令相同,只在声明所在的块级作用域内有效。const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。const声明的常量也与let一样不可重复声明。
const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。
//常量foo储存的是一个地址,这个地址指向一个对象。
const foo = {};
//不可变的只是这个地址,即不能把foo指向另一个地址,
//但对象本身是可变的,所以依然可以为其添加新属性。
foo.prop = 123;// 为 foo 添加一个属性,可以成功
foo.prop // 123
// 将 foo 指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only
const a = []; //常量a是一个数组,这个数组本身是可写的,但如果将另一个数组赋值给a,就会报错。
a.push('Hello'); // 可执行
a.length = 0; // 可执行
a = ['Dave']; // 报错
如果真的想将对象冻结,应使用Object.freeze方法。除了将对象本身冻结,对象的属性也应冻结。
const foo = Object.freeze({});//指向一个冻结对象,所以添加新属性不起作用,严格模式时还会报错。
// 常规模式时,下面一行不起作用;
// 严格模式时,该行会报错
foo.prop = 123;
//下面是一个将对象彻底冻结的函数。
var constantize = (obj) => {
Object.freeze(obj);
Object.keys(obj).forEach( (key, i) => {
if ( typeof obj[key] === 'object' ) {
constantize( obj[key] );
}
});
};
ES6 声明变量的方法有六种,分别是 var、let、const、function、import 和 class。
顶层对象,在浏览器环境指的是window对象,在Node指的是global对象。
ES5中,顶层对象的属性与全局变量是等价的。
ES6一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let、const、class声明的全局变量不属于顶层对象的属性。
var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1
let b = 1;
window.b // undefined
JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。
同一段代码为了能够在各种环境都能取到顶层对象,现在一般是使用this关键字,在不同的环境(例如浏览器和Node.js)以及不同的运行模式下(严格模式和非严格模式),this 的行为可能会有所不同。
ES2020 在语言标准的层面,引入globalThis作为顶层对象。也就是说,任何环境下,globalThis都是存在的,都可以从它拿到顶层对象,指向全局环境下的this。垫片库global-this模拟了这个提案,可以在所有环境拿到globalThis。
在变量声明语句中,解构赋值使用解构语法来指定要提取的属性和变量名。 模式部分和变量部分 通过冒号 : 分隔,冒号 左边是 模式部分,用于指定 要提取的属性的名称。冒号 右边是 变量部分,用于 指定要将提取的值赋给的变量。 真正被赋值的是后者。
ES6允许按照一定模式,从数组和对象中提取值对变量进行赋值,这被称为解构。
let [a, b, c] = [1, 2, 3]; // 可以从数组中提取值,按照对应位置,对变量赋值。
本质上,这种写法属于模式匹配,只要等号两边的模式相同,左边的变量就会被赋予对应的值,
let [foo, [[bar], baz]] = [1, [[2], 3]];
foo // 1
bar // 2
baz // 3
let [ , , third] = ["foo", "bar", "baz"];
third // "baz"
let [x, , y] = [1, 2, 3];
x // 1
y // 3
let [head, ...tail] = [1, 2, 3, 4];
head // 1
tail // [2, 3, 4]
let [x, y, ...z] = ['a'];
x // "a"
y // undefined
z // []
如果解构不成功,变量的值就等于undefined。
let [foo] = []; //解构不成功,foo值为undefined。由于数组是空的,没有元素可以被解构。
let [bar, foo] = [1]; //解构不成功,foo值为undefined。只有一个元素在数组中,而你试图解构两个值。
另一种情况是不完全解构,即等号左边的模式只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。
let [x, y] = [1, 2, 3];
x // 1
y // 2
let [a, [b], d] = [1, [2, 3], 4];
a // 1
b // 2
d // 4
如果等号的右边不是数组(或者严格地说,不是可遍历的结构),那么将会报错。
// 报错。因为等号右边的值要么转为对象以后不具备Iterator接口,要么本身就不具备Iterator接口
let [foo] = 1; // 转为对象以后不具备 Iterator 接口
let [foo] = false; // 转为对象以后不具备 Iterator 接口
let [foo] = NaN; // 转为对象以后不具备 Iterator 接口
let [foo] = undefined; // 转为对象以后不具备 Iterator 接口
let [foo] = null; // 转为对象以后不具备 Iterator 接口
let [foo] = {}; // 本身就不具备 Iterator 接口
let [x, y, z] = new Set(['a', 'b', 'c']); // 对于Set结构,也可以使用数组的解构赋值。
x // "a"
事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。
解构赋值允许指定默认值,ES6内部使用严格相等运算符(===)判断一个位置是否有值。所以,只有当一个数组成员严格等于undefined,默认值才会生效。
let [foo = true] = []; // foo = true
let [x = 1] = [undefined]; // x = 1
let [x, y = 'b'] = ['a']; // x='a', y='b'
let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'
let [x = 1] = [null];
x // null 如果一个数组成员是null,默认值就不会生效,因为null不严格等于undefined
如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候才会求值。
function f() {
console.log('aaa');
}
let [x = f()] = [1]; //因为x能取到值,所以函数f根本不会执行。
//默认值可以引用解构赋值的其他变量,但该变量必须已经声明。
let [x = 1, y = x] = []; // x=1; y=1
let [x = y, y = 1] = []; // ReferenceError: y is not defined
数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名才能取到正确的值。
let { bar, foo } = { foo: 'aaa', bar: 'bbb' };//等号左边的两个变量的次序与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。
foo // "aaa"
bar // "bbb"
let { baz } = { foo: 'aaa', bar: 'bbb' };//变量没有对应的同名属性,导致取不到值,最后等于undefined。
baz // undefined
如果解构失败,变量的值等于undefined。
let {foo} = {bar: 'baz'};
foo // undefined 等号右边的对象没有foo属性
对象的解构赋值可以很方便地将现有对象的方法赋值到某个变量。
// 例一 将Math对象的对数、正弦、余弦三个方法,赋值到对应的变量上
let { log, sin, cos } = Math;
// 例二 从console对象中解构出log方法,console对象是JS的一个内置对象,用于在浏览器控制台输出信息。
const { log } = console;
log('hello') // hello 实际上是在调用console.log('hello')
如果变量名与属性名不一致,必须写成下面这样。
let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"
let obj = { first: 'hello', last: 'world' };
let { first: f, last: l } = obj;
f // 'hello'
l // 'world'
//这实际上说明,对象的解构赋值是下面形式的简写
let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' };
对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; // foo是匹配的模式,baz才是变量。
baz // "aaa" 真正被赋值的是变量baz
foo // error: foo is not defined 而不是模式foo
与数组一样,解构也可以用于嵌套结构的对象。
const node = {
loc: { // 这时loc是模式,不是变量
start: {
line: 1,
column: 5
}
}
};
let { loc, loc: { start }, loc: { start: { line }} } = node; //loc也作为变量赋值
line // 1
loc // Object {start: Object}
start // Object {line: 1, column: 5}
//下面是嵌套赋值的例子
let obj = {};
let arr = [];
({ foo: obj.prop, bar: arr[0] } = { foo: 123, bar: true });
obj // {prop:123}
arr // [true]
如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。
// 报错 左边对象的foo属性对应一个子对象的bar属性,foo这时等于undefined,再取子属性就会报错
let {foo: {bar}} = {baz: 'baz'};
注意,对象的解构赋值可以取到继承的属性。
const obj1 = {};
const obj2 = { foo: 'bar' };
Object.setPrototypeOf(obj1, obj2); //将obj1的原型设置为obj2,obj1现在是一个伪对象,其原型是obj2
const { foo } = obj1; // 从obj1中提取foo属性,obj1本身没有foo属性,沿着原型链查找
foo // "bar"
对象的解构也可以指定默认值。默认值生效的条件是,对象的属性值严格等于undefined。
var {x: y = 3} = {x: 5};
y // 5
var { message: msg = 'Something went wrong' } = {};
msg // "Something went wrong"
var {x = 3} = {x: undefined};
x // 3
var {x = 3} = {x: null};
x // null null与undefined不严格相等
如果要将一个已经声明的变量用于解构赋值,必须非常小心。
// 错误的写法
let x;
{x} = {x: 1}; // SyntaxError: syntax error
//因为JavaScript引擎会将{x}理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免JavaScript将其解释为代码块,才能解决这个问题
// 正确的写法 放在一个圆括号里面就可以正确执行。
let x;
({x} = {x: 1});
解构赋值允许等号左边的模式中不放置任何变量名。因此,可以写出非常古怪的赋值表达式。
({} = [true, false]);
({} = 'abc');
({} = []);
由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
let arr = [1, 2, 3];
let {0 : first, [arr.length - 1] : last} = arr; //提取第一个给first和最后一个元素给last
first // 1
last // 3
字符串也可以解构赋值。因为此时,字符串被转换成了一个类似数组的对象。类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值。
const [a, b, c, d, e] = 'hello';
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"
let {length : len} = 'hello';
len // 5
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
let {toString: s} = 123;//从原始数字中提取 toString 属性
s === Number.prototype.toString // true 数值的包装对象有toString属性,变量s能取到值。
let {toString: s} = true;//从布尔值中提取 toString 属性
s === Boolean.prototype.toString // true 布尔值的包装对象有toString属性,变量s能取到值。
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined和null无法转为对象,所以对它们进行解构赋值,都会报错。
let { prop: x } = undefined; // TypeError
let { prop: y } = null; // TypeError
函数的参数也可以使用解构赋值。
function add([x, y]){ //函数add的参数表面上是一个数组,
return x + y; //但在传入参数的那一刻,数组参数就被解构成变量x和y
}
add([1, 2]); // 3
[[1, 2], [3, 4]].map(([a, b]) => a + b); // [ 3, 7 ]
函数参数的解构也可以使用默认值。
function move({x = 0, y = 0} = {}) { //函数move的参数是一个对象
return [x, y]; //通过对这个对象进行解构,得到变量x和y的值,如果解构失败,x和y等于默认值0
}
move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, 0]
move({}); // [0, 0]
move(); // [0, 0]
在这个函数中,参数{x,y}被解构到一个新的对象中,x和y的默认值都设为0(为x,y设置默认值)。如果函数被调用时没有提供参数或者提供的参数中没有x和y,那么x和y会被赋值为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]
在这个函数中,参数{x,y}被解构到一个新的对象中,并且该对象的默认值是{x:0,y:0}(为参数设置默认值)。但是这里的默认值对象和参数对象的属性名顺序必须一致,否则解构会失败。如果函数被调用时没有提供参数或者提供的参数中没有 x 和 y,那么由于解构失败,x和y的值会是undefined。
undefined会触发函数参数的默认值。
[1, undefined, 3].map((x = 'yes') => x); // [ 1, 'yes', 3 ]
解构赋值虽然很方便,但解析起来并不容易。对于编译器来说,遇到圆括号时,它可能会不确定,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。
在变量声明语句中,解构赋值的模式不能使用圆括号。因为圆括号在变量声明语句中具有特殊意义,它们用于定义函数参数、函数体或条件语句等。因此,ES6的规则是,只要有可能导致解构的歧义,就不得使用圆括号,只要有可能,就不要在模式中放置圆括号。
// 全部报错,以下语句都是变量声明语句,模式不能使用圆括号。
let [(a)] = [1];
let {x: (c)} = {};
let ({x: c}) = {};
let {(x: c)} = {};
let {(x): c} = {};
let { o: ({ p: p }) } = { o: { p: 2 } };
// 全部报错
function f([(z)]) { return z; } // 函数参数也属于变量声明,因此不能带有圆括号
function f([z,(x)]) { return x; } // 函数参数也属于变量声明,因此不能带有圆括号
// 全部报错
({ p: a }) = { p: 42 }; // 将整个模式放在圆括号之中,导致报错
([a]) = [5]; // 将整个模式放在圆括号之中,导致报错
[({ p: a }), { x: c }] = [{}], // 将一部分模式放在圆括号之中,导致报错
可以使用圆括号的情况只有一种:赋值语句的非模式部分可以使用圆括号。
[(b)] = [3]; // 正确,模式是取数组的第一个成员,跟圆括号无关
({ p: (d) } = {}); // 正确,模式是p,而不是d
[(parseInt.prop)] = [3]; // 正确,模式是取数组的第一个成员,跟圆括号无关
let x = 1;
let y = 2;
[x, y] = [y, x]; //交换两个变量的值通常需要一个临时变量,但使用解构赋值可以不需要临时变量
// 返回一个数组
function example() {
return [1, 2, 3];
}
let [a, b, c] = example();
// 返回一个对象
function example() {
return {
foo: 1,
bar: 2
};
}
let { foo, bar } = example();
// 参数是一组有次序的值
function f([x, y, z]) { ... }
f([1, 2, 3]);
// 参数是一组无次序的值
function f({x, y, z}) { ... }
f({z: 3, y: 2, x: 1});
let jsonData = {
id: 42,
status: "OK",
data: [867, 5309]
};
let { id, status, data: number } = jsonData; // 可以快速提取 JSON 数据的值
console.log(id, status, number);
// 42, "OK", [867, 5309]
function greet({ name = 'Alice', message = 'Hello' }) {
console.log(`${name}. ${message}`);
}
greet({ name: 'Bob' }); // Outputs: Bob. Hello
greet({ message: 'Hi' }); // Outputs: Alice. Hi
greet(); // Outputs: Alice. Hello
//指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || 'default foo';这样的语句。
任何部署了 Iterator 接口的对象(Array、String、Map、Set等等),都可以用for...of循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。
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) {
// ...
}
const { SourceMapConsumer, SourceNode } = require("source-map");
ES6加强了对Unicode的支持,并扩展了字符串对象。允许采用\uxxxx形式表示一个字符,其中xxxx表示字符的Unicode码点。但这种表示法只限于码点在\u0000~\uFFFF之间的字符。超出这个范围的字符必须用两个双字节的形式表示。ES6对这一点做出了改进,只要将码点放入大括号就能正确解读该字符,大括号表示法与四字节的UTF-16编码是等价的。
有了这种表示法之后,JavaScript 共有6种方法可以表示一个字符。
'\z' === 'z' // true
'\172' === 'z' // true,在八进制中,\172 对应于十进制的90,也就是字符 Z
'\x7A' === 'z' // true,在十六进制中,\x7A对应于十进制的122,也就是字符 Z
'\u007A' === 'z' // true,\u007A 是一个Unicode转义序列,它表示的是字符 z 的码点
'\u{7A}' === 'z' // true,ES6中新的Unicode转义序列的表示方法,与\u007A一样表示字符z的码点
ES2019允许JavaScript字符串直接输入U+2028(行分隔符)和U+2029(段分隔符)。
const PS = eval("'\u2029'");
注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为JSON本来就不允许直接包含正则表达式。
ES2019 改变了JSON.stringify()的行为。如果遇到0xD800到0xDFFF之间的单个码点或者不存在的配对形式,它会返回转义字符串,留给应用自己决定下一步的处理。
JSON.stringify('\u{D834}') // ""\\uD834""
JSON.stringify('\uDF06\uD834') // ""\\udf06\\ud834""
ES6为字符串添加了遍历器接口,使得字符串可以被for...of循环遍历,遍历字符串时,将自动创建一个字符串遍历器对象。除了遍历字符串,这个遍历器最大的优点是可以识别大于0xFFFF的码点,传统的for循环无法识别这样的码点。
let text = String.fromCodePoint(0x20BB7);//字符串text只有一个字符
for (let i = 0; i < text.length; i++) { //for循环会认为它包含两个字符(都不可打印)
console.log(text[i]);
}
// " "
// " "
for (let i of text) { //for...of循环会正确识别出这一个字符
console.log(i);
}
// ""
传统的JavaScript 语言,输出模板通常需要使用繁琐的拼接写法,ES6引入了模板字符串解决这个问题,它提供了更强大的字符串插值和字符串格式化功能。
模板字符串是增强版的字符串,用反引号(``)标识,它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量、表达式等等。可以在模板字符串中插入任意JavaScript表达式。
`${x} + ${y * 2} = ${x + y * 2}` //放入任意的JavaScript表达式,可以进行运算
`${obj.x + obj.y}` //引用对象属性
`foo ${fn()} bar` //调用函数
`Hello ${'World'}`// "Hello World" 大括号内部是一个字符串,将会原样输出。
//对象被放在大括号中,JavaScript会自动调用该对象的toString()方法。
let obj = {
name: "Alice",
age: 25
};
console.log(`${obj}`); // 输出 "Person(name: Alice, age: 25)"
let name = "Alice";
let age = 30;
// 嵌套模板字符串,用${}语法来嵌入表达式,这些表达式将被求值并替换为相应的值
let message = `${name} is ${age} years old.`;
console.log(message); // 输出: Alice is 30 years old.
let func = (name) => `Hello ${name}!`;
func('Jack') // "Hello Jack!"
首先,我们通过模板字符串创建一个模板:
let template = `
<% for(let i=0; i < data.supplies.length; i++) { %>
- <%= data.supplies[i] %>
<% } %>
`;
//该模板会生成一个含三个项目的无序列表,并插入到ID为box的元素中,列表中的每一项都是从data.supplies数组中取出的。
//在模板字符串中放置了一个常规模板,该模板使用<%...%>放置JS代码,使用<%= ... %>输出JS表达式。
怎么编译这个模板字符串呢?一种思路是将其转换为JavaScript表达式字符串。
echo('');
for(let i=0; i < data.supplies.length; i++) {
echo('- ');
echo(data.supplies[i]);
echo('
');
};
echo('
');
然后,我们需要编写一个模板编译函数,将模板字符串转换成JS代码,以便于动态地生成HTML:
function compile(template) {
//将模板字符串转换为JavaScript表达式,这个转换使用正则表达式就行了--------------------
//找到所有的<%= %>表达式,并替换为`echo(variable)`
const evalExpr = /<%=(.+?)%>/g;
const expr = /<%([\s\S]+?)%>/g;
template = template
.replace(evalExpr, '`); \n echo( $1 ); \n echo(`')
//找到所有的<% %>代码块,并替换为`code`
.replace(expr, '`); \n $1 \n echo(`');
//在最外层添加`echo(`和结尾的`);`,以便于动态地生成HTML
template = 'echo(`' + template + '`);';
//然后,将template封装在一个函数里面返回就可以了-----------------------------------
//将编译后的模板字符串转化为一个函数,该函数接收一个对象作为参数,并返回生成的HTML
let script = `(function parse(data){
let output = "";
function echo(html){
output += html;
} ${ template}
return output;
})`;
return script; //返回将template封装在一个函数里面返回(编译后的模板函数 )
}//这个函数会将模板字符串中的<%= variable %>替换为echo(variable),
//并将<% code %>替换为code。这样做的目的是为了在运行时动态地生成HTML。
最后,我们可以使用编译后的模板函数来生成HTML:
//使用编译后的模板函数来生成HTML,并插入到ID为'box'的元素中
let parse = eval(compile(template));//使用eval执行编译后的模板函数,得到一个函数parse
document.getElementById('box').innerHTML = parse({ supplies: ["broom", "mop", "cleaner"] });//使用parse函数,传入一个对象作为参数,并将返回的HTML设置为ID为'box'的元素的innerHTML
//
// - broom
// - mop
// - cleaner
//
模板字符串可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串,即标签模板功能。标签模板其实不是模板,而是函数调用的一种特殊形式。“标签”指的就是函数,紧跟在后面的模板字符串就是它的参数。它允许我们将一个函数与模板字符串结合起来,以生成和处理动态的模板内容。
alert`hello`
// 等同于
alert(['hello'])
但是,如果模板字符里面有变量,就不是简单的调用了,而是会将模板字符串先处理成多个参数,再调用函数。标签模板的语法是在模板字符串前面加上标识名tag,它是一个函数,整个表达式的返回值,就是tag函数处理模板字符串后的返回值。
let a = 5;
let b = 10;
tag`Hello ${ a + b } world ${ a * b }`;
//tag函数实际上以下面的形式调用
tag(['Hello ', ' world ', ''], 15, 50); //等同于tag`Hello ${15} world ${50}`;
//第一个参数:['Hello ', ' world ', ''];第二个参数: 15;第三个参数:50
函数tag依次会接收到多个参数。第一个参数是一个字符串数组,包含了模板字符串中的所有静态文本,该数组的成员是模板字符串中那些没有变量替换的部分,也就是说,变量替换只发生(插入)在数组的第一个成员与第二个成员之间、第二个成员与第三个成员之间,以此类推。其他参数都是模板字符串各个变量被替换后的值。字符串数组中的每个元素都会被用来拼接成一个完整的字符串,数值则会被用来替换模板字符串中的表达式。
标签模板的一个重要应用,就是过滤HTML字符串,防止用户输入恶意内容,是一种常见的安全措施,用于防止跨站脚本攻击(XSS)。一个标签模板可以定义允许的HTML标签和属性,然后对用户输入的HTML字符串进行解析和过滤,只保留符合模板定义的标签和属性。
标签模板的另一个应用,就是多语言转换(国际化处理)。
i18n`Welcome to ${siteName}, you are visitor number ${visitorNumber}!`
// "欢迎访问xxx,您是第xxxx位访问者!
模板字符串本身并不能取代Mustache之类的模板库,因为没有条件判断和循环处理功能,但是通过标签函数,可以自己添加这些功能。
// 下面的hashTemplate函数是一个自定义的模板处理函数
let libraryHtml = hashTemplate`
#for book in ${myBooks}
- #{book.title} by #{book.author}
#end
`;
可以使用标签模板,在JavaScript语言之中嵌入其他语言的文本或代码。
若要嵌入其他语言的文本,可以使用适当的转义字符来处理特殊字符,用反斜杠(\)进行转义;
若要嵌入其他语言的代码,需要确保该代码在JavaScript中是有效的。
模板处理函数的第一个参数(模板字符串数组),还有一个raw属性,保存的是转义后的原字符串。
console.log`123` //接受的参数实际上是一个数组,它有一个raw属性,保存的是转义后的原字符串
// ["123", raw: Array[1]]
标签模板里可以内嵌其他语言。但是,模板字符串默认会将字符串转义,导致无法嵌入其他语言。总的来说,它并不阻止我们嵌入其他语言的文本或代码,但需要确保正确地处理了任何可能的转义情况。
举例来说,标签模板里面可以嵌入LaTEX语言。
function latex(strings) {
// ...
}
let document = latex`
\newcommand{\fun}{\textbf{Fun!}} // 正常工作
\newcommand{\unicode}{\textbf{Unicode!}} // 报错
\newcommand{\xerxes}{\textbf{King!}} // 报错
Breve over the h goes \u{h}ere // 报错
`
//模板字符串会将\u00FF和\u{42}当作Unicode字符进行转义,所以\unicode解析时报错;
//而\x56会被当作十六进制字符串转义,所以\xerxes会报错。
//也就是说,\u和\x在LaTEX里面有特殊含义,但是JavaScript将它们转义了。
上面代码中,变量document内嵌的模板字符串,对于LaTEX语言来说完全是合法的,但是 JS引擎会报错,原因就在于字符串的转义。
为了解决这个问题,ES2018放松了对标签模板里面的字符串转义的限制。如果遇到不合法的字符串转义,就返回undefined,而不是报错,并且从raw属性上面可以得到原始字符串。
function tag(strs) {
strs[0] === undefined
strs.raw[0] === "\\unicode and \\u{55}";
}
tag`\unicode and \u{55}`
//本应报错,但由于放松了对字符串转义的限制,所以不报错了,JS引擎将第一个字符设置为undefined
//但是raw属性依然可以得到原始字符串,因此tag函数还是可以对原字符串进行处理
注意,这种对字符串转义的放松只在标签模板解析字符串时生效,不是标签模板的场合依然会报错。
let bad = `bad escape sequence: \unicode`; // 报错
ES5提供String.fromCharCode()方法,用于从Unicode码点返回对应字符,但是这个方法不能识别码点大于0xFFFF的字符。会发生溢出,导致最高位被舍弃。
ES6提供了String.fromCodePoint()方法,用于从Unicode码点返回对应字符,可识别大于0xFFFF的字符,弥补了String.fromCharCode()方法的不足。如果String.fromCodePoint方法有多个参数,则它们会被合并成一个字符串返回。
String.fromCodePoint.codePointAt():返回字符串中指定位置的字符的Unicode码点。
fromCodePoint和codePointAt的区别:
String.fromCodePoint(0x20BB7) // 返回 ""
String.fromCodePoint(0x78, 0x1f680, 0x79) === 'x\uD83D\uDE80y' // true
let str = ''; let firstChar = str.codePointAt(0); // 返回 134071
用于创建原始字符串,返回一个斜杠都被转义(即斜杠前面再加一个斜杠)的字符串,往往用于模板字符串的处理方法。这个方法返回一个原始字符串,不会对反斜杠进行转义,这意味着在原始字符串中,反斜杠被视为普通字符,而不是转义字符。可以作为处理模板字符串的基本方法,它会将所有变量替换,并对斜杠进行转义,方便下一步作为字符串来使用。主要用于模板字面量中,当你想要生成原始字符串时。
String.raw`Hi\n${2+3}!` // 实际返回 "Hi\\n5!",显示的是转义后的结果 "Hi\n5!"
String.raw`Hi\u000A!`; // 实际返回 "Hi\\u000A!",显示的是转义后的结果 "Hi\u000A!"
//如果原字符串的斜杠已经转义,那么String.raw()会进行再次转义。
String.raw`Hi\\n` // 返回 "Hi\\\\n"
String.raw`Hi\\n` === "Hi\\\\n" // true
String.raw()本质上是一个正常的函数,只是专用于模板字符串的标签函数。如果写成正常函数的形式,第一个参数应该是一个具有raw属性的对象,且raw属性的值应该是一个数组,对应模板字符串解析后的值。
// `foo${1 + 2}bar`
// 等同于
String.raw({ raw: ['foo', 'bar'] }, 1 + 2) // "foo3bar" 第一个参数是一个对象,它的raw属性等同于原始的模板字符串解析后得到的数组
在JavaScript内部,字符以UTF-16的格式储存,每个字符固定为2个字节。对于那些需要4个字节储存的字符(Unicode码点大于0xFFFF的字符),JavaScript不能正确处理,字符串长度会误判为2,而且charAt()方法无法读取整个字符,charCodeAt()方法只能分别返回前两个字节和后两个字节的值。
ES6提供了codePointAt()方法,能正确处理 4个字节储存的字符,返回一个字符的码点,该方法会正确返回32位的UTF-16字符的码点,对于那些两个字节储存的常规字符,它的返回结果与charCodeAt()方法相同。codePointAt()方法返回的是码点的十进制值,如果想要十六进制的值,可以使用toString()方法转换一下。
let s = 'a';
s.codePointAt(0).toString(16) // "20bb7"
s.codePointAt(2).toString(16) // "61"
//字符a在字符串s的正确位置序号应该是1,但是必须向codePointAt()方法传入 2。因为''占了0和1位。
解决这个问题的一个办法是使用for...of循环,因为它会正确识别32位的UTF-16字符。
let s = 'a';
for (let ch of s) {
console.log(ch.codePointAt(0).toString(16));
}
// 20bb7
// 61
另一种方法是使用扩展运算符(...)进行展开运算。
let arr = [...'a']; // arr.length === 2
arr.forEach(
ch => console.log(ch.codePointAt(0).toString(16))
);
// 20bb7
// 61
codePointAt()方法是测试一个字符由两个字节还是由四个字节组成的最简单方法。
function is32Bit(c) {
return c.codePointAt(0) > 0xFFFF;
}
is32Bit("") // true
is32Bit("a") // false
ES6提供字符串实例normalize()方法,用来将字符的不同表示方法统一为同样的形式,这称为Unicode正规化。
'\u01D1'.normalize() === '\u004F\u030C'.normalize() // true
Unicode定义了四种规范化形式:NFC、NFD、NFKC和NFKD:
'\u004F\u030C'.normalize('NFC').length // 1 NFC参数返回字符的合成形式
'\u004F\u030C'.normalize('NFD').length // 2 NFD参数返回字符的分解形式
normalize方法目前不能识别三个或三个以上字符的合成。这种情况下,还是只能使用正则表达式,通过Unicode编号区间判断。
这三个方法都支持第二个参数,表示开始搜索的位置。使用第二个参数n时,endsWith的行为与其他两个方法有所不同,它针对前n个字符,而其他两个方法针对从第n个位置直到字符串结束。
let s = 'Hello world!';
s.startsWith('world', 6) // true
s.endsWith('Hello', 5) // true
s.includes('Hello', 6) // false
repeat()方法返回一个新字符串,表示将原字符串重复n次。
ES2017引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部或尾部补全。padStart()用于头部补全,padEnd()用于尾部补全。padStart()和padEnd()一共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串。
'xxx'.padStart(2, 'ab') // 'xxx'
'abc'.padStart(10, '0123456789')// '0123456abc'
'x'.padEnd(4) // 'x '
'12'.padStart(10, '0') // "0000000012"
'12'.padStart(10, 'YYYY-MM-DD') // "YYYY-MM-12"
ES2019对字符串实例新增了trimStart()和trimEnd()方法。它们的行为与trim()一致,trimStart()消除字符串头部的空格,trimEnd()消除尾部的空格。它们返回的都是新字符串,不会修改原始字符串。除了空格键,这两个方法对字符串头部(或尾部)的tab键、换行符等不可见的空白符号也有效。浏览器还部署了额外的两个方法,trimLeft()是trimStart()的别名,trimRight()是trimEnd()的别名。
const s = ' abc ';
s.trim() // "abc"
s.trimStart() // "abc "
s.trimEnd() // " abc"
matchAll()方法返回一个正则表达式在当前字符串的所有匹配。它返回一个迭代器,包含了字符串中所有与正则表达式匹配的结果。这个方法主要用于处理字符串中的多行匹配。
const regex = /a/g; //全局匹配 "a" 的正则表达式
const str = 'abacaba';
const matches = str.matchAll(regex);//返回一个迭代器,包含字符串'abacaba'中所有匹配regex的结果
for (const match of matches) {
console.log(match);
}
字符串的实例方法replace()只能替换第一个匹配,如果要替换所有的匹配,不得不使用正则表达式的g修饰符。正则表达式毕竟不是那么方便和直观,ES2021引入了replaceAll()方法,可以一次性替换所有匹配。它的用法与replace()相同,返回一个新字符串,不会改变原字符串。
'aabbcc'.replace(/b/, '_') //不报错
'aabbcc'.replaceAll(/b/, '_') //报错,/b/不带有g修饰符,会导致replaceAll()报错
const str = "apple, banana, cherry";
//match参数表示当前找到的匹配项,使用它,可以为每个匹配项执行自定义操作,然后返回一个替换值。
const newStr = str.replaceAll("banana", (match) => {
return match.toUpperCase();
});
console.log(newStr); // 输出: "apple, BANANA, cherry"
这个替换函数可以接受多个参数。第一个参数是捕捉到的匹配内容,第二个参数捕捉到是组匹配(有多少个组匹配,就有多少个对应的参数)。最后还可以添加两个参数,倒数第二个参数是捕捉到的内容在整个字符串中的位置,最后一个参数是原字符串。
const str = '123abc456';
const regex = /(\d+)([a-z]+)(\d+)/g;
//match:捕捉到的匹配内容(123abc456)。p1:第一个捕获组。p2:第二个捕获组。p3:第三个捕获组。offset:匹配开始的位置。string:原始字符串。
function replacer(match, p1, p2, p3, offset, string) {
return [p1, p2, p3].join(' - '); // 替换为 p1 - p2 - p3 的形式
}
str.replaceAll(regex, replacer) // 123 - abc - 456
at()方法接受一个整数作为参数,返回参数指定位置的字符,支持负索引(即倒数的位置)。如果参数位置超出了字符串范围,at()返回undefined。
const str = 'hello';
str.at(1) // "e"
str.at(-1) // "o"
str.at(6) // "undefined"
RegExp是一个构造函数,用于创建正则表达式对象。正则表达式是一个用于描述字符串模式的特殊对象,通常用于字符串的匹配、查找和替换操作。
var regex = new RegExp('xyz', 'i'); // 等价于 var regex = /xyz/i;
var regex = new RegExp(/xyz/i); // 等价于 var regex = /xyz/i;
new RegExp(/abc/ig, 'i').flags // "i" 原有正则对象的修饰符是ig,它会被第二个参数i覆盖。
ES6之前,正则表达式的方法是定义在全局的String对象上的,而不是在RegExp对象上。字符串对象共有4 个方法,可以使用正则表达式:match()、replace()、search()和split()。这意味着这些方法可以在任何字符串上调用,而不仅仅是正则表达式的结果。
ES6引入了一个新的RegExp对象,并把所有与正则表达式相关的操作都移到了这个对象上。这意味着现在所有的正则表达式方法,包括match(), replace(), search(), 和 split(),都需要在正则表达式对象上调用,而不是在字符串对象上。这种改变有助于提高代码的可读性和可维护性,因为它明确了这些方法是在处理正则表达式,而不是普通的字符串。也使得正则表达式的操作更加一致,因为所有的正则表达式方法现在都定义在同一个对象上。
ES6对正则表达式添加了u修饰符,含义为Unicode 模式,用来正确处理大于\uFFFF的Unicode字符,即会正确处理四个字节的 UTF-16 编码。
var s = '';
/^.$/.test(s) // false 如果不添加u修饰符,正则表达式就会认为字符串为两个字符,匹配失败
/^.$/u.test(s) // true
/\u{61}/.test('a') // false 如果不加u修饰符,正则表达式无法识别\u{61}这种表示法,只会认为这匹配61个连续的u
/\u{61}/u.test('a') // true
/\u{20BB7}/u.test('') // true
/{2}/.test('') // false
/{2}/u.test('') // true
/^\S$/.test('') // false
/^\S$/u.test('') // true //加了u修饰符,它才能正确匹配码点大于0xFFFF的Unicode字符
//利用这一点,可以写出一个正确返回字符串长度的函数
function codePointLength(text) {
var result = text.match(/[\s\S]/gu);
return result ? result.length : 0;
}
var s = '';
s.length // 4
codePointLength(s) // 2
/[a-z]/i.test('\u212A') // false
/[a-z]/iu.test('\u212A') // true 使用u修饰符来明确指定正在处理的是Unicode字符串
/\,/
// /\,/ 在非Unicode字符串中使用\,时,会将其解释为两个独立的字符,而不是一个逗号。
//因此,正则表达式/\,/实际上是匹配两个字符:反斜杠和逗号。应该使用正则表达式/\\,/来匹配逗号。
/\,/u
// 报错 在Unicode字符串中使用u/\,/时,会尝试将其解释为Unicode字符。
//但由于逗号不是一个有效的Unicode字符,所以会报错。
如果要在正则表达式中使用逗号作为特殊字符,请确保在非Unicode字符串中使用双反斜杠进行转义,或者在Unicode字符串中使用有效的Unicode转义序列来表示该字符。
一个布尔属性,它决定了正则表达式是否应将字符视为Unicode字符,表示是否设置了u修饰符。当unicode属性为true时,正则表达式会以Unicode模式进行匹配,将字符视为Unicode码位,而不是它们的字符表示。
const r1 = /hello/;
const r2 = /hello/u;
r1.unicode // false
r2.unicode // true
ES6为正则表达式添加了y修饰符,叫做粘连(sticky)修饰符。 y修饰符的作用与g修饰符类似,也是全局匹配,后一次匹配都从上一次匹配成功的下一个位置开始。不同之处在于,g修饰符只要剩余位置中存在匹配就可,而y修饰符确保匹配必须从剩余的第一个位置开始,这也就是“粘连”的涵义。
var s = 'aaa_aa_a';
var r1 = /a+/g;
var r2 = /a+/y;
// 第一次执行的时候,两者行为相同
r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]
// 每次匹配,都是从剩余字符串的头部开始
r1.exec(s) // ["aa"] //由于g修饰没有位置要求,所以第二次执行会返回结果,
r2.exec(s) // null //y修饰符要求匹配必须从头部开始,所以二次执行返回null。
实际上,y修饰符号隐含了头部匹配的标志^。y修饰符的设计本意,就是让头部匹配的标志^在全局匹配中都有效。
const REGEX = /a/gy;
'aaxa'.replace(REGEX, '-') // '--xa' 最后一个a不是出现在下一次匹配的头部,所以不会被替换
单单一个y修饰符对match方法,只能返回第一个匹配,必须与g修饰符联用才能返回所有匹配。
'a1a2a3'.match(/a\d/y) // ["a1"] 只想返回第一个匹配,不需要使用g修饰符
'a1a2a3'.match(/a\d/gy) // ["a1", "a2", "a3"] 想返回所有匹配,需要与g修饰符一起使用
y修饰符的一个应用是从字符串提取token(词元),y修饰符确保了匹配之间不会有漏掉的字符。
使用粘性修饰符时,正则表达式会尽可能多地匹配字符,直到达到边界条件,这有助于确保在匹配过程中不会漏掉任何字符。
与y修饰符相匹配,ES6的正则实例对象多了sticky属性,表示是否设置了y修饰符。
var r = /hello\d/y;
r.sticky // true
ES6 为正则表达式新增了flags属性,会返回正则表达式的修饰符。
// ES5 的 source 属性
// 返回正则表达式的正文
/abc/ig.source
// "abc"
// ES6 的 flags 属性
// 返回正则表达式的修饰符
/abc/ig.flags
// 'gi'
s修饰符(也称为dotAll模式,即点(dot)代表一切字符)是一个特殊的修饰符,用于更改点(.)字符的行为。默认情况下,点(.)字符匹配除了换行符(\n)之外的任何单个字符。但是,当使用s修饰符时,点(.)字符将可以匹配任意单个字符。
// 使用没有s修饰符的匹配
/foo.bar/.test('foo\nbar') // 输出: false
// 使用s修饰符的匹配
/foo.bar/s.test('foo\nbar') // 输出: true
正则表达式引入了一个dotAll属性,返回一个布尔值,表示该正则表达式是否处在dotAll模式。
/s修饰符和多行修饰符/m不冲突,两者一起使用的情况下,.匹配所有字符,而^和$匹配每一行的行首和行尾。
const re = /foo.bar/s; // 另一种写法 const re = new RegExp('foo.bar', 's');
re.test('foo\nbar') // true
re.dotAll // true
re.flags // 's'
“后行断言”的实现,需要先匹配/(?<=y)x/的x,然后再回到左边,匹配y的部分。这种“先右后左”的执行顺序,与所有其他正则操作相反,导致了一些不符合预期的行为。
/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]
/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]
//没有“后行断言”时,第一个括号是贪婪模式,第二个括号只能捕获一个字符,所以结果是105和3。
//而“后行断言”时,由于执行顺序是从右到左,第二个括号是贪婪模式,第一个括号只能捕获一个字符,所以结果是1和053。
当没有后行断言或否定后行断言时,括号内的模式按照贪婪模式进行匹配。这意味着它会尽可能多地匹配字符。 首先尝试匹配尽可能多的数字作为一个整体,再匹配尽可能多的数字作为第二个分组。
当使用后行断言时,正则表达式会尝试匹配一个模式,但并不消耗字符(也就是说,它只是检查是否存在这样的模式,而不将其包含在匹配结果中)。在这种情况下, 第二个括号内的模式会首先尝试匹配尽可能多的数字。
/(?<=(o)d\1)r/.exec('hodor') // null 如果后行断言的反斜杠引用(\1)放在括号的后面就不会得到匹配结果,必须放在前面才可以,
/(?<=\1d(o))r/.exec('hodor') // ["r", "o"] 因为后行断言是先从左到右扫描,发现匹配以后再回过头,从右到左完成反斜杠引用。
ES2018引入了Unicode属性类,允许使用\p{...}和\P{...}(\P是\p的否定形式)代表一类Unicode字符。\p{...} 用于匹配满足条件的所有Unicode字符,而 \P{...} 是其否定形式,用于匹配不满足条件的Unicode字符。这些Unicode属性类非常有用,因为它们允许你根据字符的Unicode属性(如字母、数字、标点符号等)来进行匹配,而不仅仅是基于字符的码位或字面值。
\p{UnicodePropertyName=UnicodePropertyValue}
\p{UnicodePropertyName}
\p{UnicodePropertyValue}
v修饰符实际上ES202中引入的新特性,被称为“属性转义”修饰符。它的作用是允许在正则表达式中使用属性转义,使得正则表达式能够匹配Unicode属性。它提供两种形式的运算,一种是差集运算(A集合减去B集合),另一种是交集运算(A与B的交集)。
[A--B] // 差集运算(A减去B)
[A&&B] // 交集运算(A与B的交集)
上面两种写法中,A和B要么是字符类(例如[a-z]),要么是Unicode属性类(例如\p{ASCII})。
而且,这种运算支持方括号之中嵌入方括号,即方括号的嵌套。
[A--[0-9]] // 方括号嵌套的例子
这种运算的前提是,正则表达式必须使用新引入的v修饰符。前面说过,Unicode属性类必须搭配u修饰符使用,这个v修饰符等于代替u,使用了它就不必再写u了。
[\p{Decimal_Number}--[0-9]] // 十进制字符去除 ASCII 码的0到9
[\p{Emoji}--\p{ASCII}] // Emoji 字符去除 ASCII 码字符
请注意,v修饰符仅适用于属性转义,不适用于其他正则表达式的语法。同时,如果你想在整个正则表达式中使用Unicode模式,你仍然需要使用u修饰符。
总结来说,v修饰符主要用于属性转义,以匹配特定的Unicode字符或字符集;而u修饰符则是用于整个正则表达式的Unicode模式,允许你匹配任何Unicode字符。
以前,正则表达式使用圆括号进行组匹配。组匹配的一个问题是,每一组的匹配含义不容易看出来,而且只能用数字序号(比如matchObj[1])引用,要是组的顺序变了,引用的时候就必须修改序号。
ES2018引入了具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用。具名组匹配等于为每一组匹配加上了 ID,便于描述匹配的目的。如果组的顺序变了,也不用改变匹配后的处理代码。
const RE_DATE = /(?\d{4})-(?\d{2})-(?\d{2})/;
const matchObj = RE_DATE.exec('1999-12-31');
const year = matchObj.groups.year; // "1999"
const month = matchObj.groups.month; // "12"
const day = matchObj.groups.day; // "31"
如果具名组没有匹配,那么对应的groups对象属性会是undefined。
const RE_OPT_A = /^(?a+)?$/;
const matchObj = RE_OPT_A.exec('');
matchObj.groups.as // undefined 具名组as没有找到匹配,matchObj.groups.as属性值是undefined
'as' in matchObj.groups // true as这个键名在groups是始终存在的
有了具名组匹配以后,可使用解构赋值直接从匹配结果上为变量赋值。字符串替换时,用$<组名>引用具名组。
let {groups: {one, two}} = /^(?.*):(?.*)$/u.exec('foo:bar');
one // foo
two // bar
let re = /(?\d{4})-(?\d{2})-(?\d{2})/u;
'2015-01-02'.replace(re, '$/$/$') //第二个参数是一个字符串,不是正则表达式
// '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}`;
});
const RE_TWICE = /^(?[a-z]+)!\k!\1$/; //这两种引用语法还可以同时使用
RE_TWICE.test('abc!abc!abc') // true
RE_TWICE.test('abc!abc!ab') // false
ES2022新增了d修饰符,这个修饰符可以让exec()、match()的返回结果添加indices属性,在该属性上面可以拿到匹配的开始位置和结束位置。(该属性是一个数组,它的每个成员还是一个数组,包含了匹配结果在原始字符串的开始位置和结束位置。)
const text = 'zabbcdef';
const re = /ab/d; //正则表达式re有d修饰符,result现在就会多出一个indices属性
const result = re.exec(text);
result.index // 1
result.indices // [ [1, 3] ]
//由于这里的re不含组匹配,所以indices数组只有一个成员,表示整个匹配的开始位置是1,结束位置是3。
注意,开始位置包含在匹配结果中,相当于匹配结果的第一个字符的位置。但是,结束位置不包含在匹配结果中,而是匹配结果的下一个字符。比如,上例匹配结果的最后一个字符b的位置,是原始字符串的2号位,那么结束位置3就是下一个字符的位置。
如果正则表达式包含组匹配,那么indices属性对应的数组就会包含多个成员,提供每个组匹配的开始位置和结束位置。
const text = 'zabbcdef';
const re = /ab+(cd(ef))/d;
const result = re.exec(text);
result.indices // [ [1, 8], [4, 8], [6, 8] ]
//上面例子中,正则表达式re包含一个组匹配(cd),那么indices属性数组就有两个成员,
//第一个成员是整个匹配结果(abbcd)的开始位置和结束位置,第二个成员是组匹配(cd)的开始位置和结束位置。
const text = 'zabbcdef';
const re = /ab+(cd(ef))/d;
const result = re.exec(text);
result.indices // [ [1, 8], [4, 8], [6, 8] ]
//上面例子中,正则表达式re包含两个组匹配,所以indices属性数组就有三个成员。
如果正则表达式包含具名组匹配,indices属性数组还会有一个groups属性。该属性是一个对象,可以从该对象获取具名组匹配的开始位置和结束位置。
const text = 'zabbcdef';
const re = /ab+(?cd)/d;
const result = re.exec(text); //exec()方法返回结果的
result.indices.groups // { Z: [ 4, 6 ] } indices.groups属性是一个对象,提供具名组匹配Z的开始位置和结束位置。
如果获取组匹配不成功,indices属性数组的对应成员则为undefined,indices.groups属性对象的对应成员也是undefined。
const text = 'zabbcdef';
const re = /ab+(?ce)?/d;
const result = re.exec(text);
result.indices[1] // undefined //由于组匹配ce不成功,所以indices属性数组
result.indices.groups['Z'] // undefined 和indices.groups属性对象对应的组匹配成员Z都是undefined
以往,如果一个正则表达式在字符串里面有多个匹配,一般使用g修饰符或y修饰符,在循环里面逐一取出。ES2020增加了String.prototype.matchAll()方法,可以一次性取出所有匹配,它返回的是一个遍历器(Iterator),而不是数组。
const string = 'test1test2test3';
const regex = /t(e)(st(\d?))/g;
for (const match of string.matchAll(regex)) {//string.matchAll(regex)返回的是遍历器,可用for...of循环取出
console.log(match);
}
// ["test1", "e", "st1", "1", index: 0, input: "test1test2test3"]
// ["test2", "e", "st2", "2", index: 5, input: "test1test2test3"]
// ["test3", "e", "st3", "3", index: 10, input: "test1test2test3"]
遍历器转为数组是非常简单的,使用...运算符和Array.from()方法就可以了。
// 转为数组的方法一
[...string.matchAll(regex)]
// 转为数组的方法二
Array.from(string.matchAll(regex))
ES6提供了二进制和八进制数值的新的写法,分别用前缀0b(或0B)和0o(或0O)表示。如果要将它们转为十进制,要使用Number()方法。
Number('0b111') // 7
Number('0o10') // 8
ES2021允许JavaScript的数值使用下划线(_)作为分隔符。这个数值分隔符没有指定间隔的位数,可以每三位添加一个分隔符,也可以每一位、每两位、每四位添加一个。小数和科学计数法也可以使用数值分隔符。数值分隔符只是一种书写便利,对JavaScript内部数值的存储和输出并没有影响。
数值分隔符有几个使用注意点:①不能放在数值的最前面(leading)或最后面(trailing)。②不能两个或两个以上的分隔符连在一起。③小数点的前后不能有分隔符。④科学计数法里面,表示指数的e或E前后不能有分隔符。
除了十进制,其他进制的数值也可以使用分隔符。数值分隔符可以按字节顺序分隔数值,这在操作二进制位时,非常有用。注意,分隔符不能紧跟着进制的前缀0b、0B、0o、0O、0x、0X。
Number()、parseInt()、parseFloat()这三个将字符串转成数值的函数,不支持数值分隔符。主要原因是语言的设计者认为,数值分隔符主要是为了编码时书写数值的方便,而不是为了处理外部输入的数据。
12345_00 === 1_234_500 // true
1e10_000 // 科学计数法
0.000_001 // 小数
0b1010_0001_1000_0101 // 二进制
Number.isFinite(25) // true
Number.isFinite("25") // false
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
ES6将全局方法parseInt()和parseFloat(),移植到Number对象上面,行为完全保持不变。这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。
Number.parseInt('12.34') // 12
Number.parseFloat('123.45#') // 123.45
Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true
Number.isInteger()用来判断一个数值是否为整数,如果参数不是数值,返回false。JavaScript内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。如果对数据精度的要求较高,不建议使用Number.isInteger()判断一个数值是否为整数。
//由于JS数值存储为64位双精度格式,数值精度最多可以达到53个二进制位(1个隐藏位与52个有效位)。超过这个限度,第54位及后面的位就会被丢弃,这时可能会误判。
Number.isInteger(3.0000000000000002) // true 精度达到了小数点后16个十进制位,转成二进制位超过了53个二进制位,导致最后的那个2被丢弃了。
Number.isInteger(5E-324) // false
//数值的绝对值
ES6在Number对象上面新增一个极小的常量Number.EPSILON,它表示1与大于1 的最小浮点数之间的差。实际上是JS 能够表示的最小精度,误差若小于这个值,就可以认为已经没有意义了,即不存在误差了。引入的目的在于为浮点数计算,实质是一个可以接受的最小误差范围。
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 - 0.3 // 5.551115123125783e-17
5.551115123125783e-17.toFixed(20) // '0.00000000000000005551'
//Number.EPSILON可以用来设置能够接受的误差范围,如果两个浮点数的差小于这个值,就认为这两个浮点数相等。
function withinErrorMargin (left, right) {
return Math.abs(left - right) < Number.EPSILON * Math.pow(2, 2);
} // 部署一个误差检查函数
0.1 + 0.2 === 0.3 // false
withinErrorMargin(0.1 + 0.2, 0.3) // true
1.1 + 1.3 === 2.4 // false
withinErrorMargin(1.1 + 1.3, 2.4) // true
JS能准确表示的整数范围在-2^53到2^53之间(不含两个端点),超过这个范围,无法精确表示这个值。ES6引入Number.MAX_SAFE_INTEGER和Number.MIN_SAFE_INTEGER这两个常量,用来表示这个范围的上下限。JavaScript 能够精确表示的极限是-2^53+1到2^53-1。
Math.pow(2, 53) //9007199254740992
Math.pow(2, 53) === Math.pow(2, 53) + 1 // true 超出2^53之后,一个数就不精确了
Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1 //true
Number.MAX_SAFE_INTEGER === 9007199254740991 //true
Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER //true
Number.MIN_SAFE_INTEGER === -9007199254740991 //true
Number.isSafeInteger()则是用来判断一个整数是否落在这个范围之内。
Number.isSafeInteger(-Infinity) // false
Number.isSafeInteger(1.2) // false。1.2不是一个整数,而是一个浮点数,返回false
Number.isSafeInteger(Number.MAX_SAFE_INTEGER) // true
在Math对象上新增了17个与数学相关的方法。所有方法都是静态的,只能在Math对象上调用。
Math.trunc(-4.9) // -4
Math.trunc('123.456') // 123
Math.trunc('foo'); // NaN
Math.sign(0) // +0
Math.sign(-0) // -0
Math.sign('foo') // NaN
Math.cbrt('8') // 2
Math.cbrt('hello') // NaN
Math.clz32(0) // 32
Math.clz32(1000) // 22
//左移运算符(<<)与Math.clz32方法直接相关
Math.clz32(1 << 2) // 29
//对于小数,Math.clz32方法只考虑整数部分
Math.clz32(3.9) // 30
//对于空值或其他类型的值,Math.clz32方法会将它们先转为数值,然后再计算
Math.clz32(Infinity) // 32
(0x7fffffff * 0x7fffffff)|0 // 0
Math.imul(0x7fffffff, 0x7fffffff) // 1
Math.fround(1.125) // 1.125 未丢失有效精度
Math.fround(0.3) // 0.30000001192092896 丢失精度
Math.fround({}) // NaN
Math.hypot(3, 4); // 5
ES2016新增了指数运算符(**)。 这个运算符是右结合,而不是常见的左结合。多个指数运算符连用时,是从最右边开始计算的。指数运算符可以与等号结合,形成一个新的赋值运算符(**=)。
2 ** 3 ** 2 // 相当于 2 ** (3 ** 2)
// 512 首先计算的是第二个指数运算符,而不是第一个
let a = 1.5;
a **= 2; // 等同于 a = a * a;
let b = 4;
b **= 3; // 等同于 b = b * b * b;
BigInt只用来表示整数,没有位数的限制,任何位数的整数都可以精确表示。为了与Number类型区别,BigInt 类型的数据必须添加后缀n。
0b1101n // 二进制 可以使用各种进制表示,都要加上后缀n。
42n === 42 // false BigInt 与普通整数是两种值,它们之间并不相等。
typeof 123n // 'bigint' typeof运算符对于 BigInt 类型的数据返回bigint。
-42n // 正确 BigInt 可以使用负号(-),但是不能使用正号(+),因为会与 asm.js 冲突。
+42n // 报错
可以用Boolean()、Number()和String()这三个方法,将BigInt转为布尔值、数值和字符串类型。
数学运算方面,BigInt 类型的+、-、*和**这四个二元运算符,与Number类型的行为一致。除法运算/会舍去小数部分,返回一个整数。
BigInt 对应的布尔值,与 Number 类型一致,即0n会转为false,其他值转为true。
比较运算符(比如>)和相等运算符(==)允许 BigInt 与其他类型的值混合计算,因为这样做不会损失精度。
BigInt 与字符串混合运算时,会先转为字符串,再进行运算。
ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。参数变量是默认声明的,所以不能用let或const再次声明。使用参数默认值时,函数不能有同名参数,参数默认值是惰性求值的。
首先,阅读代码的人可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中彻底拿掉这个参数,也不会导致以前的代码无法运行。
参数默认值可以与解构赋值的默认值结合起来使用。
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
指定了默认值以后,函数的length属性将返回没有指定默认值的参数个数。指定了默认值后,length属性将失真。length的含义是该函数预期传入的参数个数,某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。如果设置了默认值的参数不是尾参数,那么length也不再计入后面的参数了。
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为在不设置参数默认值时,是不会出现的。
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。可以将参数默认值设为undefined,表明这个参数是可以省略的。
ES6引入rest参数(...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。
rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。它就是一个真正的数组,数组特有的方法都可以使用。
注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
函数的length属性不包括 rest 参数。
ES2016规定只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
函数的name属性返回该函数的函数名。
如果将一个匿名函数赋值给一个变量,ES5的name属性会返回空字符串,而ES6的name属性会返回实际的函数名。
如果将一个具名函数赋值给一个变量,ES5和ES6的name属性都返回这个具名函数原本的名字。
Function构造函数返回的函数实例,name属性的值为anonymous。bind返回的函数,name属性值会加上bound前缀。
ES6允许使用“箭头”(=>)定义函数。
如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并用return语句返回。
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
let getTempItem = id => { id: id, name: "Temp" }; // 报错
let getTempItem = id => ({ id: id, name: "Temp" }); // 不报错
//如果箭头函数只有一行语句且不需要返回值,可以采用下面的写法,就不用写大括号了
let fn = () => void doesNotReturn();
箭头函数可以与变量解构结合使用。箭头函数的一个用处是简化回调函数。
const full = ({ first, last }) => first + ' ' + last;
// 等同于
function full(person) {
return person.first + ' ' + person.last;
}
// 正常函数写法
[1,2,3].map(function (x) {
return x * x;
});
// 箭头函数写法
[1,2,3].map(x => x * x);
//下面是 rest 参数与箭头函数结合的例子
const numbers = (...nums) => nums;
numbers(1, 2, 3, 4, 5) // [1,2,3,4,5]
const headAndTail = (head, ...tail) => [head, tail];
headAndTail(1, 2, 3, 4, 5) // [1,[2,3,4,5]]
箭头函数有几个使用注意点:
(1)函数体内的this对象就是定义时所在的对象,而不是使用时所在的对象。this绑定定义时所在的作用域,而不是指向运行时所在的作用域。this对象的指向是可变的,但是在箭头函数中,它是固定的,这种特性很有利于封装回调函数。
(2)不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。
(3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
(4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数。
由于箭头函数使得this从“动态”变成“静态”,下面两个场合不应该使用箭头函数:
(1)第一个场合是定义对象的方法,且该方法内部包括this。
(2)第二个场合是需要动态this的时候,也不应使用箭头函数。
如果函数体很复杂,有许多行,或者函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性。
箭头函数可以嵌套使用:箭头函数内部还可以再使用箭头函数。
尾调用(Tail Call)是函数式编程的一个重要概念,就是指某个函数的最后一步是调用另一个函数。尾调用不一定出现在函数尾部,只要是最后一步操作即可。
function f(x){
return g(x);
}
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧取代外层函数的调用帧就可以了。
尾调用优化,即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。递归非常耗费内存,因为需要同时保存成千上百个调用帧,容易发生栈溢出错误。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生栈溢出错误。ES6中只要使用尾递归,就不会发生栈溢出(或层层递归造成的超时),相对节省内存。
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。函数式编程有一个概念,叫做柯里化,意思是将多参数的函数转换成单参数的形式。
ES6的尾调用优化只在严格模式下开启,正常模式是无效的。这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈:func.arguments返回调用时函数的参数,func.caller返回调用当前函数的那个函数。
ES2017允许函数的最后一个参数有尾逗号(trailing comma)。
toString()方法返回函数代码本身,以前会省略注释和空格。修改后的toString()方法,明确要求返回一模一样的原始代码。
JavaScript语言的try...catch结构,以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。ES2019做出了改变,允许catch语句省略参数。
扩展运算符是三个点(...)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。该运算符主要用于函数调用。提供了将数组或对象展开到其他数组或对象的语法糖。
扩展运算符与正常的函数参数可以结合使用。
扩展运算符后面还可以放置表达式。
如果扩展运算符后面是一个空数组,则不产生任何效果。
注意,只有函数调用时,扩展运算符才可以放在圆括号中,否则会报错。
替代函数的apply方法:扩展运算符可以展开数组,所以不再需要apply方法将数组转为函数的参数了。
Math.max.apply(null, [14, 3, 77]) // ES5 的写法
Math.max(...[14, 3, 77]) // ES6 的写法
Math.max(14, 3, 77); // 等同于
扩展运算符的应用
const a1 = [1, 2];// 写法一
const a2 = [...a1];// 写法二
const [...a2] = a1;
//上面的两种写法,a2都是a1的克隆。
const a1 = [{ foo: 1 }];
const a2 = [{ bar: 2 }];
const a3 = a1.concat(a2);
const a4 = [...a1, ...a2];
a3[0] === a1[0] // true
a4[0] === a1[0] // true
//a3和a4是用两种不同方法合并而成的新数组,但它们的成员都是对原数组成员的引用,这就是浅拷贝
//如果修改了引用指向的值,会同步反映到新数组。不过,这两种方法都是浅拷贝,使用时需要注意
const [...butLast, last] = [1, 2, 3, 4, 5]; // 报错
const [first, ...middle, last] = [1, 2, 3, 4, 5]; // 报错
[...'hello'] // [ "h", "e", "l", "l", "o" ]
'x\uD83D\uDE80y'.length // 4 JavaScript 会将四个字节的 Unicode 字符识别为 2 个字符
[...'x\uD83D\uDE80y'].length // 3 能够正确识别四个字节的 Unicode 字符。
数组的空位是指,数组的某一个位置没有任何值。注意,空位不是undefined,一个位置的值等于undefined,依然是有值的。空位是没有任何值,in运算符可以说明这一点。
0 in [undefined, undefined, undefined] // true ,0 号位置是有值的
0 in [, , ,] // false ,0 号位置没有值
ES6明确将空位转为undefined。
Array.from(['a',,'b']) // [ "a", undefined, "b" ] Array.from会将空位转为undefined
[...['a',,'b']] // [ "a", undefined, "b" ] 扩展运算符(...)也会将空位转为undefined
[,'a','b',,].copyWithin(2,0) // [,"a",,"a"] copyWithin()会连空位一起拷贝
new Array(3).fill('a') // ["a","a","a"] fill()会将空位视为正常的数组位置
let arr = [, ,];
for (let i of arr) { //for...of循环也会遍历空位
console.log(1);
}
// 1
// 1
entries()、keys()、values()、find()和findIndex()会将空位处理成undefined。
Array.prototype.sort() 的排序稳定性
排序稳定性(stable sorting)是排序算法的重要属性,指的是排序关键字相同的项目,排序前后的顺序不变。现在JavaScript各个主要实现的默认排序算法都是稳定的。
用于将两类对象转为真正的数组:类似数组的对象和可遍历的对象(包括ES6新增的数据结构Set 和Map)。
实际应用中,常见的类似数组的对象是DOM操作返回的NodeList集合,以及函数内部的arguments对象。Array.from都可以将它们转为真正的数组。
只要是部署了Iterator接口的数据结构,Array.from都能将其转为数组。
如果参数是一个真正的数组,Array.from会返回一个一模一样的新数组。
值得提醒的是,扩展运算符(...)也可以将某些数据结构转为数组。
扩展运算符背后调用的是遍历器接口(Symbol.iterator),如果一个对象没有部署这个接口,就无法转换。Array.from方法还支持类似数组的对象。所谓类似数组的对象,本质特征只有一点,即必须有length属性。因此,任何有length属性的对象,都可以通过Array.from方法转为数组,而此时扩展运算符就无法转换。
Array.from()的另一个应用是将字符串转为数组,然后返回字符串的长度。因为它能正确处理各种Unicode字符,可以避免 JavaScript 将大于\uFFFF的 Unicode 字符算作两个字符的 bug。
Array.from('hello') // ['h', 'e', 'l', 'l', 'o']
let namesSet = new Set(['a', 'b'])
Array.from(namesSet) // ['a', 'b']
Array.from([1, 2, 3]) // [1, 2, 3]
Array.from({ length: 3 }); // [ undefined, undefined, undefined ]
用于将一组值转换为数组。这个方法的主要目的,是弥补数组构造函数Array()的不足。因为参数个数的不同会导致Array()的行为有差异。
Array() // []
Array(3) // [, , ,] 参数个数只有一个时,实际上是指定数组的长度
Array(3, 11, 8) // [3, 11, 8]
Array.of基本上可以用来替代Array()或new Array(),并且不存在由于参数不同而导致的重载,它的行为非常统一。Array.of总是返回参数值组成的数组。如果没有参数,就返回一个空数组。
Array.of() // []
Array.of(undefined) // [undefined]
Array.of(1) // [1]
Array.of(1, 2) // [1, 2]
在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法会修改当前数组。它接受三个参数,三个参数都应是数值,若不是,会自动转为数值:
[1, 2, 3, 4, 5].copyWithin(0, 3) // [4, 5, 3, 4, 5] 将从3号位直到数组结束的成员(4和5),复制到从0号位开始的位置,结果覆盖了原来的1和2
[1, 2, 3, 4, 5].copyWithin(0, -2, -1) // [4, 2, 3, 4, 5]
使用给定值填充一个数组。用于空数组的初始化非常方便,数组中已有的元素会被全部抹去。
注意,如果填充的类型为对象,那么被赋值的是同一个内存地址的对象,而不是深拷贝对象。
['a', 'b', 'c'].fill(7, 1, 2) // ['a', 7, 'c'] 从1号位开始,向原数组填充7,到2号位之前结束
let arr = new Array(3).fill({name: "Mike"});
arr[0].name = "Ben";
arr // [{name: "Ben"}, {name: "Ben"}, {name: "Ben"}]
用于遍历数组。它们都返回一个遍历器对象,可以用for...of循环进行遍历。如果不使用for...of循环,可以手动调用遍历器对象的next方法进行遍历。
for (let [index, elem] of ['a', 'b'].entries()) {
console.log(index, elem);
}
// 0 "a"
// 1 "b"
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方法类似。Map和Set数据结构有一个has方法,需要注意与includes区分。
数组的成员有时还是数组,Array.prototype.flat()用于将嵌套的数组拉平,变成一维的数组。该方法返回一个新数组,对原数据没有影响。flat()默认只会拉平一层,如果想要拉平多层的嵌套数组,可以将flat()方法的参数写成一个整数,表示想要拉平的层数,默认为1,不管有多少层嵌套,都要转成一维数组,可以用Infinity关键字作为参数。
[1, 2, [3, 4]].flat() // [1, 2, 3, 4] 将子数组的成员取出来,添加在原来的位置
[1, 2, [3, [4, 5]]].flat(2) // [1, 2, 3, 4, 5] 参数为2,表示要拉平两层的嵌套数组
[1, [2, [3]]].flat(Infinity) // [1, 2, 3]
[1, 2, , 4, 5].flat() // [1, 2, 4, 5] 如果原数组有空位,flat()方法会跳过空位
flatMap()方法对原数组的每个成员执行一个函数(相当于执行Array.prototype.map()),然后对返回值组成的数组执行flat()方法。该方法返回一个新数组,不改变原数组。flatMap()只能展开一层数组。参数是一个遍历函数,该函数可以接受三个参数,分别是当前数组成员、当前数组成员的位置(从零开始)、原数组。还可以有第二个参数,用来绑定遍历函数里面的this。
// 相当于 [[2, 4], [3, 6], [4, 8]].flat()
[2, 3, 4].flatMap((x) => [x, x * 2]) // [2, 4, 3, 6, 4, 8]
// 相当于 [[[2]], [[4]], [[6]], [[8]]].flat()
[1, 2, 3, 4].flatMap(x => [[x * 2]]) // [[2], [4], [6], [8]]
arr.flatMap(function callback(currentValue[, index[, array]]) {
// ...
}[, thisArg])
ES6允许在大括号里面直接写入变量和函数作为对象的属性和方法。
注意,简写的对象方法不能用作构造函数,会报错。
let birth = '2000/01/01';
const Person = {
name: '张三',
//等同于birth: birth
birth,
// 等同于hello: function ()...
hello() { console.log('我的名字是', this.name); }
};
//f是一个简写的对象方法,所以obj.f不能当作构造函数使用
const obj = {
f() {
this.foo = 'bar';
}
};
new obj.f() // 报错
JavaScript 定义对象的属性有两种方法:
但是如果使用字面量方式定义对象(使用大括号),在ES5中只能使用方法一(标识符)定义属性。ES6允许字面量定义对象时,用方法二(表达式)作为对象的属性名,即把表达式放在方括号内。
obj.foo = true; // 方法一 直接用标识符作为属性名
obj['a' + 'bc'] = 123; // 方法二 用表达式作为属性名
let propKey = 'foo';
let obj = {
[propKey]: true,
['a' + 'bc']: 123
};
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],这一点要特别小心。
const keyA = {a: 1};
const keyB = {b: 2};
const myObject = {
[keyA]: 'valueA',
[keyB]: 'valueB'
};
myObject // Object {[object Object]: "valueB"}
//[keyA]和[keyB]得到的都是[object Object],所以[keyB]会把[keyA]覆盖掉,而myObject最后只有一个[object Object]属性。
函数的name属性返回函数名。对象方法也是函数,因此也有name属性。
如果对象的方法使用了取值函数(getter)和存值函数(setter),则name属性不是在该方法上面,而是该方法的属性的描述对象的get和set属性上面,返回值是方法名前加上get和set。
有两种特殊情况:bind方法创造的函数,name属性返回bound加上原函数的名字;Function构造函数创造的函数,name属性返回anonymous。
如果对象的方法是一个Symbol值,那么name属性返回的是这个Symbol值的描述。
可枚举性是指一个对象的属性是否可以被枚举,即是否可以通过循环或内置方法(如Object.keys())来获取该对象的所有属性。
对象的每个属性都有一个描述对象,用来控制该属性的行为。Object.getOwnPropertyDescriptor方法可以获取该属性的描述对象。描述对象的enumerable属性称为可枚举性,如果该属性为false,就表示某些操作会忽略当前属性。目前,有四个操作会忽略enumerable为false的属性:
其中,只有for...in会返回继承的属性,其他三个方法都会忽略继承的属性,只处理对象自身的属性。实际上,引入可枚举这个概念的最初目的,就是让某些属性可以规避掉for...in操作,不然所有内部属性和方法都会被遍历到。比如,对象原型的toString方法,以及数组的length属性,就通过可枚举性避免被for...in遍历到。
ES6规定,所有Class的原型的方法都是不可枚举的。总的来说,操作中引入继承的属性会让问题复杂化,大多数时候,我们只关心对象自身的属性。所以尽量不要用for...in循环,而用Object.keys()代替。
ES6 一共有 5 种方法可以遍历对象的属性:
(1)for…in:循环遍历对象自身的和继承的可枚举属性(不含Symbol属性)。
(2)Object.keys(obj):返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含Symbol属性)的键名。
(3)Object.getOwnPropertyNames(obj):返回一个数组,包含对象自身的所有属性(不含Symbol属性,但是包括不可枚举属性)的键名。
(4)Object.getOwnPropertySymbols(obj):返回一个数组,包含对象自身的所有Symbol属性的键名。
(5)Reflect.ownKeys(obj):返回一个数组,包含对象自身的(不含继承的)所有键名,不管键名是Symbol或字符串,也不管是否可枚举。
以上的 5 种方法遍历对象的键名,都遵守同样的属性遍历的次序规则。
指向当前对象的原型对象。注意,super关键字表示原型对象时,只能用在对象的方法之中,用在其他地方都会报错。
对象的解构赋值用于从一个对象取值,相当于将目标对象自身的所有可遍历的、但尚未被读取的属性,分配到指定的对象上面。所有的键和它们的值,都会拷贝到新对象上面。
let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };
x // 1
y // 2
z // { a: 3, b: 4 } 获取等号右边的所有尚未读取的键(a和b),将它们连同值一起拷贝过来
由于解构赋值要求等号右边是一个对象,所以如果等号右边是undefined或null,就会报错,因为它们无法转为对象。
解构赋值必须是最后一个参数,否则会报错。
注意,解构赋值的拷贝是浅拷贝,即如果一个键的值是复合类型的值(数组、对象、函数)、那么解构赋值拷贝的是这个值的引用,而不是这个值的副本。
扩展运算符的解构赋值,不能复制继承自原型对象的属性。
ES6规定,变量声明语句之中,如果使用解构赋值,扩展运算符后面必须是一个变量名,而不能是一个解构赋值表达式。
解构赋值的一个用处,是扩展某个函数的参数,引入其他操作。
对象的扩展运算符(...)用于取出参数对象的所有可遍历属性,拷贝到当前对象之中。
let z = { a: 3, b: 4 };
let n = { ...z };
n // { a: 3, b: 4 }
由于数组是特殊的对象,所以对象的扩展运算符也可以用于数组。如果扩展运算符后面是一个空对象,则没有任何效果。
如果扩展运算符后面不是对象,则会自动将其转为对象。但是如果扩展运算符后面是字符串,它会自动转成一个类似数组的对象,因此返回的不是空对象。
对象的扩展运算符等同于使用Object.assign()方法。
let aClone = { ...a };
// 等同于
let aClone = Object.assign({}, a);
//上面的例子只是拷贝了对象实例的属性,想完整克隆一个对象,还拷贝对象原型的属性,可以采用下面的写法
// 写法一 __proto__属性在非浏览器的环境不一定部署,因此推荐使用写法二和写法三
const clone1 = {
__proto__: Object.getPrototypeOf(obj),
...obj
};
// 写法二
const clone2 = Object.assign(
Object.create(Object.getPrototypeOf(obj)),
obj
);
// 写法三
const clone3 = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
)
扩展运算符可以用于合并两个对象。如果用户自定义的属性放在扩展运算符后面,则扩展运算符内部的同名属性会被覆盖掉。这用来修改现有对象部分的属性就很方便了。
let aWithOverrides = { ...a, x: 1, y: 2 };
// 等同于
let aWithOverrides = { ...a, ...{ x: 1, y: 2 } };
// 等同于
let x = 1, y = 2, aWithOverrides = { ...a, x, y };
// 等同于
let aWithOverrides = Object.assign({}, a, { x: 1, y: 2 });
//上面代码中,a对象的x属性和y属性,拷贝到新对象后会被覆盖掉---------------------------
let newVersion = {
...previousVersion,
name: 'New Name' // Override the name property
}; //newVersion对象自定义了name属性,其他属性全部复制自previousVersion对象
如果把自定义属性放在扩展运算符前面,就变成了设置新对象的默认属性值。与数组的扩展运算符一样,对象的扩展运算符后面可以跟表达式。扩展运算符的参数对象之中,如果有取值函数get,这个函数是会执行的。
let aWithDefaults = { x: 1, y: 2, ...a };
// 等同于
let aWithDefaults = Object.assign({}, { x: 1, y: 2 }, a);
// 等同于
let aWithDefaults = Object.assign({ x: 1, y: 2 }, a);
let a = { //取值函数get在扩展a对象时会自动执行,导致报错
get x() {
throw new Error('not throw yet');
}
}
let aWithXGetter = { ...a }; // 报错
编程实务中,如果读取对象内部的某个属性,往往需要判断一下该对象是否存在。可用?.运算符直接在链式调用时判断左侧的对象是否为null或undefined。如果是就不再往下运算,而是返回undefined。
const firstName = message?.body?.user?.firstName || 'default';
const fooValue = myForm.querySelector('input[name=foo]')?.value
链判断运算符有三种用法:
//下面是判断对象方法是否存在,如果存在就立即执行的例子
iterator.return?.() //iterator.return有定义就会调用该方法,否则直接返回undefined
//对于那些可能没有实现的方法,这个运算符尤其有用。
if (myForm.checkValidity?.() === false) {
// 表单校验失败
return;
}//老式浏览器的表单可能没有checkValidity这个方法,这时?.运算符就会返回undefined,判断语句就变成了undefined === false,所以就会跳过下面的代码
//下面是这个运算符常见的使用形式,以及不使用该运算符时的等价形式
a?.b //等同于 a == null ? undefined : a.b
a?.[x] //等同于 a == null ? undefined : a[x]
a?.b() //等同于 a == null ? undefined : a.b()
a?.() //等同于 a == null ? undefined : a()
使用这个运算符,有几个注意点:
delete a?.b //如果a是undefined或null,会直接返回undefined,而不会进行delete运算。
// 等同于
a == null ? undefined : delete a.b
(a?.b).c // ?.对圆括号外部没有影响,不管a对象是否存在,圆括号后面的.c总是会执行。
// 等价于 一般来说,使用?.运算符的场合不应该使用圆括号。
(a == null ? undefined : a.b).c
// 构造函数
new a?.()
new a?.b()
// 链判断运算符的右侧有模板字符串
a?.`{b}`
a?.b`{c}`
// 链判断运算符的左侧是 super
super?.()
super?.foo
// 链运算符用于赋值运算符左侧
a?.b = c
读取对象属性时,如果某个属性的值是null或undefined,有时候需要为它们指定默认值。可以用Null判断运算符??,它的行为类似||,但是只有运算符左侧的值为null或undefined时,才会返回右侧的值。该运算符的一个目的,就是跟链判断运算符?.配合使用,为null或undefined的值设置默认值。
const animationDuration = response.settings?.animationDuration ?? 300;
//上面代码中,response.settings如果是null或undefined,就会返回默认值300
这个运算符很适合判断函数参数是否赋值。
function Component(props) {
const enable = props.enabled ?? true;
// …
}
??有一个运算优先级问题,它与&&和||的优先级孰高孰低。现在的规则是,如果多个逻辑运算符一起使用,必须用括号表明优先级,否则会报错。
用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。不同之处只有两个:一是+0不等于-0,二是NaN等于自身。
用于对象的合并,将源对象(source)的所有可枚举属性复制到目标对象(target)。Object.assign方法总是拷贝一个属性的值,而不会拷贝它背后的赋值方法或取值方法。
第一个参数是目标对象,后面的参数都是源对象。
注意,①如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。②如果只有一个参数,会直接返回该参数。③如果该参数不是对象,则会先转成对象,然后返回。④由于undefined和null无法转成对象,所以如果它们作为参数,就会报错。
如果非对象参数出现在源对象的位置(即非首参数),那么处理规则有所不同。首先,这些参数都会转成对象,如果无法转成对象,就会跳过。这意味着,如果undefined和null不在首参数,就不会报错。
其他类型的值(即数值、字符串和布尔值)不在首参数也不会报错。但是,除了字符串会以数组形式拷贝入目标对象,其他值都不会产生效果。
只有字符串的包装对象会产生可枚举的实义属性,那些属性则会被拷贝。
Object.assign拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false)。属性名为Symbol值的属性也会被Object.assign拷贝。
(1)浅拷贝:Object.assign方法实行的是浅拷贝,而不是深拷贝。如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用。
(2)同名属性的替换:对于嵌套的对象,遇到同名属性,Object.assign的处理方法是替换,而不是添加。一些函数库提供Object.assign的定制版本(如Lodash的_.defaultsDeep方法),可得到深拷贝的合并。
(3)数组的处理:Object.assign可以用来处理数组,但是会把数组视为对象。
(4)取值函数的处理:只能进行值的复制,如果要复制的值是一个取值函数,那么将求值后再复制。
class Point {
constructor(x, y) {
Object.assign(this, {x, y});
}
}
Object.assign(SomeClass.prototype, {
someMethod(arg1, arg2) {
···
},
anotherMethod() {
···
}
});
// 等同于下面的写法
SomeClass.prototype.someMethod = function (arg1, arg2) {
···
};
SomeClass.prototype.anotherMethod = function () {
···
};
function clone(origin) {
return Object.assign({}, origin);
} //将原始对象拷贝到一个空对象,得到了原始对象的克隆。如果想要保持继承链,可以采用下面的代码
function clone(origin) {
let originProto = Object.getPrototypeOf(origin);
return Object.assign(Object.create(originProto), origin);
}
const merge =
(target, ...sources) => Object.assign(target, ...sources);
//如果希望合并后返回一个新对象,可以改写上面函数,对一个空对象合并。
const merge =
(...sources) => Object.assign({}, ...sources);
const DEFAULTS = {
logLevel: 0,
outputFormat: 'html'
};
function processContent(options) {
options = Object.assign({}, DEFAULTS, options); //DEFAULTS对象是默认值,options对象是用户提供的参数。Object.assign方法将DEFAULTS和options合并成一个新对象,如果两者有同名属性,则options的属性值会覆盖DEFAULTS的属性值
console.log(options);
// ...
} //注意,由于存在浅拷贝的问题,DEFAULTS对象和options对象的所有属性的值,最好都是简单类型,不要指向另一个对象。否则,DEFAULTS对象的该属性很可能不起作用。
返回指定对象所有自身属性(非继承属性)的描述对象。
该方法的引入目的,主要是为了解决Object.assign()无法正确拷贝get属性和set属性的问题。Object.getOwnPropertyDescriptors()方法配合Object.defineProperties()方法就可以实现正确拷贝。
另一个用处是配合Object.create()方法,将对象属性克隆到一个新对象,这属于浅拷贝。
可以实现一个对象继承另一个对象。也可以用来实现 Mixin(混入)模式。
用来读取或设置当前对象的原型对象(prototype)。目前所有浏览器(包括IE11)都部署了这个属性。
// es5 的写法
const obj = {
method: function() { ... }
};
obj.__proto__ = someOtherObj;
// es6 的写法
var obj = Object.create(someOtherObj);
obj.method = function() { ... };
__proto__前后的双下划线,说明它本质上是一个内部属性,而不是一个正式的对外的API,只是由于浏览器广泛支持才被加入了ES6。标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定有部署,而且新的代码最好认为这个属性是不存在的。因此无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用下面的Object.setPrototypeOf()(写操作)、Object.getPrototypeOf()(读操作)、Object.create()(生成操作)代替。
实现上,__proto__调用的是Object.prototype.__proto__。如果一个对象本身部署了__proto__属性,该属性的值就是对象的原型。
Object.getPrototypeOf({ __proto__: null }) // null
作用与__proto__相同,用来设置一个对象的原型对象(prototype),返回参数对象本身。它是ES6正式推荐的设置原型对象的方法。
Object.setPrototypeOf(object, prototype) // 格式
const o = Object.setPrototypeOf({}, null); // 用法
// 该方法等同于下面的函数
function setPrototypeOf(obj, proto) {
obj.__proto__ = proto;
return obj;
}
举个例子
let proto = {};
let obj = { x: 10 };
Object.setPrototypeOf(obj, proto); //将proto对象设为obj对象的原型
proto.y = 20;
proto.z = 40;
//所以从obj对象可以读取proto对象的属性
obj.x // 10
obj.y // 20
obj.z // 40
如果第一个参数不是对象,会自动转为对象。由于返回的还是第一个参数,所以这个操作不会产生任何效果。由于undefined和null无法转为对象,所以如果第一个参数是undefined或null就会报错。
Object.setPrototypeOf(1, {}) === 1 // true
该方法与Object.setPrototypeOf方法配套,用于读取一个对象的原型对象。如果参数不是对象,会被自动转为对象。如果参数是undefined或null,它们无法转为对象,所以会报错。
Object.getPrototypeOf(obj);
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键名。
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值。
会过滤属性名为Symbol值的属性。如果参数是一个字符串,会返回各个字符组成的一个数组。如果参数不是对象,会先将其转为对象。由于数值和布尔值的包装对象都不会为实例添加非继承的属性,所以会返回空数组。
Object.values('foo') // ['f', 'o', 'o'] 字符串会先转成一个类似数组的对象,字符串的每个字符就是该对象的一个属性
Object.values(42) // []
Object.values(true) // []
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值对数组。
基本用途是遍历对象的属性,另一个用处是将对象转为真正的Map结构。除了返回值不一样,该方法的行为与Object.values基本一致。如果原对象的属性名是一个Symbol值,该属性会被忽略。
Object.fromEntries()方法是Object.entries()的逆操作,用于将一个键值对数组转为对象。
该方法的主要目的,是将键值对的数据结构还原为对象,因此特别适合将Map结构转为对象。该方法的一个用处是配合URLSearchParams对象,将查询字符串转为对象。
Object.fromEntries(new URLSearchParams('foo=bar&baz=qux'))
// { foo: "bar", baz: "qux" }
ES6引入了一种新的原始数据类型Symbol,表示独一无二的值。它是JavaScript的第七种数据类型,前六种是undefined、null、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。
Symbol值通过Symbol函数生成。对象的属性名现在可以有两种类型:①一种是原来就有的字符串,②一种就是新增的Symbol类型。凡是属性名属于Symbol 类型的,就都是独一无二的,可以保证不会与其他属性名产生冲突。
注意,Symbol函数前不能使用new命令,否则会报错。因为生成的Symbol是一个原始类型的值,不是对象。就是说,由于Symbol值不是对象,所以不能添加属性,它是一种类似于字符串的数据类型。
Symbol函数可以接受一个字符串作为参数,表示对Symbol实例的描述,主要是为了在控制台显示,或者转为字符串时,比较容易区分。
let s1 = Symbol('foo');
let s2 = Symbol('bar');
s1 // Symbol(foo)
s2 // Symbol(bar)
s1.toString() // "Symbol(foo)"
s2.toString() // "Symbol(bar)"
如果Symbol的参数是一个对象,就会调用该对象的toString方法,将其转为字符串,然后才生成一个Symbol值。
注意,Symbol函数的参数只是表示对当前Symbol值的描述,因此,相同参数的Symbol函数的返回值是不相等的。
// 没有参数的情况
let s1 = Symbol();
let s2 = Symbol();
s1 === s2 // false
// 有参数的情况
let s1 = Symbol('foo');
let s2 = Symbol('foo');
s1 === s2 // false s1和s2都是Symbol函数的返回值,而且参数相同,但是它们是不相等的。
"your symbol is " + sym // TypeError: can't convert symbol to string
Symbol值不能与其他类型的值进行运算,会报错。但是Symbol值可以显式转为字符串,也可以转为布尔值,但是不能转为数值。
ES2019提供了一个实例属性description,直接返回Symbol的描述。
const sym = Symbol('foo'); // 创建Symbol的时候,可以添加一个描述
String(sym) // "Symbol(foo)" 但读取这个描述需要将Symbol显式转为字符串
sym.toString() // "Symbol(foo)" 但读取这个描述需要将Symbol显式转为字符串
sym.description // "foo" 使用实例属性description,直接返回Symbol的描述
由于每一个Symbol值都是不相等的,这意味着Symbol值可以作为标识符,用于对象的属性名,就能保证不会出现同名的属性。这对于一个对象由多个模块构成的情况非常有用,能防止某一个键被不小心改写或覆盖。
let mySymbol = Symbol();
let a = {}; // 第一种写法
a[mySymbol] = 'Hello!';
let a = { // 第二种写法
[mySymbol]: 'Hello!'
};
let a = {}; // 第三种写法
Object.defineProperty(a, mySymbol, { value: 'Hello!' });
a[mySymbol] // "Hello!" 以上写法都得到同样结果
//上面代码通过方括号结构和Object.defineProperty,将对象的属性名指定为一个Symbol值。
注意,Symbol值作为对象属性名时,不能用点运算符。Symbol值作为属性名时,该属性还是公开属性,不是私有属性。
const mySymbol = Symbol();
const a = {};
a.mySymbol = 'Hello!';
a[mySymbol] //undefined。因为点运算符后面总是字符串,所以不会读取mySymbol作为标识名所指代的那个值
a['mySymbol'] //"Hello!"。导致a的属性名实际上是一个字符串,而不是一个Symbol值
同理,在对象的内部,使用Symbol值定义属性时,Symbol值必须放在方括号之中。还可以用于定义一组常量,保证这组常量的值都是不相等的,最大的好处就是其他任何值都不可能有相同的值了。
魔术字符串指的是,在代码中多次出现、与代码形成强耦合的某一个具体的字符串或者数值。风格良好的代码,应该尽量消除魔术字符串,改为由含义清晰的变量代替。
常用的消除魔术字符串的方法,就是把它写成一个变量。
function getArea(shape, options) {
let area = 0;
switch (shape) {
case 'Triangle': // 魔术字符串
area = .5 * options.width * options.height;
break;
}
return area;
}
getArea('Triangle', { width: 100, height: 100 }); // 魔术字符串
//上面代码中,字符串Triangle就是一个魔术字符串。不利于将来的修改和维护-------------------
const shapeType = {
triangle: 'Triangle' //把Triangle写成shapeType对象的triangle属性,消除了强耦合
};
function getArea(shape, options) {
let area = 0;
switch (shape) {
case shapeType.triangle:
area = .5 * options.width * options.height;
break;
}
return area;
}
getArea(shapeType.triangle, { width: 100, height: 100 });
//分析发现shapeType.triangle等于哪个值并不重要,只要确保不会跟其他shapeType属性的值冲突即可
//因此,这里就很适合改用Symbol值。--------------------------------------------------
const shapeType = {
triangle: Symbol()
};
Symbol作为属性名,遍历对象的时候,该属性不会出现在for...in、for...of循环中,也不会被Object.keys()、Object.getOwnPropertyNames()、JSON.stringify()返回。但它也不是私有属性,有一个Object.getOwnPropertySymbols()方法,可以获取指定对象的所有Symbol属性名。该方法返回一个数组,成员是当前对象的所有用作属性名的Symbol值(Symbol键名)。
const obj = {};
let a = Symbol('a');
let b = Symbol('b');
obj[a] = 'Hello';
obj[b] = 'World';
const objectSymbols = Object.getOwnPropertySymbols(obj);
objectSymbols // [Symbol(a), Symbol(b)]
另一个新的API,Reflect.ownKeys()方法可以返回所有类型的键名,包括常规键名和Symbol键名。
由于以Symbol值作为键名,不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有的、但又希望只用于内部的方法。
有时,我们希望重新使用同一个Symbol值,Symbol.for()方法可以做到这一点。它接受一个字符串作为参数,然后搜索有没有以该参数作为名称的Symbol值。如果有,就返回这个Symbol值,否则就新建一个以该字符串为名称的Symbol值,并将其注册到全局。
let s1 = Symbol.for('foo');
let s2 = Symbol.for('foo');
s1 === s2 // true。s1和s2都是Symbol值,但它们都是由同样参数的Symbol.for方法生成的,所以实际上是同一个值
Symbol.for()与Symbol()这两种写法都会生成新的Symbol。它们的区别是,前者会被登记在全局环境中供搜索,后者不会。Symbol.for()不会每次调用就返回一个新的 Symbol类型的值,而是会先检查给定的key是否已经存在,如果不存在才会新建一个值。由于Symbol()写法没有登记机制,所以每次调用都会返回一个不同的值。比如,如果你调用Symbol.for("cat")30 次,每次都会返回同一个Symbol 值,但是调用Symbol("cat")30次,会返回30个不同的Symbol值。
Symbol.keyFor()方法返回一个已登记的Symbol类型值的key。
注意,Symbol.for()为Symbol值登记的名字是全局环境的,不管有没有在全局环境运行。这个全局登记特性可以用在不同的 iframe 或 service worker 中取到同一个值。
Singleton模式指的是调用一个类,任何时候返回的都是同一个实例。
对于Node来说,模块文件可以看成是一个类,为了保证每次执行这个模块文件,返回的都是同一个实例,可以把实例放到顶层对象global,并使用Symbol.for()方法生成键名,那么外部将无法引用这个值,当然也就无法改写。
除了定义自己使用的Symbol值外,ES6还提供了11个内置的Symbol值,指向语言内部使用的方法。
ES6提供了新的数据结构Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。Set本身是一个构造函数,用来生成Set数据结构。
Set函数可以接受一个数组(或者具有iterable接口的其他数据结构)作为参数,用来初始化。
// 例一 接受数组作为参数
const set = new Set([1, 2, 3, 4, 4]);
[...set] // [1, 2, 3, 4]
// 例二 接受数组作为参数
const items = new Set([1, 2, 3, 4, 5, 5, 5, 5]);
items.size // 5
// 例三 接受类似数组的对象作为参数
const set = new Set(document.querySelectorAll('div'));
set.size // 56
// 类似于
const set = new Set();
document
.querySelectorAll('div')
.forEach(div => set.add(div));
set.size // 56
可以用于去除数组重复成员,也可以用于去除字符串里面的重复字符。
[...new Set(array)] // 去除数组的重复成员
[...new Set('ababbc')].join('') // "abc" 去除字符串里面的重复字符
向Set加入值的时候,不会发生类型转换,所以5和"5"是两个不同的值。Set内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(===),主要的区别是向Set加入值时认为NaN等于自身,而精确相等运算符认为NaN不等于自身。在Set内部,两个NaN是相等的,比如向Set实例添加两次NaN,但是只会加入一个。
两个对象总是不相等的。
let set = new Set();
set.add({});
set.size // 1
set.add({});
set.size // 2 由于两个空对象不相等,所以它们被视为两个值
Array.from方法可以将Set结构转为数组。这就提供了去除数组重复成员的另一种方法。
function dedupe(array) {
return Array.from(new Set(array));
}
dedupe([1, 1, 2, 3]) // [1, 2, 3]
需要特别指出的是,Set的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用Set保存一个回调函数列表,调用时就能保证按照添加顺序调用。
(1)keys(),values(),entries()
keys方法、values方法、entries方法返回的都是遍历器对象。由于Set结构没有键名,只有键值(或者说键名和键值是同一个值),所以keys方法和values方法的行为完全一致。
let set = new Set(['red', 'green', 'blue']);
for (let item of set.keys()) {
console.log(item);
}
// red
// green
// blue
for (let item of set.values()) {
console.log(item);
}
// red
// green
// blue
for (let item of set.entries()) {
console.log(item);
}//entries方法返回的遍历器,同时包括键名和键值,所以每次输出一个数组,它的两个成员完全相等
// ["red", "red"]
// ["green", "green"]
// ["blue", "blue"]
Set结构的实例默认可遍历,它的默认遍历器生成函数就是它的values方法。这意味着,可以省略values方法,直接用for...of循环遍历Set。
Set.prototype[Symbol.iterator] === Set.prototype.values // true
let set = new Set(['red', 'green', 'blue']);
for (let x of set) {
console.log(x);
}
// red
// green
// blue
(2)forEach()
Set结构的实例与数组一样,也拥有forEach方法,用于对每个成员执行某种操作,没有返回值。
let set = new Set([1, 4, 9]);
set.forEach((value, key) => console.log(key + ' : ' + value))
// 1 : 1
// 4 : 4
// 9 : 9
forEach方法的参数就是一个处理函数。该函数的参数与数组的forEach一致,依次为键值、键名、集合本身(上例省略了该参数)。注意,Set结构的键名就是键值(两者是同一个值),因此第一个参数与第二个参数的值永远都是一样的。forEach方法还可以有第二个参数,表示绑定处理函数内部的this对象。
(3)遍历的应用
扩展运算符(...)内部使用for...of循环,所以也可以用于Set结构。扩展运算符和Set结构相结合,就可以去除数组的重复成员。
let set = new Set(['red', 'green', 'blue']);
let arr = [...set];// ['red', 'green', 'blue']
let arr = [3, 5, 2, 2, 5, 5];
let unique = [...new Set(arr)]; // [3, 5, 2]
数组的map和filter方法也可以间接用于Set,因此使用Set可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。
let a = new Set([1, 2, 3]);
let b = new Set([4, 3, 2]);
// 并集
let union = new Set([...a, ...b]); // Set {1, 2, 3, 4}
// 交集
let intersect = new Set([...a].filter(x => b.has(x))); // set {2, 3}
// (a 相对于 b 的)差集
let difference = new Set([...a].filter(x => !b.has(x))); // Set {1}
如果想在遍历操作中同步改变原来的Set结构,目前没有直接的方法,但有两种变通方法。
// 直接在遍历操作中改变原来的Set结构的两种方法
// 方法一
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类似,也是不重复的值的集合。但是,它与Set有两个区别。
WeakSet是一个构造函数,可以使用new命令创建WeakSet数据结构。作为构造函数,WeakSet可以接受一个数组或类似数组的对象作为参数。(实际上,任何具有Iterable接口的对象都可以作为WeakSet的参数。)该数组的所有成员,都会自动成为WeakSet实例对象的成员。
const a = [[1, 2], [3, 4]]; //a有两个成员,也都是数组,将a作为WeakSet构造函数的参数
const ws = new WeakSet(a); //WeakSet {[1, 2], [3, 4]}。a的成员会自动成为WeakSet的成员
//注意,是a数组的成员成为WeakSet的成员,而不是a数组本身。这意味着,数组的成员只能是对象。
ES6提供了Map数据结构。它类似于对象,也是键值对的集合,但键的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object结构提供了字符串—值的对应,Map结构提供了值—值的对应,是一种更完善的Hash结构实现。如果需要键值对的数据结构,Map比Object更合适。
const m = new Map();
const o = {p: 'Hello World'};
m.set(o, 'content') //使用Map结构的set方法,将对象o当作m的一个键
m.get(o) // "content" 使用get方法读取这个键
m.has(o) // true
m.delete(o) // true 使用delete方法删除这个键
m.has(o) // false
作为构造函数,Map也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。
const map = new Map([
['name', '张三'],
['title', 'Author']
]); //在新建Map实例时,就指定了两个键name和title
map.size // 2
map.has('name') // true
map.get('name') // "张三"
map.has('title') // true
map.get('title') // "Author"
事实上,不仅仅是数组,任何具有Iterator接口、且每个成员都是一个双元素的数组的数据结构都可以当作Map构造函数的参数。这就是说,Set和Map都可以用来生成新的Map。如果对同一个键多次赋值,后面的值将覆盖前面的值。
const set = new Set([
['foo', 1],
['bar', 2]
]);
const m1 = new Map(set);
m1.get('foo') // 1
const m2 = new Map([['baz', 3]]);
const m3 = new Map(m2);
m3.get('baz') // 3
//分别使用Set对象和Map对象当作Map构造函数的参数,结果都生成了新的Map对象
注意,只有对同一个对象的引用,Map结构才将其视为同一个键。这一点要非常小心。同理,同样的值的两个实例,在Map结构中被视为两个键
const map = new Map();
map.set(['a'], 555);
map.get(['a']) // undefined。如果读取一个未知的键,则返回undefined
//表面是针对同一个键,但实际上是两个不同的数组实例,内存地址不一样,因此get方法无法读取该键,返回undefined。
//变量k1和k2的值是一样的,但是它们在Map结构中被视为两个键
const map = new Map();
const k1 = ['a'];
const k2 = ['a'];
map
.set(k1, 111)
.set(k2, 222);
map.get(k1) // 111
map.get(k2) // 222
Map的键实际上是跟内存地址绑定的,只要内存地址不一样,就视为两个键。解决了同名属性碰撞的问题,我们扩展别人的库时,如果使用对象作为键名,就不用担心自己的属性与原作者的属性同名。
如果Map的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map将其视为一个键,比如0和-0就是一个键,布尔值true和字符串true则是两个不同的键。另外,undefined和null也是两个不同的键。虽然NaN不严格相等于自身,但Map将其视为同一个键。
注意,Map的遍历顺序就是插入顺序。Map结构的默认遍历器接口就是entries方法。
map[Symbol.iterator] === map.entries // true
Map结构转为数组结构,比较快速的方法是使用扩展运算符(...)。
const map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three'],
]);
[...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 map0 = new Map()
.set(1, 'a')
.set(2, 'b')
.set(3, 'c');
const map1 = new Map(
[...map0].filter(([k, v]) => k < 3)
); // 产生 Map 结构 {1 => 'a', 2 => 'b'}
const map2 = new Map(
[...map0].map(([k, v]) => [k * 2, '_' + v])
); // 产生 Map 结构 {2 => '_a', 4 => '_b', 6 => '_c'}
Map 还有一个forEach方法,与数组的forEach方法类似,也可以实现遍历。forEach方法还可以接受第二个参数,用来绑定this。
const reporter = {
report: function(key, value) {
console.log("Key: %s, Value: %s", key, value);
}
};
map.forEach(function(value, key, map) {
this.report(key, value);
}, reporter); //forEach方法的回调函数的this,就指向reporter
(1)Map转为数组:Map转为数组最方便的方法,就是使用扩展运算符(...)。
const myMap = new Map()
.set(true, 7)
.set({foo: 3}, ['abc']);
[...myMap] // [ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]
(2)数组转为 Map:将数组传入Map构造函数,就可以转为Map。
new Map([
[true, 7],
[{foo: 3}, ['abc']]
])
(3)Map 转为对象:如果所有Map的键都是字符串,它可以无损地转为对象。如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。
(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。另一种情况是,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的键名都是字符串
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"]]]' Map的键名有非字符串
(6)JSON 转为 Map:正常情况下,所有键名都是字符串。但是,有一种特殊情况,整个JSON就是一个数组,且每个数组成员本身又是一个有两个成员的数组。这时,它可以一一对应地转为Map。这往往是Map转为数组JSON的逆操作。
function jsonToStrMap(jsonStr) {
return objToStrMap(JSON.parse(jsonStr));
}
jsonToStrMap('{"yes": true, "no": false}') // Map {'yes' => true, 'no' => false}
function jsonToMap(jsonStr) {
return new Map(JSON.parse(jsonStr));
}
jsonToMap('[[true,7],[{"foo":3},["abc"]]]') // Map {true => 7, Object {foo: 3} => ['abc']}
WeakMap结构与Map结构类似,也是用于生成键值对的集合。WeakMap与Map的区别有两点。
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内部的引用依然存在。
Proxy可以理解为一个代理器,用于在目标对象之前架设一层“拦截”,外界对该对象的访问都必须先通过这层拦截。因此,提供了一种机制,可以对外界的访问进行过滤和改写。Proxy对象主要由两个参数构成:目标对象(target)和代理器对象(handler),当对Proxy实例进行操作时,会自动调用handler对象的对应属性,对目标对象的操作进行拦截并改写。它还有一个可选的第三个参数,名为receiver,代表的是实际调用者,也就是最初调用该方法的对象。
Proxy用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种元编程,即对编程语言进行编程。可以理解成,在目标对象之前架设一层拦截,外界对该对象的访问都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy这个词的原意是代理,用在这里表示由它来代理某些操作,可以译为代理器。
ES6原生提供Proxy构造函数,用来生成Proxy实例。
var proxy = new Proxy(target, handler);
Proxy的所有用法都是上面这种形式,不同的只是handler参数的写法。new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。同一个拦截器函数可以设置拦截多个操作。
注意,要使得Proxy起作用,必须针对Proxy实例进行操作,而不是针对目标对象进行操作。
const initData = { value: 1, };
const proxy = new Proxy(initData, {
get: function (target, key) {
//数据依赖收集
console.1og('访问:' , key);
return Reflect.get(target, key);
},
set: function (target, key, value) {
//数据更新
console.log('修改',key);
return Reflect.set(target, key, value);
},
});
//Proxy接受两个参数。
//第一个参数是所要代理的目标对象,即如果没有Proxy的介入,操作原来要访问的就是这个对象;
//第二个参数是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。
如果handler没有设置任何拦截,那就等同于直接通向原对象。
var target = {};
var handler = {};
var proxy = new Proxy(target, handler);
proxy.a = 'b';
target.a // "b" handler是一个空对象,没有任何拦截效果,访问proxy就等同于访问target
//一个技巧是将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对象上读取该属性,导致被拦截
对于可以设置、但没有设置拦截的操作,则直接落在目标对象上,按照原先的方式产生结果。 Proxy支持的拦截操作一共13种。
用于拦截某个属性的读取操作。可以接受三个参数,依次为目标对象target、属性名key和proxy实例本身receiver(严格地说,是操作行为所针对的对象),其中最后一个参数可选,第三个参数总是指向原始的读操作所在的那个对象,一般情况下就是Proxy实例。get方法可以继承。
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,代表原始的读操作所在的那个对象
利用Proxy,可以将读取属性的操作(get)转变为执行某个函数,从而实现属性的链式操作。如果一个属性不可配置且不可写,则Proxy不能修改该属性,否则通过Proxy对象访问该属性会报错。
用来拦截某个属性的赋值操作。可以接受四个参数,依次为目标对象obj、属性名prop、属性值value和Proxy实例本身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的原型对象proxy是一个Proxy实例,设置它的foo属性会触发set方法,receiver就指向原始赋值行为所在的对象myObj
myObj.foo = 'bar'; //设置myObj.foo时,myObj没有foo属性,因此引擎会到myObj的原型链去找foo
myObj.foo === myObj // true
利用set方法可以数据绑定,即每当对象发生变化时,会自动更新DOM。还可以数据绑定,即每当对象发生变化时,会自动更新DOM。注意,如果目标对象自身的某个属性不可写且不可配置,那么set方法将不起作用。严格模式下,set代理如果没有返回true,就会报错。
receiver参数代表的是实际调用者,也就是最初调用该方法的对象。在get和set陷阱中,如果目标属性是getter或setter,那么这个getter或setter内部的this会指向这个receiver。
用于拦截函数的调用、call和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和apply调用),就会被apply方法拦截
proxy.call(null, 5, 6) // 22
proxy.apply(null, [7, 8]) // 30
Reflect.apply(proxy, null, [9, 10]) // 38 直接调用Reflect.apply方法,也会被拦截
用来拦截HasProperty操作,即判断对象是否具有某个属性时,这个方法会生效。典型的操作就是in运算符。可以接受两个参数,分别是目标对象target、需查询的属性名key。可以使用has方法隐藏某些属性,不被in运算符发现。如果某个属性不可配置(或者目标对象不可扩展),则has方法就不得隐藏(即返回false)目标对象的该属性。
var obj = { a: 10 };
Object.preventExtensions(obj); //obj对象禁止扩展
var p = new Proxy(obj, {
has: function(target, prop) {
return false;
}
});
'a' in p // TypeError is thrown。obj对象禁止扩展,结果使用has拦截就会报错
值得注意的是,has方法拦截的是HasProperty操作,而不是HasOwnProperty操作,即has方法不判断一个属性是对象自身的属性,还是继承的属性。 另外,虽然for...in循环也用到了in运算符,但是has拦截对for...in循环不生效。
用于拦截new命令,可以接受三个参数,分别是目标对象target、构造函数的参数对象args、创造实例对象时new命令作用的构造函数newTarget。construct方法返回的必须是一个对象,否则会报错。
var 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
用于拦截delete操作,如果这个方法抛出错误或者返回false,当前属性就无法被delete命令删除。注意,目标对象自身的不可配置的属性不能被deleteProperty方法删除,否则报错。
主要用来拦截Object.defineProperty()操作。注意,如果目标对象不可扩展,则defineProperty()不能增加目标对象上不存在的属性,否则会报错。另外,如果目标对象的某个属性不可写或不可配置,则defineProperty()方法不得改变这两个设置。
主要用来拦截Object.getOwnPropertyDescriptor(),返回一个属性描述对象或者undefined。
主要用来拦截获取对象原型。具体来说,拦截下面这些操作:Object.prototype.__proto__、Object.prototype.isPrototypeOf()、Object.getPrototypeOf()、Reflect.getPrototypeOf()、instanceof。注意,getPrototypeOf()方法的返回值必须是对象或者null,否则报错。另外,如果目标对象不可扩展,getPrototypeOf()方法必须返回目标对象的原型对象。
用来拦截Object.isExtensible()操作。注意,该方法只能返回布尔值,否则返回值会被自动转为布尔值。该方法有一个强限制,它的返回值必须与目标对象的isExtensible属性保持一致,否则会抛出错误。
用来拦截对象自身属性的读取操作。具体来说,拦截以下操作:
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()、Object.keys()、for...in循环。注意,使用Object.keys()方法时,有三类属性会被ownKeys()方法自动过滤,不会返回:①目标对象上不存在的属性、②属性名为Symbol值、③不可遍历的属性。ownKeys()方法返回的数组成员只能是字符串或Symbol值,如果有其他类型的值或者返回的根本不是数组,就会报错。如果目标对象自身包含不可配置的属性,则该属性必须被ownKeys()方法返回,否则报错。另外,如果目标对象是不可扩展的,这时ownKeys()方法返回的数组之中,必须包含原对象的所有属性,且不能包含多余的属性,否则报错。
主要用来拦截Object.preventExtensions()。该方法必须返回一个布尔值,否则会被自动转为布尔值。这个方法有一个限制,只有目标对象不可扩展时(即Object.isExtensible(proxy)为false),proxy.preventExtensions才能返回true,否则会报错。为了防止出现这个问题,通常要在proxy.preventExtensions()方法里面,调用一次Object.preventExtensions()。
主要用来拦截Object.setPrototypeOf()方法。注意,该方法只能返回布尔值,否则会被自动转为布尔值。另外,如果目标对象不可扩展,setPrototypeOf()方法不得改变目标对象的原型。
返回一个可取消的Proxy实例。Proxy.revocable()方法返回一个对象,该对象的proxy属性是Proxy实例,revoke属性是一个函数,可以取消Proxy实例。当执行revoke函数之后,再访问Proxy实例,就会抛出一个错误。一个使用场景是,目标对象不允许直接访问,必须通过代理访问,一旦访问结束,就收回代理权,不允许再次访问。
let target = {};
let handler = {};
let {proxy, revoke} = Proxy.revocable(target, handler);
proxy.foo = 123;
proxy.foo // 123
revoke();
proxy.foo // TypeError: Revoked
虽然Proxy可以代理针对目标对象的访问,但它不是目标对象的透明代理,即不做任何拦截的情况下,也无法保证与目标对象的行为一致。主要原因就是在Proxy代理的情况下,目标对象内部的this关键字会指向Proxy代理。
const target = {
m: function () {
console.log(this === proxy);
}
};
const handler = {};
//一旦proxy代理target.m,后者内部的this就是指向proxy,而不是target
const proxy = new Proxy(target, handler);
target.m() // false
proxy.m() // true
有些原生对象的内部属性只有通过正确的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') {
//每次调用proxy.getDate(),实际上会调用target.getDate(),但this是目标对象target
return target.getDate.bind(target);//返回一个绑定到目标对象的方法
}//由于target是一个日期对象,其getDate方法会返回该日期的日部分,日部分是1,所以返回数字1
return Reflect.get(target, prop);//prop不是getDate,则用Reflect.get获取属性值
}
};
const proxy = new Proxy(target, handler);
proxy.getDate() // 1
Proxy对象可以拦截目标对象的任意属性,这使得它很合适用来写Web服务的客户端,也可以用来实现数据库的ORM层。
function createWebService(baseUrl) {
return new Proxy({}, {
get(target, propKey, receiver) {
return () => httpGet(baseUrl + '/' + propKey);
}
});
}
Reflect是一个内置的对象,它提供了一系列方法,使开发者能够通过调用这些方法来访问JavaScript的一些底层功能。由于它类似于其他语言的反射,因此取名为Reflect。它是一个全局的普通对象,其原型是Object,主要作用是将Object对象的一些明显属于语言内部的方法转移到Reflect对象上,使得这些方法可以从Reflect对象上被调用。这样做有助于使代码更加清晰和易于管理。
Reflect对象与Proxy对象一样,也是ES6为了操作对象而提供的新API。设计目的:
Reflect对象一共有13个静态方法。这些方法的作用,大部分与Object对象的同名方法的作用都是相同的,而且它与Proxy对象的方法是一一对应的。
查找并返回target对象的name属性,如果没有该属性,则返回undefined。如果name属性部署了读取函数(getter),则读取函数的this绑定receiver。如果第一个参数不是对象,会报错。
设置target对象的name属性等于value。如果name属性设置了赋值函数,则赋值函数的this绑定receiver。注意,如果 Proxy对象和 Reflect对象联合使用,前者拦截赋值操作,后者完成赋值的默认行为,而且传入了receiver,那么Reflect.set会触发Proxy.defineProperty拦截。如果Reflect.set没有传入receiver,那么就不会触发defineProperty拦截。如果第一个参数不是对象,会报错。
receiver参数代表的是实际调用者,也就是最初调用该方法的对象。在Reflect.set方法中,如果传入了receiver参数,那么该属性值将被设置到receiver上,而不是目标对象上。
对应name in obj里面的in运算符。如果第一个参数不是对象,会报错。
等同于delete obj[name],用于删除对象的属性。该方法返回一个布尔值。如果删除成功或者被删除的属性不存在,返回true;删除失败,被删除的属性依然存在,返回false。如果第一个参数不是对象,会报错。
等同于new target(...args),这提供了一种不使用new来调用构造函数的方法。如果第一个参数不是函数,会报错。
用于读取对象的__proto__属性,对应Object.getPrototypeOf(obj)。如果参数不是对象,Object.getPrototypeOf会将这个参数转为对象,然后再运行,而Reflect.getPrototypeOf会报错。
用于设置目标对象的原型(prototype),对应Object.setPrototypeOf(obj, newProto)方法。它返回一个布尔值,表示是否设置成功。
如果无法设置目标对象的原型(比如目标对象禁止扩展),Reflect.setPrototypeOf方法返回false。如果第一个参数不是对象,Object.setPrototypeOf会返回第一个参数本身,Reflect.setPrototypeOf会报错。如果第一个参数是undefined或null,Object.setPrototypeOf和Reflect.setPrototypeOf都报错。
等同于Function.prototype.apply.call(func, thisArg, args),用于绑定this对象后执行给定函数。一般来说,如果要绑定一个函数的this对象,可这样写fn.apply(obj, args),但如果函数定义了自己的apply方法,就只能写成Function.prototype.apply.call(fn, obj, args),用Reflect对象简化这种操作。
基本等同于Object.defineProperty,用来为对象定义属性。未来,后者会被逐渐废除,请从现在开始就使用Reflect.defineProperty代替它。如果Reflect.defineProperty的第一个参数不是对象,就会抛出错误,比如Reflect.defineProperty(1, 'foo')。这个方法可以与Proxy.defineProperty配合使用。
基本等同于Object.getOwnPropertyDescriptor,用于得到指定属性的描述对象,将来会替代后者。如果第一个参数不是对象,Object.getOwnPropertyDescriptor(1, 'foo')不报错,返回undefined,而Reflect.getOwnPropertyDescriptor(1, 'foo')会抛出错误,表示参数非法。
对应Object.isExtensible,返回一个布尔值,表示当前对象是否可扩展。如果参数不是对象,Object.isExtensible会返回false,因为非对象本来就是不可扩展的,而Reflect.isExtensible会报错。
用于让一个对象变为不可扩展。它返回一个布尔值,表示是否操作成功。如果参数不是对象,Object.preventExtensions在ES5环境报错,在ES6环境返回传入的参数,Reflect.preventExtensions会报错。
用于返回对象的所有属性,基本等同于Object.getOwnPropertyNames与Object.getOwnPropertySymbols之和。如果第一个参数不是对象,会报错。
观察者模式指的是函数自动观察数据对象,一旦对象有变化,函数就会自动执行。
const person = observable({ //数据对象person是观察目标
name: '张三',
age: 20
});
function print() { //函数print是观察者
console.log(`${person.name}, ${person.age}`)
}
observe(print); //一旦数据对象发生变化,print就会自动执行
person.name = '李四';
// 输出:李四, 20
下面,使用Proxy写一个观察者模式的最简单实现,即实现observable和observe这两个函数。思路是observable函数返回一个原始对象的Proxy代理,拦截赋值操作,触发充当观察者的各个函数。
const queuedObservers = new Set(); //先定义了一个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是一个代表异步操作最终完成(包括成功和失败)及结果值的对象。它是一个容器,保存着某个未来才会结束的事件(通常是一个异步操作)的结果。
它实质上是一个构造函数,自己身上有all、reject、resolve这几个的方法,原型上有then、catch等同样很眼熟的方法。
其中,then()方法可以接收两个回调函数作为参数,一个用于处理已解决的promise的值,另一个用于处理被拒绝的promise的原因,其中,第二个函数是可选的,不一定要提供。
①第一个回调函数是Promise对象的状态改变为resoved时调用(操作成功完成时)。
②第二个回调函数是Promise对象的状态变为rejected时调用(出现错误时)。
通过使用then()方法,可以将异步操作的处理逻辑与同步操作流程一致地表达出来,避免了传统回调函数嵌套使用带来的问题。
Promise是JS中用于处理可能不立即完成的操作的对象。Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。简单地说,Promise就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise是一个对象,从它可以获取异步操作的消息。Promise提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise对象有以下两个特点:
有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。
Promise也有一些缺点:
如果某些事件不断地反复发生,一般来说,使用 Stream 模式是比部署Promise更好的选择。
当创建一个新的Promise实例时,通常会传递一个执行器函数作为参数,该函数接受两个参数:resolve和reject。这两个参数都是函数,分别用于在异步操作成功完成时和失败时调用。
Promise对象有几个重要的方法:
① all(): 接受一个promise对象的数组作为参数,只有当所有promise对象都成功完成时才会解析。
② reject(): 返回一个新的promise对象,该对象在初始化时就处于拒绝状态。
③ resolve(): 返回一个新的promise对象,该对象在初始化时就处于已解决状态。
Promise 的原型(Promise.prototype)上也有一些方法,这些方法可以在创建的Promise实例上使用:
① then(): 接受两个参数:一个用于处理已解决的promise的值,另一个用于处理被拒绝的promise的原因,其中,第二个函数是可选的,不一定要提供。。
② catch(): 用于处理被拒绝的promise的原因。它是then(undefined, onRejected)的简写形式。
这些方法允许我们链式地处理异步操作的结果,并确保即使在异步操作失败时也能执行适当的清理或错误处理代码。
有时需要将现有对象转为Promise对象,Promise.resolve()方法就起到这个作用。这个方法会返回一个新的Promise实例,该实例的状态为resolved(解决),该方法接受一个参数,该参数会成为后续方法的参数,但这个参数不会改变Promise的状态,除非在链中(.then)提供一个处理函数来接收这个值,并根据需要改变状态。
const jsPromise = Promise.resolve($.ajax('/whatever.json')); //将jQuery生成的deferred对象转为一个新的Promise对象
//Promise.resolve()等价于下面的写法
Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))
Promise.resolve方法的参数分成四种情况:
如果参数是Promise实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例。
thenable对象指的是具有then方法的对象。Promise.resolve方法会将这个对象转为Promise对象,然后就立即执行thenable对象的then方法。
let thenable = {
then: function(resolve, reject) {
resolve(42); //使用resolve回调函数,传递值42
}
};
let p1 = Promise.resolve(thenable); //将thenable对象转换为一个Promise对象
p1.then(function(value) {
// thenable对象的then方法执行后,对象p1的状态就变为resolved,这时这里的这个回调函数会被执行
// 在这里,回调函数会接收到一个参数value,该参数的值为42。
console.log(value); // 42
});
如果参数是一个原始值,或者是一个不具有then方法的对象,则Promise.resolve方法返回一个新的Promise对象,状态为resolved。回调函数会立即执行,Promise.resolve方法的参数也会同时传给回调函数。
const p = Promise.resolve('Hello');
p.then(function (s){
console.log(s)
}); // Hello
//由于字符串Hello不属于异步操作(判断方法是字符串对象不具有then方法),返回Promise实例的状态从一生成就是resolved,
//所以回调函数会立即执行。Promise.resolve方法的参数会同时传给回调函数
Promise.resolve()方法允许调用时不带参数,直接返回一个resolved状态的Promise对象。所以,如果希望得到一个Promise对象,比较方便的方法就是直接调用Promise.resolve()方法。
const p = Promise.resolve(); //变量p就是一个Promise对象
p.then(function () {
// ...
});
需要注意的是,立即resolve()的Promise对象,是在本轮事件循环(event loop)的结束时执行,而不是在下一轮事件循环的开始时。
setTimeout(function () {
console.log('three');
}, 0); //setTimeout(fn, 0)在下一轮事件循环开始时执行
Promise.resolve().then(function () {
console.log('two');
}); //Promise.resolve()在本轮事件循环结束时执行
console.log('one'); //console.log('one')则是立即执行,因此最先输出
// one
// two
// three
Promise.reject(reason)方法也会返回一个新的Promise实例,该实例的状态为rejected(拒绝)。这个方法接受一个参数,即Promise被拒绝的原因,该参数会原封不动地作为reject的理由,变成后续方法的参数。
const p = Promise.reject('出错了');
// 等同于
const p = new Promise((resolve, reject) => reject('出错了'))
p.then(null, function (s) {
console.log(s)
}); // 出错了
//上面代码生成一个Promise对象的实例p,状态为rejected,回调函数会立即执行。
注意,Promise.reject()方法的参数,会原封不动地作为reject的理由,变成后续方法的参数。这一点与Promise.resolve方法不一致。
①在Promise.reject()中,你传递的参数将成为拒绝的理由,会影响Promise的状态,具体来说,会将Promise的状态从pending变为rejected。因为reject()方法将Promise对象的状态从未完成变为失败,并在异步操作失败时调用,并将异步操作报出的错误作为参数传递出去。
②在Promise.resolve()中,你传递的参数不会影响Promise的状态,因为Promise.resolve()总是返回一个已解决的Promise,它的状态由你传递的参数决定,但是这个参数不会改变Promise的状态,除非你在链中提供了一个处理函数来接收这个值。
const thenable = {
then(resolve, reject) {
reject('出错了');
}
};
Promise.reject(thenable)
.catch(e => {
console.log(e === thenable) //传递给.catch的原因参数确实是原始的thenable对象
}) // true
//上面代码中,Promise.reject方法的参数是一个thenable对象,
//执行以后,后面catch方法的参数不是reject抛出的“出错了”这个字符串,而是thenable对象
我们可以将图片的加载写成一个Promise,一旦加载完成,Promise的状态就发生变化。
const preloadImage = function (path) {
return new Promise(function (resolve, reject) {
const image = new Image();
image.onload = resolve; //将onload事件处理器设置为resolve回调函数,当图像成功加载时,这个函数会被调用,并将Promise的状态更改为已解决(fulfilled)
image.onerror = reject; //将onerror事件处理器设置为reject回调函数。当图像加载失败时(例如,由于路径错误或网络问题),这个函数会被调用,并将Promise的状态更改为已拒绝(rejected)
image.src = path; //设置图像的源路径,即从哪个URL加载图像
});
}; //最后,整个函数返回这个Promise对象,允许调用者使用.then()或.catch()方法处理成功或失败的情况
//一个使用示例可能如下:
preloadImage('/path/to/image.jpg')
.then(function() {
console.log('Image loaded successfully!');
})
.catch(function() {
console.log('Failed to load image.');
});
假设你有一个异步任务列表,并且你需要按照特定的顺序执行这些任务。你可以使用Generator函数和Promise来创建一个可以按照特定顺序执行任务的函数。
使用Generator函数管理流程,遇到异步操作的时候,通常返回一个Promise对象。
function getFoo () { //返回一个Promise,这个Promise在创建时立即被解决,并返回值'foo'
return new Promise(function (resolve, reject){
resolve('foo');
});
}
const g = function* () {
try {
const foo = yield getFoo(); //使用yield关键字来等待Promise的结果
console.log(foo); //如果Promise成功解决,它将输出解决的值
} catch (e) {
console.log(e); //如果Promise被拒绝,它将捕获错误并输出
}
};
function run (generator) { //运行生成器函数
const it = generator(); //创建生成器函数的迭代器it
function go(result) { //定义一个递归函数go来处理生成器函数的下一步
if (result.done) return result.value; //如果生成器的迭代已经完成,则返回结果的值
return result.value.then(function (value) { //否则,它将等待Promise的结果
return go(it.next(value)); //然后递归地调用生成器的下一个迭代
}, function (error) {
return go(it.throw(error)); //如果Promise被拒绝,它将使用throw方法在生成器中抛出错误
});
}
go(it.next());
}
run(g); //调用run函数并将之前定义的生成器函数g作为参数传递给它。因为getFoo函数立即解决其Promise,所以生成器函数将输出 'foo'
上面代码的Generator函数g之中,有一个异步操作getFoo,它返回的就是一个Promise对象。函数run用来处理这个Promise对象,并调用下一个next方法。
Promise.try是JavaScript中Promise对象的一个静态方法。它用于在Promise链中包装异步操作,确保这些操作是Promise返回的。由于Promise.try为所有操作提供了统一的处理机制,所以如果想用then方法管理流程,最好都用Promise.try包装一下。(让同步函数同步执行,异步函数异步执行,并且让它们具有统一的 API )
Promise.try(function() {
// 异步操作
}).then(function(result) {
// 处理结果
}).catch(function(error) { //捕获所有同步和异步的错误
// 处理错误
}); //事实上,Promise.try就是模拟try代码块,就像promise.catch模拟的是catch代码块。
ES6规定,Promise对象是一个构造函数,用来生成Promise实例。
下面代码创造了一个Promise实例,生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。
const promise = new Promise(function(resolve, reject) {
// ... some code
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
promise.then(function(value) {
// success
}, function(error) {
// failure
});
Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不用自己部署。
举个例子:
function timeout(ms) { //timeout方法返回一个Promise实例,表示一段时间以后才会发生的结果
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, 'done'); //过了指定的时间(ms参数)以后,Promise实例的状态变为resolved,传递'done'给resolve,这个参数会成为Promise的解析值
});
}
timeout(100).then((value) => { //Promise实例的状态变为resolved,就会触发then方法绑定的回调函数
console.log(value);
}); //done
Promise新建后就会立即执行,指的是当Promise对象被创建时,它的executor函数被执行。这里的例子是立即执行resolve。
let promise = new Promise(function(resolve, reject) {
console.log('Promise');
resolve();
});
promise.then(function() {
console.log('resolved.');
});
console.log('Hi!');
// Promise 当Promise对象被创建时,它的executor函数被执行,所以首先打印了Promise
// Hi! 它是直接在全局作用域中执行的,同步的,所以它会立即打印,不管前面是否有其他异步操作
// resolved 这是.then回调的输出,它在Promise状态变为resolved后执行
如果创建了一个新的Promise对象并设置了resolve或reject的状态,但没有添加.then()或.catch()回调,那么这个Promise的状态改变(无论是变为已解决还是已拒绝)不会引发任何操作。因为Promise的.then()或.catch()方法用于定义当Promise状态变为已解决或已拒绝时应该执行的回调函数。如果没有添加这些回调,那么Promise的状态改变不会产生任何效果。举个例子:
let promise = new Promise((resolve, reject) => {
resolve();
}); //这段代码创建了一个新的Promise,并且立即将其状态设为已解决。但由于没有.then()或.catch()回调函数,这个Promise的状态改变并不会引发任何操作。
//如果你想在Promise状态改变时执行某些操作,你需要添加相应的.then()或.catch()回调函数,像这样:
promise.then(() => {
console.log('Promise is resolved!');
}); //这段代码中,当Promise的状态变为已解决时,会打印出"Promise is resolved!"
举个异步加载图片的例子:
function loadImageAsync(url) {
return new Promise(function(resolve, reject) {
const image = new Image();
image.onload = function() { //如果图片加载成功,调用resolve函数并将图片对象作为参数传递,这样外部调用者可以通过.then()方法处理加载完成后的图片
resolve(image);
};
image.onerror = function() { //如果图片加载失败,调用reject函数并传递一个错误对象,这样外部调用者可以通过.catch()方法处理加载失败的情况
reject(new Error('Could not load image at ' + url));
};
image.src = url;
});
}
举个复杂点的例子:
const p1 = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('fail')), 3000)
})
const p2 = new Promise(function (resolve, reject) {
setTimeout(() => resolve(p1), 1000) //p1的状态传递给p2,也就是说,p1的状态决定了p2的状态
})
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方法指定的回调函数。
注意,调用resolve或reject并不会终结 Promise 的参数函数的执行。
new Promise((resolve, reject) => {
resolve(1);
console.log(2);
}).then(r => {
console.log(r);
});
// 2
// 1
//上面代码中,调用resolve(1)以后,后面的console.log(2)还是会执行,并且会首先打印出来
//这是因为立即resolved的Promise是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务
一般来说,调用resolve或reject后,Promise的使命就完成了,后继操作应放到then方法里面,而不应该直接写在resolve或reject的后面。所以,最好在它们前面加上return语句,这样就不会有意外。
new Promise((resolve, reject) => {
return resolve(1);
// 后面的语句不会执行
console.log(2);
})
它的作用是为Promise实例添加状态改变时的回调函数。第一个参数是resolved状态的回调函数,第二个参数(可选)是rejected状态的回调函数。then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。
getJSON("/posts.json").then(function(json) {
return json.post;
}).then(function(post) {
// ...
}); //使用then方法依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。
采用链式的then,可以指定一组按照次序调用的回调函数。这时,前一个回调函数有可能返回的还是一个Promise对象(即有异步操作),这时后一个回调函数就会等待该Promise对象的状态发生变化,才会被调用。
getJSON("/post/1.json").then(function(post) {
return getJSON(post.commentURL);
}).then(function (comments) {
console.log("resolved: ", comments);
}, function (err){
console.log("rejected: ", err);
}); //第一个then方法指定的回调函数,返回的是另一个Promise对象。
//这时,第二个then方法指定的回调函数,就会等待这个新的Promise对象状态发生变化。
//如果变为resolved,就调用第一个回调函数,如果状态变为rejected,就调用第二个回调函数。
如果采用箭头函数,上面的代码可以写得更简洁。
getJSON("/post/1.json").then(
post => getJSON(post.commentURL)
).then(
comments => console.log("resolved: ", comments),
err => console.log("rejected: ", err)
);
用于指定发生错误时的回调函数。如果异步操作抛出错误,状态就会变为rejected,就会调用catch()方法指定的回调函数,处理这个错误。另外,then()方法指定的回调函数,如果运行中抛出错误,也会被catch()方法捕获。
p.then((val) => console.log('fulfilled:', val))
.catch((err) => console.log('rejected', err));
// 等同于
p.then((val) => console.log('fulfilled:', val))
.then(null, (err) => console.log("rejected:", err));
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,再抛出错误是无效的。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 这种写法可以捕获前面then方法执行中的错误,也更接近同步的写法(try/catch)
promise
.then(function(data) { //cb
// success
})
.catch(function(err) {
// error
});
跟传统的try/catch代码块不同的是,如果没有使用catch()方法指定错误处理的回调函数,Promise对象抛出的错误不会传递到外层代码,即不会有任何反应。Promise内部的错误不会影响到Promise外部的代码,通俗的说法就是“Promise 会吃掉错误”。举个例子:
const someAsyncThing = function() {
return new Promise(function(resolve, reject) {
// 下面一行会报错,因为x没有声明
resolve(x + 2);
});
};
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对象后面要跟catch()方法,这样可以处理Promise内部发生的错误。catch()方法返回的还是一个Promise对象,因此后面还可以接着调用then()方法。catch()方法之中还能再抛出错误。
用于指定不管Promise对象最后状态如何,都会执行的操作。finally方法的回调函数不接受任何参数,这意味着没有办法知道,前面的Promise状态到底是fulfilled还是rejected。这表明,finally方法里面的操作应该是与状态无关的,不依赖于Promise的执行结果。
promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});
//不管promise最后的状态,在执行完then或catch指定的回调函数以后,都会执行finally方法指定的回调函数
finally本质上是then方法的特例。
promise
.finally(() => {
// 语句
}); //有了finally方法,则只需要写一次
// 等同于
promise
.then(
result => {
// 语句
return result;
},
error => {
// 语句
throw error;
}
); //如果不使用finally方法,同样的语句需要为成功和失败两种情况各写一次
finally方法总是会返回原来的值。
Promise.resolve(2).then(() => {}, () => {}) // resolve 的值是 undefined
Promise.resolve(2).finally(() => {}) // resolve 的值是 2
Promise.reject(3).then(() => {}, () => {}) // reject 的值是 undefined
Promise.reject(3).finally(() => {}) // reject 的值是 3
用于将多个Promise实例包装成一个新的Promise实例。Promise.all()方法的参数可以不是数组,但必须具有Iterator接口,且返回的每个成员都是Promise实例。
在所有promise完成以后再返回所有promise的结果,当所有的Promise都成功,该Promise为完成,返回值是全部Promise返回值的结果数组;如果有一个失败,则该Promise失败,返回最先失败状态的值。
const p = Promise.all([p1, p2, p3]);
//接受一个数组作为参数,p1、p2、p3都是Promise实例,如果不是,就会先调用Promise.resolve方法,将参数转为Promise实例,再进一步处理。
//另外,Promise.all()方法的参数可以不是数组,但必须具有Iterator接口,且返回的每个成员都是Promise实例。
上面代码中,p的状态由p1、p2、p3决定,分成两种情况:
举个例子:
const databasePromise = connectDatabase();
const booksPromise = databasePromise
.then(findAllBooks);
const userPromise = databasePromise
.then(getCurrentUser);
Promise.all([
booksPromise,
userPromise
])
.then(([books, user]) => pickTopRecommendations(books, user));
//booksPromise和userPromise是两个异步操作,只有等到它们的结果都返回了,才会触发pickTopRecommendations这个回调函数
注意,如果作为参数的Promise实例自己定义了catch方法,那么它一旦被rejected,并不会触发Promise.all()的catch方法。如果没有自己的catch方法,就会调用Promise.all()的catch方法。
用于将多个Promise实例包装成一个新的Promise实例。这个新的Promise实例会在输入的Promise实例中的任何一个率先改变状态(无论是fulfilled还是rejected)时,立即以同样的结果改变状态。如果参数实例有一个变成fulfilled状态,包装实例就会变成fulfilled状态;如果其中任何一个Promise对象变为rejected状态,则返回的Promise对象也会立即变为rejected状态,并且该Promise对象的结果是第一个变为rejected状态的Promise对象的错误信息。
const p = Promise.race([p1, p2, p3]);
//只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的Promise实例的返回值,就传递给p的回调函数。
//p1、p2、p3都是Promise实例,如果不是,就会先调用Promise.resolve方法,将参数转为Promise实例,再进一步处理。
通常用于设置一个超时机制,为每个Promise设置一个合理的超时时间,如果某个异步操作在指定的时间内未能得到响应,则自动放弃该操作;如果有一个结果获得快,就返回那个结果,不管结果本身是成功状态还是失败状态。下面是一个例子,如果指定时间内没有获得结果,就将Promise的状态变为reject,否则变为resolve。
const p = Promise.race([
fetch('/resource-that-may-take-a-while'),
new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('request timeout')), 5000)
})
]);
p
.then(console.log)
.catch(console.error);
//如果5秒之内fetch方法无法返回结果,变量p的状态就会变为rejected,从而触发catch方法指定的回调函数
接受一组Promise实例作为参数,包装成一个新的Promise实例。只有等到所有这些参数实例都返回结果,不管是fulfilled还是rejected,包装实例才会结束。fulfilled时对象有value属性,rejected时有reason属性,对应两种状态的返回值。
const promises = [
fetch('/api-1'),
fetch('/api-2'),
fetch('/api-3'),
];
await Promise.allSettled(promises);
removeLoadingIndicator();//对服务器发出三个请求,等到三个请求都结束,不管请求成功还是失败,加载的滚动图标就会消失
//该方法返回的新的Promise实例,一旦结束,状态总是fulfilled,不会变成rejected。
//状态变成fulfilled后,Promise的监听函数接收到的参数是一个数组,每个成员对应一个传入Promise.allSettled()的Promise实例。
有时,我们不关心异步操作的结果,只关心这些操作有没有结束。这时,Promise.allSettled()方法就很有用。如果没有这个方法,想要确保所有操作都结束就很麻烦。Promise.all()方法无法做到这一点,它无法确定所有请求都结束,想要达到这个目的,写起来很麻烦,有了Promise.allSettled()就很容易了。
接受一组Promise实例作为参数,包装成一个新的Promise实例。只要参数实例有任何一个变成fulfilled状态,包装实例就会变成fulfilled状态;如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。Promise.any()跟Promise.race()方法很像,只有一点不同,就是不会因为某个 Promise 变成rejected状态而结束。
const promises = [
fetch('/endpoint-a').then(() => 'a'),
fetch('/endpoint-b').then(() => 'b'),
fetch('/endpoint-c').then(() => 'c'),
];
try {
const first = await Promise.any(promises);
console.log(first);
} catch (error) {
console.log(error);
}
//参数数组包含三个Promise操作,其中只要有一个变成fulfilled,Promise.any()返回的Promise对象就变成fulfilled。
//如果所有三个操作都变成rejected,那么await命令就会抛出错误。
Promise.any()抛出的错误不是一个一般的错误,而是一个AggregateError实例。它相当于一个数组,每个成员对应一个被rejected的操作所抛出的错误。
//下面是 AggregateError 的实现示例------------------------------------------------
new AggregateError() extends Array -> AggregateError
const err = new AggregateError();
err.push(new Error("first error"));
err.push(new Error("second error"));
throw err;
//捕捉错误时,如果不用try...catch结构和await命令,可以像下面这样写---------------------
Promise.any(promises).then(
(first) => {
// Any of the promises was fulfilled.
},
(error) => {
// All of the promises were rejected.
}
);
遍历器(Iterator)是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署Iterator接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
Iterator的作用有三个:
Iterator 的遍历过程是这样的:
每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value和done两个属性的对象,value是当前位置的成员的值,done是一个布尔值,表示遍历是否结束,即是否还有必要再一次调用next。总之,调用指针对象的next方法就可以遍历事先给定的数据结构。
var it = makeIterator(['a', 'b']);
it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }
function makeIterator(array) {
var nextIndex = 0;
return {
next: function() {
return nextIndex < array.length ?
{value: array[nextIndex++], done: false} :
{value: undefined, done: true}; //done:false和value:undefined属性都是可以省略
}
};
}
由于Iterator只是把接口规格加到数据结构之上,所以,遍历器与它所遍历的那个数据结构实际上是分开的,完全可以写出没有对应数据结构的遍历器对象,或者说用遍历器对象模拟出数据结构。下面是一个无限运行的遍历器对象的例子。
var it = idMaker();
it.next().value // 0
it.next().value // 1
it.next().value // 2
// ...
function idMaker() {
var index = 0;
return {
next: function() {
return {value: index++, done: false};
}
};
}//遍历器生成函数idMaker,返回一个遍历器对象(即指针对象)。但是并没有对应的数据结构,或者说,遍历器对象自己描述了一个数据结构出来。
如果使用 TypeScript 的写法,遍历器接口、指针对象和next方法返回值的规格可以描述如下:
interface Iterable { //遍历器接口(Iterable)
[Symbol.iterator]() : Iterator,
}
interface Iterator { //指针对象(Iterator)
next(value?: any) : IterationResult,
}
interface IterationResult { //next方法返回值
value: any,
done: boolean,
}
Iterator接口的目的,就是为所有数据结构提供了一种统一的访问机制,即for...of循环。当使用for...of循环遍历某种数据结构时,该循环会自动去寻找Iterator接口。一种数据结构只要部署了Iterator接口(具有Symbol.iterator属性),我们就称这种数据结构是可遍历的(iterable),部署了遍历器接口,调用这个接口,就会返回一个遍历器对象。
Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数,执行后返回当前对象的遍历器对象。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为Symbol的特殊值,所以要放在方括号内。
const obj = {
[Symbol.iterator] : function () {
return {
next: function () {
return {
value: 1,
done: true
};
}
};
}
};//obj是可遍历的,因为具有Symbol.iterator属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有next方法。每次调用next方法,都会返回一个代表当前成员的信息对象,具有value和done两个属性。
原生具备Iterator接口的数据结构如下:
对于原生部署Iterator接口的数据结构,不用自己写遍历器生成函数,for...of循环会自动遍历它们。除此之外,其他数据结构(主要是对象)的Iterator接口,都需要自己在Symbol.iterator属性上面部署遍历器生成方法(原型链上的对象具有该方法也可),这样才会被for...of循环遍历。也可用while循环遍历。
对象(Object)之所以没有默认部署Iterator接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作Map结构使用,ES5没有Map结构,而ES6原生提供了。
对于类似数组的对象(存在数值键名和length属性),部署Iterator接口,有一个简便方法,就是Symbol.iterator方法直接引用数组的Iterator接口。
NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
// 或者
NodeList.prototype[Symbol.iterator] = [][Symbol.iterator];
[...document.querySelectorAll('div')] // 可以执行了
注意,普通对象部署数组的Symbol.iterator方法并无效果。如果Symbol.iterator方法对应的不是遍历器生成函数(即会返回一个遍历器对象),解释引擎将会报错。
有一些场合会默认调用Iterator接口(即Symbol.iterator方法)。
字符串是一个类似数组的对象,也原生具有Iterator接口。调用Symbol.iterator方法返回一个遍历器对象,在这个遍历器上可以调用next方法实现对于字符串的遍历。
var someString = "hi";
typeof someString[Symbol.iterator]
// "function"
var iterator = someString[Symbol.iterator]();
iterator.next() // { value: "h", done: false }
iterator.next() // { value: "i", done: false }
iterator.next() // { value: undefined, done: true }
可以覆盖原生的Symbol.iterator方法,达到修改遍历器行为的目的。
Symbol.iterator方法的最简单实现几乎不用部署任何代码,只要用yield命令给出每一步的返回值即可。
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"
遍历器对象除了具有next方法,还可以具有return方法和throw方法。如果自己写遍历器对象生成函数,那么next方法是必须部署的,return方法和throw方法是否部署是可选的。
// 触发执行return方法的情况一
for (let line of readLinesSync(fileName)) {
console.log(line); //关闭这个文件
break;
}
// 触发执行return方法的情况二
for (let line of readLinesSync(fileName)) {
console.log(line); //关闭这个文件
throw new Error(); //抛出错误
}
一个数据结构只要部署了Symbol.iterator属性,就被视为具有iterator接口,就可以用for...of循环遍历它的成员。就是说,for...of循环内部调用的是数据结构的Symbol.iterator方法。for...of循环可以使用的范围包括数组、Set和Map结构、某些类似数组的对象(比如arguments对象、DOM NodeList对象)、Generator对象以及字符串。
数组原生具备iterator接口(即默认部署了Symbol.iterator属性),for...of循环本质上就是调用这个接口产生的遍历器。for...of循环可以代替数组实例的forEach方法。
var arr = ['a', 'b', 'c', 'd'];
for (let a in arr) {
console.log(a); // 0 1 2 3
}
for (let a of arr) {
console.log(a); // a b c d (允许遍历获得键值)
}
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结构也原生具有Iterator接口,可以直接使用for...of循环。①遍历的顺序是按照各个成员被添加进数据结构的顺序。②Set结构遍历时,返回的是一个值,而Map结构遍历时,返回的是一个数组,该数组的两个成员分别为当前Map成员的键名和键值。
let map = new Map().set('a', 1).set('b', 2);
for (let pair of map) {
console.log(pair);
}
// ['a', 1]
// ['b', 2]
for (let [key, value] of map) {
console.log(key + ' : ' + value);
}
// a : 1
// b : 2
有些数据结构是在现有数据结构的基础上计算生成的。比如,ES6的数组、Set、Map都部署了以下三个方法,调用后都返回遍历器对象。
let arr = ['a', 'b', 'c'];
for (let pair of arr.entries()) {
console.log(pair);
} //这三个方法调用后生成的遍历器对象所遍历的都是计算生成的数据结构
// [0, 'a']
// [1, 'b']
// [2, 'c']
类似数组的对象是指那些拥有与数组类似的结构,但并非严格意义上的数组对象。比如字符串、DOM NodeList 对象、arguments对象。
对于字符串来说,for...of循环还会正确识别32位UTF-16字符。并不是所有类似数组的对象都具有Iterator接口,可用Array.from方法将其转为数组。
let arrayLike = { length: 2, 0: 'a', 1: 'b' };
// 报错
for (let x of arrayLike) {
console.log(x);
}
// 正确
for (let x of Array.from(arrayLike)) {
console.log(x);
}
对于普通的对象,for...of结构不能直接使用,会报错,必须部署了Iterator接口后才能使用。但是这样情况下,for...in循环依然可以用来遍历键名。
对于普通的对象,for...in循环可遍历键名,for...of循环会报错。一种方法是使用Object.keys方法将对象的键名生成一个数组,然后遍历这个数组。另一个方法是使用Generator函数将对象重新包装一下。
for (var key of Object.keys(someObject)) { //使用Object.keys方法将对象的键名生成一个数组
console.log(key + ': ' + someObject[key]);
}
function* entries(obj) {//使用Generator函数将对象重新包装一下
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循环可以遍历数组的键名。for...in循环主要是为遍历对象而设计的,不适用于遍历数组。
语法上,Generator函数是一个状态,封装了多个内部状态。执行Generator函数会返回一个遍历器对象,即Generator还是一个遍历器对象生成函数。返回的遍历器对象可以依次遍历Generator函数内部的每一个状态。
形式上,Generator函数是一个普通函数,但有两个特征。一是function关键字与函数名之间有一个星号;二是函数体内部使用yield表达式定义不同的内部状态(yield在英语里的意思就是产出)。
Generator函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用Generator函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)。每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。
由于Generator函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。
遍历器对象的next方法的运行逻辑如下。
(1)遇到yield表达式就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值作为返回的对象的value属性值。
(2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。
(3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。
(4)如果该函数没有return语句,则返回的对象的value属性值为undefined。
注意,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为JavaScript提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。
任意一个对象的Symbol.iterator方法等于该对象的遍历器生成函数,调用该函数会返回对象的一个遍历器对象。Generator就是遍历器生成函数,因此可以把Generator赋值给对象的Symbol.iterator属性,从而使得该对象具有Iterator接口。
Generator是实现状态机的最佳结构。它可以不用外部变量保存状态,因为它本身就包含了一个状态信息,即目前是否处于暂停态。
var clock = function* () {
while (true) {
console.log('Tick!');
yield;
console.log('Tock!');
yield;
}
}; //clock函数一共有两种状态(Tick和Tock),每运行一次就改变一次状态
协程是一种程序运行的方式,可以理解成协作的线程或协作的函数,多个线程互相协作完成异步任务。协程既可以用单线程实现(特殊的子例程),也可以用多线程实现(特殊的线程)。协程是以多占用内存为代价实现多任务的并行。同一时间可以有多个线程处于运行状态,但是运行的协程只能有一个,其他协程都处于暂停状态。协程是合作式的,执行权由协程自己分配。引入协程以后,每个任务可以保持自己的调用栈,抛出错误的时候可以找到原始的调用栈。最大优点是代码写法非常像同步操作,如果去除yield简直一模一样。
协程有点像函数,又有点像线程。它的运行流程大致如下。
上面流程的协程A就是异步任务,因为它分成两段(或多段)执行。
function* asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
} //asyncJob是一个协程,它的奥妙就在其中的yield命令
//它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。
//协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。
Generator函数是ES6对协程的实现,但属于不完全实现。Generator函数被称为半协程,意思是只有Generator函数的调用者,才能将程序的执行权还给Generator函数。如果是完全执行的协程,任何函数都可以让暂停的协程继续执行。如果将Generator函数当作协程,完全可以将多个需要互相协作的任务写成Generator函数,它们之间使用yield表达式交换控制权。
Generator函数执行产生的上下文环境,一旦遇到yield命令,就会暂时退出堆栈,但是并不消失,里面的所有变量和对象会冻结在当前状态。等到对它执行next命令时,这个上下文环境又会重新加入调用栈,冻结的变量和对象恢复执行。
function* gen() {
yield 1;
return 2;
}
let g = gen();
console.log(
g.next().value, //第一次调用g.next(),Generator函数执行到yield 1,因此value 为 1
g.next().value, //第二次调用g.next(),Generator函数执行到return 2,然后结束。
);
Generator可以暂停函数执行,返回任意表达式的值。这种特点使得Generator有多种应用场景。
Generator函数可以暂停函数执行,因此,可以把异步操作写在yield表达式里面,等到调用next方法时再往后执行。实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield表达式下面,反正要等到调用next方法时再执行。所以,它可以用来处理异步操作,改写回调函数。
Ajax 是典型的异步操作,通过Generator函数部署Ajax操作,可以用同步的方式表达。
function* main() {
var result = yield request("http://some.url");
var resp = JSON.parse(result);
console.log(resp.value);
}
function request(url) {
makeAjaxCall(url, function(response){
it.next(response); //makeAjaxCall函数中的next方法必须加上response参数,因为yield表达式本身是没有值的,总是等于undefined
});
}
var it = main();
it.next();
如果有一个多步操作非常耗时,可以使用Generator函数改善代码运行流程。比如利用for...of循环会自动依次执行yield命令的特性,提供一种控制流管理的方法。
let steps = [step1Func, step2Func, step3Func]; //数组steps封装了一个任务的多个步骤
function* iterateSteps(steps){
for (var i=0; i< steps.length; i++){
var step = steps[i];
yield step();
}
} //Generator函数iterateSteps则是依次为这些步骤加上yield命令
//将任务分解成步骤之后,还可以将项目分解成多个依次执行的任务
let jobs = [job1, job2, job3]; //数组jobs封装了一个项目的多个任务
function* iterateJobs(jobs){
for (var i=0; i< jobs.length; i++){
var job = jobs[i];
yield* iterateSteps(job.steps);
}
} //Generator函数iterateJobs则是依次为这些任务加上yield*命令
//最后,就可以用for...of循环一次性依次执行所有任务的所有步骤
for (var step of iterateJobs(jobs)){
console.log(step.id);
} //注意!!!上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤
利用Generator函数可以在任意对象上部署Iterator接口,即可以在任意对象上部署next方法。
function* iterEntries(obj) {
let keys = Object.keys(obj);
for (let i=0; i < keys.length; i++) {
let key = keys[i];
yield [key, obj[key]];
}
} //myObj是一个普通对象,通过iterEntries函数,就有了 Iterator 接口
let myObj = { foo: 3, bar: 7 };
for (let [key, value] of iterEntries(myObj)) {
console.log(key, value);
}
// foo 3
// bar 7
Generator可以看作是数据结构(数组结构),因为Generator函数可以返回一系列的值,这意味着它可以对任意表达式提供类似数组的接口。Generator 使得数据或者操作具备了类似数组的接口。
next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。
yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。Generator函数从暂停状态到恢复运行,它的上下文状态是不变的。通过next方法的参数,就有办法在Generator函数开始运行之后,继续向函数体内部注入值。也就是说,可以在Generator函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。
function* dataConsumer() {
console.log('Started');
console.log(`1. ${yield}`);
console.log(`2. ${yield}`);
return 'result';
} //每次通过next方法向Generator函数输入值,然后打印出来
let genObj = dataConsumer();
genObj.next(); // Started
genObj.next('a') // 1. a
genObj.next('b') // 2. b
如果想要第一次调用next方法时就能够输入值,可以在Generator函数外面再包一层,否则是无法做到的。因为Generator函数在被创建时默认会立即执行,并且没有参数。如果想在第一次调用next方法时输入参数,需要使用一个外部函数来创建并返回这个Generator函数,并在外部函数中处理参数。
function* myGeneratorFunction() {
let value = yield 'hello';
console.log(value);
}
function createGenerator(input) {
return myGeneratorFunction().next(input).value;
} //createGenerator函数接收一个参数input,并返回myGeneratorFunction的第一次迭代的value属性
const result = createGenerator('world'); //可以通过调用createGenerator函数并传入参数来影响myGeneratorFunction的执行
console.log(result); // 输出:'world'
可以自动遍历Generator函数运行时生成的Iterator对象,且此时不再需要调用next方法。
可以写出遍历任意对象的方法。原生的JavaScript对象没有遍历接口,无法使用for...of循环,通过Generator函数为它加上这个接口,就可以用了。
function* objectEntries(obj) {
let propKeys = Reflect.ownKeys(obj);
for (let propKey of propKeys) {
yield [propKey, obj[propKey]];
}
} //通过Generator函数objectEntries为它加上遍历器接口
let jane = { first: 'Jane', last: 'Doe' }; //对象jane原生不具备Iterator接口,无法用for...of遍历
for (let [key, value] of objectEntries(jane)) { //就可以用for...of遍历了
console.log(`${key}: ${value}`);
}
// first: Jane
// last: Doe
//加上遍历器接口的另一种写法是将Generator函数加到对象的Symbol.iterator属性上面
jane[Symbol.iterator] = objectEntries;
for (let [key, value] of jane) {
console.log(`${key}: ${value}`);
}
除了for...of循环外,扩展运算符(...)、解构赋值和Array.from方法内部调用的都是遍历器接口。这意味着,它们都可以将Generator函数返回的Iterator对象作为参数。
可以在函数体外抛出错误,然后在Generator函数体内捕获。throw方法可以接受一个参数,该参数会被catch语句接收,建议抛出Error对象的实例。
var g = function* () {
try {
yield;
} catch (e) {
console.log(e);
}
};
var i = g();
i.next();
i.throw(new Error('出错了!'));
// Error: 出错了!(…)
注意,不要混淆遍历器对象的throw方法和全局的throw命令。上面代码的错误是用遍历器对象的throw方法抛出的,不是用throw命令抛出的。后者只能被函数体外的catch语句捕获。
如果Generator函数内部没有部署try...catch代码块,那么throw方法抛出的错误将被外部try...catch代码块捕获。
如果Generator函数内部和外部都没有部署try...catch代码块,那么程序将报错,直接中断执行。
throw方法抛出的错误要被内部捕获,前提是必须至少执行过一次next方法。因为第一次执行next方法等同于启动执行Generator函数的内部代码,否则Generator函数还没有开始执行,这时throw方法抛错只可能抛出在函数外部。
throw方法被捕获以后会附带执行下一条yield表达式。也就是说,会附带执行一次next方法。只要Generator函数内部部署了try...catch代码块,那么遍历器的throw方法抛出的错误不会影响到遍历器的状态,不影响下一次遍历。
Generator函数体外抛出的错误可以在函数体内捕获;反过来,Generator函数体内抛出的错误也可以被函数体外的catch捕获。一旦执行过程中抛出错误且没有被内部捕获,就不会再执行下去了。如果此后还调用next方法,将返回一个value属性等于undefined、done属性等于true的对象,即 JS引擎认为这个Generator已经运行结束了。
可以返回给定的值并且终结遍历Generator函数。如果return方法调用时不提供参数,则返回值的value属性为undefined。调用return()方法后,就开始执行finally代码块,不执行try里面剩下的代码了,然后等到finally代码块执行完,再返回return()方法指定的返回值。
它们的作用都是让Generator函数恢复执行,并且使用不同的语句替换yield表达式。
const g = function* (x, y) {
let result = yield x + y;
return result;
};
const gen = g(1, 2);
gen.next(); // Object {value: 3, done: false}
gen.next(1); // Object {value: 1, done: true} 将yield表达式替换成一个值1,若next没有参数,就相当于替换成undefined
// 相当于将 let result = yield x + y
// 替换成 let result = 1;
gen.throw(new Error('出错了')); // Uncaught Error: 出错了
// 相当于将 let result = yield x + y
// 替换成 let result = throw(new Error('出错了'));
gen.return(2); // Object {value: 2, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = return 2;
ES6提供了yield*表达式,用来在一个Generator函数里面执行另一个Generator函数。
从语法角度看,如果yield表达式后面跟的是一个遍历器对象,需要在yield表达式后面加上星号,表明它返回的是一个遍历器对象,这被称为yield*表达式。
function* inner() {
yield 'hello!';
}
function* outer1() { //outer1没有使用yield*,返回一个遍历器对象
yield 'open';
yield inner();
yield 'close';
}
var gen = outer1()
gen.next().value // "open"
gen.next().value // 返回一个遍历器对象
gen.next().value // "close"
function* outer2() { //outer2使用了yield*,返回该遍历器对象的内部值
yield 'open'
yield* inner()
yield 'close'
}
var gen = outer2()
gen.next().value // "open"
gen.next().value // "hello!" 返回遍历器对象的内部值
gen.next().value // "close"
yield*后面的Generator函数(没有return语句时),等同于在Generator函数内部部署一个for...of循环。它不过是for...of的一种简写形式,完全可以用后者替代前者。反之,在有return语句时,则需要用var value = yield* iterator的形式获取return语句的值。
function* concat(iter1, iter2) {
yield* iter1;
yield* iter2;
}
// 等同于
function* concat(iter1, iter2) {
for (var value of iter1) {
yield value;
}
for (var value of iter2) {
yield value;
}
}
如果yield*后面跟着一个数组,由于数组原生支持遍历器,因此就会遍历数组成员。 实际上,任何数据结构只要有Iterator接口,就可以被yield*遍历。
function* gen(){
yield* ["a", "b", "c"];
} //yield命令后面如果不加星号,返回的是整个数组,加了星号就表示返回的是数组的遍历器对象
gen().next() // { value:"a", done:false }
如果被代理的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" 函数foo的return语句向函数bar提供了返回值
// {value: 4, done: false}
it.next() // {value: undefined, done: true}
yield*命令可以很方便地取出嵌套数组的所有成员。
function* iterTree(tree) {
if (Array.isArray(tree)) {
for(let i=0; i < tree.length; i++) {
yield* iterTree(tree[i]);
}
} else {
yield tree;
}
}
const tree = [ 'a', ['b', 'c'], ['d', 'e'] ];
for(let x of iterTree(tree)) {
console.log(x);
}
// a
// b
// c
// d
// e
//由于扩展运算符...默认调用Iterator接口,所以上面这个函数也可以用于嵌套数组的平铺
[...iterTree(tree)] // ["a", "b", "c", "d", "e"]
如果一个对象的属性是Generator函数,可以简写成下面的形式。
let obj = {
* myGeneratorMethod() { //前面有一个星号,表示这个属性是一个Generator函数
···
}
};
//它的完整形式如下,与上面的写法是等价的
let obj = {
myGeneratorMethod: function* () {
// ···
}
};
Generator函数总是返回一个遍历器,ES6规定这个遍历器是Generator函数的实例,也继承了Generator函数的prototype对象上的方法。
function* g() {}
g.prototype.hello = function () {
return 'hi!';
};
let obj = g(); //g返回的遍历器obj是g的实例,而且继承了g.prototype
obj instanceof g // true
obj.hello() // 'hi!'
但是,如果把g当作普通的构造函数,并不会生效,因为g返回的总是遍历器对象,而不是this对象。
function* g() {
this.a = 11; //Generator函数g在this对象上面添加了一个属性a
}
let obj = g();
obj.next();
obj.a // undefined 但是obj对象拿不到这个属性
Generator函数也不能跟new命令一起用,会报错。
function* F() {
yield this.x = 2;
yield this.y = 3;
}
new F() // TypeError: F is not a constructor
//new命令跟构造函数F一起使用,结果报错,因为F不是构造函数
有个方法可让Generator函数返回一个正常的对象实例,既可以用next方法又可以获得正常的this:首先,生成一个空对象,使用call方法绑定Generator函数内部的this。这样,构造函数调用以后,这个空对象就是Generator函数的实例对象了。
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
ES6诞生以前,异步编程的方法大概有下面四种。
Generator函数将JavaScript异步编程带入了一个全新的阶段。
Generator函数是协程在ES6的实现,最大特点就是可以交出函数的执行权(即暂停执行)。整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。
function* gen(x) {
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
Generator函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。它还有两个特性使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。next返回值的value属性是Generator函数向外输出数据;next方法还可以接受参数,向Generator函数体内输入数据。
function* gen(x){
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true } 带有参数2,可传入Generator函数,被函数体内的变量y接收,这一步的value属性返回的就是2(变量y的值)
Generator函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。出错的代码与处理错误的代码实现了时间和空间上的分离。
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}
var g = gen(1);
g.next();
g.throw('出错了'); //函数体外用指针对象的throw方法抛出的错误,可被函数体内的try...catch代码块捕获
// 出错了
虽然Generator函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。举个例子看看如何使用Generator函数执行一个真实的异步任务。
var fetch = require('node-fetch');
function* gen(){
var url = 'https://api.github.com/users/github'; //先读取一个远程接口
var result = yield fetch(url); //然后从JSON格式的数据解析信息
console.log(result.bio);
} //这段代码非常像同步操作,除了加上了yield命令
//执行这段代码的方法如下
var g = gen(); //首先执行Generator函数,获取遍历器对象
var result = g.next(); //然后使用next方法执行异步任务的第一阶段
result.value.then(function(data){ //由于Fetch模块返回的是一个Promise对象,因此要用then方法调用下一个next方法
return data.json();
}).then(function(data){
g.next(data);
});
Thunk函数是自动执行Generator函数的一种方法。
参数的求值策略
Thunk 函数的含义
编译器的传名调用实现,往往是将参数放到一个临时函数中,再将这个临时函数传入函数体。这个临时函数就叫做Thunk函数。它是传名调用的一种实现策略,用来替换某个表达式。
function f(m) {
return m * 2;
}
f(x + 5);
// 等同于
var thunk = function () {
return x + 5;
}; //函数f的参数x+5被一个函数替换了,凡是用到原参数的地方,对Thunk函数求值即可
function f(thunk) {
return thunk() * 2;
}
JavaScript 语言的 Thunk 函数
JavaScript语言是传值调用,它的Thunk函数替换的不是表达式,而是将多参数函数替换成一个只接受回调函数作为参数的单参数函数。任何函数,只要参数有回调函数,就能写成Thunk函数的形式。
function f(a, cb) {
cb(a);
}
const ft = Thunk(f);
ft(1)(console.log) // 1
Thunkify 模块
生产环境的转换器建议使用Thunkify模块。它的源码主要多了一个检查机制,变量called确保回调函数只运行一次。只允许回调函数执行一次。
// $ npm install thunkify
var thunkify = require('thunkify');
var fs = require('fs');
var read = thunkify(fs.readFile);
read('package.json')(function(err, str){
// ...
});
Generator 函数的流程管理
Generator函数可以自动执行。Thunk函数可以用于Generator函数的自动流程管理,它可以在回调函数里将执行权交还给Generator函数。
var g = gen();
var r1 = g.next();
r1.value(function (err, data) {
if (err) throw err;
var r2 = g.next(data);
r2.value(function (err, data) {
if (err) throw err;
g.next(data);
});
}); //Generator函数的执行过程,其实是将同一个回调函数反复传入next方法的value属性
Thunk 函数的自动流程管理
Thunk函数可以自动执行Generator函数,但它并不是Generator函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制自动控制Generator函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
function run(fn) {
var gen = fn();
function next(err, data) { //内部的next函数就是Thunk的回调函数
var result = gen.next(data); //next函数先将指针移到Generator函数的下一步(gen.next方法)
if (result.done) return; //然后判断Generator函数是否结束(result.done属性),是就直接退出
result.value(next); //如果没结束,就将next函数再传入Thunk函数(result.value属性)
}
next();
}
function* g() {
// ...
}
run(g); //这是一个基于Thunk函数的Generator执行器
//有了这个执行器,执行Generator函数方便多了。不管内部有多少个异步操作,直接把Generator函数传入run函数即可。当然,前提是每一个异步操作都要是Thunk函数,也就是说,跟在yield命令后面的必须是Thunk函数
var g = function* (){
var f1 = yield readFileThunk('fileA');
var f2 = yield readFileThunk('fileB');
// ...
var fn = yield readFileThunk('fileN');
};
run(g);//函数g封装了n个异步的读取文件操作,只要执行run函数,这些操作就会自动完成
是一个小工具,用于Generator函数的自动执行。co函数返回一个Promise对象,因此可以用then方法添加回调函数。
var gen = function* () {
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
var co = require('co');
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。
co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成才进行下一步。这时,要把并发的操作都放在数组或对象里面,跟在yield语句后面。
co(function* () {
var values = [n1, n2, n3];
yield values.map(somethingAsync);
});
function* somethingAsync(x) {
// do something async
return y
} //上面的代码允许并发三个somethingAsync异步操作,等到它们全部完成才会进行下一步
async函数就是Generator函数的语法糖。它可以让我们以同步的方式写异步代码,而不需要回调函数,可以通过async/await语法来实现异步操作。async函数完全可以看作多个异步操作包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。
async函数就是将Generator函数的星号(*)替换成async,将yield替换成await,仅此而已。
async函数对Generator函数的改进体现在以下四点:
async函数返回一个Promise对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}
asyncPrint('hello world', 50); //指定50毫秒以后输出hello world
//由于async函数返回的是Promise对象,可以作为await命令的参数。所以,上面的例子也可以写成下面的形式
async function timeout(ms) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}
asyncPrint('hello world', 50);
async函数有多种使用形式。
// 函数声明
async function foo() {}
// 函数表达式
const foo = async function () {};
// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)
// Class 的方法
class Storage {
constructor() {
this.cachePromise = caches.open('avatars');
}
async getAvatar(name) {
const cache = await this.cachePromise;
return cache.match(`/avatars/${name}.jpg`);
}
}
const storage = new Storage();
storage.getAvatar('jake').then(…);
// 箭头函数
const foo = async () => {};
返回 Promise 对象
async函数返回一个Promise对象。
async函数内部return语句返回的值,会成为then方法回调函数的参数。
async函数内部抛出错误,会导致返回的Promise对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。
async function f() {
return 'hello world'; //函数f内部return命令返回的值会被then方法回调函数接收到
}
f().then(v => console.log(v)) // "hello world"
//------------------------------------------------------------------------------
async function f() {
throw new Error('出错了');
}
f().then(
v => console.log(v),
e => console.log(e)
)// Error: 出错了
Promise 对象的状态变化
async函数返回的Promise对象,必须等到内部所有await命令后面的Promise对象执行完才会发生状态改变,除非遇到return语句或者抛出错误。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。
await 命令
正常情况下,await命令后面是一个Promise对象,返回该对象的结果。如果不是Promise对象,就直接返回对应的值。另一种情况是,await命令后面是一个thenable对象(即定义了then方法的对象),那么await会将其视为Promise对象。
async function f() {
// 等同于
// return 123;
return await 123;
}
f().then(v => console.log(v)) // 123
await命令后的Promise对象如果变为reject状态,reject的参数会被catch方法的回调函数接收到。
async function f() {
await Promise.reject('出错了');
}
f()
.then(v => console.log(v))
.catch(e => console.log(e))
// 出错了
//注意,上面代码中,await语句前面没有return,但是reject方法的参数依然传入了catch方法的回调函数。这里如果在await前面加上return,效果是一样的。
任何一个await语句后面的Promise对象变为reject状态,那么整个async函数都会中断执行。
async function f() {
await Promise.reject('出错了');
await Promise.resolve('hello world'); // 不会执行。因为第一个await语句状态变成了reject
}
有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个await放在try...catch结构里面,这样不管这个异步操作是否成功,第二个await都会执行。另一种方法是await后面的Promise对象再跟一个catch方法,处理前面可能出现的错误。
async function f() {
try {
await Promise.reject('出错了'); //将第一个await放在try...catch结构里面
} catch(e) {
}
return await Promise.resolve('hello world');
}
f()
.then(v => console.log(v))
// hello world
//------------------------------------------------------------------------------
async function f() {
await Promise.reject('出错了')
.catch(e => console.log(e)); //await后面的Promise对象再跟一个catch方法
return await Promise.resolve('hello world');
}
f()
.then(v => console.log(v))
// 出错了
// hello world
错误处理
如果await后面的异步操作出错,那么等同于async函数返回的Promise对象被reject。防止出错的方法,也是将其放在try...catch代码块中。如果有多个await命令,可以统一放在try...catch结构中。
const superagent = require('superagent');
const NUM_RETRIES = 3;
async function test() {
let i;
for (i = 0; i < NUM_RETRIES; ++i) {
try { //使用try...catch结构,实现多次重复尝试
await superagent.get('http://google.com/this-throws-an-error');
break;
} catch(err) {}
}
console.log(i); // 3
}
test(); //如果await操作成功,就会使用break语句退出循环;如果失败,会被catch语句捕捉,然后进入下一轮循环
使用注意点
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一种写法
async function myFunction() {
await somethingThatReturnsAPromise()
.catch(function (err) {
console.log(err);
});
}
let foo = await getFoo();
let bar = await getBar();
//上面代码中,getFoo和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;
async function dbFuc(db) {
let docs = [{}, {}, {}];
// 报错 因为await用在普通函数之中了
docs.forEach(function (doc) {
await db.post(doc);
});
}
//但是,如果将forEach方法的参数改成async函数,也有问题-----------------------------
function dbFuc(db) { //这里不需要 async
let docs = [{}, {}, {}]; //这时三个db.post将是并发执行,即同时执行,而不是继发执行
// 可能得到错误结果
docs.forEach(async function (doc) {
await db.post(doc);
});
}
//正确的写法是采用for循环------------------------------------------------------
async function dbFuc(db) {
let docs = [{}, {}, {}];
for (let doc of docs) {
await db.post(doc);
}
}
//另一种方法是使用数组的reduce方法----------------------------------------------
async function dbFuc(db) {
let docs = [{}, {}, {}];
await docs.reduce(async (_, doc) => { //reduce方法的第一个参数是async函数,导致该函数的第一个参数是前一步操作返回的Promise对象,所以必须使用await等待它操作结束
await _;
await db.post(doc);
}, undefined); 另外,reduce方法返回的是docs数组最后一个成员的async函数的执行结果,也是一个Promise对象,导致在它前面也必须加上await
}
const a = async () => {
await b();
c();
}; //b()运行的时候,a()是暂停执行,上下文环境都保存着,一旦b()或c()报错,错误堆栈将包括a()
async函数的实现原理,就是将Generator函数和自动执行器包装在一个函数里。
async function fn(args) {
// ...
}
// 等同于
function fn(args) {
return spawn(function* () {
// ...
});
} //所有的async函数都可以写成第二种形式,其中的spawn函数就是自动执行器。
实际开发中,经常遇到一组异步操作需要按照顺序完成。我们可以用async函数实现。
async function logInOrder(urls) {
// 并发读取远程URL
const textPromises = urls.map(async url => {
const response = await fetch(url);
return response.text();
}); //虽然map方法的参数是async函数,但它是并发执行的,因为只有async函数内部是继发执行,外部不受影响
// 按次序输出
for (const textPromise of textPromises) {
console.log(await textPromise);
} //for..of循环内部使用了await,因此实现了按顺序输出。
}
允许在模块的顶层独立使用await命令,是为了更好地支持异步模块加载,使得异步操作更加简洁和直观。它保证只有等到异步操作完成,模块才会输出值。
顶层的await命令有点像交出代码的执行权给其他的模块加载,等异步操作完成后,再拿回执行权,继续向下执行。模块的使用者完全不用关心依赖模块的内部有没有异步操作,正常加载即可,这时,模块的加载会等待依赖模块的异步操作完成,才执行后面的代码,有点像暂停在那里。
下面是顶层await的一些使用场景:
// import() 方法加载
const strings = await import(`/i18n/${navigator.language}`);
// 数据库操作
const connection = await dbConnector();
// 依赖回滚
let jQuery;
try {
jQuery = await import('https://cdn-a.com/jQuery');
} catch {
jQuery = await import('https://cdn-b.com/jQuery');
}
注意,如果加载多个包含顶层await命令的模块,加载命令是同步执行的。
// x.js
console.log("X1");
await new Promise(r => setTimeout(r, 1000));
console.log("X2");
// y.js
console.log("Y");
// z.js
import "./x.js";
import "./y.js";
console.log("Z");
//X1、Y、X2、Z。并没有等待x.js加载完成再去加载y.js。
ES6提供了更接近传统语言的写法,引入了Class(类)这个概念作为对象的模板。通过class关键字,可以定义类。基本上,它可以看作只是一个语法糖,它的绝大部分功能ES5都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。
class Point { //constructor方法就是构造方法,而this关键字则代表实例对象
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
//注意,定义类的方法时,前面不需加上function,直接把函数定义放进去就行,方法间不需逗号分隔,加了会报错
ES6的类,完全可以看作构造函数的另一种写法,类的数据类型就是函数,类本身就指向构造函数。使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。
class Bar {
doStuff() {
console.log('stuff');
}
}
var b = new Bar();
b.doStuff() // "stuff"
构造函数的prototype属性在ES6的类上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。在类的实例上面调用方法,其实就是调用原型上的方法。
class Point {
constructor() {
// ...
}
toString() {
// ...
}
toValue() {
// ...
}
}
// 等同于
Point.prototype = {
constructor() {},
toString() {},
toValue() {},
}; //在类的实例上面调用方法,其实就是调用原型上的方法
class B {}
let b = new B();
b.constructor === B.prototype.constructor // true。b是B类的实例,它的constructor方法就是B类原型的constructor方法
由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign方法可以很方便地一次向类添加多个方法。类的内部所有定义的方法都是不可枚举的。
class Point {
constructor(){
// ...
}
toString() {
// ...
}
}
Object.assign(Point.prototype, {
toNumber(){}, //很方便地一次向类添加多个方法
toValue(){}
});
//prototype对象的constructor属性直接指向类的本身,这与ES5的行为是一致的。
Point.prototype.constructor === Point // true
//toString方法是Point类内部定义的方法,它是不可枚举的,这一点与ES5的行为不一致。
Object.keys(Point.prototype) // []
//如下代码所示,采用ES5的写法,toString方法就是可枚举的
var Point = function (x, y) { // ... };
Point.prototype.toString = function() { // ... };
Object.keys(Point.prototype)// ["toString"]
类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加。类必须使用new调用,否则会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行。
class Point {
}
// 等同于
class Point {
constructor() {}
} //定义了一个空的类Point,JavaScript引擎会自动为它添加一个空的constructor方法
constructor方法默认返回实例对象(即this),完全可以指定返回另外一个对象。
class Foo {
constructor() {
return Object.create(null); //constructor函数返回一个全新的对象,导致实例对象不是Foo类的实例
}
}
new Foo() instanceof Foo // false
生成类的实例的写法与ES5完全一样,也是使用new命令。如果忘记加上new,像函数那样调用Class,将会报错。与ES5一样,实例的属性除非显式定义在其本身(即定义在this对象上),否则都是定义在原型上(即定义在class上)。
类的所有实例共享一个原型对象,即__proto__属性是相等的。这也意味着,可以通过实例的__proto__属性为类添加方法。使用实例的__proto__属性改写原型必须相当谨慎,不推荐使用,因为这会改变类的原始定义,影响到所有实例。
var p1 = new Point(2,3);
var p2 = new Point(3,2);
p1.__proto__.printName = function () { return 'Oops' };
p1.printName() // "Oops" 在p1的原型上添加了一个printName方法
p2.printName() // "Oops" 由于p1的原型就是p2的原型,因此p2也可以调用这个方法
var p3 = new Point(4,2);
p3.printName() // "Oops" 此后新建的实例p3也可以调用这个方法
在类的内部可以用get和set关键字对某个属性设置存值函数和取值函数,拦截该属性的存取行为。存值函数和取值函数是设置在属性的Descriptor对象上的。
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'
类的属性名可以采用表达式。
let methodName = 'getArea';
class Square {
constructor(length) {
// ...
}
[methodName]() {
// ...
}
} //Square类的方法名getArea是从表达式得到的
与函数一样,类也可以使用表达式的形式定义。
const MyClass = class Me {
getClassName() {
return Me.name;
}
}; //Me只在Class的内部可用,指代当前类,在Class外部,这个类只能用MyClass引用
let inst = new MyClass();
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined
//如果类的内部没用到的话,可以省略Me,也就是可以写成下面的形式。
const MyClass = class { /* ... */ };
采用Class表达式,可以写出立即执行的Class。
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}('张三'); //person是一个立即执行的类的实例
person.sayName(); // "张三"
{
let Foo = class {};
class Bar extends Foo { //Bar继承Foo的时候,Foo已经有定义了
}
}//如果存在class的提升,就会报错,因为class会被提升到代码头部,而let命令是不提升的,所以导致Bar继承Foo的时候,Foo还没有定义
class Point {}
Point.name // "Point"
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; //箭头函数内部的this总是指向定义时所在的对象。
}
}//箭头函数位于构造函数内部,它的定义生效时是在构造函数执行时,这时,箭头函数所在的运行环境肯定是实例对象,所以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关键字,就表示该方法不会被实例继承,不能通过实例调用,而是直接通过类来调用,这就称为静态方法。
如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。
注意,如果静态方法包含this关键字,这个this指的是类,而不是实例。
静态方法可以与非静态方法重名,父类的静态方法可以被子类继承,静态方法也是可以从super对象上调用的。
实例属性除了定义在constructor()方法里面的this上面,也可以定义在类的最顶层。这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。
class foo {
bar = 'hello';
baz = 'world';
constructor() {
// ...
}
} //一眼就能看出foo类有两个实例属性,一目了然,写起来也比较简洁
静态属性指的是Class本身的属性,即Class.propName,而不是定义在实例对象(this)上的属性。静态属性不能通过实例对象来访问,而是通过类本身来访问,不会被实例继承。写法是在实例属性的前面加上static关键字,这个新写法大大方便了静态属性的表达。
// 老写法。老写法的静态属性定义在类的外部,整个类生成以后再生成静态属性。这样让人很容易忽略这个静态属性,也不符合相关代码应该放在一起的代码组织原则
class Foo {
// ...
}
Foo.prop = 1;
// 新写法。显式声明(declarative),而不是赋值处理,语义更好
class Foo {
static prop = 1;
}
静态属性和方法可以被子类继承,但子类不能覆盖父类的静态属性和方法。在访问静态属性和方法时,可以通过类名来区分来自父类还是子类的静态属性和方法。
私有属性和私有方法不能被子类继承,它们只能被父类本身所使用和管理。只能在所属的类内部被访问和修改,而不能被其他类访问或继承,子类无法直接访问,因为它们在父类的外部是不可见的。
私有方法和私有属性是只能在类的内部访问的方法和属性,外部不能访问。这是常见需求,有利于代码的封装,但ES6不提供,只能通过变通方法模拟实现。
class Widget {
// 公有方法
foo (baz) {
this._bar(baz);
}
// 私有方法
_bar(baz) {
return this.snaf = baz;
} //_bar前的下划线表示一个只限于内部使用的私有方法,但不保险,在类的外部还是可以调用到这个方法
// ...
}
class Widget {
foo (baz) { //foo是公开方法,内部调用了bar.call(this, baz),使得bar实际上成为了当前模块的私有方法
bar.call(this, baz);
}
// ...
}
function bar(baz) {
return this.snaf = baz;
}
const bar = Symbol('bar');
const snaf = Symbol('snaf');
export default class myClass{
// 公有方法
foo(baz) {
this[bar](baz);
}
// 私有方法
[bar](baz) {
return this[snaf] = baz;
} //bar和snaf都是Symbol值,一般情况下无法获取到它们,因此达到了私有方法和私有属性的效果
// ...
};
//但也不是绝对不行,Reflect.ownKeys()依然可以拿到它们
const inst = new myClass(); //Symbol值的属性名依然可以从类的外部拿到
Reflect.ownKeys(myClass.prototype) // [ 'constructor', 'foo', Symbol(bar) ]
class IncreasingCounter {
#count = 0;
get value() {
console.log('Getting the current value!');
return this.#count; //只能在里面用
}
increment() {
this.#count++;
}
} //#count就是私有属性,只能在类的内部使用(this.#count),如果在类的外部使用就会报错
const counter = new IncreasingCounter(); //在类的外部,读取私有属性,就会报错
counter.#count // 报错
counter.#count = 42 // 报错
另外,私有属性也可以设置getter和setter方法。私有属性不限于从this引用,只要是在类的内部,实例也可以引用私有属性。私有属性和私有方法前面也可以加上static关键字,表示这是一个静态的私有属性或私有方法。
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会返回子类。利用这个特点,可以写出不能独立使用必须继承后才能使用的类。在函数外部使用new.target会报错。
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); // 正确
Class可以通过extends关键字实现继承,这比ES5的通过修改原型链实现继承要清晰和方便很多。
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的constructor(x, y)
this.color = color;
}
toString() {
return this.color + ' ' + super.toString(); // 调用父类的toString()
}
} //super关键字在这里表示父类的构造函数,用来新建父类的this对象。
子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类自己的this对象必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。
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方法才能调用父类实例。父类的静态方法也会被子类继承。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
this.color = color; // ReferenceError
super(x, y);
this.color = color; // 正确
}
}
可以用来从子类上获取父类。可以使用这个方法判断一个类是否继承了另一个类。
Object.getPrototypeOf(ColorPoint) === Point // true
super关键字既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。
class A {
constructor() {
console.log(new.target.name); //new.target指向当前正在执行的函数
}
}
class B extends A {
constructor() {
super();
m() { //super()用在B类的m方法中就会造成语法错误
super(); // 报错
}
}
} //super虽然代表了父类A的构造函数,但是返回的是子类B的实例,即super内部的this指的是B的实例,因此super()在这里相当于A.prototype.constructor.call(this)。
new A() // A
new B() // B 在super()执行时,它指向的是子类B的构造函数,而不是父类A的构造函数。也就是说,super()内部的this指向的是B。
class A {
constructor() {
this.p = 2; //p是父类A实例的属性,super.p就引用不到它
}
}
class B extends A {
get m() {
return super.p; //将super当作对象。这时,super在普通方法中,指向A.prototype,所以super.p()就相当于A.prototype.p()
}
}
let b = new B();
b.m // undefined。p是父类A实例的属性,super.p就引用不到它。
//如果属性定义在父类的原型对象上,super就可以取到。
class A {}
A.prototype.x = 2; //x是定义在A.prototype上面的,所以super.x可以取到它的值
class B extends A {
constructor() {
super();
console.log(super.x) // 2
}
}
let b = new B();
在子类普通方法中通过super调用父类的方法时,方法内部的this指向当前的子类实例,所以如果通过super对某个属性赋值,这时super就是this,赋值的属性会变成子类实例的属性。
class A {
constructor() {
this.x = 1;
}
}
class B extends A {
constructor() {
super();
this.x = 2;
super.x = 3; //super.x赋值为3,这时等同于对this.x赋值为3
console.log(super.x); // undefined 当读取super.x时,读的是A.prototype.x,所以返回undefined
console.log(this.x); // 3
}
}
let b = new B();
如果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); //super在静态方法中指向父类
}
myMethod(msg) {
super.myMethod(msg); //super在普通方法中指向父类的原型对象
}
}
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(); //指向父类的静态方法,这个方法里面的this指向的是B,而不是B的实例
}
}
B.x = 3;
B.m() // 3
注意,使用super的时候,必须显式指定是作为函数还是作为对象使用,否则会报错。由于对象总是继承其他对象的,所以可以在任意一个对象中使用super关键字。
大多数浏览器的ES5实现中,每个对象都有__proto__属性,指向对应的构造函数的prototype属性。
Class作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。
class A {
}
class B extends A {
}
B.__proto__ === A // true。子类B的__proto__属性指向父类A
B.prototype.__proto__ === A.prototype // true。子类B的prototype属性的__proto__属性指向父类A的prototype
//这样的结果是因为,类的继承是按照下面的模式实现的
class A {
}
class B {
}
Object.setPrototypeOf(B.prototype, A.prototype); //B的实例继承A的实例
Object.setPrototypeOf(B, A); //B继承A的静态属性
const b = new B();
两条继承链
//作为一个对象,子类B的原型(__proto__属性)是父类A------------------------------------
Object.setPrototypeOf(B.prototype, A.prototype); // B的实例继承A的实例
// 等同于
B.prototype.__proto__ = A.prototype;//-----------------------------------------
//作为一个构造函数,子类B的原型对象(prototype属性)是父类的原型对象(prototype属性)的实例
Object.setPrototypeOf(B, A); //B继承A的静态属性
// 等同于
B.__proto__ = A;//-------------------------------------------------------------
B.prototype = Object.create(A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;
extends关键字后面可以跟多种类型的值。下面列举两种情况。
class B extends A {
} //只要是一个有prototype属性的函数就能被B继承。由于函数都有prototype属性(除了Function.prototype函数),因此A可以是任意函数
第一种情况,子类继承Object类。
class A extends Object {
} //A其实就是构造函数Object的复制,A的实例就是Object的实例。
A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true
第二种情况,不存在任何继承。
class A {
}//A作为一个基类(即不存在任何继承),就是一个普通函数,所以直接继承Function.prototype。但是A调用后返回一个空对象(即Object实例),所以A.prototype.__proto__指向构造函数(Object)的prototype属性
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true
子类实例的__proto__属性的__proto__属性,指向父类实例的__proto__属性。也就是说,子类的原型的原型,是父类的原型。因此,通过子类实例的__proto__.__proto__属性,可以修改父类实例的行为。
p2.__proto__.__proto__.printName = function () {
console.log('Ha');
}; //在ColorPoint的实例p2上向Point类添加方法,结果影响到了Point的实例p1。
p1.printName() // "Ha"
原生构造函数是指语言内置的构造函数,通常用来生成数据结构。ES6允许继承原生构造函数定义子类,因为ES6是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可继承。这意味着ES6可以自定义原生数据结构(比如Array、String等)的子类,这是ES5无法做到的。
ECMAScript的原生构造函数大致有下面这些。
注意,继承Object的子类无法通过super方法向父类Object传参。 ES6改变了Object构造函数的行为,一旦发现Object方法不是通过new Object()这种形式调用,Object构造函数会忽略参数。
Mixin指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。可以将多个对象合成为一个类,使用的时候只要继承这个类即可。
const a = {
a: 'a'
};
const b = {
b: 'b'
};
const c = {...a, ...b}; // {a: 'a', b: 'b'} c对象是a对象和b对象的合成,具有两者的接口
ES6模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系以及输入和输出的变量,CommonJS和AMD模块都只能在运行时确定这些东西。
模块功能主要由两个命令构成:export和import。
export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
ES6模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。可以在编译时就完成模块加载,这使得静态分析成为可能。有了它,就能进一步拓宽JavaScript的语法。
// ES6模块。实质是从fs模块加载3个方法,其他方法不加载。这种加载称为编译时加载或静态加载
import { stat, exists, readFile } from 'fs';
除了静态加载带来的各种好处,ES6模块还有以下好处。
import命令会被JS引擎静态分析,先于模块内的其他语句执行(import命令叫做连接binding其实更合适)。import和export命令只能在模块的顶层,不能在代码块中(比如在if代码块中,或在函数中)。这样的设计有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载不可能实现。
import()函数支持动态加载模块。import()返回一个Promise对象。import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于Node的require方法,区别主要是前者是异步加载,后者是同步加载。
下面是import()的一些适用场合:
button.addEventListener('click', event => { //只有用户点击了按钮才会加载这个模块
import('./dialogBox.js')
.then(dialogBox => {
dialogBox.open();
})
.catch(error => {
/* Error handling */
})
});
if (condition) { //如果满足条件就加载模块 A,否则加载模块 B
import('moduleA').then(...);
} else {
import('moduleB').then(...);
}
import(f()) //根据函数f的返回结果加载不同的模块
.then(...);
注意点:
import('./myModule.js')
.then(({export1, export2}) => { //export1和export2都是myModule.js的输出接口,可以解构获得
// ...·
});
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]) => {
···
});
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();
ES6的模块自动采用严格模式,不管有没有在模块头部加上"use strict"。主要有以下限制。
尤其注意this的限制。ES6模块中,顶层的this指向undefined,即不应该在顶层代码使用this。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。
export命令除了输出变量,还可以输出函数或类(class)。
通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
// 报错
function f() {}
export f;
// 正确
export function f() {};
// 正确
function f() {}
export {f};
export语句输出的接口与其对应的值是动态绑定关系,即通过该接口可以取到模块内部实时的值。
export var foo = 'bar'; //输出变量foo,值为bar
setTimeout(() => foo = 'baz', 500); //500毫秒之后变成baz
export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错。这是因为处于条件代码块中,就没法做静态优化了,违背了ES6模块的设计初衷。
使用export命令定义了模块的对外接口以后,其他JS文件就可以通过import命令加载这个模块。
import命令接受一对大括号,里面指定要从其他模块导入的变量名,大括号里面的变量名必须与被导入模块对外接口的名称相同。
如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import命令输入的变量都是只读的,因为它的本质是输入接口,不允许在加载模块的脚本里面改写接口。建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。
import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉JavaScript引擎该模块的位置。
注意,import命令具有提升效果,会提升到整个模块的头部首先执行。
import在静态解析阶段执行,所以它是一个模块之中最早执行的,所以不能使用表达式、变量和if结构等,这些只有在运行时才能得到结果的语法结构。
// 报错
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语句,那么只会执行一次,而不会执行多次。
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。注意,模块整体加载所在的那个对象应该是可以静态分析的,所以不允许运行时改变。
import * as circle from './circle'; //整体加载
console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));
// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};
export default命令用于为模块指定默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次,所以import命令后面才不用加大括号,因为只可能唯一对应export default命令。export default也可以用来输出类。
例如,一个模块文件默认输出一个匿名函数,那么其他模块加载该模块时,import命令可以为该匿名函数指定任意名字,这时就不需要知道原模块输出的函数名。export default命令用在非匿名函数前也是可以的。需要注意的是,这时import命令后面不使用大括号。
// 第一组。使用export default时,对应的import语句不需要使用大括号
export default function crc32() { // 输出
// ...
}
import crc32 from 'crc32'; // 输入
// 第二组。不使用export default时,对应的import语句需要使用大括号。
export function crc32() { // 输出
// ...
};
import {crc32} from 'crc32'; // 输入
本质上,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; //将变量a的值赋给变量default
// 错误
export default var a = 1;
因为export default命令的本质是将后面的值赋给default变量,所以可以直接将一个值写在export default之后。
// 正确
export default 42;
// 报错
export 42; //没有指定对外的接口
有了export default命令,输入模块时就非常直观了。
import _ from 'lodash';//输入 lodash 模块
import _, { each, forEach } from 'lodash'; //如果想在一条import语句中同时输入默认方法和其他接口
如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
export { foo, bar } from 'my_module'; //写成一行后,foo和bar实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foo和bar
// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };
//模块的接口改名和整体输出,也可以采用这种写法。--------------------------------------
// 接口改名
export { foo as myFoo } from 'my_module';
// 整体输出
export * from 'my_module';
//默认接口的写法如下。------------------------------------------------------------
export { default } from 'foo';
//具名接口改为默认接口的写法如下。--------------------------------------------------
export { es6 as default } from './someModule';
// 等同于
import { es6 } from './someModule';
export default es6;
//同样地,默认接口也可以改名为具名接口。---------------------------------------------
export { default as es6 } from './someModule';
//之前,有一种import语句没有对应的复合写法,ES2020补上了这个写法。---------------------
export * as ns from "mod";
// 等同于
import * as ns from "mod";
export {ns};
模块之间也可以继承。注意,export *命令并不会导出从其他模块导入的 default 导出项。因为 export * 只导出那些被明确标记为导出的项,而默认导出并不属于这种情况,它在每个模块中只能有一个,并且它并不需要一个明确的导出名称。
如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。
// constants.js 模块
export const A = 1;
export const B = 3;
export const C = 4;
// test1.js 模块
import * as constants from './constants';
console.log(constants.A); // 1
console.log(constants.B); // 3
// test2.js 模块
import {A, B} from './constants';
console.log(A); // 1
console.log(B); // 3
如果要使用的常量非常多,可以建一个专门的constants目录,将各种常量写在不同的文件里面,保存在该目录下。然后将这些文件输出的常量合并在index.js里面。使用时,直接加载index.js就可以了。
// constants/db.js
export const db = {
url: 'http://my.couchdbserver.local:5984',
admin_username: 'admin',
admin_password: 'admin password'
};
// constants/user.js
export const users = ['root', 'admin', 'staff', 'ceo', 'chief', 'moderator'];
// constants/index.js
export {db} from './db';
export {users} from './users';
// script.js
import {db, users} from './constants/index';
JavaScript语法规定throw是一个命令,用来抛出错误,不能用于表达式之中。现在有一个提案,允许throw用于表达式。语法上,throw表达式里面的throw不再是一个命令,而是一个运算符。为了避免与throw命令混淆,规定throw出现在行首,一律解释为throw语句,而不是throw表达式。
// 参数的默认值
function save(filename = throw new TypeError("Argument required")) {
}
// 箭头函数的返回值
lint(ast, {
with: () => throw new Error("avoid using 'with' statements.")
});
// 条件表达式
function getEncoder(encoding) {
const encoder = encoding === "utf8" ?
new UTF8Encoder() :
encoding === "utf16le" ?
new UTF16Encoder(false) :
encoding === "utf16be" ?
new UTF16Encoder(true) :
throw new Error("Unsupported encoding");
}
// 逻辑表达式
class Product {
get id() {
return this._id;
}
set id(value) {
this._id = value || throw new Error("Invalid value");
}
}
多参数的函数有时需要绑定其中的一个或多个参数,然后返回一个新函数。现在有一个提案,使得绑定参数并返回一个新函数更加容易。这叫做函数的部分执行。
const add = (x, y) => x + y;
const addOne = add(1, ?);
const maxGreaterThanZero = Math.max(0, ...);
根据新提案,?是单个参数的占位符,...是多个参数的占位符。以下的形式都属于函数的部分执行。
f(x, ?)
f(x, ...)
f(?, x)
f(..., x)
f(?, x, ?)
f(..., x, ...)
?和...只能出现在函数的调用之中,并且会返回一个新函数。
const g = f(?, 1, ...);
// 等同于
const g = (x, ...y) => f(x, 1, ...y);
函数的部分执行,也可以用于对象的方法。
let obj = {
f(x, y) { return x + y; },
};
const g = obj.f(?, 3);
g(1) // 4
函数的部分执行有一些特别注意的地方
JavaScript的管道是一个运算符,写作|>。它的左边是一个表达式,右边是一个函数。管道运算符把左边表达式的值传入右边的函数进行求值。
x |> f
// 等同于
f(x)
管道运算符最大的好处,就是可以把嵌套的函数写成从左到右的链式表达式。
// 传统的写法
exclaim(capitalize(doubleSay('hello'))) // "Hello, hello!"
// 管道的写法
'hello'
|> doubleSay
|> capitalize
|> exclaim // "Hello, hello!"
管道运算符只能传递一个值,这意味着它右边的函数必须是一个单参数函数。如果是多参数函数,就必须进行柯里化,改成单参数的版本。
function double (x) { return x + x; }
function add (x, y) { return x + y; }
let person = { score: 25 };
person.score
|> double
|> (_ => add(7, _)) // 57
//add函数需要两个参数。但管道运算符只能传入一个值,因此需要事先提供另一个参数,并将其改成单参数的箭头函数_ => add(7, _)
管道运算符对于await函数也适用。
x |> await f
// 等同于
await f(x)
const userAge = userId |> await fetchUserById |> getAgeFromUser;
// 等同于
const userAge = getAgeFromUser(await fetchUserById(userId));
现在有一个提案,允许 JavaScript 的数值使用下划线(_)作为分隔符。数值分隔符没有指定间隔的位数,也就是说,可以每三位添加一个分隔符,也可以每一位、每两位、每四位添加一个。小数和科学计数法也可以使用数值分隔符。除了十进制,其他进制的数值也可以使用分隔符,注意,分隔符不能紧跟着进制的前缀0b、0B、0o、0O、0x、0X。
数值分隔符有几个使用注意点。
下面三个将字符串转成数值的函数,不支持数值分隔符。主要原因是提案的设计者认为,数值分隔符主要是为了编码时书写数值的方便,而不是为了处理外部输入的数据。
目前有一个提案,引入了Math.signbit()方法判断一个数的符号位是否设置了。
该方法的算法如下:
Math.signbit(2) //false
Math.signbit(-2) //true
Math.signbit(0) //false
Math.signbit(-0) //true 该方法正确返回了-0的符号位是设置了的
箭头函数可以绑定this对象,大大减少了显式绑定this对象的写法(call、apply、bind)。但箭头函数并不适用于所有场合,所以有一个提案,提出了函数绑定运算符,用来取代call、apply、bind调用。
函数绑定运算符是并排的两个冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象作为上下文环境(即this对象),绑定到右边的函数上面。
foo::bar;
// 等同于
bar.bind(foo);
foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);
const hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key) {
return obj::hasOwnProperty(key);
}
如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。
var method = obj::obj.foo;
// 等同于
var method = ::obj.foo;
let log = ::console.log;
// 等同于
var log = console.log.bind(console);
如果双冒号运算符的运算结果还是一个对象,就可以采用链式写法。
import { map, takeWhile, forEach } from "iterlib";
getPlayers()
::map(x => x.character())
::takeWhile(x => x.strength > 100)
::forEach(x => console.log(x));
Realm API 提供沙箱功能,允许隔离代码,防止那些被隔离的代码拿到全局对象。提供一个Realm()构造函数,用来生成一个Realm 对象,该对象的global属性指向一个新的顶层对象,这个顶层对象跟原始的顶层对象类似。Realm()构造函数可接受一个参数对象,该参数对象的intrinsics属性可指定Realm沙箱继承原始顶层对象的方法。用户可以自己定义Realm的子类,用来定制自己的沙箱。
有一个提案,为JavaScript脚本引入了#!命令,写在脚本文件或者模块文件的第一行。有了这一行以后,Unix命令行就可以直接执行脚本。对JavaScript引擎来说,会把#!理解成注释,忽略掉这一行。
装饰器可以用来装饰整个类。装饰器是一个对类进行处理的函数,装饰器函数的第一个参数就是所要装饰的目标类。注意,装饰器对类的行为的改变是代码编译时发生的,而不是在运行时。这意味着,装饰器能在编译阶段运行代码。也就是说,装饰器本质就是编译时执行的函数。
@testable //一个装饰器
class MyTestableClass {
// ...
}
function testable(target) { //参数target是MyTestableClass类本身,就是会被装饰的类
target.isTestable = true; //修改了MyTestableClass这个类的行为,为它加上了静态属性isTestable
}
MyTestableClass.isTestable // true
//前面的例子是为类添加一个静态属性,如果想添加实例属性,可以通过目标类的prototype对象操作
function testable(target) {
target.prototype.isTestable = true; //是在目标类的prototype对象上添加属性,因此可以在实例上调用
}
@testable
class MyTestableClass {}
let obj = new MyTestableClass();
obj.isTestable // true
基本上,装饰器的行为就是下面这样。
@decorator
class A {}
// 等同于
class A {}
A = decorator(A) || A;
如果觉得一个参数不够用,可以在装饰器外面再封装一层函数。
function testable(isTestable) {
return function(target) {
target.isTestable = isTestable;
}
}
@testable(true) //testable可以接受参数,这就等于可以修改装饰器的行为
class MyTestableClass {}
MyTestableClass.isTestable // true
@testable(false)
class MyClass {}
MyClass.isTestable // false
装饰器不仅可以装饰类,还可以装饰类的属性。
class Person {
@readonly //装饰器readonly用来装饰类的name方法
name() { return `${this.first} ${this.last}` }
}
装饰器会修改属性的描述对象,然后被修改的描述对象再用来定义属性。
function readonly(target, name, descriptor){
// descriptor对象原来的值如下
// {
// value: specifiedFunction,
// enumerable: false,
// configurable: true,
// writable: true
// };
descriptor.writable = false;
return descriptor;
}
readonly(Person.prototype, 'name', descriptor);
// 类似于
Object.defineProperty(Person.prototype, 'name', descriptor);
装饰器函数readonly一共可以接受三个参数。第一个参数是类的原型对象,装饰器的本意是要装饰类的实例,但是这个时候实例还没生成,所以只能去装饰原型(这不同于类的装饰,那种情况时target参数指的是类本身);第二个参数是所要装饰的属性名,第三个参数是该属性的描述对象。
如果同一个方法有多个装饰器,会像剥洋葱一样,先从外到内进入,然后由内向外执行。
function dec(id){
console.log('evaluated', id);
return (target, property, descriptor) => console.log('executed', id);
}
class Example {
@dec(1)
@dec(2)
method(){}
} //外层装饰器@dec(1)先进入,但是内层装饰器@dec(2)先执行
// evaluated 1
// evaluated 2
// executed 2
// executed 1
除了注释,装饰器还能用来类型检查。所以,对于类来说,这项功能相当有用。从长期来看,它将是JavaScript代码静态分析的重要工具。
装饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。类是不会提升的,所以就没有这方面的问题。
var counter = 0;
var add = function () {
counter++; //意图是执行后counter等于1
};
@add
function foo() {
} //但是实际上结果是counter等于0,因为函数提升
如果一定要装饰函数,可以采用高阶函数的形式直接执行。
function doSomething(name) {
console.log('Hello, ' + name);
}
function loggingDecorator(wrapped) {
return function() {
console.log('Starting');
const result = wrapped.apply(this, arguments);
console.log('Finished');
return result;
}
}
const wrapped = loggingDecorator(doSomething);
是一个第三方模块,提供了几个常见的装饰器,通过它可以更好地理解装饰器。
@autobind:autobind装饰器使得方法中的this对象绑定原始对象。
@readonly:readonly装饰器使得属性或方法不可写。
@override:override装饰器检查子类的方法是否正确覆盖了父类的同名方法,如果不正确会报错。
@deprecate (别名@deprecated):deprecate或deprecated装饰器在控制台显示一条警告,表示该方法将废除。
@suppressWarnings:suppressWarnings装饰器抑制deprecated装饰器导致的console.warn()调用。但是,异步代码发出的调用除外。
我们可以使用装饰器使得对象的方法被调用时,自动发出一个事件。
const postal = require("postal/lib/postal.lodash");
export default function publish(topic, channel) { //定义了一个名为publish的装饰器
const channelName = channel || '/';
const msgChannel = postal.channel(channelName);
msgChannel.subscribe(topic, v => {
console.log('频道: ', channelName);
console.log('事件: ', topic);
console.log('数据: ', v);
});
return function(target, name, descriptor) {
const fn = descriptor.value; //改写descriptor.value使得原方法被调用时自动发出一个事件
descriptor.value = function() {
let value = fn.apply(this, arguments);
msgChannel.publish(topic, value);
};
};
}
//它使用的事件“发布/订阅”库是Postal.js,用法如下
// index.js
import publish from './publish';
class FooComponent {
@publish('foo.some.message', 'component')
someMethod() {
return { my: 'data' };
}
@publish('foo.some.other')
anotherMethod() {
// ...
}
}
let foo = new FooComponent();
foo.someMethod();
foo.anotherMethod();
//以后,只要调用someMethod或者anotherMethod,就会自动发出一个事件。
$ bash-node index.js
频道: component
事件: foo.some.message
数据: { my: 'data' }
频道: /
事件: foo.some.other
数据: undefined
在装饰器的基础上,可以实现Mixin模式。所谓Mixin模式就是对象继承的一种替代方案,中文译为混入,意为在一个对象中混入另外一个对象的方法。
const Foo = {
foo() { console.log('foo') }
};
class MyClass {}
Object.assign(MyClass.prototype, Foo); //通过Object.assign方法可将foo方法混入MyClass类
let obj = new MyClass();
obj.foo() // 'foo' 导致MyClass的实例obj对象都具有foo方法
我们可以部署一个通用脚本mixins.js,将Mixin写成一个装饰器,然后就可以使用这个装饰器为类混入各种方法,但是这样会改写类的prototype对象,如果不喜欢这一点,可通过类的继承实现Mixin。
如果需要混入多个方法,就生成多个混入类。
class MyClass extends Mixin1(Mixin2(MyBaseClass)) {
/* ... */
} //这种写法的一个好处是可以调用super,因此可以避免在混入过程中覆盖父类的同名方法。
也是一种装饰器,效果与Mixin类似,但是提供更多功能,比如防止同名方法的冲突、排除混入某些方法、为混入的方法起别名等等。
import { traits } from 'traits-decorator';
class TFoo {
foo() { console.log('foo') }
}
const TBar = {
bar() { console.log('bar') }
};
@traits(TFoo, TBar) //通过traits装饰器在MyClass类上混入了TFoo类的foo方法和TBar对象的bar方法
class MyClass { }
let obj = new MyClass();
obj.foo() // foo
obj.bar() // bar
Trait不允许混入同名方法。一种解决方法是使用绑定运算符(::)在类中排除其中一个方法,另一种方法是为其中一个方法起一个别名。alias和excludes方法可以结合起来使用。
@traits(TExample::excludes('foo','bar')::alias({baz:'exampleBaz'}))
class MyClass {} //排除了TExample的foo方法和bar方法,为baz方法起了别名exampleBaz
//as方法则为上面的代码提供了另一种写法
@traits(TExample::as({excludes:['foo', 'bar'], alias: {baz: 'exampleBaz'}}))
class MyClass {}
函数绑定运算符(::)是一种用于将一个对象绑定到函数上下文中的运算符。它通常用于将一个对象作为上下文环境(this对象)绑定到函数上,以便在函数中引用该对象的属性和方法。
在JavaScript中,使用函数绑定运算符可以 将一个对象绑定到一个函数上, 以便在函数内部使用this关键字来 访问 该对象的属性和方法。还可以用于 将对象的方法绑定到对象本身上, 可以更方便地 调用 该方法并 利用 对象的属性。
Mixin允许向一个类里面注入一些代码,使得一个类的功能能够混入另一个类。实质上是多重继承的一种解决方案,但是避免了多重继承的复杂性,而且有利于代码复用。
Mixin就是一个正常的类,不仅定义了接口,还定义了接口的实现。子类通过在this对象上面绑定方法,达到多重继承的目的。
Trait是另外一种多重继承的解决方案。它与Mixin很相似,但是有一些细微的差别。
指的是将一个多参数的函数拆分成一系列函数,每个拆分后的函数都只接受一个参数。
function add (a, b) {
return a + b;
}
add(1, 1) // 2
//柯里化就是将上面的函数拆分成两个函数,每个函数都只接受一个参数。
function add (a) {
return function (b) {
return a + b;
}
}
// 或者采用箭头函数写法
const add = x => y => x + y;
const f = add(1);
f(1) // 2
指的是将多个函数合成一个函数。
const compose = f => g => x => f(g(x));
const f = compose (x => x * 4) (x => x + 3); //一个函数合成器,用于将两个函数合成一个函数
f(2) // 20
指的是改变函数前两个参数的顺序。
var divide = (a, b) => a / b;
var flip = f.flip(divide);
flip(10, 5) // 0.5 参数倒置以后得到的新函数结果就是5除以10,结果得到0.5
flip(1, 10) // 10
var three = (a, b, c) => [a, b, c];
var flip = f.flip(three);
flip(1, 2, 3); // => [2, 1, 3]
//如果原函数有3个参数,则只颠倒前两个参数的位置
let f = {};
f.flip =
fn =>
(a, b, ...args) => fn(b, a, ...args.reverse());
指的是函数执行到满足条件为止。如果满足条件就返回结果,否则不断递归执行。
let condition = x => x > 100; //执行到x大于100为止
let inc = x => x + 1; //x初值为0时,会一直执行到101
let until = f.until(condition, inc);
until(0) // 101
condition = x => x === 5; //执行到x等于5为止
until = f.until(condition, inc); //所以x最后的值是 5
until(3) // 5
//执行边界的实现如下
let f = {};
f.until = (condition, f) =>
(...args) => {
var r = f.apply(null, args);
return condition(r) ? r : f.until(condition, f)(r);
};
队列操作包括以下几种。
f.head(5, 27, 3, 1) // 5
f.last(5, 27, 3, 1) // 1
f.tail(5, 27, 3, 1) // [27, 3, 1]
f.init(5, 27, 3, 1) // [5, 27, 3]
//这些方法的实现如下
let f = {};
f.head = (...xs) => xs[0];
f.last = (...xs) => xs.slice(-1);
f.tail = (...xs) => Array.prototype.slice.call(xs, 1);
f.init = (...xs) => xs.slice(0, -1);
合并操作分为concat和concatMap两种。前者就是将多个数组合成一个,后者则是先处理一下参数,然后再将处理结果合成一个数组。
f.concat([5], [27], [3]) // [5, 27, 3]
f.concatMap(x => 'hi ' + x, 1, [[2]], 3) // ['hi 1', 'hi 2', 'hi 3']
//这两种方法的实现代码如下
let f = {};
f.concat =
(...xs) => xs.reduce((a, b) => a.concat(b));
f.concatMap =
(f, ...xs) => f.concat(xs.map(f));
配对操作分为zip和zipWith两种方法。zip操作将两个队列的成员一一配对,合成一个新的队列。如果两个队列不等长,较长的那个队列多出来的成员会被忽略。zipWith操作的第一个参数是一个函数,然后会将后面的队列成员一一配对,输入该函数,返回值就组成一个新的队列。
let a = [0, 1, 2];
let b = [3, 4, 5];
let c = [6, 7, 8];
f.zip(a, b) // [[0, 3], [1, 4], [2, 5]]
f.zipWith((a, b) => a + b, a, b, c) // [9, 12, 15] 第一个参数是一个求和函数,它将后面三个队列的成员一一配对进行相加
//这两个方法的实现如下
let f = {};
f.zip = (...xs) => {
let r = [];
let nple = [];
let length = Math.min.apply(null, xs.map(x => x.length));
for (var i = 0; i < length; i++) {
xs.forEach(
x => nple.push(x[i])
);
r.push(nple);
nple = [];
}
return r;
};
f.zipWith = (op, ...xs) =>
f.zip.apply(null, xs).map(
(x) => x.reduce(op)
);