ES6与JavaScript
- ES6是JavaScript的下一代标准:ES6,也被称为ECMAScript 2015,是JavaScript语言的最新版本。它引入了许多新的语法和功能,对语言进行了广泛的增强和改进。
- ES6对JavaScript进行了广泛的增强和改进:ES6引入了模块化、箭头函数、模板字符串、解构赋值、默认参数、let和const关键字等新特性,使得JavaScript开发更加灵活和强大。
- ES6是ECMAScript标准的一个版本:ECMAScript是JavaScript语言的国际标准。ES6作为ECMAScript的第六个版本,提供了许多新的特性,使得JavaScript开发更加规范和标准化。随着时间的推移,ECMAScript标准也在不断演进,后续版本如ES7、ES8等也引入了更多的新功能。
- JavaScript是ECMAScript标准的一种实现:虽然ES6是JavaScript的下一代标准,但并非所有的JavaScript引擎都完全支持ES6的所有特性。需要babel转码器,还需要polyfill或corejs。
Babel 是一个广泛使用的 ES6 转码器,可以将 ES6 代码转为 ES5 代码,从而在老版本的浏览器执行。这意味着,你可以用 ES6 的方式编写程序,又不用担心现有环境是否支持。
let 和 const 命令
1、let命令
- 不存在变量提升
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冲突。因此,这个函数不会报错
- 在第一个例子中,尝试在同一个作用域内重复声明arg,导致语法错误。
- 在第二个例子中,在一个新的块级作用域内声明了arg,这是允许的,因为这个新的arg只在其所在的块内可见。
2、块级作用域
ES5只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景:
- 第一种场景,内层变量可能会覆盖外层变量。
- 第二种场景,用来计数的循环变量泄露为全局变量。
let实际上为JavaScript新增了块级作用域。外层代码块不受内层代码块的影响。允许块级作用域的任意嵌套,每一层都是一个单独的作用域,内层作用域可以定义外层作用域的同名变量,块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。
- 块级作用域与函数声明
- ES5规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
- ES6引入了块级作用域,明确允许在块级作用域之中声明函数,并规定块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。
- 为了减轻不兼容性问题,ES6的浏览器的实现可以不遵守上面的规定,有自己的行为方式。①允许在块级作用域内声明函数。②函数声明类似于var,即会提升到全局作用域或函数作用域的头部。③函数声明会提升到所在的块级作用域的头部。(注意,上面三条规则只对ES6的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let处理。根据这三条规则,浏览器的ES6环境中,块级作用域内声明的函数,行为类似于var声明的变量。)
考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
// 块级作用域内部的函数声明语句,建议不要使用
{
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() {}
3、const命令
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] );
}
});
};
4、ES6声明变量的六种方法
ES6 声明变量的方法有六种,分别是 var、let、const、function、import 和 class。
- var:用于声明变量,可以修改,如果不初始化会输出 undefined,不会报错。变量作用域为全局或函数级。
- let:用于声明块级作用域变量,其作用域只在 let 命令所在的代码块内有效。不存在变量提升,必须在声明后使用,否则报错。存在暂时性死区,只能在声明的位置后面使用。不可重复声明。
- const:用于声明常量,一旦声明,常量的值就不能改变。不存在变量提升,必须立即初始化,否则会报错。其作用域为块级作用域,只在同一作用域内不能重复声明。
- function:用于声明函数或方法内部的变量。
- import:用于导入模块或库中的变量、函数等。
- class:用于声明类或对象。
5、顶层对象
顶层对象,在浏览器环境指的是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
6、globalThis 对象
JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。
- 浏览器里面,顶层对象是window,但Node和Web Worker没有window。
- 浏览器和Web Worker里面,self也指向顶层对象,但是Node没有self。
- Node里面,顶层对象是global,但其他环境都不支持。
同一段代码为了能够在各种环境都能取到顶层对象,现在一般是使用this关键字,在不同的环境(例如浏览器和Node.js)以及不同的运行模式下(严格模式和非严格模式),this 的行为可能会有所不同。
- 全局环境中,this会返回顶层对象(在浏览器中指window 对象;在Node.js中指global 对象)。但是,Node模块和ES6模块中,this返回的是当前模块。
- 函数里面的this,非严格模式下,如果函数不是作为对象的方法被调用,而是单纯作为函数运行,this会指向顶层对象。在严格模式下,this会返回undefined。
- 不管是严格模式,还是普通模式,new Function(‘return this’)()总是会返回全局对象,因为在这里上下文中没有定义 this。但是,如果浏览器用了CSP(Content Security Policy内容安全策略),那么eval、new Function这些方法都可能无法使用。
ES2020 在语言标准的层面,引入globalThis作为顶层对象。也就是说,任何环境下,globalThis都是存在的,都可以从它拿到顶层对象,指向全局环境下的this。垫片库global-this模拟了这个提案,可以在所有环境拿到globalThis。
变量的解构赋值
在变量声明语句中,解构赋值使用解构语法来指定要提取的属性和变量名。 模式部分和变量部分
通过冒号 : 分隔,冒号
左边是
模式部分,用于指定
要提取的属性的名称。冒号
右边是
变量部分,用于
指定要将提取的值赋给的变量。
真正被赋值的是后者。
1、数组的解构赋值
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
2、对象的解构赋值
数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名才能取到正确的值。
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
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
4、数值和布尔值的解构赋值
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
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
5、函数参数的解构赋值
函数的参数也可以使用解构赋值。
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 ]
6、圆括号问题
解构赋值虽然很方便,但解析起来并不容易。对于编译器来说,遇到圆括号时,它可能会不确定,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。
不能使用圆括号的情况
在变量声明语句中,解构赋值的模式不能使用圆括号。因为圆括号在变量声明语句中具有特殊意义,它们用于定义函数参数、函数体或条件语句等。因此,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]; // 正确,模式是取数组的第一个成员,跟圆括号无关
7、解构赋值的用途
- 交换变量的值:可以用来交换两个变量的值。
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});
- 提取 JSON 数据:对于提取JSON对象中的数据,解构赋值尤其有用。
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';这样的语句。
- 遍历 Map 结构:使用解构赋值可以方便地遍历Map结构并获取键名和键值。
任何部署了 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");
字符串的扩展
1、字符的Unicode表示法
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的码点
直接输入 U+2028 和 U+2029
ES2019允许JavaScript字符串直接输入U+2028(行分隔符)和U+2029(段分隔符)。
const PS = eval("'u2029'");
注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为JSON本来就不允许直接包含正则表达式。
JSON.stringify() 的改造
ES2019 改变了JSON.stringify()的行为。如果遇到0xD800到0xDFFF之间的单个码点或者不存在的配对形式,它会返回转义字符串,留给应用自己决定下一步的处理。
JSON.stringify('u{D834}') // ""\uD834""
JSON.stringify('uDF06uD834') // ""\udf06\ud834""
2、字符串的遍历器接口
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);
}
// "𠮷"
3、模板字符串(“)
传统的JavaScript 语言,输出模板通常需要使用繁琐的拼接写法,ES6引入了模板字符串解决这个问题,它提供了更强大的字符串插值和字符串格式化功能。
模板字符串是增强版的字符串,用反引号(“)标识,它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量、表达式等等。可以在模板字符串中插入任意JavaScript表达式。
- 如果在模板字符串中需要使用反引号,则前面要用反斜杠()转义。
- 如果使用模板字符串表示多行字符串,所有的空格、换行和缩进都会被保留在输出之中。如果不想要这个换行,可以使用trim方法(用于删除字符串开头和结尾的空白字符,包括空格、制表符、换行符等)消除它。
- 模板字符串中嵌入变量,需要将变量名写在${}中,模板字符串的大括号内部,就是执行JavaScript代码。①大括号内部可以放入任意的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!"
4、实例:模板编译
首先,我们通过模板字符串创建一个模板:
let template = `<ul>
<% for(let i=0; i < data.supplies.length; i++) { %>
<li><%= data.supplies[i] %></li>
<% } %>
</ul>`;
//该模板会生成一个含三个项目的无序列表,并插入到ID为box的元素中,列表中的每一项都是从data.supplies数组中取出的。
//在模板字符串中放置了一个常规模板,该模板使用<%...%>放置JS代码,使用<%= ... %>输出JS表达式。
怎么编译这个模板字符串呢?一种思路是将其转换为JavaScript表达式字符串。
echo('<ul>');
for(let i=0; i < data.supplies.length; i++) {
echo('<li>');
echo(data.supplies[i]);
echo('</li>');
};
echo('</ul>');
然后,我们需要编写一个模板编译函数,将模板字符串转换成JS代码,以便于动态地生成HTML:
function compile(template) {
//将模板字符串转换为JavaScript表达式,这个转换使用正则表达式就行了--------------------
//找到所有的<%= %>表达式,并替换为`echo(variable)`
const evalExpr = /<%=(.+?)%>/g;
const expr = /<%([sS]+?)%>/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
// <ul>
// <li>broom</li>
// <li>mop</li>
// <li>cleaner</li>
// </ul>
5、标签模板(tag“)
模板字符串可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串,即标签模板功能。标签模板其实不是模板,而是函数调用的一种特殊形式。“标签”指的就是函数,紧跟在后面的模板字符串就是它的参数。它允许我们将一个函数与模板字符串结合起来,以生成和处理动态的模板内容。
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`
<ul>
#for book in ${myBooks}
<li><i>#{book.title}</i> by #{book.author}</li>
#end
</ul>
`;
可以使用标签模板,在JavaScript语言之中嵌入其他语言的文本或代码。
若要嵌入其他语言的文本,可以使用适当的转义字符来处理特殊字符,用反斜杠()进行转义;
若要嵌入其他语言的代码,需要确保该代码在JavaScript中是有效的。
模板处理函数的第一个参数(模板字符串数组),还有一个raw属性,保存的是转义后的原字符串。
console.log`123` //接受的参数实际上是一个数组,它有一个raw属性,保存的是转义后的原字符串
// ["123", raw: Array[1]]
6、模板字符串的限制
标签模板里可以内嵌其他语言。但是,模板字符串默认会将字符串转义,导致无法嵌入其他语言。总的来说,它并不阻止我们嵌入其他语言的文本或代码,但需要确保正确地处理了任何可能的转义情况。
举例来说,标签模板里面可以嵌入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`; // 报错
字符串的新增方法
1、String.fromCodePoint()
ES5提供String.fromCharCode()方法,用于从Unicode码点返回对应字符,但是这个方法不能识别码点大于0xFFFF的字符。会发生溢出,导致最高位被舍弃。
ES6提供了String.fromCodePoint()方法,用于从Unicode码点返回对应字符,可识别大于0xFFFF的字符,弥补了String.fromCharCode()方法的不足。如果String.fromCodePoint方法有多个参数,则它们会被合并成一个字符串返回。
String.fromCodePoint.codePointAt():返回字符串中指定位置的字符的Unicode码点。
fromCodePoint和codePointAt的区别:
- fromCodePoint方法定义在String对象上,而codePointAt方法定义在字符串的实例对象上。
- fromCodePoint()用来从码点创建一个新的字符串,codePointAt()用来获取特定位置的码点值。
String.fromCodePoint(0x20BB7) // 返回 "𠮷"
String.fromCodePoint(0x78, 0x1f680, 0x79) === 'xuD83DuDE80y' // true
let str = '𠮷'; let firstChar = str.codePointAt(0); // 返回 134071
2、String.raw()
用于创建原始字符串,返回一个斜杠都被转义(即斜杠前面再加一个斜杠)的字符串,往往用于模板字符串的处理方法。这个方法返回一个原始字符串,不会对反斜杠进行转义,这意味着在原始字符串中,反斜杠被视为普通字符,而不是转义字符。可以作为处理模板字符串的基本方法,它会将所有变量替换,并对斜杠进行转义,方便下一步作为字符串来使用。主要用于模板字面量中,当你想要生成原始字符串时。
String.raw`Hin${2+3}!` // 实际返回 "Hi\n5!",显示的是转义后的结果 "Hin5!"
String.raw`Hiu000A!`; // 实际返回 "Hi\u000A!",显示的是转义后的结果 "Hiu000A!"
//如果原字符串的斜杠已经转义,那么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属性等同于原始的模板字符串解析后得到的数组
3、实例方法:codePointAt()
在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
4、实例方法:normalize()
ES6提供字符串实例normalize()方法,用来将字符的不同表示方法统一为同样的形式,这称为Unicode正规化。
'u01D1'.normalize() === 'u004Fu030C'.normalize() // true
Unicode定义了四种规范化形式:NFC、NFD、NFKC和NFKD:
- NFC(Normalization Form Canonical Composition):标准等价合成。默认参数。返回多个简单字符的合成字符。所谓标准等价指的是视觉和语义上的等价。例如,”é” 会被转换为 “eu0301″。
- NFD(Normalization Form Canonical Decomposition):标准等价分解。在标准等价的前提下,返回合成字符分解的多个简单字符。例如,字符串”no-break space”会被转换为两个空格字符。
- NFKC(Normalization Form Compatibility Composition):兼容等价合成。返回合成字符,兼容等价指的是语义上存在等价,但视觉上不等价,比如“囍”和“喜喜”。(这只是用来举例,normalize方法不能识别中文)。例如,”滑雪” 这个词会被转换为 “🎿”
- NFKD(Normalization Form Compatibility Decomposition):兼容等价分解。在兼容等价的前提下,返回合成字符分解的多个简单字符。例如,”滑雪”这个词在会被分解为 “雪” 和 “橇”。
'u004Fu030C'.normalize('NFC').length // 1 NFC参数返回字符的合成形式
'u004Fu030C'.normalize('NFD').length // 2 NFD参数返回字符的分解形式
normalize方法目前不能识别三个或三个以上字符的合成。这种情况下,还是只能使用正则表达式,通过Unicode编号区间判断。
5、实例方法:includes(), startsWith(), endsWith()
- includes():返回布尔值,表示是否找到了参数字符串。
- startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
- endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。
这三个方法都支持第二个参数,表示开始搜索的位置。使用第二个参数n时,endsWith的行为与其他两个方法有所不同,它针对前n个字符,而其他两个方法针对从第n个位置直到字符串结束。
let s = 'Hello world!';
s.startsWith('world', 6) // true
s.endsWith('Hello', 5) // true
s.includes('Hello', 6) // false
6、实例方法:repeat()
repeat()方法返回一个新字符串,表示将原字符串重复n次。
- 参数是小数,会被取整(向下取整)。例如,’na’.repeat(2.9) // “nana”
- 参数是负数或者Infinity,会报错。
- 参数是 0 到-1 之间的小数,则等同于 0,这是因为会先进行取整运算。0 到-1 之间的小数取整以后等于-0,repeat视同为 0。
- 参数NaN等同于 0。
- 参数是字符串,则会先转换成数字。
7、实例方法:padStart(),padEnd()
ES2017引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部或尾部补全。padStart()用于头部补全,padEnd()用于尾部补全。padStart()和padEnd()一共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串。
- 如果原字符串的长度等于或大于最大长度,则字符串补全不生效,返回原字符串。
- 如果用来补全的字符串与原字符串,两者的长度之和超过了最大长度,则会截去超出位数的补全字符串。
- 如果省略第二个参数,默认使用空格补全长度。
- padStart()的常见用途是为数值补全指定位数。另一个用途是提示字符串格式。
'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"
8、实例方法:trimStart(),trimEnd()
ES2019对字符串实例新增了trimStart()和trimEnd()方法。它们的行为与trim()一致,trimStart()消除字符串头部的空格,trimEnd()消除尾部的空格。它们返回的都是新字符串,不会修改原始字符串。除了空格键,这两个方法对字符串头部(或尾部)的tab键、换行符等不可见的空白符号也有效。浏览器还部署了额外的两个方法,trimLeft()是trimStart()的别名,trimRight()是trimEnd()的别名。
const s = ' abc ';
s.trim() // "abc"
s.trimStart() // "abc "
s.trimEnd() // " abc"
9、实例方法:matchAll()
matchAll()方法返回一个正则表达式在当前字符串的所有匹配。它返回一个迭代器,包含了字符串中所有与正则表达式匹配的结果。这个方法主要用于处理字符串中的多行匹配。
const regex = /a/g; //全局匹配 "a" 的正则表达式
const str = 'abacaba';
const matches = str.matchAll(regex);//返回一个迭代器,包含字符串'abacaba'中所有匹配regex的结果
for (const match of matches) {
console.log(match);
}
10、实例方法:replaceAll()
字符串的实例方法replace()只能替换第一个匹配,如果要替换所有的匹配,不得不使用正则表达式的g修饰符。正则表达式毕竟不是那么方便和直观,ES2021引入了replaceAll()方法,可以一次性替换所有匹配。它的用法与replace()相同,返回一个新字符串,不会改变原字符串。
- String.prototype.replaceAll(searchValue, replacement)接受两个必需的参数:
- searchValue: 搜索模式,要在字符串中查找的值。可以是一个字符串,或是一个全局的正则表达式(带有g修饰符)。
- replacement: 替换的文本,用于替换找到的每个匹配项的值。可以是一个字符串,或是一个函数。其中可以使用一些特殊字符串,如$&、$` 、$’、$n等。
'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
11、实例方法:at()
at()方法接受一个整数作为参数,返回参数指定位置的字符,支持负索引(即倒数的位置)。如果参数位置超出了字符串范围,at()返回undefined。
const str = 'hello';
str.at(1) // "e"
str.at(-1) // "o"
str.at(6) // "undefined"
正则的扩展
1、RegExp构造函数
RegExp是一个构造函数,用于创建正则表达式对象。正则表达式是一个用于描述字符串模式的特殊对象,通常用于字符串的匹配、查找和替换操作。
- 参数是字符串,这时第二个参数表示正则表达式的修饰符(flag)。
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覆盖。
2、字符串的正则方法
ES6之前,正则表达式的方法是定义在全局的String对象上的,而不是在RegExp对象上。字符串对象共有4 个方法,可以使用正则表达式:match()、replace()、search()和split()。这意味着这些方法可以在任何字符串上调用,而不仅仅是正则表达式的结果。
ES6引入了一个新的RegExp对象,并把所有与正则表达式相关的操作都移到了这个对象上。这意味着现在所有的正则表达式方法,包括match(), replace(), search(), 和 split(),都需要在正则表达式对象上调用,而不是在字符串对象上。这种改变有助于提高代码的可读性和可维护性,因为它明确了这些方法是在处理正则表达式,而不是普通的字符串。也使得正则表达式的操作更加一致,因为所有的正则表达式方法现在都定义在同一个对象上。
- String.prototype.match 调用 RegExp.prototype[Symbol.match]
- String.prototype.replace 调用 RegExp.prototype[Symbol.replace]
- String.prototype.search 调用 RegExp.prototype[Symbol.search]
- String.prototype.split 调用 RegExp.prototype[Symbol.split]
3、u修饰符(末尾添加u)
ES6对正则表达式添加了u修饰符,含义为Unicode 模式,用来正确处理大于uFFFF的Unicode字符,即会正确处理四个字节的 UTF-16 编码。
- 点字符(.):识别匹配除了换行符以外的任何单个字符。对于码点大于0xFFFF的Unicode字符,点字符不能识别,必须加上u修饰符。
var s = '𠮷';
/^.$/.test(s) // false 如果不添加u修饰符,正则表达式就会认为字符串为两个字符,匹配失败
/^.$/u.test(s) // true
- Unicode 字符表示法:ES6新增使用大括号表示Unicode字符,这种表示法在正则表达式中必须加上u修饰符才能识别当中的大括号,否则会被解读为量词。uXXXX表示一个四位数的Unicode码点。
/u{61}/.test('a') // false 如果不加u修饰符,正则表达式无法识别u{61}这种表示法,只会认为这匹配61个连续的u
/u{61}/u.test('a') // true
/u{20BB7}/u.test('𠮷') // true
- 量词:使用u修饰符后,所有量词都会正确识别码点大于0xFFFF的Unicode字符。用于指定前面的元素(可以是字符、组或模式)出现的次数。例如,*表示前面的元素可以出现零次或多次,+表示前面的元素至少出现一次。
/𠮷{2}/.test('𠮷𠮷') // false
/𠮷{2}/u.test('𠮷𠮷') // true
- 预定义模式:正则表达式中有一些预定义的字符集或模式,如d代表数字,w代表单词字符,s代表空白字符,S代表非空白字符等。u修饰符也影响到预定义模式能否正确识别码点大于0xFFFF的Unicode字符。
/^S$/.test('𠮷') // false
/^S$/u.test('𠮷') // true //加了u修饰符,它才能正确匹配码点大于0xFFFF的Unicode字符
//利用这一点,可以写出一个正确返回字符串长度的函数
function codePointLength(text) {
var result = text.match(/[sS]/gu);
return result ? result.length : 0;
}
var s = '𠮷𠮷';
s.length // 4
codePointLength(s) // 2
- i 修饰符:许多正则表达式实现中表示不区分大小写模式,意味着匹配将不区分大小写。有些Unicode字符的编码不同但是字型很相近,比如u004B与u212A都是大写的K。不加u修饰符,就无法识别非规范的K字符。
/[a-z]/i.test('u212A') // false
/[a-z]/iu.test('u212A') // true 使用u修饰符来明确指定正在处理的是Unicode字符串
- 转义:在正则表达式中,有些字符有特殊的意义。为了表示这些字符的字面值,我们需要使用反斜杠 进行转义。例如,要匹配字面上的点字符.,我们需要使用.。没有u修饰符的情况下,正则中没有定义的转义(如逗号的转义,)无效,而在u模式会报错。
/,/
// /,/ 在非Unicode字符串中使用,时,会将其解释为两个独立的字符,而不是一个逗号。
//因此,正则表达式/,/实际上是匹配两个字符:反斜杠和逗号。应该使用正则表达式/\,/来匹配逗号。
/,/u
// 报错 在Unicode字符串中使用u/,/时,会尝试将其解释为Unicode字符。
//但由于逗号不是一个有效的Unicode字符,所以会报错。
如果要在正则表达式中使用逗号作为特殊字符,请确保在非Unicode字符串中使用双反斜杠进行转义,或者在Unicode字符串中使用有效的Unicode转义序列来表示该字符。
RegExp.prototype.unicode 属性
一个布尔属性,它决定了正则表达式是否应将字符视为Unicode字符,表示是否设置了u修饰符。当unicode属性为true时,正则表达式会以Unicode模式进行匹配,将字符视为Unicode码位,而不是它们的字符表示。
const r1 = /hello/;
const r2 = /hello/u;
r1.unicode // false
r2.unicode // true
4、y修饰符
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(/ad/y) // ["a1"] 只想返回第一个匹配,不需要使用g修饰符
'a1a2a3'.match(/ad/gy) // ["a1", "a2", "a3"] 想返回所有匹配,需要与g修饰符一起使用
y修饰符的一个应用是从字符串提取token(词元),y修饰符确保了匹配之间不会有漏掉的字符。
使用粘性修饰符时,正则表达式会尽可能多地匹配字符,直到达到边界条件,这有助于确保在匹配过程中不会漏掉任何字符。
- g修饰符(全局匹配)的作用是使正则表达式在匹配过程中忽略非法字符。
- y修饰符(粘性匹配)的作用是使正则表达式具有粘性或贪婪的特性。
- g修饰符会忽略非法字符,而y修饰符不会。使用g修饰符可以帮助我们在匹配过程中更容易地发现并处理错误,因为它会忽略非法字符。而使用y修饰符时,我们需要注意非法字符的影响,因为它不会忽略它们。
RegExp.prototype.sticky 属性
与y修饰符相匹配,ES6的正则实例对象多了sticky属性,表示是否设置了y修饰符。
var r = /hellod/y;
r.sticky // true
5、RegExp.prototype.flags 属性
ES6 为正则表达式新增了flags属性,会返回正则表达式的修饰符。
// ES5 的 source 属性
// 返回正则表达式的正文
/abc/ig.source
// "abc"
// ES6 的 flags 属性
// 返回正则表达式的修饰符
/abc/ig.flags
// 'gi'
6、s修饰符:dotAll 模式
s修饰符(也称为dotAll模式,即点(dot)代表一切字符)是一个特殊的修饰符,用于更改点(.)字符的行为。默认情况下,点(.)字符匹配除了换行符(n)之外的任何单个字符。但是,当使用s修饰符时,点(.)字符将可以匹配任意单个字符。
// 使用没有s修饰符的匹配
/foo.bar/.test('foonbar') // 输出: false
// 使用s修饰符的匹配
/foo.bar/s.test('foonbar') // 输出: true
正则表达式引入了一个dotAll属性,返回一个布尔值,表示该正则表达式是否处在dotAll模式。
/s修饰符和多行修饰符/m不冲突,两者一起使用的情况下,.匹配所有字符,而^和$匹配每一行的行首和行尾。
const re = /foo.bar/s; // 另一种写法 const re = new RegExp('foo.bar', 's');
re.test('foonbar') // true
re.dotAll // true
re.flags // 's'
7、后行断言
- “先行断言”指的是,x只有在y前面才匹配,必须写成/x(?=y)/。“先行否定断言”指的是,x只有不在y前面才匹配,必须写成/x(?!y)/。
- “后行断言”指的是,x只有在y后面才匹配,必须写成/(?<=y)x/。“后行否定断言”指的是,x只有不在y后面才匹配,必须写成/(?<!y)x/。
“后行断言”的实现,需要先匹配/(?<=y)x/的x,然后再回到左边,匹配y的部分。这种“先右后左”的执行顺序,与所有其他正则操作相反,导致了一些不符合预期的行为。
- 首先,后行断言的组匹配与正常情况下结果是不一样的。
/(?<=(d+)(d+))$/.exec('1053') // ["", "1", "053"]
/^(d+)(d+)$/.exec('1053') // ["1053", "105", "3"]
//没有“后行断言”时,第一个括号是贪婪模式,第二个括号只能捕获一个字符,所以结果是105和3。
//而“后行断言”时,由于执行顺序是从右到左,第二个括号是贪婪模式,第一个括号只能捕获一个字符,所以结果是1和053。
当没有后行断言或否定后行断言时,括号内的模式按照贪婪模式进行匹配。这意味着它会尽可能多地匹配字符。
首先尝试匹配尽可能多的数字作为一个整体,再匹配尽可能多的数字作为第二个分组。
当使用后行断言时,正则表达式会尝试匹配一个模式,但并不消耗字符(也就是说,它只是检查是否存在这样的模式,而不将其包含在匹配结果中)。在这种情况下,
第二个括号内的模式会首先尝试匹配尽可能多的数字。
- 其次,“后行断言”的反斜杠引用,也与通常的顺序相反,必须放在对应的那个括号之前。
/(?<=(o)d1)r/.exec('hodor') // null 如果后行断言的反斜杠引用(1)放在括号的后面就不会得到匹配结果,必须放在前面才可以,
/(?<=1d(o))r/.exec('hodor') // ["r", "o"] 因为后行断言是先从左到右扫描,发现匹配以后再回过头,从右到左完成反斜杠引用。
8、Unicode属性类
ES2018引入了Unicode属性类,允许使用p{…}和P{…}(P是p的否定形式)代表一类Unicode字符。p{…} 用于匹配满足条件的所有Unicode字符,而 P{…} 是其否定形式,用于匹配不满足条件的Unicode字符。这些Unicode属性类非常有用,因为它们允许你根据字符的Unicode属性(如字母、数字、标点符号等)来进行匹配,而不仅仅是基于字符的码位或字面值。
- Unicode属性类的标准形式需要同时指定属性名和属性值。
p{UnicodePropertyName=UnicodePropertyValue}
- 对于某些属性,可以只写属性名,或者只写属性值。
p{UnicodePropertyName}
p{UnicodePropertyValue}
- P{…}是p{…}中,注意,这两种类只对Unicode有效,所以使用的时候一定要加上u修饰符。如果不加u修饰符,正则表达式使用p和P会报错。
- 由于 Unicode 的各种属性非常多,所以这种新的类的表达能力非常强。
- p{Letter}: 匹配任何字母字符。
- p{Mark}: 匹配任何标记字符,如连字、重音符号等。
- p{Number}: 匹配任何数字字符,包括阿拉伯数字、罗马数字和其他数字系统。
- p{Punctuation}: 匹配任何标点符号。
- p{Symbol}: 匹配任何符号,如数学符号、货币符号等。
- p{Separator}: 匹配任何分隔符,如空格、制表符、换行符等。
- p{Other}: 匹配任何其他类型的字符。
- p{White_Space}: 匹配任何空白字符,如空格、制表符、换行符等。
- p{Uppercase}: 匹配任何大写字母。
- p{Lowercase}: 匹配任何小写字母。
- p{Titlecase}: 匹配任何标题字母。
- p{Modifier}: 匹配任何修饰符字符,如重音符号、音标符号等。
- p{Nonspacing}: 匹配任何非间距字符,如连字、组合标记等。
- p{Math}: 匹配任何数学字符,如数学符号、运算符等。
- p{Emoji}: 匹配任何表情符号。
- p{Emoji_Presentation}: 匹配任何表情符号表示形式。
- p{Emoji_Modifier}: 匹配任何表情符号修饰符。
- p{Emoji_Component}: 匹配任何表情符号组件。
9、v修饰符:Unicode属性类的运算(开头添加v)
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字符。
10、具名组匹配
以前,正则表达式使用圆括号进行组匹配。组匹配的一个问题是,每一组的匹配含义不容易看出来,而且只能用数字序号(比如matchObj[1])引用,要是组的顺序变了,引用的时候就必须修改序号。
ES2018引入了具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用。具名组匹配等于为每一组匹配加上了 ID,便于描述匹配的目的。如果组的顺序变了,也不用改变匹配后的处理代码。
const RE_DATE = /(?<year>d{4})-(?<month>d{2})-(?<day>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 = /^(?<as>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}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');
one // foo
two // bar
let re = /(?<year>d{4})-(?<month>d{2})-(?<day>d{2})/u;
'2015-01-02'.replace(re, '$<day>/$<month>/$<year>') //第二个参数是一个字符串,不是正则表达式
// '02/01/2015'
replace方法的第二个参数也可以是函数。具名组匹配在原来的基础上新增了最后一个函数参数:具名组构成的一个对象。函数内部可以直接对这个对象进行解构赋值。
'2015-01-02'.replace(re, (
matched, // 整个匹配结果 2015-01-02
capture1, // 第一个组匹配 2015
capture2, // 第二个组匹配 01
capture3, // 第三个组匹配 02
position, // 匹配开始的位置 0
S, // 原字符串 2015-01-02
groups // 具名组构成的一个对象{year, month, day},包含所有具名组的匹配结果
) => {
let {day, month, year} = groups;
return `${day}/${month}/${year}`;
});
引用
- 具名引用:如果要在正则表达式内部引用某个”具名组匹配”,可以使用k<组名>的写法。
- 数字引用:数字引用(1)依然有效。通过在匹配的组后面加上数字来引用它们,数字引用的顺序基于组的出现顺序。
const RE_TWICE = /^(?<word>[a-z]+)!k<word>!1$/; //这两种引用语法还可以同时使用
RE_TWICE.test('abc!abc!abc') // true
RE_TWICE.test('abc!abc!ab') // false
11、d修饰符:正则匹配索引
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+(?<Z>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+(?<Z>ce)?/d;
const result = re.exec(text);
result.indices[1] // undefined //由于组匹配ce不成功,所以indices属性数组
result.indices.groups['Z'] // undefined 和indices.groups属性对象对应的组匹配成员Z都是undefined
12、String.prototype.matchAll()
以往,如果一个正则表达式在字符串里面有多个匹配,一般使用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))
数值的扩展
1、二进制和八进制表示法
ES6提供了二进制和八进制数值的新的写法,分别用前缀0b(或0B)和0o(或0O)表示。如果要将它们转为十进制,要使用Number()方法。
Number('0b111') // 7
Number('0o10') // 8
2、数值分隔符
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 // 二进制
3、Number.isFinite(), Number.isNaN()
- Number.isFinite()用来检查一个数值是否为有限的(finite),即不是Infinity。如果参数类型不是数值,一律返回false。
- Number.isNaN()用来检查一个值是否为NaN。如果参数类型不是NaN,一律返回false,只有对于NaN才返回true,。
Number.isFinite(25) // true
Number.isFinite("25") // false
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
4、Number.parseInt(), Number.parseFloat()
ES6将全局方法parseInt()和parseFloat(),移植到Number对象上面,行为完全保持不变。这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。
Number.parseInt('12.34') // 12
Number.parseFloat('123.45#') // 123.45
Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true
5、Number.isInteger()
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
//数值的绝对值<Number.MIN_VALUE(5E-324),即<JS能分辨的最小值,会被自动转为 0, 这时也会误判。
Number.isInteger(5E-325) // true 5E-325由于值太小,会被自动转为0,因此返回true
6、Number.EPSILON
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
7、安全整数和 Number.isSafeInteger()
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
8、Math 对象的扩展
在Math对象上新增了17个与数学相关的方法。所有方法都是静态的,只能在Math对象上调用。
常见方法
- Math.trunc(x):用于去除一个数的小数部分,返回整数部分。对于非数值,Math.trunc内部使用Number方法将其先转为数值。对于空值和无法截取整数的值,返回NaN。
Math.trunc(-4.9) // -4
Math.trunc('123.456') // 123
Math.trunc('foo'); // NaN
- Math.sign(x):用来判断一个数到底是正数、负数、还是零。它会返回五种值:①参数为正数,返回+1;②参数为负数,返回-1;③参数为 0,返回0;④参数为-0,返回-0;⑤其他值,返回NaN。对于非数值,会先将其转换为数值。对于那些无法转为数值的值,会返回NaN。
Math.sign(0) // +0
Math.sign(-0) // -0
Math.sign('foo') // NaN
- Math.cbrt():用于计算一个数的立方根。对于非数值,Math.cbrt()方法内部也是先使用Number()方法将其转为数值。
Math.cbrt('8') // 2
Math.cbrt('hello') // NaN
- Math.clz32():将参数转为32位无符号整数的形式,然后返回这个32位值里面有多少个前导0。
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
- Math.imul():返回两个数以32位带符号整数形式相乘的结果,返回一个32位的带符号整数。对于那些很大的数的乘法,低位数值往往都是不精确的,Math.imul方法可以返回正确的低位数值。
(0x7fffffff * 0x7fffffff)|0 // 0
Math.imul(0x7fffffff, 0x7fffffff) // 1
- Math.fround():返回一个数的32位单精度浮点数形式。如果小数的精度超过24个二进制位,返回值就会不同于原值,否则返回值不变(即与64位双精度值一致)。对于 NaN 和 Infinity,此方法返回原值。对于其它类型的非数值,Math.fround 方法会先将其转为数值,再返回单精度浮点数。
Math.fround(1.125) // 1.125 未丢失有效精度
Math.fround(0.3) // 0.30000001192092896 丢失精度
Math.fround({}) // NaN
- Math.hypot():返回所有参数的平方和的平方根。如果参数不是数值,Math.hypot方法会将其转为数值。只要有一个参数无法转为数值,就会返回 NaN。
Math.hypot(3, 4); // 5
对数方法
- Math.expm1(x):返回 ex – 1,即Math.exp(x) – 1。
- Math.log1p(x):返回1 + x的自然对数,即Math.log(1 + x)。如果x小于-1,返回NaN。
- Math.log10(x):返回以 10 为底的x的对数。如果x小于 0,则返回 NaN。
- Math.log2(x):返回以 2 为底的x的对数。如果x小于 0,则返回 NaN。
双曲函数方法
- Math.sinh(x) :返回x的双曲正弦(hyperbolic sine)
- Math.cosh(x) :返回x的双曲余弦(hyperbolic cosine)
- Math.tanh(x) :返回x的双曲正切(hyperbolic tangent)
- Math.asinh(x) :返回x的反双曲正弦(inverse hyperbolic sine)
- Math.acosh(x) :返回x的反双曲余弦(inverse hyperbolic cosine)
- Math.atanh(x) :返回x的反双曲正切(inverse hyperbolic tangent)
9、指数运算符
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;
10、BigInt 数据类型
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 与字符串混合运算时,会先转为字符串,再进行运算。
函数的扩展
1、函数参数的默认值
ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。参数变量是默认声明的,所以不能用let或const再次声明。使用参数默认值时,函数不能有同名参数,参数默认值是惰性求值的。
首先,阅读代码的人可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中彻底拿掉这个参数,也不会导致以前的代码无法运行。
参数默认值可以与解构赋值的默认值结合起来使用。
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
指定了默认值以后,函数的length属性将返回没有指定默认值的参数个数。指定了默认值后,length属性将失真。length的含义是该函数预期传入的参数个数,某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。如果设置了默认值的参数不是尾参数,那么length也不再计入后面的参数了。
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为在不设置参数默认值时,是不会出现的。
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。可以将参数默认值设为undefined,表明这个参数是可以省略的。
2、rest 参数
ES6引入rest参数(…变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。
rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。它就是一个真正的数组,数组特有的方法都可以使用。
注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
函数的length属性不包括 rest 参数。
3、严格模式
ES2016规定只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
4、name 属性
函数的name属性返回该函数的函数名。
如果将一个匿名函数赋值给一个变量,ES5的name属性会返回空字符串,而ES6的name属性会返回实际的函数名。
如果将一个具名函数赋值给一个变量,ES5和ES6的name属性都返回这个具名函数原本的名字。
Function构造函数返回的函数实例,name属性的值为anonymous。bind返回的函数,name属性值会加上bound前缀。
5、箭头函数
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的时候,也不应使用箭头函数。
如果函数体很复杂,有许多行,或者函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性。
箭头函数可以嵌套使用:箭头函数内部还可以再使用箭头函数。
6、尾调用优化
尾调用(Tail Call)是函数式编程的一个重要概念,就是指某个函数的最后一步是调用另一个函数。尾调用不一定出现在函数尾部,只要是最后一步操作即可。
function f(x){
return g(x);
}
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧取代外层函数的调用帧就可以了。
尾调用优化,即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。递归非常耗费内存,因为需要同时保存成千上百个调用帧,容易发生栈溢出错误。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生栈溢出错误。ES6中只要使用尾递归,就不会发生栈溢出(或层层递归造成的超时),相对节省内存。
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。函数式编程有一个概念,叫做柯里化,意思是将多参数的函数转换成单参数的形式。
ES6的尾调用优化只在严格模式下开启,正常模式是无效的。这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈:func.arguments返回调用时函数的参数,func.caller返回调用当前函数的那个函数。
7、函数参数的尾逗号
ES2017允许函数的最后一个参数有尾逗号(trailing comma)。
8、Function.prototype.toString()
toString()方法返回函数代码本身,以前会省略注释和空格。修改后的toString()方法,明确要求返回一模一样的原始代码。
9、catch 命令的参数省略
JavaScript语言的try…catch结构,以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。ES2019做出了改变,允许catch语句省略参数。
数组的扩展
1、扩展运算符
扩展运算符是三个点(…)。它好比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]; // 报错
- 字符串:扩展运算符还可以将字符串转为真正的数组。凡是涉及到操作四个字节的Unicode字符的函数,最好都用扩展运算符改写。
[...'hello'] // [ "h", "e", "l", "l", "o" ]
'xuD83DuDE80y'.length // 4 JavaScript 会将四个字节的 Unicode 字符识别为 2 个字符
[...'xuD83DuDE80y'].length // 3 能够正确识别四个字节的 Unicode 字符。
- 实现了 Iterator 接口的对象:任何定义了遍历器接口的对象,都可以用扩展运算符转为真正的数组。对于那些没有部署Iterator接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。如果对没有Iterator接口的对象使用扩展运算符,将会报错。
- Map和Set结构、Generator函数:扩展运算符内部调用的是数据结构的Iterator接口,因此只要具有Iterator接口的对象,都可以使用扩展运算符,比如Map结构。Generator函数运行后,返回一个遍历器对象,因此也可以使用扩展运算符。
2、数组的空位
数组的空位是指,数组的某一个位置没有任何值。注意,空位不是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各个主要实现的默认排序算法都是稳定的。
3、Array.from()
用于将两类对象转为真正的数组:类似数组的对象和可遍历的对象(包括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 ]
4、Array.of()
用于将一组值转换为数组。这个方法的主要目的,是弥补数组构造函数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]
5、实例方法:copyWithin()
在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法会修改当前数组。它接受三个参数,三个参数都应是数值,若不是,会自动转为数值:
- target(必需):从该位置开始替换数据。如果为负值,表示倒数。
- start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
- end(可选):到该位置前停止读取数据,默认等于数组长度。若为负值,表示从末尾开始计算。
[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]
6、实例方法:find(),findIndex(),findLast(),findLastIndex()
- find方法:用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined。
- 数组实例的findIndex方法的用法与find方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1。
- 这两个方法都可以发现NaN,弥补了数组的indexOf方法的不足。indexOf方法内部使用严格相等运算符进行判断,无法识别数组的NaN成员,但是findIndex方法可以借助Object.is方法做到。
7、实例方法:fill()
使用给定值填充一个数组。用于空数组的初始化非常方便,数组中已有的元素会被全部抹去。
注意,如果填充的类型为对象,那么被赋值的是同一个内存地址的对象,而不是深拷贝对象。
['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"}]
8、实例方法:entries(),keys() 和 values()
用于遍历数组。它们都返回一个遍历器对象,可以用for…of循环进行遍历。如果不使用for…of循环,可以手动调用遍历器对象的next方法进行遍历。
- keys()是对键名的遍历
- values()是对键值的遍历
- entries()是对键值对的遍历
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']
9、实例方法:includes()
返回一个布尔值,表示某个数组是否包含给定的值,与字符串的includes方法类似。Map和Set数据结构有一个has方法,需要注意与includes区分。
- Map的has是用来查找键名的,比如Map.prototype.has(key)、WeakMap.prototype.has(key)、Reflect.has(target, propertyKey)。
- Set 的has是用来查找值的,比如Set.prototype.has(value)、WeakSet.prototype.has(value)。
10、实例方法:flat(),flatMap()
数组的成员有时还是数组,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])
对象的扩展
1、属性的简洁表示法
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() // 报错
2、属性名表达式
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]属性。
3、方法的 name 属性
函数的name属性返回函数名。对象方法也是函数,因此也有name属性。
如果对象的方法使用了取值函数(getter)和存值函数(setter),则name属性不是在该方法上面,而是该方法的属性的描述对象的get和set属性上面,返回值是方法名前加上get和set。
有两种特殊情况:bind方法创造的函数,name属性返回bound加上原函数的名字;Function构造函数创造的函数,name属性返回anonymous。
如果对象的方法是一个Symbol值,那么name属性返回的是这个Symbol值的描述。
4、属性的可枚举性和遍历
可枚举性是指一个对象的属性是否可以被枚举,即是否可以通过循环或内置方法(如Object.keys())来获取该对象的所有属性。
对象的每个属性都有一个描述对象,用来控制该属性的行为。Object.getOwnPropertyDescriptor方法可以获取该属性的描述对象。描述对象的enumerable属性称为可枚举性,如果该属性为false,就表示某些操作会忽略当前属性。目前,有四个操作会忽略enumerable为false的属性:
- for…in循环:只遍历对象自身的和继承的可枚举的属性。
- Object.keys():返回对象自身的所有可枚举的属性的键名。
- JSON.stringify():只串行化对象自身的可枚举的属性。
- Object.assign(): 忽略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 种方法遍历对象的键名,都遵守同样的属性遍历的次序规则。
- 首先遍历所有数值键,按照数值升序排列。
- 其次遍历所有字符串键,按照加入时间升序排列。
- 最后遍历所有Symbol键,按照加入时间升序排列。
5、super 关键字
指向当前对象的原型对象。注意,super关键字表示原型对象时,只能用在对象的方法之中,用在其他地方都会报错。
6、对象的扩展运算符
解构赋值
对象的解构赋值用于从一个对象取值,相当于将目标对象自身的所有可遍历的、但尚未被读取的属性,分配到指定的对象上面。所有的键和它们的值,都会拷贝到新对象上面。
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 }; // 报错
7、链判断运算符
编程实务中,如果读取对象内部的某个属性,往往需要判断一下该对象是否存在。可用?.运算符直接在链式调用时判断左侧的对象是否为null或undefined。如果是就不再往下运算,而是返回undefined。
const firstName = message?.body?.user?.firstName || 'default';
const fooValue = myForm.querySelector('input[name=foo]')?.value
链判断运算符有三种用法:
- obj?.prop // 对象属性
- obj?.[expr] // 同上
- func?.(…args) // 函数或对象方法的调用
//下面是判断对象方法是否存在,如果存在就立即执行的例子
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 运算符。
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
- 右侧不得为十进制数值。为了保证兼容以前的代码,允许foo?.3:0被解析成foo ? .3 : 0,因此规定如果?.后面紧跟一个十进制数字,那么?.不再被看成是一个完整的运算符,而会按照三元运算符进行处理,也就是说,那个小数点会归属于后面的十进制数字,形成一个小数。
8、Null 判断运算符
读取对象属性时,如果某个属性的值是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;
// …
}
??有一个运算优先级问题,它与&&和||的优先级孰高孰低。现在的规则是,如果多个逻辑运算符一起使用,必须用括号表明优先级,否则会报错。
对象的新增方法
1、Object.is()
用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。不同之处只有两个:一是+0不等于-0,二是NaN等于自身。
2、Object.assign()
用于对象的合并,将源对象(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对象的该属性很可能不起作用。
3、Object.getOwnPropertyDescriptors()
返回指定对象所有自身属性(非继承属性)的描述对象。
该方法的引入目的,主要是为了解决Object.assign()无法正确拷贝get属性和set属性的问题。Object.getOwnPropertyDescriptors()方法配合Object.defineProperties()方法就可以实现正确拷贝。
另一个用处是配合Object.create()方法,将对象属性克隆到一个新对象,这属于浅拷贝。
可以实现一个对象继承另一个对象。也可以用来实现 Mixin(混入)模式。
4、__proto__属性,Object.setPrototypeOf(),Object.getPrototypeOf()
__proto__属性
用来读取或设置当前对象的原型对象(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
Object.setPrototypeOf()
作用与__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.getPrototypeOf()
该方法与Object.setPrototypeOf方法配套,用于读取一个对象的原型对象。如果参数不是对象,会被自动转为对象。如果参数是undefined或null,它们无法转为对象,所以会报错。
Object.getPrototypeOf(obj);
5、Object.keys(),Object.values(),Object.entries()
Object.keys()
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键名。
Object.values()
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值。
会过滤属性名为Symbol值的属性。如果参数是一个字符串,会返回各个字符组成的一个数组。如果参数不是对象,会先将其转为对象。由于数值和布尔值的包装对象都不会为实例添加非继承的属性,所以会返回空数组。
Object.values('foo') // ['f', 'o', 'o'] 字符串会先转成一个类似数组的对象,字符串的每个字符就是该对象的一个属性
Object.values(42) // []
Object.values(true) // []
Object.entries()
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值对数组。
基本用途是遍历对象的属性,另一个用处是将对象转为真正的Map结构。除了返回值不一样,该方法的行为与Object.values基本一致。如果原对象的属性名是一个Symbol值,该属性会被忽略。
6、Object.fromEntries()
Object.fromEntries()方法是Object.entries()的逆操作,用于将一个键值对数组转为对象。
该方法的主要目的,是将键值对的数据结构还原为对象,因此特别适合将Map结构转为对象。该方法的一个用处是配合URLSearchParams对象,将查询字符串转为对象。
Object.fromEntries(new URLSearchParams('foo=bar&baz=qux'))
// { foo: "bar", baz: "qux" }
Symbol
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值可以显式转为字符串,也可以转为布尔值,但是不能转为数值。
1、Symbol.prototype.description
ES2019提供了一个实例属性description,直接返回Symbol的描述。
const sym = Symbol('foo'); // 创建Symbol的时候,可以添加一个描述
String(sym) // "Symbol(foo)" 但读取这个描述需要将Symbol显式转为字符串
sym.toString() // "Symbol(foo)" 但读取这个描述需要将Symbol显式转为字符串
sym.description // "foo" 使用实例属性description,直接返回Symbol的描述
2、作为属性名的 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值必须放在方括号之中。还可以用于定义一组常量,保证这组常量的值都是不相等的,最大的好处就是其他任何值都不可能有相同的值了。
3、实例:消除魔术字符串
魔术字符串指的是,在代码中多次出现、与代码形成强耦合的某一个具体的字符串或者数值。风格良好的代码,应该尽量消除魔术字符串,改为由含义清晰的变量代替。
常用的消除魔术字符串的方法,就是把它写成一个变量。
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()
};
4、属性名的遍历
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值作为键名,不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有的、但又希望只用于内部的方法。
5、Symbol.for(),Symbol.keyFor()
有时,我们希望重新使用同一个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 中取到同一个值。
6、实例:模块的 Singleton 模式
Singleton模式指的是调用一个类,任何时候返回的都是同一个实例。
对于Node来说,模块文件可以看成是一个类,为了保证每次执行这个模块文件,返回的都是同一个实例,可以把实例放到顶层对象global,并使用Symbol.for()方法生成键名,那么外部将无法引用这个值,当然也就无法改写。
7、内置的 Symbol 值
除了定义自己使用的Symbol值外,ES6还提供了11个内置的Symbol值,指向语言内部使用的方法。
- Symbol.hasInstance:指向一个内部方法。当其他对象使用instanceof运算符判断是否为该对象的实例时,会调用这个方法。比如,foo instanceof Foo在语言内部,实际调用的是Foo[Symbol.hasInstance](foo)。
- Symbol.isConcatSpreadable:等于一个布尔值,表示该对象用于Array.prototype.concat()时是否可以展开。
- Symbol.species:指向一个构造函数。创建衍生对象时会使用该属性,定义了Symbol.species属性,创建衍生对象时就会使用这个属性返回的函数作为构造函数。作用在于,实例对象在运行过程中需要再次调用自身的构造函数时,会调用该属性指定的构造函数。主要的用途是,有些类库是在基类的基础上修改的,子类使用继承的方法时,可能希望返回基类的实例,而不是子类的实例。
- Symbol.match:指向一个函数。当执行str.match(myObject)时,如果该属性存在,会调用它,返回该方法的返回值。
- Symbol.replace:指向一个方法,当该对象被String.prototype.replace方法调用时,会返回该方法的返回值。Symbol.replace方法会收到两个参数,第一个参数是replace方法正在作用的对象,第二个参数是替换后的值。
- Symbol.search:指向一个方法,当该对象被String.prototype.search方法调用时,会返回该方法的返回值。
- Symbol.split:对象的Symbol.split属性指向一个方法,当该对象被String.prototype.split方法调用时,会返回该方法的返回值。
- Symbol.iterator:指向该对象的默认遍历器方法。对象进行for…of循环时会调用Symbol.iterator方法,返回该对象的默认遍历器。
- Symbol.toPrimitive:指向一个方法。该对象被转为原始类型的值时会调用这个方法,返回该对象对应的原始类型值。Symbol.toPrimitive被调用时,会接受一个字符串参数,表示当前运算的模式,一共有三种模式。
- Number:该场合需要转成数值
- String:该场合需要转成字符串
- Default:该场合可以转成数值,也可以转成字符串
- Symbol.toStringTag:指向一个方法。在该对象上面调用Object.prototype.toString方法时,如果这个属性存在,它的返回值会出现在toString方法返回的字符串中,表示对象的类型。就是说,这个属性可以用来定制[object Object]或[object Array]中object后面的那个字符串。
- Symbol.unscopables:指向一个对象。该对象指定了使用with关键字时哪些属性会被with环境排除。
Set 和 Map 数据结构
1、Set
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 由于两个空对象不相等,所以它们被视为两个值
Set 实例的属性和方法
- Set结构的实例有以下属性。
- Set.prototype.constructor:构造函数,默认就是Set函数。
- Set.prototype.size:返回Set实例的成员总数。
- Set实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。
- Set.prototype.add(value):添加某个值,返回 Set 结构本身。
- Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
- Set.prototype.has(value):返回一个布尔值,表示该值是否为Set的成员。
- Set.prototype.clear():清除所有成员,没有返回值。
Array.from方法可以将Set结构转为数组。这就提供了去除数组重复成员的另一种方法。
function dedupe(array) {
return Array.from(new Set(array));
}
dedupe([1, 1, 2, 3]) // [1, 2, 3]
遍历操作
- Set 结构的实例有四个遍历方法,可以用于遍历成员。
- Set.prototype.keys():返回键名的遍历器
- Set.prototype.values():返回键值的遍历器
- Set.prototype.entries():返回键值对的遍历器
- Set.prototype.forEach():使用回调函数遍历每个成员
需要特别指出的是,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结构映射出一个新的结构,然后赋值给原来的Set结构;
- 另一种是利用Array.from方法。
// 直接在遍历操作中改变原来的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
2、WeakSet
WeakSet结构与Set类似,也是不重复的值的集合。但是,它与Set有两个区别。
- 首先,WeakSet的成员只能是对象,而不能是其他类型的值。WeakSet只能放置对象。
- 其次,WeakSet中的对象都是弱引用,即垃圾回收机制不考虑WeakSet对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于WeakSet之中。WeakSet里面的引用都不计入垃圾回收机制。
- WeakSet没有size属性,没有办法遍历它的成员。
- WeakSet不能遍历,是因为成员都是弱引用,随时可能消失,遍历机制无法保证成员的存在,很可能刚刚遍历结束,成员就取不到了。
- WeakSet的一个用处是储存DOM节点,而不用担心这些节点从文档移除时会引发内存泄漏。
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数组本身。这意味着,数组的成员只能是对象。
- WeakSet 结构有以下三个方法。
- WeakSet.prototype.add(value):向 WeakSet 实例添加一个新成员。
- WeakSet.prototype.delete(value):清除 WeakSet 实例的指定成员。
- WeakSet.prototype.has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例之中。
3、Map
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 结构的实例有以下属性和操作方法。
- size 属性:size属性返回Map结构的成员总数。
- Map.prototype.set(key, value):set方法设置键名key对应的键值为value,然后返回整个Map结构。如果key已经有值,则键值会被更新,否则就新生成该键。set方法返回的是当前的Map对象,因此可以采用链式写法。
- Map.prototype.get(key):get方法读取key对应的键值,如果找不到key,返回undefined。
- Map.prototype.has(key):has方法返回一个布尔值,表示某个键是否在当前Map对象之中。
- Map.prototype.delete(key):delete方法删除某个键,返回true。如果删除失败,返回false。
- Map.prototype.clear():clear方法清除所有成员,没有返回值。
遍历操作
- Map 结构原生提供三个遍历器生成函数和一个遍历方法。
- Map.prototype.keys():返回键名的遍历器。
- Map.prototype.values():返回键值的遍历器。
- Map.prototype.entries():返回所有成员的遍历器。
- Map.prototype.forEach():遍历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']}
4、WeakMap
WeakMap结构与Map结构类似,也是用于生成键值对的集合。WeakMap与Map的区别有两点。
- 首先,WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。
- 其次,WeakMap的键名所指向的对象都是弱引用,不计入垃圾回收机制,没有遍历操作。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。一旦不再需要,WeakMap里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
- 如果要往对象上添加数据又不想干扰垃圾回收机制,就可以使用WeakMap。一个典型应用场景是,在网页的DOM元素上添加数据,就可以使用WeakMap结构。当该DOM元素被清除,其所对应的WeakMap记录就会自动被移除。
- ①没有size属性,没有遍历操作(即没有keys()、values()和entries()方法),②无法清空(即不支持clear方法)。因此,WeakMap只有四个方法可用:get()、set()、has()、delete()。
- WeakMap的专用场合就是,它的键所对应的对象可能会在将来消失。WeakMap结构有助于防止内存泄漏。注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。
const wm = new WeakMap();
let key = {};
let obj = {foo: 1};
wm.set(key, obj);
obj = null;
wm.get(key) // Object {foo: 1}
//键值obj是正常引用,所以即使在WeakMap外部消除了obj的引用,WeakMap内部的引用依然存在。
- 另一个用处是部署私有属性。
Proxy
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种。
1、Proxy 实例的方法
- get()
用于拦截某个属性的读取操作。可以接受三个参数,依次为目标对象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对象访问该属性会报错。
- set()
用来拦截某个属性的赋值操作。可以接受四个参数,依次为目标对象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。
- apply()
用于拦截函数的调用、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方法,也会被拦截
- has()
用来拦截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循环不生效。
- construct()
用于拦截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
- deleteProperty()
用于拦截delete操作,如果这个方法抛出错误或者返回false,当前属性就无法被delete命令删除。注意,目标对象自身的不可配置的属性不能被deleteProperty方法删除,否则报错。
- defineProperty()
主要用来拦截Object.defineProperty()操作。注意,如果目标对象不可扩展,则defineProperty()不能增加目标对象上不存在的属性,否则会报错。另外,如果目标对象的某个属性不可写或不可配置,则defineProperty()方法不得改变这两个设置。
- getOwnPropertyDescriptor()
主要用来拦截Object.getOwnPropertyDescriptor(),返回一个属性描述对象或者undefined。
- getPrototypeOf()
主要用来拦截获取对象原型。具体来说,拦截下面这些操作:Object.prototype.__proto__、Object.prototype.isPrototypeOf()、Object.getPrototypeOf()、Reflect.getPrototypeOf()、instanceof。注意,getPrototypeOf()方法的返回值必须是对象或者null,否则报错。另外,如果目标对象不可扩展,getPrototypeOf()方法必须返回目标对象的原型对象。
- isExtensible()
用来拦截Object.isExtensible()操作。注意,该方法只能返回布尔值,否则返回值会被自动转为布尔值。该方法有一个强限制,它的返回值必须与目标对象的isExtensible属性保持一致,否则会抛出错误。
- ownKeys()
用来拦截对象自身属性的读取操作。具体来说,拦截以下操作:
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()、Object.keys()、for…in循环。注意,使用Object.keys()方法时,有三类属性会被ownKeys()方法自动过滤,不会返回:①目标对象上不存在的属性、②属性名为Symbol值、③不可遍历的属性。ownKeys()方法返回的数组成员只能是字符串或Symbol值,如果有其他类型的值或者返回的根本不是数组,就会报错。如果目标对象自身包含不可配置的属性,则该属性必须被ownKeys()方法返回,否则报错。另外,如果目标对象是不可扩展的,这时ownKeys()方法返回的数组之中,必须包含原对象的所有属性,且不能包含多余的属性,否则报错。
- preventExtensions()
主要用来拦截Object.preventExtensions()。该方法必须返回一个布尔值,否则会被自动转为布尔值。这个方法有一个限制,只有目标对象不可扩展时(即Object.isExtensible(proxy)为false),proxy.preventExtensions才能返回true,否则会报错。为了防止出现这个问题,通常要在proxy.preventExtensions()方法里面,调用一次Object.preventExtensions()。
- setPrototypeOf()
主要用来拦截Object.setPrototypeOf()方法。注意,该方法只能返回布尔值,否则会被自动转为布尔值。另外,如果目标对象不可扩展,setPrototypeOf()方法不得改变目标对象的原型。
2、Proxy.revocable()
返回一个可取消的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
3、this 问题
虽然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
4、实例:Web 服务的客户端
Proxy对象可以拦截目标对象的任意属性,这使得它很合适用来写Web服务的客户端,也可以用来实现数据库的ORM层。
function createWebService(baseUrl) {
return new Proxy({}, {
get(target, propKey, receiver) {
return () => httpGet(baseUrl + '/' + propKey);
}
});
}
Reflect
Reflect是一个内置的对象,它提供了一系列方法,使开发者能够通过调用这些方法来访问JavaScript的一些底层功能。由于它类似于其他语言的反射,因此取名为Reflect。它是一个全局的普通对象,其原型是Object,主要作用是将Object对象的一些明显属于语言内部的方法转移到Reflect对象上,使得这些方法可以从Reflect对象上被调用。这样做有助于使代码更加清晰和易于管理。
Reflect对象与Proxy对象一样,也是ES6为了操作对象而提供的新API。设计目的:
- 将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty)放到Reflect对象上。现阶段,某些方法同时在Object和Reflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。
- 修改某些Object方法的返回结果,让其变得更合理。比如Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false。
- 让Object操作都变成函数行为。某些Object操作是命令式,比如name in obj和delete obj[name],而Reflect.has(obj, name)和Reflect.deleteProperty(obj, name)让它们变成了函数行为。
- Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。
静态方法
Reflect对象一共有13个静态方法。这些方法的作用,大部分与Object对象的同名方法的作用都是相同的,而且它与Proxy对象的方法是一一对应的。
- Reflect.get(target, name, receiver)
查找并返回target对象的name属性,如果没有该属性,则返回undefined。如果name属性部署了读取函数(getter),则读取函数的this绑定receiver。如果第一个参数不是对象,会报错。
- Reflect.set(target, name, value, receiver)
设置target对象的name属性等于value。如果name属性设置了赋值函数,则赋值函数的this绑定receiver。注意,如果 Proxy对象和 Reflect对象联合使用,前者拦截赋值操作,后者完成赋值的默认行为,而且传入了receiver,那么Reflect.set会触发Proxy.defineProperty拦截。如果Reflect.set没有传入receiver,那么就不会触发defineProperty拦截。如果第一个参数不是对象,会报错。
receiver参数代表的是实际调用者,也就是最初调用该方法的对象。在Reflect.set方法中,如果传入了receiver参数,那么该属性值将被设置到receiver上,而不是目标对象上。
- Reflect.has(target, name)
对应name in obj里面的in运算符。如果第一个参数不是对象,会报错。
- Reflect.deleteProperty(target, name)
等同于delete obj[name],用于删除对象的属性。该方法返回一个布尔值。如果删除成功或者被删除的属性不存在,返回true;删除失败,被删除的属性依然存在,返回false。如果第一个参数不是对象,会报错。
- Reflect.construct(target, args)
等同于new target(…args),这提供了一种不使用new来调用构造函数的方法。如果第一个参数不是函数,会报错。
- Reflect.getPrototypeOf(target)
用于读取对象的__proto__属性,对应Object.getPrototypeOf(obj)。如果参数不是对象,Object.getPrototypeOf会将这个参数转为对象,然后再运行,而Reflect.getPrototypeOf会报错。
- Reflect.setPrototypeOf(target, prototype)
用于设置目标对象的原型(prototype),对应Object.setPrototypeOf(obj, newProto)方法。它返回一个布尔值,表示是否设置成功。
如果无法设置目标对象的原型(比如目标对象禁止扩展),Reflect.setPrototypeOf方法返回false。如果第一个参数不是对象,Object.setPrototypeOf会返回第一个参数本身,Reflect.setPrototypeOf会报错。如果第一个参数是undefined或null,Object.setPrototypeOf和Reflect.setPrototypeOf都报错。
- Reflect.apply(target, thisArg, args)
等同于Function.prototype.apply.call(func, thisArg, args),用于绑定this对象后执行给定函数。一般来说,如果要绑定一个函数的this对象,可这样写fn.apply(obj, args),但如果函数定义了自己的apply方法,就只能写成Function.prototype.apply.call(fn, obj, args),用Reflect对象简化这种操作。
- Reflect.defineProperty(target, name, desc)
基本等同于Object.defineProperty,用来为对象定义属性。未来,后者会被逐渐废除,请从现在开始就使用Reflect.defineProperty代替它。如果Reflect.defineProperty的第一个参数不是对象,就会抛出错误,比如Reflect.defineProperty(1, ‘foo’)。这个方法可以与Proxy.defineProperty配合使用。
- Reflect.getOwnPropertyDescriptor(target, name)
基本等同于Object.getOwnPropertyDescriptor,用于得到指定属性的描述对象,将来会替代后者。如果第一个参数不是对象,Object.getOwnPropertyDescriptor(1, ‘foo’)不报错,返回undefined,而Reflect.getOwnPropertyDescriptor(1, ‘foo’)会抛出错误,表示参数非法。
- Reflect.isExtensible(target)
对应Object.isExtensible,返回一个布尔值,表示当前对象是否可扩展。如果参数不是对象,Object.isExtensible会返回false,因为非对象本来就是不可扩展的,而Reflect.isExtensible会报错。
- Reflect.preventExtensions(target)
用于让一个对象变为不可扩展。它返回一个布尔值,表示是否操作成功。如果参数不是对象,Object.preventExtensions在ES5环境报错,在ES6环境返回传入的参数,Reflect.preventExtensions会报错。
- Reflect.ownKeys(target)
用于返回对象的所有属性,基本等同于Object.getOwnPropertyNames与Object.getOwnPropertySymbols之和。如果第一个参数不是对象,会报错。
2、实例:使用 Proxy 实现观察者模式
观察者模式指的是函数自动观察数据对象,一旦对象有变化,函数就会自动执行。
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 对象
Promise是一个代表异步操作最终完成(包括成功和失败)及结果值的对象。它是一个容器,保存着某个未来才会结束的事件(通常是一个异步操作)的结果。
它实质上是一个构造函数,自己身上有all、reject、resolve这几个的方法,原型上有then、catch等同样很眼熟的方法。
其中,then()方法可以接收两个回调函数作为参数,一个用于处理已解决的promise的值,另一个用于处理被拒绝的promise的原因,其中,第二个函数是可选的,不一定要提供。
①第一个回调函数是Promise对象的状态改变为resoved时调用(操作成功完成时)。
②第二个回调函数是Promise对象的状态变为rejected时调用(出现错误时)。
通过使用then()方法,可以将异步操作的处理逻辑与同步操作流程一致地表达出来,避免了传统回调函数嵌套使用带来的问题。
1、Promise 的含义
Promise是JS中用于处理可能不立即完成的操作的对象。Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。简单地说,Promise就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise是一个对象,从它可以获取异步操作的消息。Promise提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise对象有以下两个特点:
- 对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
- 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:①从pending变为fulfilled和②从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为resolved(已定型)。如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。
Promise也有一些缺点:
- 首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。
- 其次,如果不设置回调函数,Promise内部抛出的错误不会反应到外部。
- 第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
如果某些事件不断地反复发生,一般来说,使用 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)的简写形式。
这些方法允许我们链式地处理异步操作的结果,并确保即使在异步操作失败时也能执行适当的清理或错误处理代码。
2、Promise.resolve()
有时需要将现有对象转为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实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例。
- 参数是一个thenable对象
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方法的对象,或根本就不是对象
如果参数是一个原始值,或者是一个不具有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
3、Promise.reject()
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对象
4、应用
加载图片
我们可以将图片的加载写成一个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来创建一个可以按照特定顺序执行任务的函数。
使用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方法。
5、Promise.try()
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代码块。
6、基本用法
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引擎提供,不用自己部署。
- resolve函数的作用是,将Promise对象的状态从未完成变为成功(即从pending变为resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;
- reject函数的作用是,将Promise对象的状态从未完成变为失败(即从pending变为rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
- then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为resolved时调用,第二个回调函数是Promise对象的状态变为rejected时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受Promise对象传出的值作为参数。
- 如果调用resolve函数和reject函数时带有参数,那么它们的参数会被传递给回调函数。reject函数的参数通常是Error对象的实例,表示抛出的错误;resolve函数的参数除了正常的值以外,还可能是另一个Promise实例,即一个异步操作的结果是返回另一个异步操作。
举个例子:
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);
})
7、Promise.prototype.then()
它的作用是为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)
);
8、Promise.prototype.catch()
用于指定发生错误时的回调函数。如果异步操作抛出错误,状态就会变为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()方法之中还能再抛出错误。
9、Promise.prototype.finally()
用于指定不管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
10、Promise.all()
用于将多个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决定,分成两种情况:
- (1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
- (2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。
举个例子:
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方法。
11、Promise.race()
用于将多个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方法指定的回调函数
12、Promise.allSettled()
接受一组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()就很容易了。
13、Promise.any()
接受一组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 和 for…of 循环
1、Iterator(遍历器)的概念
遍历器(Iterator)是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署Iterator接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
Iterator的作用有三个:
- (1) 为各种数据结构提供一个统一的、简便的访问接口;
- (2) 使得数据结构的成员能够按某种次序排列;
- (3) ES6创造了一种新的遍历命令for…of循环,Iterator接口主要供for…of消费。
Iterator 的遍历过程是这样的:
- (1) 创建一个指针对象,指向当前数据结构的起始位置。遍历器对象本质上就是一个指针对象。
- (2) 第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
- (3) 第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
- (4) 不断调用指针对象的next方法,直到它指向数据结构的结束位置。
每一次调用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,
}
2、默认 Iterator 接口
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接口的数据结构如下:
- Array
- Map
- Set
- String
- TypedArray
- 函数的 arguments 对象
- NodeList 对象
对于原生部署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方法对应的不是遍历器生成函数(即会返回一个遍历器对象),解释引擎将会报错。
3、调用 Iterator 接口的场合
有一些场合会默认调用Iterator接口(即Symbol.iterator方法)。
- (1) 解构赋值:对数组和 Set 结构进行解构赋值时,会默认调用Symbol.iterator方法。
- (2) 扩展运算符:扩展运算符(…)也会调用默认的Iterator接口。实际上,这提供了一种简便机制,可以将任何部署了Iterator接口的数据结构转为数组。也就是说,只要某个数据结构部署了Iterator接口,就可以对它使用扩展运算符,将其转为数组。
- (3) yield*:yield*后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口。
- (4) 其他场合:由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。
- for…of
- Array.from()
- Map(), Set(), WeakMap(), WeakSet()(比如new Map([[‘a’,1],[‘b’,2]]))
- Promise.all()
- Promise.race()
4、字符串的 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方法,达到修改遍历器行为的目的。
5、Iterator 接口与 Generator 函数
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"
6、遍历器对象的 return(),throw()
遍历器对象除了具有next方法,还可以具有return方法和throw方法。如果自己写遍历器对象生成函数,那么next方法是必须部署的,return方法和throw方法是否部署是可选的。
- return方法的使用场合是:如果for…of循环提前退出(通常是因为出错,或者有break语句),就会调用return方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署return方法。
- 注意,return方法必须返回一个对象,这是Generator规格决定的。
- throw方法主要是配合Generator函数使用,一般的遍历器对象用不到这个方法。
// 触发执行return方法的情况一
for (let line of readLinesSync(fileName)) {
console.log(line); //关闭这个文件
break;
}
// 触发执行return方法的情况二
for (let line of readLinesSync(fileName)) {
console.log(line); //关闭这个文件
throw new Error(); //抛出错误
}
7、for…of 循环
一个数据结构只要部署了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方法。
- JavaScript原有的for…in循环只能获得对象的键名,不能直接获取键值。ES6提供for…of循环允许遍历获得键值。for…of循环调用遍历器接口,数组的遍历器接口只返回具有数字索引的属性,for…in循环则数字索引和字符串索引都能返回。
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" (只返回具有数字索引的属性)
}
- 如果要通过for…of循环获取数组的索引,可以借助数组实例的entries方法和keys方法。
Set 和 Map 结构
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都部署了以下三个方法,调用后都返回遍历器对象。
- entries():返回一个遍历器对象,用来遍历[键名, 键值]组成的数组。对于数组,键名就是索引值;对于Set,键名与键值相同。Map结构的Iterator接口,默认就是调用entries方法。
- keys():返回一个遍历器对象,用来遍历所有的键名。
- values():返回一个遍历器对象,用来遍历所有的键值。
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循环主要是为遍历对象而设计的,不适用于遍历数组。
- for…in有几个缺点:
- 数组的键名是数字,但是for…in循环是以字符串作为键名“0”、“1”、“2”等等。
- for…in循环不仅遍历数字键名,还会遍历手动添加的其他键,甚至包括原型链上的键。
- 某些情况下,for…in循环会以任意顺序遍历键名。
- 相比上面几种做法,for…of有一些显著的优点:
- 有着同for…in一样的简洁语法,但是没有for…in那些缺点。
- 不同于forEach方法,它可以与break、continue和return配合使用。
- 提供了遍历所有数据结构的统一操作接口。
Generator 函数的语法
1、简介
语法上,Generator函数是一个状态,封装了多个内部状态。执行Generator函数会返回一个遍历器对象,即Generator还是一个遍历器对象生成函数。返回的遍历器对象可以依次遍历Generator函数内部的每一个状态。
形式上,Generator函数是一个普通函数,但有两个特征。一是function关键字与函数名之间有一个星号;二是函数体内部使用yield表达式定义不同的内部状态(yield在英语里的意思就是产出)。
Generator函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用Generator函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)。每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。
yield 表达式
由于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)的语法功能。
与 Iterator 接口的关系
任意一个对象的Symbol.iterator方法等于该对象的遍历器生成函数,调用该函数会返回对象的一个遍历器对象。Generator就是遍历器生成函数,因此可以把Generator赋值给对象的Symbol.iterator属性,从而使得该对象具有Iterator接口。
2、含义
Generator 与状态机
Generator是实现状态机的最佳结构。它可以不用外部变量保存状态,因为它本身就包含了一个状态信息,即目前是否处于暂停态。
var clock = function* () {
while (true) {
console.log('Tick!');
yield;
console.log('Tock!');
yield;
}
}; //clock函数一共有两种状态(Tick和Tock),每运行一次就改变一次状态
Generator 与协程
协程是一种程序运行的方式,可以理解成协作的线程或协作的函数,多个线程互相协作完成异步任务。协程既可以用单线程实现(特殊的子例程),也可以用多线程实现(特殊的线程)。协程是以多占用内存为代价实现多任务的并行。同一时间可以有多个线程处于运行状态,但是运行的协程只能有一个,其他协程都处于暂停状态。协程是合作式的,执行权由协程自己分配。引入协程以后,每个任务可以保持自己的调用栈,抛出错误的时候可以找到原始的调用栈。最大优点是代码写法非常像同步操作,如果去除yield简直一模一样。
协程有点像函数,又有点像线程。它的运行流程大致如下。
- 第一步,协程A开始执行。
- 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
- 第三步,(一段时间后)协程B交还执行权。
- 第四步,协程A恢复执行。
上面流程的协程A就是异步任务,因为它分成两段(或多段)执行。
function* asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
} //asyncJob是一个协程,它的奥妙就在其中的yield命令
//它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。
//协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。
Generator函数是ES6对协程的实现,但属于不完全实现。Generator函数被称为半协程,意思是只有Generator函数的调用者,才能将程序的执行权还给Generator函数。如果是完全执行的协程,任何函数都可以让暂停的协程继续执行。如果将Generator函数当作协程,完全可以将多个需要互相协作的任务写成Generator函数,它们之间使用yield表达式交换控制权。
Generator 与上下文
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,然后结束。
);
3、应用
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);
} //注意!!!上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤
部署 Iterator 接口
利用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 使得数据或者操作具备了类似数组的接口。
4、next 方法的参数
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'
5、for…of 循环
可以自动遍历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对象作为参数。
6、Generator.prototype.throw()
可以在函数体外抛出错误,然后在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已经运行结束了。
7、Generator.prototype.return()
可以返回给定的值并且终结遍历Generator函数。如果return方法调用时不提供参数,则返回值的value属性为undefined。调用return()方法后,就开始执行finally代码块,不执行try里面剩下的代码了,然后等到finally代码块执行完,再返回return()方法指定的返回值。
8、next()、throw()、return() 的共同点
它们的作用都是让Generator函数恢复执行,并且使用不同的语句替换yield表达式。
- next()是将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;
- throw()是将yield表达式替换成一个throw语句。
gen.throw(new Error('出错了')); // Uncaught Error: 出错了
// 相当于将 let result = yield x + y
// 替换成 let result = throw(new Error('出错了'));
- return()是将yield表达式替换成一个return语句。
gen.return(2); // Object {value: 2, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = return 2;
9、yield* 表达式
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"]
10、作为对象属性的 Generator 函数
如果一个对象的属性是Generator函数,可以简写成下面的形式。
let obj = {
* myGeneratorMethod() { //前面有一个星号,表示这个属性是一个Generator函数
···
}
};
//它的完整形式如下,与上面的写法是等价的
let obj = {
myGeneratorMethod: function* () {
// ···
}
};
11、Generator 函数的this
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
Generator 函数的异步应用
1、传统方法
ES6诞生以前,异步编程的方法大概有下面四种。
- 回调函数
- 事件监听
- 发布/订阅
- Promise对象
Generator函数将JavaScript异步编程带入了一个全新的阶段。
2、基本概念
- 异步:简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。这种不连续的执行就叫做异步。
- 回调函数:就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字callback,直译过来就是”重新调用”。
- 回调地狱:多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数可能都要跟着修改。这种情况就称为回调函数地狱。
- Promise:可以解决回调地狱的问题,是一种新的写法,允许将回调函数的嵌套改成链式调用。提供then方法加载回调函数,catch方法捕捉执行过程中抛出的错误。最大问题是代码冗余,原来的任务被Promise包装了一下,不管什么操作,一眼看去都是一堆then,原来的语义变得很不清楚。
3、Generator 函数
协程的 Generator 函数实现
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 函数的数据交换和错误处理
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);
});
4、Thunk 函数
Thunk函数是自动执行Generator函数的一种方法。
参数的求值策略
- 传值调用(call by value):即在进入函数体之前,就计算x+5的值(等于6),再将这个值传入函数f。
- 传名调用(call by name):即直接将表达式x+5传入函数体,只在用到它的时候求值。
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函数,这些操作就会自动完成
5、co 模块
是一个小工具,用于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 函数
async函数就是Generator函数的语法糖。它可以让我们以同步的方式写异步代码,而不需要回调函数,可以通过async/await语法来实现异步操作。async函数完全可以看作多个异步操作包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。
async函数就是将Generator函数的星号(*)替换成async,将yield替换成await,仅此而已。
async函数对Generator函数的改进体现在以下四点:
- 内置执行器。Generator函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行与普通函数一模一样,只要一行。
- 更好的语义。async和await比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
- 更广的适用性。co模块约定,yield命令后面只能是Thunk函数或Promise对象,而async函数的await命令后面,可以是Promise对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即resolved的Promise对象)。
- 返回值是 Promise。async函数的返回值是Promise对象,这比Generator函数的返回值是Iterator对象方便多了。可以用then方法指定下一步的操作。
1、基本用法
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 () => {};
2、语法
返回 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语句捕捉,然后进入下一轮循环
使用注意点
- await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try…catch代码块中。
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一种写法
async function myFunction() {
await somethingThatReturnsAPromise()
.catch(function (err) {
console.log(err);
});
}
- 多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。
let foo = await getFoo();
let bar = await getBar();
//上面代码中,getFoo和getBar是两个独立的异步操作,被写成继发关系,这样比较耗时,因为只有getFoo完成以后才会执行getBar,
//完全可以让getFoo和getBar同时触发,这样就会缩短程序的执行时间-------------------------
// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
- await命令只能用在async函数之中,如果用在普通函数,就会报错。
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
}
- async 函数可以保留运行堆栈。
const a = async () => {
await b();
c();
}; //b()运行的时候,a()是暂停执行,上下文环境都保存着,一旦b()或c()报错,错误堆栈将包括a()
3、async 函数的实现原理
async函数的实现原理,就是将Generator函数和自动执行器包装在一个函数里。
async function fn(args) {
// ...
}
// 等同于
function fn(args) {
return spawn(function* () {
// ...
});
} //所有的async函数都可以写成第二种形式,其中的spawn函数就是自动执行器。
4、与其他异步处理方法的比较
- Promise:虽然Promise的写法比回调函数的写法大大改进,但一眼看上去,代码完全都是Promise的API(then、catch等等),操作本身的语义反而不容易看出来。
- Generator:语义比Promise写法更清晰,但它必须有一个任务运行器自动执行Generator函数。如果使用Generator写法,自动执行器需要用户自己提供。
- Async:实现最简洁最符合语义,几乎没有语义不相关的代码。它将Generator写法中的自动执行器改在语言层面提供,不暴露给用户,因此代码量最少。
5、实例:按顺序完成异步操作
实际开发中,经常遇到一组异步操作需要按照顺序完成。我们可以用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,因此实现了按顺序输出。
}
6、顶层 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。
Class 的基本语法
1、简介
类的由来
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"]
constructor() 方法
类的默认方法,通过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也可以调用这个方法
取值函数(getter)和存值函数(setter)
在类的内部可以用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是从表达式得到的
Class 表达式
与函数一样,类也可以使用表达式的形式定义。
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(); // "张三"
类的注意点
- 严格模式:类和模块的内部默认就是严格模式,所以不需要使用use strict指定运行模式。只要代码写在类或模块中,就只有严格模式可用。考虑到未来所有的代码其实都是运行在模块中,所以ES6实际上把整个语言升级到了严格模式。
- 不存在提升:类不存在变量提升(hoist),这一点与ES5完全不同。 ES6不会把类的声明提升到代码头部,因为必须保证子类在父类之后定义。
{
let Foo = class {};
class Bar extends Foo { //Bar继承Foo的时候,Foo已经有定义了
}
}//如果存在class的提升,就会报错,因为class会被提升到代码头部,而let命令是不提升的,所以导致Bar继承Foo的时候,Foo还没有定义
- name 属性:由于本质上,ES6的类只是ES5的构造函数的一层包装,所以函数的许多特性都被Class继承,包括name属性。name属性总是返回紧跟在class关键字后面的类名。
class Point {}
Point.name // "Point"
- Generator 方法:如果某个方法之前加上星号(*),就表示该方法是一个Generator函数。
- this 的指向:类的方法内部如果含有this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能报错。有三种解决方法:在构造方法中绑定this、使用箭头函数、使用Proxy。
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());
2、静态方法
类相当于实例的原型,所有在类中定义的方法都会被实例继承。如果在一个方法前加上static关键字,就表示该方法不会被实例继承,不能通过实例调用,而是直接通过类来调用,这就称为静态方法。
如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。
注意,如果静态方法包含this关键字,这个this指的是类,而不是实例。
静态方法可以与非静态方法重名,父类的静态方法可以被子类继承,静态方法也是可以从super对象上调用的。
3、实例属性的新写法
实例属性除了定义在constructor()方法里面的this上面,也可以定义在类的最顶层。这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。
class foo {
bar = 'hello';
baz = 'world';
constructor() {
// ...
}
} //一眼就能看出foo类有两个实例属性,一目了然,写起来也比较简洁
4、静态属性
静态属性指的是Class本身的属性,即Class.propName,而不是定义在实例对象(this)上的属性。静态属性不能通过实例对象来访问,而是通过类本身来访问,不会被实例继承。写法是在实例属性的前面加上static关键字,这个新写法大大方便了静态属性的表达。
// 老写法。老写法的静态属性定义在类的外部,整个类生成以后再生成静态属性。这样让人很容易忽略这个静态属性,也不符合相关代码应该放在一起的代码组织原则
class Foo {
// ...
}
Foo.prop = 1;
// 新写法。显式声明(declarative),而不是赋值处理,语义更好
class Foo {
static prop = 1;
}
静态属性和方法可以被子类继承,但子类不能覆盖父类的静态属性和方法。在访问静态属性和方法时,可以通过类名来区分来自父类还是子类的静态属性和方法。
私有属性和私有方法不能被子类继承,它们只能被父类本身所使用和管理。只能在所属的类内部被访问和修改,而不能被其他类访问或继承,子类无法直接访问,因为它们在父类的外部是不可见的。
5、私有方法和私有属性
私有方法和私有属性是只能在类的内部访问的方法和属性,外部不能访问。这是常见需求,有利于代码的封装,但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;
}
- ③一种方法是利用Symbol值的唯一性,将私有方法的名字命名为一个Symbol值。
const bar = Symbol('bar');
const snaf = Symbol('snaf');
export default class myClass{
// 公有方法
foo(baz) {
this[bar](baz);
}
// 私有方法
[bar](baz) {
return this[snaf] = baz;
} //bar和snaf都是Symbol值,一般情况下无法获取到它们,因此达到了私有方法和私有属性的效果
// ...
};
//但也不是绝对不行,Reflect.ownKeys()依然可以拿到它们
const inst = new myClass(); //Symbol值的属性名依然可以从类的外部拿到
Reflect.ownKeys(myClass.prototype) // [ 'constructor', 'foo', Symbol(bar) ]
- 目前,有一个提案为class加了私有属性。方法是在属性名之前使用#表示,这种写法不仅可以写私有属性,还可以用来写私有方法。由于井号#是属性名的一部分,使用时必须带有#一起使用,所以#x和x是两个不同的属性。
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关键字,表示这是一个静态的私有属性或私有方法。
6、new.target 属性
new是从构造函数生成实例对象的命令。ES6为new命令引入了一个new.target属性,该属性一般用在构造函数中,指向当前正在执行的函数,返回new命令作用于的那个构造函数。
如果构造函数不是通过new命令或Reflect.construct()调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。
function Person(name) {
if (new.target !== undefined) {
this.name = name;
} else {
throw new Error('必须使用 new 命令生成实例');
}
}
// 另一种写法
function Person(name) {
if (new.target === Person) {
this.name = name;
} else {
throw new Error('必须使用 new 命令生成实例');
}
}
var person = new Person('张三'); // 正确
var notAPerson = Person.call(person, '张三'); // 报错
//上面代码确保构造函数只能通过new命令调用
Class内部调用new.target,会返回当前Class。注意,子类继承父类时,new.target会返回子类。利用这个特点,可以写出不能独立使用必须继承后才能使用的类。在函数外部使用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 的继承
1、简介
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; // 正确
}
}
2、Object.getPrototypeOf()
可以用来从子类上获取父类。可以使用这个方法判断一个类是否继承了另一个类。
Object.getPrototypeOf(ColorPoint) === Point // true
3、super 关键字
super关键字既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。
- 第一种情况,super作为函数调用时,代表父类的构造函数。ES6要求子类的构造函数必须执行一次super函数。作为函数时,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。
- 第二种情况,super作为对象时,①在普通方法中,指向父类的原型对象(所以此时,定义在父类实例上的方法或属性是无法通过super调用的);②在静态方法中,指向父类。
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关键字。
4、类的 prototype 属性和__proto__属性
大多数浏览器的ES5实现中,每个对象都有__proto__属性,指向对应的构造函数的prototype属性。
Class作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。
- 子类的__proto__属性:表示构造函数的继承,总是指向父类。
- 子类prototype属性的__proto__属性:表示方法的继承,总是指向父类的prototype属性。
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__.__proto__属性,可以修改父类实例的行为。
p2.__proto__.__proto__.printName = function () {
console.log('Ha');
}; //在ColorPoint的实例p2上向Point类添加方法,结果影响到了Point的实例p1。
p1.printName() // "Ha"
5、原生构造函数的继承
原生构造函数是指语言内置的构造函数,通常用来生成数据结构。ES6允许继承原生构造函数定义子类,因为ES6是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可继承。这意味着ES6可以自定义原生数据结构(比如Array、String等)的子类,这是ES5无法做到的。
ECMAScript的原生构造函数大致有下面这些。
- Boolean()
- Number()
- String()
- Array()
- Date()
- Function()
- RegExp()
- Error()
- Object()
注意,继承Object的子类无法通过super方法向父类Object传参。 ES6改变了Object构造函数的行为,一旦发现Object方法不是通过new Object()这种形式调用,Object构造函数会忽略参数。
6、Mixin 模式的实现
Mixin指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。可以将多个对象合成为一个类,使用的时候只要继承这个类即可。
const a = {
a: 'a'
};
const b = {
b: 'b'
};
const c = {...a, ...b}; // {a: 'a', b: 'b'} c对象是a对象和b对象的合成,具有两者的接口
Module 的语法
1、概述
ES6模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系以及输入和输出的变量,CommonJS和AMD模块都只能在运行时确定这些东西。
模块功能主要由两个命令构成:export和import。
export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
ES6模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。可以在编译时就完成模块加载,这使得静态分析成为可能。有了它,就能进一步拓宽JavaScript的语法。
// ES6模块。实质是从fs模块加载3个方法,其他方法不加载。这种加载称为编译时加载或静态加载
import { stat, exists, readFile } from 'fs';
除了静态加载带来的各种好处,ES6模块还有以下好处。
- 无需UMD模块格式,将来服务器和浏览器都会支持ES6模块格式。目前,通过各种工具库,其实已经做到了这一点。
- 将来浏览器的新API就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
- 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。
2、import()
import命令会被JS引擎静态分析,先于模块内的其他语句执行(import命令叫做连接binding其实更合适)。import和export命令只能在模块的顶层,不能在代码块中(比如在if代码块中,或在函数中)。这样的设计有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载不可能实现。
import()函数支持动态加载模块。import()返回一个Promise对象。import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于Node的require方法,区别主要是前者是异步加载,后者是同步加载。
下面是import()的一些适用场合:
- 按需加载。import()可以在需要的时候再加载某个模块。
button.addEventListener('click', event => { //只有用户点击了按钮才会加载这个模块
import('./dialogBox.js')
.then(dialogBox => {
dialogBox.open();
})
.catch(error => {
/* Error handling */
})
});
- 条件加载。import()可以放在if代码块,根据不同的情况加载不同的模块。
if (condition) { //如果满足条件就加载模块 A,否则加载模块 B
import('moduleA').then(...);
} else {
import('moduleB').then(...);
}
- 动态的模块路径。import()允许模块路径动态生成。
import(f()) //根据函数f的返回结果加载不同的模块
.then(...);
注意点:
- import()加载模块成功以后,这个模块会作为一个对象当作then方法的参数。因此,可以使用对象解构赋值的语法获取输出接口。
import('./myModule.js')
.then(({export1, export2}) => { //export1和export2都是myModule.js的输出接口,可以解构获得
// ...·
});
- 如果模块有default输出接口,可以用参数直接获得。
import('./myModule.js')
.then(myModule => {
console.log(myModule.default);
});
//也可以使用具名输入的形式
import('./myModule.js')
.then(({default: theDefault}) => {
console.log(theDefault);
});
//如果想同时加载多个模块,可以采用下面的写法
Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
])
.then(([module1, module2, module3]) => {
···
});
- import()也可以用在async函数之中。
async function main() {
const myModule = await import('./myModule.js');
const {export1, export2} = await import('./myModule.js');
const [module1, module2, module3] =
await Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
]);
}
main();
3、严格模式
ES6的模块自动采用严格模式,不管有没有在模块头部加上”use strict”。主要有以下限制。
- 变量必须声明后再使用
- 函数的参数不能有同名属性,否则报错
- 不能使用with语句
- 不能对只读属性赋值,否则报错
- 不能使用前缀 0 表示八进制数,否则报错
- 不能删除不可删除的属性,否则报错
- 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
- eval不会在它的外层作用域引入变量
- eval和arguments不能被重新赋值
- arguments不会自动反映函数参数的变化
- 不能使用arguments.callee
- 不能使用arguments.caller
- 禁止this指向全局对象
- 不能使用fn.caller和fn.arguments获取函数调用的堆栈
- 增加了保留字(比如protected、static和interface)
尤其注意this的限制。ES6模块中,顶层的this指向undefined,即不应该在顶层代码使用this。
4、export 命令
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果希望外部能够读取模块内部的某个变量,就必须使用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模块的设计初衷。
5、import 命令
使用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语句,那么只会执行一次,而不会执行多次。
6、模块的整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。注意,模块整体加载所在的那个对象应该是可以静态分析的,所以不允许运行时改变。
import * as circle from './circle'; //整体加载
console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));
// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};
7、export default 命令
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语句中同时输入默认方法和其他接口
8、export 与 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};
9、模块的继承
模块之间也可以继承。注意,export *命令并不会导出从其他模块导入的 default 导出项。因为 export * 只导出那些被明确标记为导出的项,而默认导出并不属于这种情况,它在每个模块中只能有一个,并且它并不需要一个明确的导出名称。
10、跨模块常量
如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。
// 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';
Module 的加载实现
1、浏览器加载
传统方法
- 默认方法:通过<script>标签加载JavaScript脚本,默认是同步加载执行的。渲染引擎如果遇到<script>就会停下来,直到脚本下载执行完成,才会继续渲染。如果脚本体积很大,下载和执行的时间就会很长,因此造成浏览器堵塞,用户会感觉到浏览器卡死了,没有任何响应。
- 异步方法:包括defer和async属性。其中,defer属性会让该标签引用的脚本等到整个页面在内存中正常渲染结束(DOM结构完全生成,以及其他脚本执行完成),才会执行,并且引用的其他脚本执行完成之后,才会执行;多个defer脚本会按照它们在页面上出现的顺序依次执行。而async属性则类似于异步回调函数,一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染;多个async脚本不能保证执行的顺序。
加载规则
- 浏览器加载ES6模块也使用<script>标签,但是要加入type=”module”属性。
- 浏览器对于带有type=”module”的<script>都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完再执行模块脚本,等同于打开了<script>标签的defer属性。
- 如果网页有多个<script type=”module”>,它们会按照在页面出现的顺序依次执行。
- <script>标签的async属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后再恢复渲染。
- 一旦使用了async属性,<script type=”module”>就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块。
- ES6模块也允许内嵌在网页中,语法行为与加载外部脚本完全一致。
对于外部的模块脚本(上例是foo.js),有几点需要注意。
- 代码是在模块作用域中运行,而不是在全局作用域运行。模块内部的顶层变量外部不可见。
- 模块脚本自动采用严格模式,不管有没有声明use strict。
- 模块中可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
- 模块中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字是无意义的。
- 同一个模块如果加载多次,将只执行一次。
利用顶层的this等于undefined这个语法点,可以侦测当前代码是否在 ES6 模块中。
const isNotModuleScript = this !== undefined;
2、ES6 模块与 CommonJS 模块的差异
ES6模块与CommonJS模块有两个重大差异。
- CommonJS模块输出的是一个值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值,原始值会被缓存,除非写成一个函数才能得到内部变动后的值。ES6模块输出的是值的引用。
- CommonJS模块是运行时加载,ES6模块是编译时输出接口。因为CommonJS加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而ES6模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
- import命令加载CommonJS模块只能整体加载,不能只加载单一的输出项。
- ES6模块的运行机制与CommonJS不一样。JS引擎对脚本静态分析时,遇到模块加载命令import就会生成一个只读引用,等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6的import有点像Unix系统的符号连接,原始值变了,import加载的值也会跟着变。因此,ES6模块是动态引用(动态地去被加载的模块取值),并且不会缓存值(运行结果),模块里面的变量绑定其所在的模块。
- ES6输入的模块变量只是一个符号连接,所以这个变量是只读的,对它进行重新赋值会报错。export通过接口输出的是同一个值。不同的脚本加载这个接口得到的都是同样的实例。
package.json文件中,
main字段指定CommonJS入口给Node.js 使用。当你使用require() 导入一个模块时,Node.js 会查找package.json文件中的main字段来确定要加载哪个文件。如果main字段不存在或指定的文件不存在,Node.js会尝试加载index.js或index.node文件。
module字段指定ES6模块入口给打包工具使用,因为Node.js不认识module字段。当使用require()导入一个模块时,Node.js会查找package.json文件中的main字段来确定要加载哪个文件。如果main字段不存在或指定的文件不存在,Node.js会尝试加载index.js或index.node文件。
这样允许库或框架的维护者同时提供CommonJS和ES6版本的代码,满足不同用户的需求。例如,Node.js用户可以继续使用require()语法,而前端开发者可通过打包工具享受ES6模块带来的优势。
{
"name": "my-package",
"version": "1.0.0",
"main": "dist/index.cjs.js", // CommonJS 入口
"module": "dist/index.esm.js", // ES6 模块入口
// 其他配置...
}
3、Node.js 的模块加载方法
Node.js要求ES6模块采用.mjs后缀文件名。就是说,只要脚本文件里使用import或export命令,那么就必须采用.mjs后缀名。Node.js遇到.mjs文件,就认为它是ES6模块,默认启用严格模式,不必在每个模块文件顶部指定”use strict”。如果不希望将后缀名改成.mjs,可以在项目的package.json文件中指定type字段为module。一旦设置了以后,该目录里面的JS脚本就被解释成ES6模块。
如果这时还要使用CommonJS模块,那么需要将CommonJS脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成CommonJS模块。
总结为一句话:.mjs文件总是以ES6模块加载,.cjs文件总是以CommonJS模块加载,.js文件的加载取决于package.json里面type字段的设置。
注意,ES6模块与CommonJS模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import。
main 字段
package.json文件有两个字段可以指定模块的入口文件:main和exports。比较简单的模块可以只使用main字段指定模块加载的入口文件。要加上 “type”: “module”,否则会被解释为CommonJS模块。
exports 字段
exports字段的优先级高于main字段。它有多种用法。
子目录别名:package.json文件的exports字段可以指定脚本或子目录的别名。如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}//上面的代码指定src/submodule.js别名为submodule,然后就可以从别名加载这个文件。
import submodule from 'es-module-package/submodule';
// 加载 ./node_modules/es-module-package/src/submodule.js
main的别名:exports字段的别名如果是.,就代表模块的主入口,优先级高于main字段,且可直接简写成exports字段的值。exports只有支持ES6的Node.js才认识,所以可用来兼容旧版本的Node.js。
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
//老版本的Node.js(不支持ES6模块)的入口文件是main-legacy.cjs,新版本的Node.js的入口文件是main-modern.cjs
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}
条件加载:利用.这个别名,可以为ES6模块和CommonJS指定不同的入口。目前,这个功能需要在Node.js运行的时候,打开–experimental-conditional-exports标志。
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs", //指定require()命令的入口文件(即CommonJS的入口)
"default": "./main.js" //指定其他情况的入口(即ES6的入口)
}
}
}//上面的写法可以简写如下
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}//注意,如果同时还有其他别名,就不能采用简写,否则或报错
{
// 报错
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}
ES6 模块加载 CommonJS 模块
ES6模块通过Node.js内置的module.createRequire()方法可以加载CommonJS模块。
// cjs.cjs
module.exports = 'cjs';
// esm.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjs = require('./cjs.cjs');
cjs === 'cjs'; // true
CommonJS 模块加载 ES6 模块
CommonJS的require命令不能加载ES6模块,会报错,只能使用import()这个方法加载。
(async () => {
await import('./my-app.mjs');
})();
Node.js 的内置模块
Node.js的内置模块可以整体加载,也可以加载指定的输出项。
// 整体加载
import EventEmitter from 'events';
const e = new EventEmitter();
// 加载指定的输出项
import { readFile } from 'fs';
readFile('./foo.txt', (err, source) => {
if (err) {
console.error(err);
} else {
console.log(source);
}
});
加载路径
ES6模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。import命令和package.json文件的main字段如果省略脚本的后缀名,会报错。
// ES6 模块中将报错
import { something } from './index';
为了与浏览器的import加载规则相同,Node.js 的.mjs文件支持 URL 路径。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。所以,只要文件名中含有:、%、#、?等特殊字符,最好对这些字符进行转义。
import './foo.mjs?query=1'; // 加载 ./foo 传入参数 ?query=1
//脚本路径带有参数?query=1,Node会按URL规则解读
目前,Node.js 的import命令只支持加载本地模块(file:协议)和data:协议,不支持加载远程模块。脚本路径只支持相对路径,不支持绝对路径(即以/或//开头的路径)。Node的import命令是异步加载,这一点与浏览器的处理方法相同。
内部变量
ES6模块应该是通用的,同一个模块不用修改就可以用在浏览器环境和服务器环境。为了达到这个目标,Node规定ES6模块中不能使用CommonJS模块的特有的一些内部变量。
this关键字。ES6模块的顶层this指向undefined;CommonJS模块的顶层this指向当前模块,这是两者的一个重大差异。
以下这些顶层变量在ES6模块中都是不存在的。
- arguments
- require
- module
- exports
- __filename
- __dirname
4、循环加载
循环加载指的是a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。通常,循环加载表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现。模块加载机制必须考虑循环加载的情况。
对于JavaScript语言来说,目前最常见的两种模块格式CommonJS和ES6处理循环加载的方法是不一样的,返回的结果也不一样。
CommonJS 模块的加载原理
CommonJS的一个模块就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令也不会再次执行该模块,而是到缓存中取值。也就是说,CommonJS模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
CommonJS 模块的循环加载
CommonJS模块的重要特性是加载时执行,即脚本代码在require的时候就会全部执行。一旦出现某个模块被循环加载,就只输出已经执行的部分,还未执行的部分不会输出。CommonJS输入的是被输出值的拷贝,不是引用。由于CommonJS模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。所以,输入变量的时候必须非常小心。
ES6 模块的循环加载
在ES6模块中,如果一个模块A导入另一个模块B,而模块B又导入了模块A,系统将尝试解析这些依赖。但与CommonJS不同的是,ES6模块不会返回一个部分初始化的模块对象。相反,它会确保每个模块的代码都完全执行一次,然后再将导出的值提供给其他模块。ES6 模块是动态引用,如果用import从一个模块加载变量,那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证真正取值的时候能够取到值。
编程风格
1、块级作用域
let 取代 var:ES6提出了两个新的声明变量的命令let和const。其中,let完全可以取代var,因为两者语义相同,而且let没有副作用。var命令存在变量提升效用,let命令没有这个问题。
全局常量和线程安全:在let和const之间,建议优先使用const,尤其是在全局环境,不应该设置变量,只应设置常量。
const优于let有几个原因。一是const可以提醒阅读代码的人这个变量不应该改变,防止了无意间修改变量值所导致的错误;二是const比较符合函数式编程思想,运算不改变值,只是新建值,而且这样也有利于将来的分布式运算;三是 JavaScript 编译器会对const进行优化,所以多使用const有利于提高程序的运行效率,也就是说let和const的本质区别其实是编译器内部的处理不同。
所有的函数都应该设置为常量。长远来看,JavaScript 可能会有多线程的实现(比如Intel公司的 River Trail那一类的项目),这时let表示的变量只应出现在单线程运行的代码中,不能是多线程共享的,这样有利于保证线程安全。
2、ESLint 的使用
ESLint 是一个语法规则和代码风格的检查工具,可以用来保证写出语法正确、风格统一的代码。
3、字符串
静态字符串一律使用单引号或反引号,不使用双引号。动态字符串使用反引号。
// bad
const a = "foobar";
const b = 'foo' + a + 'bar';
// acceptable
const c = `foobar`;
// good
const a = 'foobar';
const b = `foo${a}bar`;
4、解构赋值
使用数组成员对变量赋值时,优先使用解构赋值。
函数的参数如果是对象的成员,优先使用解构赋值。
如果函数返回多个值,优先使用对象的解构赋值,而不是数组的解构赋值。这样便于以后添加返回值,以及更改返回值的顺序。
// good
const [first, second] = arr;
// good
function getFullName(obj) {
const { firstName, lastName } = obj;
}
// best
function getFullName({ firstName, lastName }) {
}
// good
function processInput(input) {
return { left, right, top, bottom };
}
const { left, right } = processInput(input);
5、对象
单行定义的对象,最后一个成员不以逗号结尾。多行定义的对象,最后一个成员以逗号结尾。
对象尽量静态化,一旦定义,就不得随意添加新的属性。如果添加属性不可避免,要使用Object.assign方法。
如果对象的属性名是动态的,可以在创造对象的时候,使用属性表达式定义。
对象的属性和方法尽量采用简洁表达法,这样易于描述和书写。
// good
const a = { k1: v1, k2: v2 };
const b = {
k1: v1,
k2: v2,
};
// good
const a = { x: null };
a.x = 3;
Object.assign(a, { y: 3 }); //if reshape unavoidable
// good
const obj = {
id: 5,
name: 'San Francisco',
[getKey('enabled')]: true, //这个属性名需要计算得到,这时最好采用属性表达式,在新建obj时,将该属性与其他属性定义在一起
};
// good
const atom = {
ref,
value: 1,
addValue(value) {
return atom.value + value;
},
};
6、数组
使用扩展运算符(…)拷贝数组。
使用Array.from方法将类似数组的对象转为数组。
// good
const itemsCopy = [...items];
const foo = document.querySelectorAll('.foo');
const nodes = Array.from(foo);
7、函数
立即执行函数可以写成箭头函数的形式。
那些使用匿名函数当作参数的场合,尽量用箭头函数代替。因为这样更简洁,而且绑定了this。
箭头函数取代Function.prototype.bind,不应再用self/_this/that绑定this。
简单的、单行的、不会复用的函数,建议采用箭头函数。如果函数体较为复杂,行数较多,还是应该采用传统的函数写法。所有配置项都应集中在一个对象放在最后一个参数,布尔值不能直接作为参数。
不要在函数体内使用arguments变量,使用rest 运算符(…)代替。因为rest运算符显式表明你想要获取参数,而且arguments是一个类似数组的对象,而rest运算符可以提供一个真正的数组。
使用默认值语法设置函数参数的默认值。
(() => {
console.log('Welcome to the Internet.');
})();
// good
[1, 2, 3].map((x) => {
return x * x;
});
// best
[1, 2, 3].map(x => x * x);
// best
const boundMethod = (...params) => method.apply(this, params);
// good
function divide(a, b, { option = false } = {}) {
}
// good
function concatenateAll(...args) {
return args.join('');
}
// good
function handleThings(opts = {}) {
// ...
}
8、Map 结构
注意区分Object和Map,只有模拟现实世界的实体对象时才使用Object。如果只需key: value的数据结构,使用Map结构,因为Map有内建的遍历机制。
let map = new Map(arr);
for (let key of map.keys()) {
console.log(key);
}
for (let value of map.values()) {
console.log(value);
}
for (let item of map.entries()) {
console.log(item[0], item[1]);
}
9、Class
总是用Class取代需要prototype的操作,因为Class的写法更简洁,更易于理解。
使用extends实现继承,因为这样更简单,不会有破坏instanceof运算的危险。
// good
class Queue {
constructor(contents = []) {
this._queue = [...contents];
}
pop() {
const value = this._queue[0];
this._queue.splice(0, 1);
return value;
}
}
// good
class PeekableQueue extends Queue {
peek() {
return this._queue[0];
}
}
10、模块
Module语法是JavaScript模块的标准写法,坚持使用这种写法。使用import取代require,使用export取代module.exports。如果模块只有一个输出值,就使用export default,如果模块有多个输出值,就不使用export default,export default与普通的export不要同时使用。
不要在模块输入中使用通配符。因为这样可以确保模块中有一个默认输出。
如果模块默认输出一个函数,函数名的首字母应该小写。
如果模块默认输出一个对象,对象名的首字母应该大写。
// good
import { func1, func2 } from 'moduleA';
// ES6的写法
import React from 'react';
class Breadcrumbs extends React.Component {
render() {
return <nav />;
}
};
export default Breadcrumbs;
// good
import myObject from './importModule';
function makeStyleGuide() {//如果模块默认输出一个函数,函数名的首字母应该小写
}
export default makeStyleGuide;
const StyleGuide = {//如果模块默认输出一个对象,对象名的首字母应该大写。
es6: {
}
};
export default StyleGuide;
读懂规格
1、概述
规格文件是计算机语言的官方标准,详细描述语法规则和实现方法。规格是解决问题的“最后一招”。这对 JavaScript 语言很有必要。因为它的使用场景复杂,语法规则不统一,例外很多,各种运行环境的行为不一致,导致奇怪的语法问题层出不穷,任何语法书都不可能囊括所有情况。查看规格,不失为一种解决语法问题的最可靠、最权威的终极方法。
2、术语
抽象操作
抽象操作就是引擎的一些内部方法,外部不能调用。规格定义了一系列的抽象操作,规定了它们的行为,留给各种引擎自己去实现。许多函数的算法都会多次用到同样的步骤,所以ES6规格将它们抽出来定义成抽象操作,方便描述。
Record 和 field
ES6规格将键值对的数据结构称为Record,其中的每一组键值对称为field。就是说,一个Record由多个field组成,而每个field都包含一个键名(key)和一个键值(value)。
[[Notation]]
ES6规格大量使用[[Notation]]这种书写法,比如[[Value]]、[[Writable]]、[[Get]]、[[Set]]等等。它用来指代field的键名。
举例来说,obj是一个Record,它有一个Prototype属性。ES6规格不会写obj.Prototype,而是写obj.[[Prototype]]。一般来说,使用[[Notation]]这种书写法的属性都是对象的内部属性。
所有的JavaScript函数都有一个内部属性[[Call]],用来运行该函数。
Completion Record
每一个语句都会返回一个Completion Record,表示运行结果。每个Completion Record有一个[[Type]]属性,表示运行结果的类型,它有五种可能的值。
- normal
- return
- throw
- break
- continue
如果[[Type]]的值是normal,就称为 normal completion,表示运行正常。其他的值都称为 abrupt completion。开发者只需要关注[[Type]]为throw的情况,即运行出错;break、continue、return这三个值都只出现在特定场景,可以不用考虑。
3、抽象操作的标准流程
Let result be ? AbstractOp().
return result.
//调用抽象操作AbstractOp(),得到result,这是一个Completion Record。如果result属于abrupt completion就直接返回,如果没有返回就表示result属于normal completion
// ?就代表AbstractOp()可能会报错。一旦报错,就返回错误,否则取出值。将result的值设置为resultCompletionRecord.[[Value]],返回result。
//除了?,ES 6 规格还使用另一个简写符号!
Let result be ! AbstractOp().
return result.
//!代表AbstractOp()不会报错,返回的一定是normal completion,总是可以取出值。
4、相等运算符
相等运算符用于比较两个值,返回true或false。
0 == null // false 由于0的类型是数值,null的类型是Null运算的结果,因此算法细节里的前11步都得不到结果,要到第12步才能得到false
null==undefined //true
undefined==null //true
5、数组的空位
数组的空位会反映在length属性,也就是说空位有自己的位置,但是这个位置的值是未定义的,即这个值是不存在的。如果一定要读取,结果就是undefined(因为undefined在JS语言中表示不存在)。
const a1 = [undefined, undefined, undefined];
const a2 = [, , ,];
a1.length // 3
a2.length // 3
a1[0] // undefined
a2[0] // undefined
a1[0] === a2[0] // true
0 in a1 // true
0 in a2 // false (数组a2取不到属性名,这个属性名根本就不存在)
a1.hasOwnProperty(0) // true
a2.hasOwnProperty(0) // false (数组a2取不到属性名,这个属性名根本就不存在)
Object.keys(a1) // ["0", "1", "2"]
Object.keys(a2) // [] (数组a2取不到属性名,这个属性名根本就不存在)
a1.map(n => 1) // [1, 1, 1]
a2.map(n => 1) // [, , ,] (数组a2没有发生遍历)
6、数组的 map 方法
const arr = [, , ,];
arr.map(n => {
console.log(n);
return 1;
}) // [, , ,]
上面代码中,arr是一个全是空位的数组,map遍历成员时发现是空位,就直接跳过,不会进入回调函数。因此回调函数里的console.log语句根本不会执行,整个map方法返回一个全是空位的新数组。
异步遍历器
1、同步遍历器的问题
举个例子,变量it是一个遍历器,每次调用it.next()方法就返回一个对象,表示当前遍历位置的信息。这里隐含着一个规定,it.next()方法必须是同步的,只要调用就必须立刻返回值。也就是说,一旦执行it.next()方法,就必须同步地得到value和done这两个属性。如果遍历指针正好指向同步操作,当然没有问题。但如果next()方法返回的是一个Promise对象就不行,不符合Iterator协议,只要代码里面包含异步操作都不行。也就是说,Iterator协议里面next()方法只能包含同步操作。
目前的解决方法是,将异步操作包装成Thunk函数或Promise对象,即next()方法返回值的value属性是一个Thunk函数或Promise对象,用来放置异步操作,等待以后返回真正的值,而done属性则还是同步产生的。但是这样写很麻烦,不太符合直觉,语义也比较绕。
ES2018引入了异步遍历器,为异步操作提供原生的遍历器接口,即value和done这两个属性都是异步产生。
2、异步遍历的接口
异步遍历器的最大的语法特点就是调用遍历器的next方法,返回的是一个Promise对象。因此,可以使用then方法指定这个Promise对象的状态变为resolve以后的回调函数。回调函数的参数则是一个具有value和done两个属性的对象,这个跟同步遍历器是一样的。
一个对象的同步遍历器的接口部署在Symbol.iterator属性上面。同样,对象的异步遍历器接口部署在Symbol.asyncIterator属性上面。不管是什么样的对象,只要它的Symbol.asyncIterator属性有值,就表示应该对它进行异步遍历。
异步遍历器的next方法返回的是一个Promise对象,因此,可以把它放在await命令后面,next方法用await处理以后就不必使用then方法了。
注意,异步遍历器的next方法是可以连续调用的,不必等到上一步产生的Promise对象resolve以后再调用。这种情况下,next方法会累积起来,自动按照每一步的顺序运行下去。①可以把所有的next方法放在Promise.all方法里面,②还可以一次性调用所有的next方法,然后await最后一步操作。
//把所有的next方法放在Promise.all方法里面
const asyncIterable = createAsyncIterable(['a', 'b']);
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
const [{value: v1}, {value: v2}] = await Promise.all([
asyncIterator.next(), asyncIterator.next()
]);
console.log(v1, v2); // a b
//一次性调用所有的next方法,然后await最后一步操作
async function runner() {
const writer = openFile('someFile.txt');
writer.next('hello');
writer.next('world');
await writer.return();
}
runner();
3、for await…of
for…of循环用于遍历同步的Iterator接口。新引入的for await…of循环则是用于遍历异步的Iterator接口。注意,for await…of循环也可以用于同步遍历器。
async function f() {
for await (const x of createAsyncIterable(['a', 'b'])) {
console.log(x);
}
}
// a
// b
//createAsyncIterable()返回一个拥有异步遍历器接口的对象,for...of循环自动调用这个对象的异步遍历器的next方法,会得到一个Promise对象,await用来处理这个Promise对象,一旦resolve,就把得到的值(x)传入for...of的循环体
for await…of循环的一个用途,是部署了asyncIterable操作的异步接口可以直接放入这个循环。
await用来处理next方法返回的Promise对象,一旦resolve,就把得到的值传入for…of的循环体;如果被reject,for await…of就会报错,要用try…catch捕捉。
4、异步 Generator 函数
就像Generator函数返回一个同步遍历器对象一样,异步Generator函数的作用是返回一个异步遍历器对象。在语法上,异步Generator函数就是async函数与Generator函数的结合。
async function* gen() { //gen是一个异步Generator函数,执行后返回一个异步Iterator对象
yield 'hello';
}
const genObj = gen();
genObj.next().then(x => console.log(x)); //对该对象调用next方法,返回一个Promise对象
// { value: 'hello', done: false }
异步遍历器的设计目的之一就是Generator函数处理同步操作和异步操作时,能够使用同一套接口。
// 同步 Generator 函数
function* map(iterable, func) {
const iter = iterable[Symbol.iterator](); //同步遍历器
while (true) {
const {value, done} = iter.next();
if (done) break;
yield func(value);
}
}
// 异步 Generator 函数
async function* map(iterable, func) {
const iter = iterable[Symbol.asyncIterator](); //异步遍历器
while (true) {
const {value, done} = await iter.next();
if (done) break;
yield func(value);
}
}
//map是一个Generator函数,第一个参数是可遍历对象iterable,第二个参数是一个回调函数func。map的作用是将iterable每一步返回的值使用func进行处理
异步操作前面使用await关键字标明,即await后面的操作应该返回Promise对象。凡是使用yield关键字的地方就是next方法停下来的地方,它后面的表达式的值(即await file.readLine()的值),会作为next()返回对象的value属性,这一点是与同步Generator函数一致的。
异步Generator函数内部能够同时使用await和yield命令。await命令用于将外部操作产生的值输入函数内部,yield命令用于将函数内部的值输出。
异步Generator函数可以与for await…of循环结合起来使用。
async function* prefixLines(asyncIterable) {
for await (const line of asyncIterable) {
yield '> ' + line;
}
}//如果像这个例子那样,yield命令后面是一个字符串,会被自动包装成一个Promise对象。
异步Generator函数的返回值是一个异步Iterator,即每次调用它的next方法,会返回一个Promise对象,也就是说,跟在yield命令后面的应该是一个Promise对象。
如果异步Generator函数抛出错误,会导致Promise对象的状态变为reject,抛出的错误被catch方法捕获。
注意,普通的 async 函数返回的是一个Promise对象,而异步Generator函数返回的是一个异步Iterator对象。async函数和异步Generator函数是封装异步操作的两种方法,都用来达到同一种目的。区别在于,前者自带执行器,后者通过for await…of执行,或者自己编写执行器。
异步Generator函数也可以通过next方法的参数接收外部传入的数据。同步的数据结构也可以使用异步Generator函数。
JavaScript有四种函数形式:普通函数、async 函数、Generator 函数和异步Generator函数。
- 当需要执行同步操作且不需要特殊的控制流或迭代机制时,应使用普通函数。它们适用于大多数基本的编程任务,如计算、数据处理和简单的逻辑操作。
- 如果是一系列按照顺序执行的异步操作(比如读取文件,然后写入新内容,再存入硬盘),可以使用async函数。它们适用于网络请求、定时操作、文件读写等需要等待的异步任务。
- 当需要创建一个可以逐步产生值的迭代器时,应使用Generator函数。它们适用于需要逐步处理数据或控制代码执行流程的场景,如分页加载数据、逐步计算等。
- 如果是一系列产生相同数据结构的异步操作(比如一行一行读取文件),可以使用异步Generator函数。它们适用于处理流式数据、逐步处理大量异步数据或需要异步生成值的场景。
5、yield* 语句
yield*语句也可以跟一个异步遍历器。与同步Generator函数一样,for await…of循环会展开yield*。
async function* gen1() {
yield 'a';
yield 'b';
return 2;
}
async function* gen2() {
const result = yield* gen1(); // result最终会等于2
}
//与同步 Generator 函数一样,for await...of循环会展开yield*
(async function () {
for await (const x of gen2()) {
console.log(x);
}
})();
// a
// b
ArrayBuffer
1、ArrayBuffer 对象
ArrayBuffer对象代表储存二进制数据的一段内存,它不能直接读写,只能通过视图(TypedArray视图和DataView视图)来读写,视图的作用是以指定格式解读二进制数据。ArrayBuffer也是一个构造函数,可以分配一段可存放数据的连续内存区域。
const buf = new ArrayBuffer(32); //生成一段32字节的内存区域
const dataView = new DataView(buf); //建立DataView视图
dataView.getUint8(0) // 0 以不带符号的8位整数格式从头读取8位二进制数据,结果得到0,因为原始内存的ArrayBuffer对象默认所有位都是0
DataView提供了更灵活的方式来读写二进制数据,DataView视图的创建需要提供ArrayBuffer对象实例作为参数。另一种TypedArray视图,它是一种特殊的数组类型,与DataView视图的一个区别是,它不是一个构造函数,而是一组构造函数,代表不同的数据格式。
const buffer = new ArrayBuffer(12);
const x1 = new Int32Array(buffer); //32位带符号整数(Int32Array构造函数)
x1[0] = 1;
const x2 = new Uint8Array(buffer); //8位不带符号整数(Uint8Array构造函数)
//由于两个视图对应的是同一段内存,一个视图修改底层内存,会影响到另一个视图。
x2[0] = 2;
x1[0] // 2
TypedArray视图的构造函数,除了接受ArrayBuffer实例作为参数,还可以接受普通数组作为参数,直接分配内存生成底层的ArrayBuffer实例,并同时完成对这段内存的赋值。
const typedArray = new Uint8Array([0,1,2]); //新建一个不带符号的8位整数视图,直接使用普通数组作为参数,对底层内存的赋值同时完成
typedArray.length // 3
typedArray[0] = 5;
typedArray // [5, 1, 2]
TypedArray更适合处理大量同类型的数据,并且要求高性能的场景;
DataView则更适合处理包含多种数据类型或者需要灵活控制字节顺序的复杂二进制数据。
ArrayBuffer.prototype.byteLength
ArrayBuffer实例的byteLength属性返回所分配的内存区域的字节长度。如果要分配的内存区域很大,有可能分配失败(因为没有那么多的连续空余内存),所以有必要检查是否分配成功。
const buffer = new ArrayBuffer(32);
buffer.byteLength // 32
if (buffer.byteLength === n) {
// 成功
} else {
// 失败
}
ArrayBuffer.prototype.slice()
ArrayBuffer实例有一个slice方法,允许将内存区域的一部分拷贝生成一个新的ArrayBuffer对象。
const buffer = new ArrayBuffer(8);
const newBuffer = buffer.slice(0, 3); //拷贝buffer对象的前3个字节(从0开始到第3个字节前面结束),生成一个新的ArrayBuffer对象
slice方法包含两步,第一步是先分配一段新内存,第二步是将原来那个ArrayBuffer对象拷贝过去。
slice方法接受两个参数,第一个参数表示拷贝开始的字节序号(含该字节),第二个参数表示拷贝截止的字节序号(不含该字节)。如果省略第二个参数,则默认到原ArrayBuffer对象的结尾。
除了slice方法,ArrayBuffer对象不提供任何直接读写内存的方法,只允许在其上方建立视图,然后通过视图读写。
ArrayBuffer.isView()
ArrayBuffer有一个静态方法isView,返回一个布尔值,表示参数是否为ArrayBuffer的视图实例。这个方法大致相当于判断参数是否为TypedArray实例或DataView实例。
const buffer = new ArrayBuffer(8);
ArrayBuffer.isView(buffer) // false
const v = new Int32Array(buffer);
ArrayBuffer.isView(v) // true
2、TypedArray 视图
ArrayBuffer对象作为内存区域,可以存放多种类型的数据。同一段内存,不同数据有不同的解读方式,这就叫做视图。ArrayBuffer有两种视图,一种是TypedArray视图,另一种是DataView视图。前者的数组成员都是同一个数据类型,后者的数组成员可以是不同的数据类型。
目前,TypedArray视图一共包括9种类型,每一种视图都是一种构造函数:
- Int8Array:8 位有符号整数,长度 1 个字节。
- Uint8Array:8 位无符号整数,长度 1 个字节。
- Uint8ClampedArray:8 位无符号整数,长度 1 个字节,溢出处理不同。
- Int16Array:16 位有符号整数,长度 2 个字节。
- Uint16Array:16 位无符号整数,长度 2 个字节。
- Int32Array:32 位有符号整数,长度 4 个字节。
- Uint32Array:32 位无符号整数,长度 4 个字节。
- Float32Array:32 位浮点数,长度 4 个字节。
- Float64Array:64 位浮点数,长度 8 个字节。
这 9 个构造函数生成的数组统称为TypedArray视图。它们很像普通数组,都有length属性,都能用方括号运算符([])获取单个元素,所有数组的方法在它们上面都能使用。
普通数组与 TypedArray 数组的差异主要在以下方面:
- TypedArray 数组的所有成员都是同一种类型。
- TypedArray 数组的成员是连续的,不会有空位。
- TypedArray 数组成员的默认值为 0。比如,new Array(10)返回一个普通数组,里面没有任何成员,只是 10 个空位;new Uint8Array(10)返回一个 TypedArray 数组,里面 10 个成员都是 0。
- TypedArray 数组只是一层视图,本身不储存数据,它的数据都储存在底层的ArrayBuffer对象之中,要获取底层对象必须使用buffer属性。
构造函数
TypedArray 数组提供 9 种构造函数,用来生成相应类型的数组实例。构造函数有多种用法。
- TypedArray(buffer, byteOffset=0, length?):同一个ArrayBuffer对象上可以根据不同的数据类型建立多个视图。注意,byteOffset必须与所要建立的数据类型一致,否则会报错。
如果想从任意字节开始解读ArrayBuffer对象,必须使用DataView视图,因为TypedArray视图只提供9种固定的解读格式。
- TypedArray(length):视图还可以不通过ArrayBuffer对象,直接分配内存而生成。
- TypedArray(typedArray):TypedArray 数组的构造函数可以接受另一个TypedArray实例作为参数。注意,此时生成的新数组只是复制了参数数组的值,对应的底层内存是不一样的。新数组会开辟一段新的内存储存数据,不会在原数组的内存之上建立视图。
- TypedArray(arrayLikeObject):构造函数的参数也可以是一个普通数组,然后直接生成TypedArray实例。注意,这时TypedArray视图会重新开辟内存,不会在原数组的内存上建立视图。
TypedArray 数组也可以转换回普通数组。
数组方法:普通数组的操作方法和属性对 TypedArray 数组完全适用。TypedArray 数组与普通数组一样部署了Iterator接口,所以可以被遍历。
字节序:指的是数值在内存中的表示方式。与普通数组相比,TypedArray数组的最大优点就是可以直接操作内存,不需要数据类型转换,所以速度快得多。
BYTES_PER_ELEMENT 属性:每一种视图的构造函数都有一个BYTES_PER_ELEMENT属性,表示这种数据类型占据的字节数。
ArrayBuffer 与字符串的互相转换:使用原生TextEncoder和TextDecoder方法。
溢出:不同的视图类型所能容纳的数值范围是确定的。超出这个范围,就会出现溢出。TypedArray数组的溢出处理规则,简单来说,就是抛弃溢出的位,然后按照视图类型进行解释。
- 正向溢出(overflow):当输入值大于当前数据类型的最大值,结果等于当前数据类型的最小值加上余值,再减去 1。
- 负向溢出(underflow):当输入值小于当前数据类型的最小值,结果等于当前数据类型的最大值减去余值的绝对值,再加上 1。
TypedArray.prototype.buffer:返回整段内存区域对应的ArrayBuffer对象。只读。
TypedArray.prototype.byteLength:返回TypedArray数组占据的内存长度,单位为字节。只读。
TypedArray.prototype.byteOffset:返回TypedArray数组从底层ArrayBuffer对象的哪个字节开始。只读。
TypedArray.prototype.length:表示TypedArray数组含有多少个成员。注意将 length 属性和 byteLength 属性区分,前者是成员长度,后者是字节长度。
TypedArray.prototype.set():用于复制数组(普通数组或TypedArray数组),也就是将一段内容完全复制到另一段内存。
TypedArray.prototype.subarray():对于TypedArray数组的一部分,再建立一个新的视图。
TypedArray.prototype.slice():返回一个指定位置的新的TypedArray实例。
TypedArray.of():用于将参数转为一个TypedArray实例。
TypedArray.from():接受一个可遍历的数据结构(比如数组)作为参数,返回一个基于这个结构的TypedArray实例。还可以将一种TypedArray实例转为另一种。它会将第一个参数指定的TypedArray数组拷贝到另一段内存之中,处理之后再将结果转成指定的数组格式。
3、复合视图
由于视图的构造函数可以指定起始位置和长度,所以在同一段内存中可以依次存放不同类型的数据,这叫做复合视图。
const buffer = new ArrayBuffer(24); //下面将一个24字节长度的ArrayBuffer对象分成三部分
const idView = new Uint32Array(buffer, 0, 1); //字节0到字节3:1个32位无符号整数
const usernameView = new Uint8Array(buffer, 4, 16); //字节4到字节19:16个8位整数
const amountDueView = new Float32Array(buffer, 20, 1); //字节20到字节23:1个32位浮点数
4、DataView 视图
如果一段数据包括多种类型(比如服务器传来的HTTP数据),这时除了建立ArrayBuffer对象的复合视图以外,还可以通过DataView视图进行操作。
DataView视图提供更多操作选项,而且支持设定字节序。本来在设计目的上,ArrayBuffer对象的各种TypedArray视图是用来向网卡、声卡之类的本机设备传送数据,所以使用本机的字节序就可以了;而DataView视图是用来处理网络设备传来的数据,所以大端字节序或小端字节序是可以自行设定的。
DataView视图本身也是构造函数,接受一个ArrayBuffer对象作为参数,生成视图。
new DataView(ArrayBuffer buffer [, 字节起始位置 [, 长度]]);
DataView实例有以下属性,含义与TypedArray实例的同名方法相同。
- DataView.prototype.buffer:返回对应的 ArrayBuffer 对象
- DataView.prototype.byteLength:返回占据的内存字节长度
- DataView.prototype.byteOffset:返回当前视图从对应的 ArrayBuffer 对象的哪个字节开始
DataView实例提供 8 个方法读取内存。
- getInt8:读取 1 个字节,返回一个 8 位整数。
- getUint8:读取 1 个字节,返回一个无符号的 8 位整数。
- getInt16:读取 2 个字节,返回一个 16 位整数。
- getUint16:读取 2 个字节,返回一个无符号的 16 位整数。
- getInt32:读取 4 个字节,返回一个 32 位整数。
- getUint32:读取 4 个字节,返回一个无符号的 32 位整数。
- getFloat32:读取 4 个字节,返回一个 32 位浮点数。
- getFloat64:读取 8 个字节,返回一个 64 位浮点数。
这一系列get方法的参数都是一个字节序号(不能是负数,否则会报错),表示从哪个字节开始读取。
如果一次读取两个或两个以上字节,就必须明确数据的存储方式到底是小端字节序还是大端字节序。默认情况下,DataView的get方法使用大端字节序解读数据,如果要使用小端字节序解读,必须在get方法的第二个参数指定true。
dv.setInt32(0, 25, false); // 在第1个字节,以大端字节序写入值为25的32位整数
dv.setInt32(4, 25); // 在第5个字节,以大端字节序写入值为25的32位整数
dv.setFloat32(8, 2.5, true); // 在第9个字节,以小端字节序写入值为2.5的32位浮点数
5、二进制数组的应用
AJAX
XMLHttpRequest第二版XHR2允许服务器返回二进制数据,这时分成两种情况。如果明确知道返回的二进制数据类型,可以把返回类型(responseType)设为arraybuffer;如果不知道,就设为blob。
Canvas
网页Canvas元素输出的二进制像素数据就是 TypedArray 数组。
WebSocket
WebSocket可以通过ArrayBuffer发送或接收二进制数据。
Fetch API
Fetch API 取回的数据就是ArrayBuffer对象。
File API
如果知道一个文件的二进制数据类型,也可以将这个文件读取为ArrayBuffer对象。
6、SharedArrayBuffer
JavaScript是单线程的,Web worker引入了多线程:主线程用来与用户互动,Worker线程用来承担计算任务。每个线程的数据都是隔离的,通过postMessage()通信。下面是一个例子。
// 主线程
const w = new Worker('myworker.js'); //主线程新建了一个Worker线程
// 主线程
w.postMessage('hi'); //主线程通过w.postMessage向Worker线程发消息
w.onmessage = function (ev) { //通过message事件监听Worker线程的回应
console.log(ev.data);
}
// Worker 线程
onmessage = function (ev) { //Worker线程也通过监听message事件获取主线程发来的消息并作出反应
console.log(ev.data);
postMessage('ho');
}
线程之间的数据交换可以是各种格式,不仅仅是字符串,也可以是二进制数据。这种交换采用的是复制机制,即一个进程将需要分享的数据复制一份,通过postMessage交给另一个进程。如果数据量比较大,这种通信的效率显然比较低。很容易想到,这时可以留出一块内存区域,由主线程与Worker线程共享,两方都可以读写,那么就会大大提高效率,协作起来也会比较简单(不像postMessage那么麻烦)。
ES2017引入SharedArrayBuffer,允许Worker线程与主线程共享同一块内存。SharedArrayBuffer的API与ArrayBuffer一模一样,唯一的区别是后者无法共享数据。共享内存也可以在Worker线程创建,发给主线程。
// 主线程
// 新建 1KB 共享内存
const sharedBuffer = new SharedArrayBuffer(1024);
// 主线程将共享内存的地址发送出去
w.postMessage(sharedBuffer); //postMessage方法的参数是SharedArrayBuffer对象
// 在共享内存上建立视图,供写入数据
const sharedArray = new Int32Array(sharedBuffer);
// Worker线程
onmessage = function (ev) {
// 主线程共享的数据,就是 1KB 的共享内存
const sharedBuffer = ev.data; //Worker线程从事件的data属性上面取到数据
// 在共享内存上建立视图,方便读写
const sharedArray = new Int32Array(sharedBuffer);
// ...
};
SharedArrayBuffer与ArrayBuffer一样,本身是无法读写的,必须在上面建立视图,然后通过视图读写。
Atomics 对象
多线程共享内存最大的问题就是如何防止两个线程同时修改某个地址,或者说,当一个线程修改共享内存以后,必须有一个机制让其他线程同步。SharedArrayBuffer API 提供Atomics对象,保证所有共享内存的操作都是原子性的,并且可以在所有线程内同步。
Atomics对象可以保证一个操作所对应的多条机器指令一定是作为一个整体运行的,中间不会被打断。也就是说,它所涉及的操作都可以看作是原子性的单操作,这可以避免线程竞争,提高多线程共享内存时的操作安全。Atomics对象提供多种方法。
- Atomics.store(),Atomics.load()
store()方法用来向共享内存写入数据,load()方法用来从共享内存读出数据。比起直接的读写操作,它们的好处是保证了读写操作的原子性。编译器不会为了优化而打乱机器指令的执行顺序。
- Atomics.exchange()
Worker线程要写入数据,可以使用上面的Atomics.store()方法,也可以使用Atomics.exchange()方法。它们的区别是,Atomics.store()返回写入的值,而Atomics.exchange()返回被替换的值。
- Atomics.wait(),Atomics.wake()
使用while循环等待主线程的通知不是很高效,如果用在主线程就会造成卡顿,Atomics对象提供了wait()和wake()方法用于等待通知。这两个方法相当于锁内存,即在一个线程进行操作时,让其他线程休眠(建立锁),等到操作结束,再唤醒那些休眠的线程(解除锁)。
Atomics.wait()等同于告诉Worker线程,只要满足给定条件就在这一行Worker线程进入休眠。主线程一旦更改了指定位置的值,就可以唤醒Worker线程,就会让它继续往下运行。
注意,浏览器的主线程不宜设置休眠,这会导致用户失去响应。而且主线程实际上会拒绝进入休眠。
- 运算方法
共享内存上面的某些运算是不能被打断的,即不能在运算过程中,让其他线程改写内存上面的值。Atomics 对象提供了一些运算方法,防止数据被改写。
- 其他方法
Atomics对象还有以下方法。
- Atomics.compareExchange(sharedArray, index, oldval, newval):如果sharedArray[index]等于oldval,就写入newval,返回oldval。
- Atomics.isLockFree(size):返回一个布尔值,表示Atomics对象是否可以处理某个size的内存锁定。如果返回false,应用程序就需要自己来实现锁定。
Atomics.compareExchange的一个用途是,从 SharedArrayBuffer 读取一个值,然后对该值进行某个操作,操作结束以后,检查一下 SharedArrayBuffer 里面原来那个值是否发生变化(即被其他线程改写过)。如果没有改写过,就将它写回原来的位置,否则读取新的值,再重头进行一次操作。
最新提案
1、do 表达式
本质上,块级作用域是一个语句,将多个操作封装在一起,没有返回值。有一个提案使得块级作用域可以变为表达式,也就是说可以返回值,办法就是在块级作用域之前加上do,使它变为do表达式,然后就会返回内部最后执行的表达式的值。
let x = do {
let t = f();
t * t + 1;
}; //变量x会得到整个块级作用域的返回值(t * t + 1)
do表达式的好处是可以封装多个语句,让程序更加模块化,就像乐高积木那样一块块拼装起来。
let x = do { //根据函数foo的执行结果调用不同的函数,将返回结果赋给变量x
if (foo()) { f() }
else if (bar()) { g() }
else { h() }
};
do表达式在JSX语法中非常好用,如果不用do表达式,就只能用三元判断运算符(?:)。那样的话,一旦判断逻辑复杂,代码就会变得很不易读。
2、import.meta
开发者使用一个模块时,有时需要知道模板本身的一些信息(比如模块路径)。有一个提案为import 命令添加了一个元属性import.meta,返回当前模块的元信息。
import.meta只能在模块内部使用,如果在模块外部使用会报错。这个属性返回一个对象,该对象的各种属性就是当前运行的脚本的元信息。具体包含哪些属性,标准没有规定,由各个运行环境自行决定。一般来说,import.meta至少会有下面两个属性。
import.meta.url
import.meta.url返回当前模块的URL路径。举例来说,当前模块主文件的路径是https://foo.com/main.js,import.meta.url就返回这个路径。如果模块里面还有一个数据文件data.txt,那么就可以用下面的代码,获取这个数据文件的路径。
new URL('data.txt', import.meta.url)
//注意,Node.js环境中,import.meta.url返回的总是本地路径,即是file:URL协议的字符串,比如file:///home/user/foo.js。
import.meta.scriptElement
import.meta.scriptElement是浏览器特有的元属性,返回加载模块的那个<script>元素,相当于document.currentScript属性。
// HTML 代码为
// <script type="module" src="my-module.js" data-foo="abc"></script>
// my-module.js 内部执行下面的代码
import.meta.scriptElement.dataset.foo
// "abc"
3、throw 表达式
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");
}
}
4、函数的部分执行
多参数的函数有时需要绑定其中的一个或多个参数,然后返回一个新函数。现在有一个提案,使得绑定参数并返回一个新函数更加容易。这叫做函数的部分执行。
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
函数的部分执行有一些特别注意的地方
- 函数的部分执行基于原函数。如果原函数发生变化,部分执行生成的新函数也会立即反映这种变化。
- 如果预先提供的值是一个表达式,那么这个表达式并不会在定义时求值,而是在每次调用时求值。
- 如果新函数的参数多于占位符的数量,那么多余的参数将被忽略。
- …只会被采集一次,如果函数的部分执行使用了多个…,那么每个…的值都将相同。
5、管道运算符
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));
6、数值分隔符
现在有一个提案,允许 JavaScript 的数值使用下划线(_)作为分隔符。数值分隔符没有指定间隔的位数,也就是说,可以每三位添加一个分隔符,也可以每一位、每两位、每四位添加一个。小数和科学计数法也可以使用数值分隔符。除了十进制,其他进制的数值也可以使用分隔符,注意,分隔符不能紧跟着进制的前缀0b、0B、0o、0O、0x、0X。
数值分隔符有几个使用注意点。
- 不能在数值的最前面(leading)或最后面(trailing)。
- 不能两个或两个以上的分隔符连在一起。
- 小数点的前后不能有分隔符。
- 科学计数法里面,表示指数的e或E前后不能有分隔符。
下面三个将字符串转成数值的函数,不支持数值分隔符。主要原因是提案的设计者认为,数值分隔符主要是为了编码时书写数值的方便,而不是为了处理外部输入的数据。
- Number()
- parseInt()
- parseFloat()
7、Math.signbit()
目前有一个提案,引入了Math.signbit()方法判断一个数的符号位是否设置了。
该方法的算法如下:
- 如果参数是NaN,返回false
- 如果参数是-0,返回true
- 如果参数是负值,返回true
- 其他情况返回false
Math.signbit(2) //false
Math.signbit(-2) //true
Math.signbit(0) //false
Math.signbit(-0) //true 该方法正确返回了-0的符号位是设置了的
8、双冒号运算符(函数绑定运算符)
箭头函数可以绑定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));
9、Realm API
Realm API 提供沙箱功能,允许隔离代码,防止那些被隔离的代码拿到全局对象。提供一个Realm()构造函数,用来生成一个Realm 对象,该对象的global属性指向一个新的顶层对象,这个顶层对象跟原始的顶层对象类似。Realm()构造函数可接受一个参数对象,该参数对象的intrinsics属性可指定Realm沙箱继承原始顶层对象的方法。用户可以自己定义Realm的子类,用来定制自己的沙箱。
10、#!命令
有一个提案,为JavaScript脚本引入了#!命令,写在脚本文件或者模块文件的第一行。有了这一行以后,Unix命令行就可以直接执行脚本。对JavaScript引擎来说,会把#!理解成注释,忽略掉这一行。
Decorator
1、类的装饰
装饰器可以用来装饰整个类。装饰器是一个对类进行处理的函数,装饰器函数的第一个参数就是所要装饰的目标类。注意,装饰器对类的行为的改变是代码编译时发生的,而不是在运行时。这意味着,装饰器能在编译阶段运行代码。也就是说,装饰器本质就是编译时执行的函数。
@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
2、方法的装饰
装饰器不仅可以装饰类,还可以装饰类的属性。
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代码静态分析的重要工具。
3、为什么装饰器不能用于函数?
装饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。类是不会提升的,所以就没有这方面的问题。
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);
4、core-decorators.js
是一个第三方模块,提供了几个常见的装饰器,通过它可以更好地理解装饰器。
@autobind:autobind装饰器使得方法中的this对象绑定原始对象。
@readonly:readonly装饰器使得属性或方法不可写。
@override:override装饰器检查子类的方法是否正确覆盖了父类的同名方法,如果不正确会报错。
@deprecate (别名@deprecated):deprecate或deprecated装饰器在控制台显示一条警告,表示该方法将废除。
@suppressWarnings:suppressWarnings装饰器抑制deprecated装饰器导致的console.warn()调用。但是,异步代码发出的调用除外。
5、使用装饰器实现自动发布事件
我们可以使用装饰器使得对象的方法被调用时,自动发出一个事件。
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
6、Mixin
在装饰器的基础上,可以实现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,因此可以避免在混入过程中覆盖父类的同名方法。
7、Trait
也是一种装饰器,效果与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允许向一个类里面注入一些代码,使得一个类的功能能够混入另一个类。实质上是多重继承的一种解决方案,但是避免了多重继承的复杂性,而且有利于代码复用。
Mixin就是一个正常的类,不仅定义了接口,还定义了接口的实现。子类通过在this对象上面绑定方法,达到多重继承的目的。
Trait
Trait是另外一种多重继承的解决方案。它与Mixin很相似,但是有一些细微的差别。
- Mixin可以包含状态(state),Trait不包含,即Trait里面的方法都是互不相干,可以线性包含的。比如,Trait1包含方法A和B,Trait2继承了Trait1,同时还包含一个自己的方法C,实际上就等同于直接包含方法A、B、C。
- 对于同名方法的碰撞,Mixin包含了解决规则,Trait则是报错。
函数式编程
1、柯里化
指的是将一个多参数的函数拆分成一系列函数,每个拆分后的函数都只接受一个参数。
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
2、函数合成
指的是将多个函数合成一个函数。
const compose = f => g => x => f(g(x));
const f = compose (x => x * 4) (x => x + 3); //一个函数合成器,用于将两个函数合成一个函数
f(2) // 20
3、参数倒置
指的是改变函数前两个参数的顺序。
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());
4、执行边界
指的是函数执行到满足条件为止。如果满足条件就返回结果,否则不断递归执行。
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);
};
5、队列操作
队列操作包括以下几种。
- head: 取出队列的第一个非空成员。
- last: 取出有限队列的最后一个非空成员。
- tail: 取出除了“队列头”以外的其他非空成员。
- init: 取出除了“队列尾”以外的其他非空成员。
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);
6、合并操作
合并操作分为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));
7、配对操作
配对操作分为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)
);
原文地址:https://blog.csdn.net/Vivien_CC/article/details/135966372
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.7code.cn/show_67413.html
如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除!