ES6+陌生知识点整理


前言

本文由ES6 教程整理所得,仅整理本人不熟悉的相关知识点。

let 和 const

暂时性死区

只要块级作用域内存在 letconst,它所声明的变量就绑定在了这个区域。该块级作用域的任何地方都不会再收到区域外同名变量的影响。
这种特性叫做”暂时性死区“(temporal dead zone,简称 TDZ)。

我们知道,letconst 不允许在声明前调用。因此就会出现下面这种情况:

1
2
3
4
5
let temp = "test";
if (true) {
temp = "start"; // ReferenceError
let temp;
}

尽管赋值语句在编写在声明前,但由于 TDZ 的存在,该区域已和外界的 temp 变量无关,被编译器理解为声明前调用,抛出错误。

同时由于这个特性的存在,众所周知安全的 typeof 也不再那么安全(typeof 可以检测未定义的变量而不报错)。

1
2
typeof temp;
let temp; // ReferenceError

作用域提升

JavaScript 要求变量声明必须要存在于当前语句的顶层。
但对于如下代码,var 是不会报错的,因为 var 存在作用域提升。

1
2
3
4
if (true) var temp = "test";
// 等同于
var temp;
if (true) temp = "test";

而由于 letconst 不存在作用域提升,因此会报错。

1
2
// SyntaxError: Lexical declaration cannot appear in a single-statement context
if (true) let temp = "test";

块级作用域

在 es5 之前,只有全局作用域和函数作用域。
letconst 的出现实际上为 JavaScript 新增了块级作用域。

块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。

1
2
3
4
5
6
7
8
9
10
11
// IIFE 写法
(function () {
var tmp = "test";
// ...
}());

// 块级作用域写法
{
let tmp = "test";
// ...
}

顶层对象

ES5 中,使用 varfunction 定义的变量存在如下特性。

1
2
var temp = "test";
window.temp // "test"

而在 ES6 中,新增加的声明方式 letconstclassimport 不再支持这种特性。

1
2
let temp = "test";
window.temp // undefined

globalThis

由于 nodejs 环境下的顶级对象为 global,浏览器环境下为 window

为了能够同样的代码在不同的环境下都能获取顶级对象,ES2020 引入了 globalThis 对象,
可以直接通过该对象获取当前环境的顶级对象。

变量的解构赋值

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象,然后在进行解构赋值。

因此可以通过解构赋值来获取一些基本类型的属性或方法比如:对布尔值解构赋值获取 toString 方法。

数组

1
const [a, [b], c] = [1, [2, 3], [4]]; // a: 1 b: 2 c: 4

这东西很简单,这里只说明一个要点。

这种解构赋值并非数组特有,只要某种数据结构具有 Iterator(可遍历的,后面会讲) 接口,都可以采用数组形式的解构赋值。

1
const [a, [b], c] = new Set([1, [2, 3], [4]]); // a: 1 b: 2 c: 4

对象

class 进行解构赋值是被允许的,同样也可以获得被继承的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Parent {
name = "father";
}

class Test extends Parent{
temp;
constructor(temp) {
super();
this.temp = temp;
}
}

const { name, temp } = new Test(114514);
name // "father"
temp // 114514

对象的解构赋值是可以嵌套的,如下

1
2
3
4
5
6
7
8
9
10
const obj = {
p: [
'Hello',
{ y: 'World' }
]
};

const { p: [x, { y }] } = obj;
x // "Hello"
y // "World"

注意:这里的 p 只是模式,而非变量,不会被赋值。如果 p 也要被赋值,可以写成这样

1
const { p, p: [x, { y }] } = obj;

由于数组也是特殊的对象,因此也可以用对象的方式对数组进行解构

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

字符串的扩展

字符串的迭代器接口

ES6 为字符串新增了 Iterator 接口,使得其可以通过 for...of 遍历。

1
2
3
for ( let item of "wdnmd" ) {
console.log( item );
}

模板字符串

模板字符串中的所有的空格换行都会被保留。

1
2
3
4
5
element.innerHtml = `
<article>
<p>段落一</p>
</article>
`

像上面这种会在开头结尾处出现换行符号,如果需要去除则需要使用 trim()

标签模板

模板字符串可以紧跟在一个函数之后,来起到如下效果。

1
2
3
4
const foo = str => console.log( str );
foo`wdnmd`; // ["wdnmd"]
// 等同于
foo( ["wdnmd"] );

不过注意,标签模板方式获得的数组参数,其中还存在一个 raw 属性,正常的数组是不存在这个属性的。
例如上面的打印结果其实为 {0: "wdnmd", length: 1, raw: ["wdnmd"]}
这点将会影响到后续提到的 String.raw 方法。

但当模板字符串存在变量时,则会发生一些变化。

1
2
3
4
const foo = ( str, ...value ) => console.log( str, value );
foo`height ${18 * 10} age ${20 + 5}`; // [ "height ", " age ", "" ] [ 180, 25 ]
// 等同于
foo( [`height `, ` age `, ``], 180, 25 );

由上可知,在字符串模板包含变量时,将会先从变量处切割字符串为数组,然后将各个变量计算后依次以后续参数传入。

字符串新增方法

String.raw

该方法将对字符串的斜杠进行转义(即斜杠前面再加一个斜杠),主要用于通过标签模板针对模板字符串进行转义。

1
2
String.raw`\wdnmd`; // "\\wdnmd"
String.raw`age\n${20 + 5}`; // "age\\n25"

但如果按照对标签模板的简单理解进行调用的话则会报错。
因为直接传入的数组是不存在 raw 属性的,需要手动设置参数才可以。

1
2
3
4
5
6
String.raw( [ "\wdnmd" ] ); // Uncaught TypeError: Cannot convert undefined or null to object

// 不会报错
const str = [ "\wdnmd" ];
str.raw = [ "\wdnmd" ];
String.raw( str ); // "wdnmd"

但这样也太麻烦了,更别说只是不报错而已,而且转义效果也并没有实现。所以还是老实用标签模板吧。

includes()、startsWith()、endsWith()

ES6 之前,只有 indexOf 用来确定一个字符串内是否包含某个字符串。

ES6 新增了三种方法,均返回布尔值,不传递第二个参数时为针对整个字符串搜索,传递第二个参数时效果如下。

  • includes(str, [n): 是否找到字符串,从第 n 个位置开始搜索 str
  • startsWith(str, [n): 是否在原字符串的头部,从第 n 个位置开始搜索 str
  • endsWith(str, [n): 是否在原字符串的尾部,针对前 n 个字符搜索 str
1
2
3
4
5
const str = "wdnmd";

str.includes( "n", 2 ); // true
str.startsWith( "n", 2 ); // true
str.endsWith( "n", 3 ); // true

repeat()

表示将原字符串重复 n 次,当传入小数时会被取整,NaN 会被视为 0,不能传入小于-1的负数Infinity

且由于存在自动取整,-1 与 0 之间的数会被取整为 0,不会报错。

1
2
3
4
5
6
const str = "wdnmd";
str.repeat(2); // "wdnmdwdnmd"
str.repeat(2.8); // "wdnmdwdnmd"
str.repeat(0); // ""
str.repeat(NaN); // ""
str.repeat(-0.9); // ""

padStart()、padEnd()

ES2017 引入的功能,padStart() 用于头部补全,padEnd() 用于尾部补全。

共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串

当用来补全的字符串与原字符串,两者的长度之和超过了最大长度,则会截去超出位数的补全字符串。

1
2
"25".padStart( 10, "YYYY-MM-DD" ); // "YYYY-MM-25"
"2022".padEnd( 10, "YYYY-MM-DD" ); // "2022YYYY-M"

如果省略第二个参数,默认使用空格补全长度。

1
"x".padStart( 6 ); // "     x"

trimStart()、trimEnd()

ES2019 引入的功能,行为与 trim() 一致,用以消除字符串头部/尾部的空格、tab 键、换行符等。

1
2
3
4
5
const s = " wdnmd ";

s.trim() // "wdnmd"
s.trimStart() // "wdnmd "
s.trimEnd() // " wdnmd"

浏览器中还有额外的两个方法,trimLeft()trimStart() 的别名,trimRight()trimEnd() 的别名。

replace()、replaceAll()

在以往的 replace 只能替换掉单个字符串,想全部替换只能通过 使用g修饰符的正则表达式才能做到。ES2021 新增了 replaceAll() 方法。

replace 相同,第一个参数为搜索模式(字符串正则表达式),第二个参数为要替换的字符

1
"wdnmd".replaceAll("d", "c"); // "wcnmc"

不过当第一个参数为正则表达式时,必须要携带 g 修饰符,否则报错。

对于 replace()replaceAll(),第二个参数均可以传入一个函数,函数的参数分别为:匹配的内容正则组匹配内容在字符串的位置原字符串
当不存在组或未使用正则时,仅存在匹配的内容内容在字符串的位置原字符串三个参数

1
2
3
4
5
6
7
8
9
const str = "this name is not my name";
str.replace(/n(a)m(e)/g, (...value) => {
console.log(value);
return "book";
}) // "this book is not my book"

// 打印结果
// [ "name", "a", "e", 5, "this name is not my name" ]
// [ "name", "a", "e", 20, "this name is not my name" ]

数值的扩展

Number 对象上的判断方法,对于非 Number 类型参数,均返回 false

Number.isFinite(), Number.isNaN()

这两个方法不会做自动类型转换。而传统的全局方法 isFinite()isNaN() 则会先进行 Number() 类型转换后再做判断。

Number.isFinite() 检查数值是否为有限的,对于 Infinity 与所有非 Number 类型均返回 false
Number.isNaN()对于所有非 NaN 值均返回 false

1
2
3
4
5
isFinite( "1" ); // true
Number.isFinite( "1" ); // false

isNaN( "wdnmd" ); // true
Number.isNaN( "wdnmd" ); // false

Number.parseInt(), Number.parseFloat()

这两个方法被从全局方法移植到了 Number 对象上,行为完全不变,但使得语言模块化,建议使用。

Number.isInteger()

判断一个数字是否为整数,对于非 Number 类型参数均返回 false

由于整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。

1
2
Number.isInteger( 25 ); // true
Number.isInteger( 25.0 ); // true

由于 JavaScript 数值精度最多可以达到 53 个二进制位,当数值超过这个范围后,后面的内容会被裁剪,导致该方法出现误判。

1
Number.isInteger( 3.0000000000000002 ); // true

因此对数据精度要求比较高时,不建议用该方法。

Math 对象的扩展

Math 的方法对于非数值,会先使用 Number() 对其进行类型转换。

Math.trunc()

去除一个数的小数部分,返回整数部分。对于空值与无法截取的值,返回 NaN

1
Math.trunc( "114.514" ); // 114

Math.sign()

用来判断一个数到底是正数、负数、还是零。

它会返回五种值,均为 Number 类型。

  • 参数为正数,返回+1
  • 参数为负数,返回-1
  • 参数为 0,返回0
  • 参数为-0,返回-0
  • 其他值,返回NaN

BigInt 数据类型

为了解决 Number 类型无法显示超过一定范围的值的问题(显示 Infinity 或超出位强制归零),于 ES2020 引入,是 ECMAScript 的第八种数据类型。

其没有位数的限制,仅用来表示整数。为了与 Number 类型区别,BigInt 类型的数据必须添加后缀 n

1
2
3
4
5
6
7
8
9
1234 // 普通整数
1234n // BigInt

// BigInt 的运算
1n + 2n // 3n

2172141653n * 15346349309n; // 33334444555566667777n
// 普通整数无法保持精度
2172141653 * 15346349309; // 33334444555566670000

BigInt 与普通整数是两种值,它们之间并不相等。

1
2
114n == 114; // true
114n === 114; // false

BigInt 与普通整数是两种值,不能和普通整数做运算,会报错。

1
114n + 114; // TypeError

BigInt 同样可以使用各种进制表示,都要加上后缀n

1
2
3
0b1101n // 二进制
0o777n // 八进制
0xFFn // 十六进制

typeof运算符对于 BigInt 类型的数据返回bigint

1
typeof 114n; // "bigint"

BigInt 可以使用负号(-),但是不能使用正号(+),因为会与 asm.js 冲突。

1
+114n; // TypeError

BigInt()

转化方式类似 Number(),不过不同的是,该函数要求必须要有参数,而且参数必须可以正常转为 Number 类型,下面的用法都会报错。

1
2
3
4
5
BigInt(); // TypeError
BigInt( undefined); //TypeError
BigInt( null ); // TypeError
BigInt( "123n" ); // SyntaxError
BigInt( "abc" ); // SyntaxError

如果参数是小数,也会报错

1
2
BigInt( 1.5 ); // RangeError
BigInt( "1.5" ); // SyntaxError

对 BigInt 进行转换时,其他不做赘述,String 则需要注意一下,转换后的结果中是不存在 n 的。

1
String( 1n ); // "1"

而在数学运算中,基本与 Number 类型一致。不过要注意 / 运算结果将会舍去小数部分。

1
7n / 3n; // 2n

函数的扩展

函数的 length 属性

length 属性表示该函数预期传入的参数个数,存在默认值的参数与 rest 参数不在统计范围内。

1
2
3
4
5
6
7
8
function foo( a, b ) {}
foo.length; // 2

function foo( a, b = 1 ) {}
foo.length; // 1

function foo( a, ...rest ) {}
foo.length; // 1

函数的 name 属性

该属性返回函数名。

1
2
3
4
5
6
7
function foo() {}
foo.name; // "foo"

(() => {}).name; // ""

const foo1 = () => {};
foo1.name; // "foo1"

bind 返回的函数,name 属性值会加上 bound 前缀。

1
2
3
4
const foo = () => {};
foo.bind({}).name; // "bound foo"

(() => {}).bind({}).name; // "bound "

Function 构造函数创造的函数,name 属性返回 anonymous

1
( new Function() ).name; // "anonymous"

箭头函数

箭头函数有几个注意点:

  • 不可以使用 arguments 对象,该对象在函数体内不存在。
  • 不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。
  • 由于箭头函数没有自己的 this,所以当然也就不能用 call()apply()bind() 这些方法去改变 this 的指向。

catch 命令的参数省略

ES2019 做出了改变,允许 catch 语句省略参数。

1
2
3
4
5
try {
// ...
} catch {
// ...
}

数组的扩展

扩展运算符

扩展运算符可以放在圆括号中,但只允许在函数调用时使用,否则会报错。

1
2
const foo = ( a, b ) => void console.log( a, b );
foo( ...[1, 2] ); // 1 2

通过这样,可以丢掉我们的 apply() 方法了。

1
2
3
4
5
// ES5
Math.max.apply( null, [114, 514] );

// ES6
Math.max( ...[114, 514] );

也可以与解构赋值结合,快速生成数组。

1
2
3
4
5
6
// ES5
a = list[0];
rest = list.slice(1);

// ES6
[a, ...rest] = list;

任何定义了迭代器(Iterator)接口的对象,都可以用扩展运算符转为真正的数组。

1
2
3
// 字符串转数组
[..."wdnmd"]; // [ "w", "d", "n", "m", "d" ]
[...new Set( [1, 2] )]; // [1, 2]

Array.form

用于将类似数组的对象(array-like object)可遍历(iterable)的对象转为真正的数组。

如下将类似数组的对象转为数组。

1
2
3
4
5
6
7
8
const arrayLike = {
0: "a",
1: "b",
2: "c",
length: 3
};

Array.from( arrayLike ); // [ "a", "b", "c" ]

虽然大体上与扩展运算符(...)类似,但其实存在本质上的不同。

扩展字符串背后调用的是迭代器接口(Symbol.iterator),如果一个对象没有部署该接口,就无法转换。
Array.from 不仅支持迭代器接口,还支持类似数组的对象,即拥有length属性

1
Array.from( { length: 3 } ); // [ undefined, undefined, undefined ];

Array.from() 还可以接受一个函数作为第二个参数,作用类似于数组的 map() 方法,用来对每个元素进行处理。

1
2
3
4
Array.from( [ 1, 2, 3 ], ( item, key ) => {
return item * ( key + 1 );
} );
// [ 1, 4, 9 ]

如果第二个参数的函数里用到了 this 关键字,还可以传入 Array.from() 的第三个参数,用来绑定 this

Array.of()

用于将一组值,转换为数组。由于 Array()new Array() 会因为参数的数量导致行为不统一,Array.of() 基本可以替代这两个方法。

1
2
3
4
5
Array( 3 ); // [ , , , ]
Array( 1, 1, 4 ); // [ 1, 1, 4 ]

Array.of( 3 ); // [ 3 ]
Array.of( 1, 1, 4 ); // [ 1, 1, 4 ]

entries()

对键值对进行遍历,与 key()value() 一样,返回一个迭代器对象

1
2
3
4
5
for ( const item of [ "a", "b" ].entries() ) {
console.log( item );
}
// [ 0, "a" ]
// [ 1, "b" ]

includes()

ES2016 引入,表示某个数组是否包含给定的值,与 Stringincludes() 类似,同样存在两个参数。

在此之前的 indexOf,除了语义化较差以外,由于内部使用 === 严格比较,导致会对 NaN 误判。而 includes() 不存在这两个问题。

1
2
3
4
5
[ 1, 2, 3 ].includes( 2 ); // true
[ 1, 2, 3 ].includes( 2, 2 ); // false

[ 1, 2, NaN ].indexOf( NaN ); // -1
[ 1, 2, NaN ].includes( NaN ); // true

flat(), flatMap()

flat() 用于将嵌套数组内容拉平,变为一维数组。存在一个参数,来控制拉平几层,默认一层。
如果想要彻底展开为一维数组不论多少层,可以传递 Infinity 参数。

1
2
3
4
const arr = [ 1, [ 2, [ 3, 4 ] ] ];
arr.flat(); // [ 1, 2, [ 3, 4 ] ]
arr.flat( 2 ); // [ 1, 2, 3, 4 ]
arr.flat( Infinity ); // [ 1, 2, 3, 4 ]

如果原数组有空位,flat() 方法会过滤掉空位。

1
[ 1, , 3 ].flat(); // [ 1, 3 ]

flatMap() 则传入一个函数,该函数对每个成员进行一次处理(类似 map() 方法),然后对返回的数组执行 flat()

1
2
3
4
5
const arr = [ 1, 2, 3, 4 ];
arr.flatMap( ( item, key ) => {
return [ [ item * ( key + 1 ) ] ];
} );
// [ [ 1 ], [ 4 ], [ 9 ], [ 16 ] ]

它还存在第二个参数,用于绑定遍历函数内的 this 指向。

数组的空位

JavaScript 对空位的处理一直都比较混乱,ES6 的生成方法将不会再生成空位,统一以 undefined 处理。会出现空位的操作如下

1
2
new Array( 3 ); // [ empty × 3 ]
arr = [ 1, , 3 ]; // [ 1, empty, 3 ]

空位不是 undefinedundefined 对数组来说仍是有值的

Array.from() 和扩展字符串(...)会将数组的空位转为 undefined

1
2
Array.from( [ 1, , 3 ] ); // [ 1, undefined, 3 ]
[ ...[ 1, , 3 ] ]; // [ 1, undefined, 3 ]

各遍历方法对空位的态度如下:

  • forEach(), filter(), reduce(), every()some() 都会跳过空位项,不会对其进行处理。
  • map() 同样也会跳过,但会保留空位的值。
  • join()toString() 会将空位视为 undefined,而 undefinednull 会被处理成空字符串。
  • flatMap() 则会直接过滤掉空值。
  • fill() 会将空位视为正常的数组位置进行填充。
  • for...of 也会遍历空位。
  • entries()keys()values()find()findIndex() 会将空位处理成 undefined

由于空位的处理规则非常不统一,所以建议避免出现空位。

对象的扩展

ES6 的对象可以缩写,不做赘述。但有一点需要注意,缩写的方法不能用作构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不缩写正常运行
const obj = {
foo: function ( val ) {
this.value = val;
}
}
new obj.foo( "wdnmd" ); // foo { value: 'wdnmd' }

// 缩写后报错
const obj1 = {
foo( val ) {
this.value = val;
}
}
new obj1.foo( "wdnmd" ); // TypeError: obj1.foo is not a constructor

对象内方法的 name 属性

对象方法也是函数,因此也有 name 属性。但有几点需要注意。

若方法名是一个 Symbol 值,那么 name 属性返回的是这个 Symbol 值的描述。

1
2
3
4
5
6
7
const [ key1, key2 ] = [ Symbol(), Symbol("desc") ];
const obj = {
[ key1 ]() {},
[ key2 ]() {}
}
obj[ key1 ].name; // ""
obj[ key2 ].name; // "[desc]"

若目标方法为取值函数(getter)和存值函数(setter),则 name 属性不是在该方法上面,而是该方法的属性的描述对象(下面会讲)的 get
set 属性上面,返回值是方法名前加上 getset

1
2
3
4
5
6
7
8
9
const obj = {
get foo() {},
set foo(value) {}
}
obj.foo.name; // TypeError: Cannot read property "name" of undefined

const descriptor = Object.getOwnPropertyDescriptor( obj, "foo" );
descriptor.get.name; // "set foo"
descriptor.set.name; // "get foo"

可枚举性与遍历

可枚举性

对象的每个属性都有一个描述对象,使用 Object.getOwnPropertyDescriptor 可以获取该对象。对于描述对象的操作以及描述对象属性更详细的内容,
参参照文章JS获取对象的属性个数

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
name: "稀音",
age: 14
}

Object.getOwnPropertyDescriptor( obj, "name" );
// {
// value: "稀音",
// writable: true,
// enumerable: true,
// configurable: true
// }

其中 enumerable 为是否可枚举,当它为 false 时,会被以下操作忽略掉。

  • for...in:不会遍历不可枚举属性
  • Object.keys()Object.values()Object.entries(): 结果中不会包含不可枚举属性
  • JSON.stringify():不会串行化不可枚举属性。
  • Object.assign(): 不会拷贝不可枚举属性。

本质上,为了避免被 for...in 等方法遍历,原型上的属性均被设置为了不可枚举属性。对于不可枚举属性,在 console 中的键名显示为淡红色

另外,ES6 规定,所有 Class 的原型的方法都是不可枚举的。

属性的遍历

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)

返回键名数组,包含对象自身所有属性。

以上的 5 种方法遍历对象的键名,都遵守同样的属性遍历的次序规则。

  • 首先遍历所有数值键,按照数值升序排列。
  • 其次遍历所有字符串键,按照加入时间升序排列。
  • 最后遍历所有 Symbol 键,按照加入时间升序排列。

super 关键字

该关键字指向当前对象的原型对象,即 super 等同于 Object.getPrototypeOf(this)this.__proto__ 。不过 super 仅能在对象的方法
(仅有简写方法才被认为是对象的方法)中使用,且不能单独使用,否则会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 报错
const obj = {
foo: super.attr
}

// 报错
const obj = {
foo: () => super.attr
}

// 报错
const obj = {
foo: function () {
return super.attr
}
}

// 报错
const obj = {
foo() {
return super;
}
}

// 正确
const obj = {
foo() {
return super.attr;
}
}

上面第一种不在方法中使用,二、三则不是简写形式不被认为是对象的方法,四则单独使用 super。均会报错。

不过对于调用原型对象的方法时,super.foo 的实现逻辑其实是 Object.getPrototypeOf(this).foo.call(this)。即方法的 this 是指向当前对象
的。这点需要注意。

对象的扩展运算符

解构赋值

当使用扩展运算符 (...) 对对象进行解构赋值时,仅会包含对象自身可枚举属性。

需要注意的是,此时不受迭代器 Symbol.iterator 接口的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const obj = {};
// 设置不可枚举属性
Object.defineProperties(obj, {
name: {
value: "稀音",
enumerable: false // 不可枚举
},
age: {
value: 14,
enumerable: true // 可枚举
}
})
// 设置原型对象
const proto = { sex: "女" };
Object.setPrototypeOf( obj, proto );

const obj1 = { ...obj };
console.log( obj1 ); // { age: 14 }

可以看到,obj1 中并不存在不可枚举属性 name 与原型对象属性 sex

针对继承的属性举另一个例子:

1
2
3
4
5
6
7
8
9
// Object.create(proto[,propertiesObject]) 用来创建对象的原型,表示要继承的对象
const o = Object.create({ x: 1, y: 2 });
o.z = 3;

const { x, ...newObj } = o;
const { y, z } = newObj;
x // 1
y // undefined
z // 3

可以看到变量 x 有值而变量 y 取不到值,扩展运算符( ... )无法获取所继承的属性,而普通的解构赋值可以。

扩展运算符

扩展运算符可用于取出另一个对象中自身可枚举属性

1
2
const obj1 = { a: 1, b: 2 };
const obj2 = { ...obj1 }; // { a: 1, b: 2 }

而由于数组也是特殊的对象,因此扩展运算符也可以用于数组。

1
const obj = { ...[ "a", "b", "c" ] }; // { "0": "a", "1": "b", "2": "c" }

当扩展运算符的后面不是对象时,将会隐式进行对象的转换。

例如 { ...1 } 等同于 { ...Object( 1 ) } ,结果是转为数值的包装对象 Number{1} 。由于该对象没有自身属性,所以返回一个空对象。

1
const obj = { ...1 }; // {}

但有一种特殊情况,当扩展运算符后面跟随字符串时,则会自动转换为一个类似数组的对象。

1
const obj = { ..."hi" }; // { "0": "h", "1": 'i' }

对象的扩展运算符等同于使用 Object.assign() 方法,均可以起到浅克隆的作用。

1
2
3
const aClone = { ...obj1 };
// 等同于
const aClone = Object.assign( {}, obj1 );

上面的克隆仅克隆了对象自身可枚举属性,若需要克隆完全相同的符合各属性描述对象的以及原型对象的对象,则可以如下操作

1
2
3
4
const clone = Object.create(
Object.getPrototypeOf( obj ),
Object.getOwnPropertyDescriptors( obj )
)

扩展运算符的参数对象之中,如果有取值函数 get,这个函数是会执行的。

1
2
3
4
5
6
7
8
9
const obj = {
get foo() {
console.log( "get 方法指令了哟" );
return "run";
}
};

const obj1 = { ...obj }; // "get 方法指令了哟"
console.log( obj1 ); // { foo: "run" }

对象的新增方法

Object.is()

ES5 比较二值相等时使用 =====,前者会自动转换为数值类型,后者则不能正确判断 NaN+0 等于 -0

ES6通过 Object.is() 实现了“Same-value equality”(同值相等)算法。该方法与 === 行为基本一致,但有以下区别。

1
2
3
4
5
+0 === -0; // true
Object.is( +0, -0 ); // false

NaN === NaN; // false
Object.is( NaN. NaN ); // true

Object.assign()

Object.assign() 传递两个参数时效果与扩展运算符(...)基本一致。

1
2
3
const objExpend = { ...obj1, ...obj2 };
// 等同于
const objAssign = Object.assign( obj1, obj2 );

但有几点需要注意,Object.assign() 的第一个参数必须可以转换为对象,因此第一个参数为 nullundefined 时,会报错。

1
2
const objExpend = { ...null, ...{} }; // {}
const objAssign = Object.assign( null, {} ); // TypeError: Cannot convert undefined or null to object

Object.assign() 也可以用来处理数组,会把数组当作对象处理并对同名键值的位置进行覆盖。

但与 ... 那种破坏了数组的类型的方式不同,Object.assign() 会保持输出的依旧是数组类型,照常可以使用 forEach 等数组方法。

这个原理也很简单,因为 ... 不会遍历不可枚举和原型链上的属性。

1
2
const objExpend = { ...[ 1, 2, 3 ], ...[ 4, 5 ] }; // { "0": 4, "1": 5, "2": 3 }
const objAssign = Object.assign( [ 1, 2, 3 ], [ 4, 5 ] ); // [ 4, 5, 3 ]

... 相同,当对象的参数中存在取值函数 get 时,Object.assign() 将会求值后在进行赋值。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
get foo() {
console.log( "get 方法指令了哟" );
return "run";
}
};

const obj1 = { ...obj }; // "get 方法指令了哟"
console.log( obj1 ); // { foo: "run" }

const obj2 = Object.assign( {}, obj ); // "get 方法指令了哟"
console.log( obj2 ); // { foo: "run" }

当只有一个参数时,Object.assign() 会直接返回该参数。

1
2
const obj = { a: 1 };
Object.assign( obj ) === obj; // true

如果该参数不是对象,则会先转成对象,然后返回。

1
2
Object.assign( 2 ); // Number {2}
typeof Object.assign( 2 ); // "object"

当对象为 nullundefined 时,由于他们无法转成对象,所以会报错。

1
Object.assign( null ); // TypeError: Cannot convert undefined or null to object

Object.getOwnPropertyDescriptors()

ES5 的 Object.getOwnPropertyDescriptor() 方法会返回某个对象属性的描述对象(descriptor)。ES2017 引入了 Object.getOwnPropertyDescriptors() 方法,
返回指定对象所有自身属性(非继承属性)的描述对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const obj = {
foo: 123,
get bar() { return "abc" }
};
Object.getOwnPropertyDescriptors( obj );
// {
// foo: { value: 123, writable: true, enumerable: true, configurable: true },
// bar: {
// get: [Function: get bar],
// set: undefined,
// enumerable: true,
// configurable: true
// }
// }

由于 Object.assign 无法做到对 getset 的正确拷贝,它仅拷贝一个属性的值。如下

1
2
3
4
5
6
const obj = {
set foo( value ) {
console.log( "wdnmd" );
}
}
Object.assign( {}, obj ); // { foo: undefined }

因此可以借用 Object.defineProperties() 来实现正常拷贝,下面代码会在把 source 的属性拷贝给 target 的同时,将描述对象也一并拷贝。

1
2
3
4
5
const shallowMerge = ( target, source ) => Object.defineProperties(
target,
Object.getOwnPropertyDescriptors( source )
);
shallowMerge( {}, obj ); // { foo: [Setter] }

上述方法只是合并两个方法,并不会完全的将原型对象也一并拷贝,可以结合 Object.create() 来实现对包括原型对象的浅拷贝。

1
2
3
4
5
6
7
8
9
10
11
const shallowClone = ( obj ) => Object.create(
Object.getPrototypeOf( obj ),
Object.getOwnPropertyDescriptors( obj )
);

const obj = Object.create( { a: 1 }, {
b: { value: 2, writable: true, enumerable: true, configurable: true }
}); // { b: 2 };

const obj1 = shallowClone( obj ); // { b: 2 }
obj1.a; // 1

上面的操作之所以是浅拷贝,是因为如果属性的 value 是一个对象,则被原封不动的拿了过来,内存地址依旧指向一处地方。

由此可知,深拷贝也是不难实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 深克隆对象
* @param to 待克隆对象
* @return 克隆后的对象
*/
function deepClone<T = any> ( to: T ): T {
const obj = Object.create( Object.getPrototypeOf( to ) );
const descriptors = Object.getOwnPropertyDescriptors( to );
for ( const key in descriptors ) {
const desc = descriptors[key];
if ( desc.value && typeof desc.value === "object" ) {
desc.value = deepClone( desc.value );
}
Reflect.defineProperty( obj, key, desc );
}
return obj;
}

我们也可以结合 Object.create() 进行对象的继承。

1
2
3
4
5
6
7
const prot = Object.create( { a: 1 } );
// 使 obj 继承自 prot
const obj = Object.create( prot, Object.getOwnPropertyDescriptors( {
b: 1
} ) );

obj.a; // 1

Object.setPrototypeOf(),Object.getPrototypeOf() 与 Object.create()

浏览器存在一个 __proto__ 属性,可用于获取以及设置当前对象的原型对象。但该属性被 ES6 规定仅浏览器必须部署该属性,因此一般情况下尽量不要用,选择
getPrototypeOf (读)、setPrototypeOf (写)、create (生成)来对原型对象进行操作。

Object.create()

该方法创建一个指定原型对象描述对象的对象,有两个参数。

  • 参数1:将被作为新对象的原型对象的对象,只能为 Objectnull,当为 null 时,创建一个不继承任何原型的对象。
  • 参数2:描述对象,将会作为对象的自身属性并对各个属性进行描述对象的设置。value 未指定时为 undefined,其他属性未指定时默认为 false
1
2
3
4
5
const obj = Object.create( { a: 1 },  {
b: { value: 2, writable: true, enumerable: true, configurable: true }
});
console.log( obj ); // { b: 2 }
obj.a; // 1

通过 Object.create( null ) 可以创建一个没有继承任何原型的对象。相比于 {},因为不需要去原型链上查找,所以在某些情况下会有更好的性能。

1
2
3
4
5
const obj = Object.create( null );
obj.toString; // undefined

const obj1 = {};
obj1.toString; // function toString() { [native code] }

Object.setPrototypeOf(),Object.getPrototypeOf()

用以读写对象的原型对象,其中 getPrototypeOf 的参数与 setPrototypeOf 的第一个参数不是对象时会自动转换为对象。因此对于 nullundefined 这种无法转换对象的则会报错。

1
2
3
4
const obj = Object.setPrototypeOf( {}, { a: 1 } );
console.log( obj ); // {}
obj.a; // 1
Object.getPrototypeOf( obj ); // { a: 1 }

Object.values()、Object.entries()

ES2017 引入,与Object.key() 一样,结果数组中均不包含不可枚举属性Symbol属性
且当参数不是对象时,会自动将转换为对象。遇到 nullundefined 这种无法转换对象的则会报错。

Object.fromEntries()

Object.entries() 的逆操作,用于将一个键值对数组转为对象。

1
2
3
4
Object.fromEntries( [
[ "a", 1 ],
[ "b", 2 ]
] ); // { a: 1, b: 2 }

其接受的参数的是 可迭代对象,一个键值对列表。因此他不止可以转换二维数组,还可以接收 MapHeadersFormDataURLSearchParams 等对象。

1
2
3
4
5
6
7
8
9
10
// Map
Object.fromEntries( new Map( [ "name", "mari" ] ) ); // { name: 'mari' }
// Headers
Object.fromEntries( new Headers( [ [ "name", "mari" ] ] ) ); // { name: 'mari' }
// FormData
const formData = new FormData();
formData.append( "name", "mari" );
Object.fromEntries( formData ); // { name: 'mari' }
// URLSearchParams
Object.fromEntries( new URLSearchParams( "name=mari" ) ); // { name: 'mari' }

Object.hasOwn()

ES2022 新增,用于判断是否为自身属性。而在此之前存在 hasOwnProperty() 方法来进行判断,两者的使用区别如下

1
2
3
4
5
6
7
const foo = Object.create({ a: 1 }, { b: { value: 2 } });

foo.hasOwnProperty( "a" ); // false
foo.hasOwnProperty( "b" ); // true

Object.hasOwn( foo, "a" ); // false
Object.hasOwn( foo, "b" ); // true

当针对不继承任何原型的 Object 时,hasOwnProperty() 会报错,Object.hasOwn() 则不会,很容易理解,不存在原型对象也就不存在
hasOwnProperty这个方法。

1
2
3
4
5
//
const obj = Object.create( null );

obj.hasOwnProperty( "a" ); // TypeError: obj.hasOwnProperty is not a function
Object.hasOwn( obj, "a" ); // false

运算符的扩展

指数运算符

ES2016 新增,右结合,而非常见的左结合,连用时有限从右侧开始计算。

1
2
3
2 ** 2 ** 3; // 256
// 等同于
2 ** ( 2 ** 3 );

可以与 = 结合为 **=

1
2
3
let a = 2;
a **= 3;
console.log( a ); // 8

链判断运算符

ES2020 引入,短路机制,只要不满足条件,就不再往下执行。有三种写法

1
2
3
obj?.prop // 对象属性是否存在,存在即获取
obj?.[ expr ] // 同上
func?.( ...args ) // 函数或对象方法是否存在,存在即执行

一般来说,使用 ?. 运算符的场合,不应该使用圆括号。因为 ?. 运算符不会对括号外部进行影响。

1
( a?.b ).c // 可能会出现 undefined.c 导致报错

有如下几种禁止写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 构造函数
new a?.()
new a?.b()

// 链判断运算符的右侧有模板字符串
a?.`{b}`
a?.b`{c}`

// 链判断运算符的左侧是 super
super?.()
super?.foo

// 链运算符用于赋值运算符左侧
a?.b = c

有一点需要注意,右侧不得为十进制数值。这是因为为了兼容以前的代码,允许 foo?.3:0 被解析成 foo ? .3 : 0。当右侧为十进制数值时,?.
将会被拆分,视为一个三元运算符,导致语句不完整报错。

Null 判断运算符

ES2020 引入,当左侧为 nullundefined 时才会执行右侧代码。

1
null ?? 1; // 1

?? 本质上是逻辑运算,与 &&|| 存在优先级问题。ES6 规定,??&&|| 多个逻辑运算符一起使用时,必须使用括号表明优先级,否则报错。
其实建议所有的逻辑运算符一起使用时都应该使用括号表明优先级。

逻辑赋值运算符

ES2021 引入了三个新的逻辑赋值运算符(logical assignment operators),将逻辑运算符与赋值运算符进行结合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 或赋值运算符
x ||= y
// 等同于
x || (x = y)

// 与赋值运算符
x &&= y
// 等同于
x && (x = y)

// Null 赋值运算符
x ??= y
// 等同于
x ?? (x = y)

可用于为变量或属性设置默认值。

1
2
3
4
5
// 老的写法
user.id = user.id || 1;

// 新的写法
user.id ||= 1;

Symbol

表示独一无二的值,ES6 新引入的原始数据类型(字符串、数值等均为原始数据类型),并非对象。因此 Symbol 值不能添加属性,也不能使用 new 关键字。
它是一种类似 String 的数据类型。

1
2
const sym = Symbol();
typeof sym; // "symbol"

Symbol() 函数可以接受一个字符串作为参数,表示对 Symbol 实例的描述。这主要是为了在控制台显示,或者转为字符串时,比较容易区分。
相同描述的 Symbol 值是不相等的,Symbol 永远都不会与其他值相等。

1
2
3
const sym = Symbol( "wdnmd" );
sym; // Symbol( wdnmd )
sym.toString(); // "Symbol( wdnmd )"

当描述参数为一个对象时,将会调用对象的 toString() 方法来转为一个字符串。

1
2
3
4
5
6
Symbol( { a: 1 } ); // Symbol([object Object])
Symbol( {
toString() {
return "wdnmd";
}
} ); // Symbol( wdnmd )

Symbol值不能与其他类型的值进行运算,会报错。

1
2
const sym = Symbol( "wdnmd" );
sym + "cxk"; // TypeError: Cannot convert a Symbol value to a string

Symbol值可以显式转为字符串、布尔值,但不能转为数值。

1
2
3
4
5
6
7
8
const sym = Symbol( "wdnmd" );
sym.toString(); // "Symbol( wdnmd )"

Boolean( sym ); // true
!!sym; // true

Number( sym ); // TypeError: Cannot convert a Symbol value to a number
BigInt( sym ); // TypeError: Cannot convert Symbol( wdnmd ) to a BigInt

Symbol.prototype.description

ES2019 提供了一个实例属性 description 来方便地获取 Symbol 值的描述。

1
2
const sym = Symbol( "wdnmd" );
sym.description; // "wdnmd"

Symbol 作对象属性名

由于 Symbol 值的特性,作为对象的属性名时,可以保证不会出现同名的属性。由于.运算符后只能是字符串,因此设置属性只可以用方括号结构。

1
2
3
4
5
6
7
8
9
10
11
const sym = Symbol( "wdnmd" );
// 方式一
const obj = {};
obj[ sym ] = "这是一个symbol值";
// 方式二
const obj1 = {
[ sym ]: "这是一个symbol值"
};
// 方式三
const obj2 = {};
Object.definePropertie( obj2, sym, { value: "这是一个symbol值" } );

注意: Symbol 值作为属性名时,该属性还是公开属性,不是私有属性。

消除魔术字符串

魔术字符串(指一类多次出现但其值是什么无关紧要的字符串)

1
2
3
4
5
6
7
8
9
10
11
12
const obj = { value: "wdnmd" };
const check = obj.value === "wdnmd";

// 正常情况下我们的操作是
const value = "wdnmd";
const obj = { value };
const check = obj.value === value;

// 但其值并没有意义,因此可修改为
const value = Symbol();
const obj = { value };
const check = obj.value === value;

属性名的遍历

遍历对象时,以 Symbol 值作为属性名的属性不会出现在 for...infor...of 循环中,也不会被 Object.keys()
Object.getOwnPropertyNames()JSON.stringify()返回。

但上面讲了,Symbol 值的属性名并不是私有属性。所以 ES6 提供了一个方法 Object.getOwnPropertySymbols() 来获取对象所有的 Symbol 值的属性。

1
2
3
4
5
6
const sym = Symbol( "wdnmd" );
const obj = {
[ sym ]: "这是一个symbol值"
};
const symList = Object.getOwnPropertySymbols( obj ); // [ Symbol( wdnmd ) ]
symList.includes( sym ); // true

另一个新的 API,Reflect.ownKeys() 可以返回所有的键名,其中包括 Symbol 值的键名,被放在最后输出。将在后面讲到。

1
2
3
4
5
6
const obj = {
[ Symbol( "wdnmd" ) ]: 1,
a: 2,
1: 3
};
Reflect.ownKeys( obj ); // [ "1", "a", Symbol( wdnmd ) ]

由于 Symbol 值的键名不会被常规遍历的特性,我们可以用来为对象定义一些非私有的、但又希望只用于内部的方法。

Symbol.for(),Symbol.keyFor()

Symbol.for()

当希望使用同一个 Symbol 值时,可以使用 Symbol.for() 方法。

它接收一个字符串作为参数,然后搜索有没有以该参数作为名称的 Symbol 值。如果有,就返回这个 Symbol 值,否则就新建一个以该字符串为名称的 Symbol 值,
并将其注册到全局

1
2
3
const sym1 = Symbol.for( "wdnmd" );
const sym2 = Symbol.for( "wdnmd" );
sym1 === sym2; // true

Symbol.for()Symbol() 都会生成新的 Symbol。区别是前者会注册到全局以供搜索,后者不会。Symbol() 每次都会创建一个全新的 Symbol,
Symbol.for()则有可能拿到相同的 Symbol。

Symbol.keyFor()

这个方法返回一个已登记在全局的 Symbol 类型值的key,当该 Symbol 未登记在全局时,返回 undefined

1
2
3
4
5
const sym1 = Symbol( "sym1" );
const sym2 = Symbol.for( "sym2" );

Symbol.keyFor( sym1 ); // undefined
Symbol.keyFor( sym2 ); // "sym2"

Symbol.for() 未传递参数时,注册到全局的键名为 "undefined"

1
2
3
const sym1 = Symbol.for();
const sym2 = Symbol.for();
sym1 === sym2; // true

内置的 Symbol 值

ES6 提供了 11 个内置的 Symbol 值,指向语言内部使用的方法。详见内置的 Symbol 值

Set

ES6 新增,属于新的数据结构。它类似于数组,但是成员的值都是唯一的,没有重复的值。

Set 本身是一个构造函数,用来生成 Set 数据结构。可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。

1
2
3
4
5
new Set(); // Set( 0 ) { size: 0 }
new Set( [ 1, 2, 3 ] ); // Set( 3 ) { 1, 2, 3 }
// 因不能存在重复值,因此只有四个元素
new Set( "wdnmd" ); // Set( 4 ) { "w", "d", "n", "m" }
new Set( document.querySelectorAll( "div" ) );

Set 可以被 ... 展开,因此利用不可重复的特性,能够快速的用来数组去重。

1
2
Array.from( new Set( [ 1, 1, 2, 3 ] ) ); // [ 1, 2, 3 ]
[ ...new Set( [ 1, 1, 2, 3 ] ) ]; // [ 1, 2, 3 ]

Set 中加入值时不会发生类型转换。

1
new Set( [ 5, "5" ] ); // Set( 2 ) { 5, '5' }

Set 中判断是否相等的算法类似于 ===,但不同的是它加入了对 NaN 的准确的判断。

1
new Set( [ NaN, NaN ] ); // Set( 1 ) { NaN }

Set 实例的属性和方法

实例属性

Set 结构的实例有以下属性。

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

实例方法

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

操作方法

  • Set.prototype.add( value ):添加某个值,返回 Set 结构本身。
  • Set.prototype.delete( value ):删除某个值,返回一个布尔值,表示删除是否成功。
  • Set.prototype.has( value ):返回一个布尔值,表示该值是否为Set的成员。
  • Set.prototype.clear():清除所有成员,没有返回值。
1
2
3
4
5
6
7
8
9
10
11
const set = new Set();
// 1 被添加了两次,第二次因为重复未添加
set.add( 1 ).add( 2 ).add( 1 ); // Set( 2 ) { 1, 2 }

set.delete( 1 ); // true
set; // Set(1) { 2 }

set.has( 2 ); // true

set.clear();
set; // // Set( 0 ) { size: 0 }

遍历方法

Set 结构的实例有四个遍历方法,可以用于遍历成员。

keys(),values(),entries()

这三个方法返回的都是迭代器对象。由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以 keys 方法和 values 方法的行为完全一致。

1
2
3
4
5
6
const set = new Set( [ "kanade", "mari", "kokoro" ] );
set.keys(); // SetIterator { "kanade", "mari", "kokoro" }
set.values(); // SetIterator { "kanade", "mari", "kokoro" }
set.entries(); // SetIterator { "kanade" => "kanade", "mari" => "mari", "kokoro" => "kokoro" }

Object.is( set.keys, set.values ); // true

Set 结构的实例默认可遍历,它的默认迭代器生成函数就是它的 keysvalues 方法。

1
Object.is( Set.prototype[ Symbol.iterator ], Set.prototype.values ); // true

因此这意味着可以直接通过 for...of 来遍历 Set 结构数据,也可以让 ... 作用于 Set 结构数据。

1
2
3
4
5
6
7
8
9
const set = new Set( [ "kanade", "mari", "kokoro" ] );
for( const s of set ) {
console.log( s );
}
// "kanade"
// "mari"
// "kokoro"

[ ...set ]; // [ "kanade", "mari", "kokoro" ]
forEach()

格式与数组的 forEach() 类似,但由于 Set 结构的键名和键值是一样的,因此 forEach() 的两个参数永远是一样的.第二个参数用来绑定 this

1
2
3
4
5
const set = new Set( [ "kanade" ] );
set.forEach( ( value, key, set ) => {
console.log( value, value === key, set );
} );
// "kanade" true

另外,forEach 方法还可以有第二个参数,表示绑定处理函数内部的 this 对象。

其他遍历方法

Set 数据构虽然不存在 map()filter() 等方法,但可以通过 Array.from()... 等方法来转换为数组间接使用这些方法。

1
2
3
const set = new Set( [ "kanade", "mari", "kokoro" ] );
Array.from( set ).filter( s => s !== "kokoro" ); // [ "kanade", "mari" ]
[ ...set ].map( ( s, sKey ) => s += sKey ); // [ "kanade0", "mari1", "kokoro2" ]

因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const a = new Set( [ 1, 2, 3 ] );
const b = new Set( [ 4, 3, 2 ] );

// 并集
new Set( [ ...a, ...b ] );
// Set { 1, 2, 3, 4 }

// 交集
new Set([ ...a ].filter( x => b.has( x ) ) );
// set { 2, 3 }

// (a 相对于 b 的)差集
new Set( [ ...a ].filter( x => !b.has( x ) ) );
// Set { 1 }

Map

传统的对象(Object),本质上是键值对的集合(Hash 结构),但它仅能使用字符串当键值。当使用对象作键值时会被自动替换为字符串。

1
2
3
4
const params = { a: 1 };
const obj = { [ params ]: 2 };
// params 被自动转成了字符串 "[object Object]"
obj[ "[object Object]" ]; // 2

为了解决这个问题,使得键值对中的键可以为任意类型,ES6 新增了 Map 数据结构。

1
2
3
4
5
6
const params = { a: 1 };
const map = new Map( [
[ params, 1 ]
] );
map; // Map( 1 ) { { a: 1 } => 1 }
map.get( params ); // 1

Map构造函数接受数组作为参数,实际上执行的是下面的算法。

1
2
3
4
5
6
7
8
9
10
const items = [
[ "name", "kanade" ],
[ "title", "angle"]
];

const map = new Map();

items.forEach(
( [ key, value ] ) => map.set( key, value )
);

事实上,任何具有 Iterator 接口、且每个成员都是一个双元素的数组的数据结构都可以当作 Map 构造函数的参数。这就是说,SetMap 都可以用来生成新的 Map。

1
2
3
4
5
6
7
8
9
const items = [
[ "name", "kanade" ],
[ "title", "angle"]
]

// 下面三种写法都会得到:Map( 2 ) { "name" => "kanade", "title" => "angle" }
new Map( new Set( items ) );
new Map( new Map( items ) );
new Map( new URLSearchParams( items ) );

Map 数据结构中,同名键将会被覆盖,遵循类似严格相等算法(===)。但其中有一个特例:虽然 NaN 不严格相等于自身,但 Map 将其视为同一个键。

1
2
3
const map = new Map();
map.set( NaN, 1 ); // Map( 1 ) { NaN => 1 }
map.set( NaN, 2 ); // Map( 1 ) { NaN => 2 }

Map 实例的属性和方法

实例的属性

Map 的实例上存在一个属性 size,返回 Map 结构的成员总数。

1
2
3
4
new Map( [
[ "name", "kanade" ],
[ "title", "angle"]
] ).size; // 2

实例的方法

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

操作方法

  • Map.prototype.set( key, value ):设置键名 key 对应的键值为 value,返回整个 Map 结构。如果 key 已经有值,则键值会被更新。
  • Map.prototype.get( key ):读取 key 对应的键值,如果找不到 key,返回 undefined
  • Map.prototype.has( key ):返回一个布尔值,表示该键是否存在当前 Map 对象中。
  • Map.prototype.delete( key ):删除某个键,返回布尔值,表示是否删除成功。
  • Map.prototype.clear():清除所有成员,没有返回值。

设置键名 key 对应的键值为 value,然后返回整个 Map 结构。 如果 key 已经有值,则键值会被更新,否则就新生成该键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const m = new Map();

m.set( "age", 17 )
.set( undefined, "empty" )
.set( "name", "marry" )
.set( "name", "mari" )
.set ( { a: 1 }, "data" );
m; // Map( 3 ) { "age" => 17, undefined => "empty", "name" => "mari", { a: 1 } => "data" }

m.get( undefined ); // "empty"

m.has( "age" ); // true

m.delete( "name" ); // true
m; // Map( 3 ) { "age" => 17, undefined => "empty", { a: 1 } => "object" }

m.clear();
m; // Map( 0 ) {}

遍历方法

Map 结构的实例同样有四个遍历方法,可以用于遍历成员。

keys(),values(),entries()

这三个方法返回的都是迭代器对象。需要特别注意的是,Map 的遍历顺序就是插入顺序。

1
2
3
4
5
6
7
const map = new Map( [
[ "name", "kanade" ],
[ "title", "angle"]
] );
map.keys(); // [Map Iterator] { "name", "title" }
map.values(); // [Map Iterator] { "kanade", "angle" }
map.entries(); // [Map Entries] { [ "name", "kanade" ], [ "title", "angle" ] }

Map 结构的实例默认可遍历,它的默认迭代器生成函数就是它的 entries 方法。

1
2
3
4
5
6
7
8
Object.is( Map.prototype[ Symbol.iterator ], Map.prototype.entries ); // true

const map = new Map( [
[ "name", "kanade" ],
[ "title", "angle"]
] );
[ ...map.entries() ]; // [ [ "name", "kanade" ], [ "title", "angle" ] ]
[ ...map ]; // [ [ "name", "kanade" ], [ "title", "angle" ] ]
forEach()

格式与数组的 forEach() 类似,第三个参数用来绑定 this

1
2
3
4
5
6
7
8
new Map( [
[ "name", "kanade" ],
[ "title", "angle"]
] ).forEach( ( value, key, map) => {
console.log( value, key );
} )
// "kanade" "name"
// "angle" "title"

Map 与其他类型的转换

数组与 Map 的互转不再作赘述。

Map 转对象

当 Map 的键值全为 StringNumberSymbol 类型时,可以无损的转换为对象。

1
2
3
4
5
6
7
8
9
const map = new Map( [
[ "name", "kanade" ],
[ "title", "angle"]
] );

const obj = Object.create( null );
for ( const [ k, v ] of map ) {
obj[ k ] = v;
}

对象转 Map

1
2
3
4
5
6
const obj = {
name: "kanade",
title: "angle"
}
new Map( Object.entries( obj ) );
// Map(2) { "name" => "kanade", "title" => "angle" }

Map 转为 JSON

Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名都是字符串,这时可以选择先将 Map 转为对象,然后转为 Json。

但当键名中存在非字符串时,可以先将 Map 转为数组,然后转为 JSON。

JSON 转 Map

无非就是先转对象然后转为 Map。不过如果 Json 的数据结构恰好能组成 Map 数据结构时,可以通过数组转为 Map。

WeakSet、WeakMap、WeakRef

这是三个与垃圾回收机制有关的数据类型,可详见Set 和 Map 数据结构

Reflect

ES6 为了操作对象提供了一个全新的 API,其设计目的有这样几个:

(1) 将 Object 对象的一些明显属于语言内部的方法(比如 Object.defineProperty ),放到 Reflect 对象上。

(2) 修改某些 Object 方法的返回结果,让其变得更合理。

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

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

(5) 有了 Reflect 对象以后,很多操作会更易读。

1
2
3
4
5
// 老写法
Math.floor.apply( undefined, [1.75] ); // 1

// 新写法
Reflect.apply( Math.floor, undefined, [1.75] ); // 1

静态方法

Reflect.get( target, name, receiver )

查找并返回 target 对象的 name 属性,如果没有该属性,则返回 undefined

如果 name 属性部署了取值函数(getter),则取值函数的 this 绑定 receiverreceiver 默认为 target

1
2
3
4
5
6
7
8
9
10
11
const obj = {
name: "kanade",
age: 16,
get desc() {
return `${ this.name } ${ this.age }`
}
};

Reflect.get( obj, "name" ); // "kanade"
Reflect.get( obj, "desc" ); // "kanade 16"
Reflect.get( obj, "desc", { name: "marry", age: 140 } ); // "marry 140"

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

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

设置 target 对象的 name 属性等于 value,返回布尔值,确定是否设置成功。

如果 name 属性设置了赋值函数(setter),则赋值函数的 this 绑定 receiverreceiver 默认为 target

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {
name: "kanade",
age: 16,
set foo( value ) {
this.age = value;
}
};

Reflect.set( obj, "name", "marry" ); // true
obj; // { name: "marry", age: 16, foo: [ Setter ] }

Reflect.set( obj, "foo", 140 ); // true
obj; // { name: "marry", age: 140, foo: [ Setter ] }

const otherObj = {};
Reflect.set( obj, "foo", 140, otherObj ); // true
otherObj; // { age: 140 }

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

Reflect.has( obj, name )

Reflect.has 方法对应 name in obj 里面的 in 运算符。

1
2
3
4
// 旧写法
"name" in {}; // false
// 新写法
Reflect.has( {}, "name" ); // false

第一个参数不是对象时会报错。

Reflect.deleteProperty( obj, name )

等同于 delete obj[name],用于删除对象的属性。

1
2
3
4
5
6
7
8
const obj = {
name: "kanade",
age: 16
}

delete obj.name; // true
Reflect.deleteProperty( obj, "age" ); // true
obj; // {}

第一个参数不是对象时会报错。

严格模式下使用 delete 删除无法删除的属性会报错,而 Reflect.deleteProperty 不会报错,正常得到 false。所以 delete 操作符的返回值还有什么用吗

例如针对被冻结的对象:

1
2
3
4
"use strict";
const person = Object.freeze( { age: 17 } );
delete person.age; // TypeError: Cannot delete property "age" of #<Object>
Reflect.deleteProperty( person, "age" ); // false

不可配置的属性也是同理:

1
2
3
4
5
6
"use strict";
const person = Object.create( null, {
age: { value: 17, configurable: false }
} )
delete person.age; // TypeError: Cannot delete property 'age' of [object Object]
Reflect.deleteProperty( person, "age" ); // false

Reflect.construct( target, args );

等同于 new target(...args),这提供了一种不使用 new,来调用构造函数的方法。

1
2
3
4
5
6
7
function wife( name ) {
this.name = name;
}
// 旧写法
new wife( "marry" ).name; // "marry"
// 新写法
Reflect.construct( wife, [ "marry" ] ).name; // "marry"

第一个参数不是函数时会报错,class 也不可以。

Reflect.getPrototypeOf(obj)

对应 Object.getPrototypeOf( obj )

1
2
3
4
5
const obj = Object.create( { type: "diy" } );
// 旧写法
Object.getPrototypeOf( obj ); // { type: "diy" }
// 新写法
Reflect.getPrototypeOf( obj ); // { type: "diy" }

这两者的区别是:当第一个参数不是对象时,前者会试图转换为对象,而 Reflect 方法则会直接报错。

Reflect.setPrototypeOf( obj, newProto )

对应 Object.setPrototypeOf( obj, newProto ),区别是 Object 的方法为返回设置后的对象,Reflect 方法则返回一个布尔值,表示是否设置成功。

1
2
3
4
5
6
7
const obj = {};
// 旧写法
Object.setPrototypeOf( obj, Array.prototype ); // Array {}
// 新写法
Reflect.setPrototypeOf( obj, Array.prototype ); // true

obj.length; // 0

如果无法设置目标对象的原型(比如,目标对象禁止扩展),Reflect.setPrototypeOf 方法返回 false,而 Object.setPrototypeOf 则会报错。

1
2
3
4
const obj = Object.freeze( {} );

Object.setPrototypeOf( obj, {} ); // TypeError: #<Object> is not extensible
Reflect.setPrototypeOf( obj, {} ); // false

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

1
2
Object.setPrototypeOf( 1, {} ); // 1
Reflect.setPrototypeOf( 1, {} ); // TypeError: Reflect.setPrototypeOf called on non-object

Reflect.apply( func, thisArg, args )

Reflect.apply 方法等同于 Function.prototype.apply.call( func, thisArg, args ),用于绑定this对象后执行给定函数。

一般来说,如果要绑定一个函数的 this 对象,可以这样写 fn.apply(obj, args),但是如果函数定义了自己的 apply 方法,就只能写成 Function.prototype.apply.call( fn, obj, args ),采用 Reflect 对象可以简化这种操作。

1
2
3
4
5
Math.max.apply( null, [1, 3, 2] ); // 3
// 旧写法
Function.prototype.apply.call( Math.max, null, [ 1, 3, 2 ] ); // 3
// 新写法
Reflect.apply( Math.max, null, [ 1, 3, 2 ] ); // 3

Reflect.defineProperty( target, propertyKey, attributes )

基本等同于 Object.defineProperty ,用来为对象定义属性。不同的是旧属性会返回生成的对象,新属性则会返回布尔值,表示是否设置成功。

而且旧属性将会在未来废除,建议从现在开始就使用 Reflect.defineProperty

1
2
3
4
5
6
7
8
9
10
11
const obj = {};
// 旧写法
Object.defineProperty( obj, "now", {
value: () => Date.now(),
configurable: true
} ); // { now: ƒ }
// 新写法
Reflect.deleteProperty( obj, "now", {
value: () => Date.now(),
configurable: true
} ); // true,若上面未配置 configurable 为 true,此时该属性禁止修改特性,会返回 false

第一个参数不是对象时会报错。

Reflect.getOwnPropertyDescriptor( target, propertyKey )

基本等同于 Object.getOwnPropertyDescriptor,用于得到指定属性的描述对象,将来会替代掉后者。

1
2
3
4
5
6
const obj = Object.create( {}, { age: { value: 17 } } );
// 旧写法
Object.getOwnPropertyDescriptor( obj, "age" );
// 新写法
Reflect.getOwnPropertyDescriptor( obj, "age" );
// 结果均为: { value: 17, writable: false, enumerable: false, configurable: false }

两者的区别是,当第一个参数不是对象时,旧写法会返回 undefined,新写法则直接报错。

Reflect.isExtensible ( target )

对应 Object.isExtensible,返回一个布尔值,表示当前对象是否可扩展。

1
2
3
4
5
const obj = {};
// 旧写法
Object.isExtensible( obj ); // true
// 新写法
Reflect.isExtensible( obj ); // true

如果参数不是对象,Object.isExtensible 会返回false,因为非对象本来就是不可扩展的,而 Reflect.isExtensible 会报错。

Reflect.preventExtensions( target )

对应 Object.preventExtensions 方法,用于让一个对象变为不可扩展。返回一个布尔值,表示是否操作成功。

1
2
3
4
5
const obj = {};
// 旧写法
Object.preventExtensions( obj ); // Object {}
// 新写法
Reflect.preventExtensions( obj ); // true

如果参数不是对象,Object.preventExtensions 在 ES5 环境报错,在 ES6 环境返回传入的参数,而 Reflect.preventExtensions 会报错。

Reflect.ownKeys ( target )

用于返回对象的所有属性,基本等同于 Object.getOwnPropertyNamesObject.getOwnPropertySymbols 之和。

1
2
3
4
5
6
7
8
9
10
11
const obj = {
name: "kanade",
age: 16,
[Symbol.for( "sex" )]: "girl",
[Symbol.for( "hair" )]: "white",
};

Object.getOwnPropertyNames( obj ); // [ "name", "age" ]
Object.getOwnPropertySymbols( obj ); // [ Symbol( sex ), Symbol( hair ) ]

Reflect.ownKeys( obj ); // [ "name", "age", Symbol( sex ), Symbol( hair ) ]

Proxy

ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。

1
const proxy = new Proxy( target, handler );

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截。因此提供了一种机制,可以对外界的访问进行过滤和改写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const obj = { name: "kanade" };

const proxy = new Proxy( obj, {
get( target, name, receiver ) {
console.log( "proxy get", receiver );
return Reflect.get( target, name, receiver ) + " get";
},
set( target, name, value, receiver ) {
console.log( "proxy set" );
Reflect.set( target, name, value + " set", receiver );
}
} )

Reflect.get( proxy, "name" ); // "kanade get"

proxy.name = "marry";
// "proxy set"
// "marry"

Reflect.set( proxy, "name", "marry" )
// "proxy set"
// false

proxy.name += "1";
// "proxy get"
// "proxy set"
// "marry set get1"

从上面可知,在上面的代码中 proxy 拦截重写了取值与赋值操作,其中 setget 的参数和 Reflect.setReflect.get() 的参数完全一致。

而且要使得 Proxy 起作用,必须针对 Proxy 实例进行操作,而不是针对目标对象进行操作。尝试操作上面代码中的目标对象,会出现下面的结果:

1
2
// 针对目标对象操作无效
Reflect.get( obj, "name" ); // "kanade"

且经过针对 Proxy 实例的操作后,目标对象也会跟随发生改变。尝试打印上面代码中的目标对象 obj,会出现下面的结果。

1
obj; // { name: "marry set get1 set" }

如果 handler 没有设置任何拦截,那就等同于直接通向原对象。

1
2
3
4
5
const obj = { name: "kanade" };
const proxy = new Proxy( obj, {} );

Reflect.set( obj, "name", "marry" );
obj; // { name: "marry" }

Proxy 实例也可以作为其他对象的原型对象,下面代码中由于 obj 对象上没有 wdnmd 属性,根据原型链,代码便尝试向原型上读取该属性,由于原型是 proxy 实例,导致被拦截。

1
2
3
4
5
6
7
const obj = Object.create( new Proxy( {}, {
get( target, name, receiver ) {
return 114514;
}
} ) );

Reflect.get( obj, "wdnmd" ); // 114514

Proxy 实例的方法

前面讲过,Reflect 对象的方法与 Proxy 实例的方法一一对应。同样,Proxy 实例拥有与 Reflect 完全相同的 13 个方法,分别对相应的操作进行拦截.

这里仅说明部分方法注意事项,详细可参考Proxy 实例的方法

set()

其返回布尔值,表示是否设置成功。当严格模式下返回 false 时,将抛出错误:

1
2
3
4
5
6
7
8
9
"use strict";

const proxy = new Proxy( {}, {
set( target, p, newValue ) {
return false;
}
} );
proxy.value = 10;
// Uncaught TypeError: 'set' on proxy: trap returned falsish for property 'value'

Proxy.revocable()

方法返回一个对象,该对象的 proxy 属性是 Proxy 实例,revoke 属性是一个函数,可以取消 Proxy 实例。

1
2
3
4
5
6
7
8
9
10
const target = {};
const handler = {};

const { proxy, revoke } = Proxy.revocable(target, handler);

proxy.foo = 123;
proxy.foo // 123

revoke();
proxy.foo // TypeError: Cannot perform "get" on a proxy that has been revoked

this 问题

Proxy 并不是目标对象的透明代理。由于 this 的存在,即使不做任何拦截的情况下,也无法保证与目标对象的行为一致。
当 Proxy 进行代理时,对象内部的 this 将指向 proxy 代理。

另外,Proxy 拦截函数内部的 this,指向的是 handler 对象。

Promise

已经非常熟悉了,这里只列举知识盲区。

注意事项与误区

then()的第二个参数

Promise 实例的 then() 方法存在两个回调函数参数,除了我们熟知的第一个参数外,第二个参数为 rejected 状态的回调函数。
因此 Promise 实例的 .catch() 方法其实为 .then( undefined, rejection ) 的简写形式。

finally()的本质

finally()ES2018 引入, 本质上为 then() 特例,即:

1
2
3
4
5
6
7
8
9
10
11
12
new Promise().finally( () => {
// 实现
} );

// 等同于
new Promise().then( res => {
// 实现
return res;
}, err => {
// 实现
throw err;
} )

这代表着若是没有 finally(),我们需要将同样的实现方法在两个地方调用。

promise实例作为resolve的参数

当一个 Promise 实例 resolve 函数的参数为另一个 Promise 实例时,自身的状态将会无效,转而由参数 Promise 实例来决定自身状态,示例:

1
2
3
4
5
6
7
8
9
10
11
const p1 = new Promise( ( resolve, reject ) => {
setTimeout( () => reject( new Error('fail') ), 2000 );
} );

const p2 = new Promise( ( resolve, reject ) => {
setTimeout( () => resolve( p1 ), 1000 );
} );

p2
.then( result => console.log( "result: " + result ) )
.catch( error => console.log( "error:" + error.message ) );

在示例中,p2resolvep1 作为参数。此时将由 p1 决定 p2 的状态,p2 自己的状态则会无效。
最终结果表现为等待两秒后输出 error: failthen() 方法的回调函数不会被执行。

resolve与rejected的执行机制

then 方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行。
因此调用 resolvereject 仅会立即改变 Promise 实例的状态,并不会终结 Promise 的参数函数的执行。

1
2
3
4
5
6
7
8
9
10
new Promise( ( resolve, reject ) => {
console.log( 1 );
resolve();
console.log( 2 );
} ).then( () => {
console.log( 3 );
} );
// 1
// 2
// 3

由于会立即改变 Promise 实例的状态,而且我们都知道 Promise 实例的 fulfilledrejected 状态不会互相转化。
因此在 resolve() 后抛出的错误均不会进入 .catch 方法,也不会提示错误,例如:

1
2
3
4
5
6
7
new Promise( resolve => {
resolve();
throw new Error( "error" );
} )
.then( () => console.log( "success" ) )
.catch( () => console.log( "error" ) );
// "success"

由于这种特性,一般建议在 resolvereject 之前使用 return 关键字。

Promise吃掉错误

当未使用 catchPromise 内部的错误进行捕获时,该错误会打印,但不会影响外部代码的运行,俗称的:Promise 会”吃掉”错误。这本质是由事件循环造成的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new Promise( (resolve, reject) => {
throw new Error( "error" );
resolve();
} ).then( () => {
console.log( 1 );
} );

console.log( 2 )

setTimeout( () => {
console.log( 3 );
}, 2000 );

// 2
// UnhandledPromiseRejectionWarning: Error: error
// 3 (两秒后)

不过对于 Node.js 环境,存在一个 unhandledRejection 事件,用于处理未捕获的错误。该事件可以监听到上面这种报错。

不过 Node 有计划在未来废除该事件。如果 Promise 内部有未捕获的错误,会直接终止进程,并且进程的退出码不为 0。

1
2
3
4
5
6
7
// 在上面的代码追加这一段
process.on( "unhandledRejection", ( err, p ) => {
console.log( "给我康康你的错误信息:" + err.message );
});

// "给我康康你的错误信息:我故意抛个错"
// "报错不影响我" (两秒后)

Promise 的组合方法

Promise 有多个方法,均可以将多个 Promise 实例合为一个。结合而成的实例只要状态发生变化,就不再执行未执行的 Promise 实例。

  • Promise.all():全部变为 fulfilled 才转为 fulfilled,有一个变为 rejected 就会变为 rejected
  • Promise.race():谁先改变状态就变成它的状态。
  • Promise.allSettled():ES2020 引入,全部实例执行完毕后才转为 fulfilledthen 获得一个每个实例执行结果的数组,包含执行状态属性 statusreasonvaluestatefulfilled 时拥有 value 属性,表示实例 resolve 的结果;反之为 reason 属性,表示报错信息。
  • Promise.any():ES2021 引入,与 Promise.all() 完全相反,有一个变为 fulfilled 就会变为 fulfilled,全部变为 rejected 才转为 rejected

Promise.resolve() 与 Promise.reject()

用来将现有对象转为 Promise 对象。根据参数不同分为三种情况

参数是一个 Promise 实例

Promise.resolve() 将不做任何修改,原封不动的返回该实例。

参数是一个 thenable 对象

thenable 对象指的是具有 then 方法的对象,例如如下对象。

1
2
3
4
5
6
const thenable = {
then( resolve, reject ) {
console.log( "我是 then,我被执行了" );
resolve( 42 );
}
};

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

1
2
3
4
5
6
// 对于上面的 thenable 对象
const p1 = Promise.resolve( thenable ); // "我是 then,我被执行了"
p1.then( res => {
console.log( res );
} );
// 42

then 方法未执行参数1或2即 resolvereject 方法,那么所得的 Promise 实例为 padding 状态。

参数不是 thenable 对象或根本不是对象或为空

Promise.resolve() 返回一个新的 Promise 对象,状态为 fulfilled。且参数同时传给回调函数 then,允许参数为空。

1
2
3
4
5
6
7
Promise.resolve( 114514 ).then( res => {
console.log( res ); // 114514
} );
// 参数为空
Promise.resolve().then( res => {
console.log( res ); // undefined
} );

Promise.reject()

同样也会返回一个新的 Promise 实例,该实例的状态为 rejected

而它的参数,会原封不动地作为 reject 的理由,变成后续方法的参数。

1
2
3
Promise.reject( "出错了" ).catch( err => {
console.log( err ); // "出错了"
} );

fetch

fetch 是 XMLHttpRequest 的升级版,用于在 JavaScript 脚本里面发出 HTTP 请求。已经非常熟悉了,这里仅整理个人的陌生点。

response 对象

fetch() 请求成功后,会得到一个 Response 对象。

1
const response = await fetch( url );

同步属性

Response 对象存在一些同步属性。

  • ok: 布尔值,表示请求是否成功,请求状态码在 200-299 时为 true,反之为 false。这里不用考虑重定向的情况(状态码为 3xx),
    因为 fetch 会将跳转的状态码自动转为 200
  • status: http 请求响应的状态码
  • url: http 实际请求的 URL 地址,当 URL 存在重定向,返回的为最终访问的 URL
  • redirected: 布尔值,表示请求是否发生过重定向
  • type: http 请求的类型
    • basic: 普通请求,即同源请求
    • cors: 非同源请求
    • error: 网络错误
    • opaque: 简单的非同源请求,仅当 fetch option 的 mode 属性设置为 no-cors 时得到该值
    • opaqueredirect: 仅当 fetch option 的 redirect 属性设置为 manual 时得到该值

判断请求是否成功

通常情况下,fetch 发出请求后只有网络超时无法连接时才会报错(Promise 变为 rejected),即使服务端返回了 4xx5xx

所以寄希望于下面这种捕获错误来判断请求成功与否的办法是得不到期望效果的。

1
2
3
4
5
6
7
8
9
10
// 下面这两种无法捕获到请求状态码异常的情况
try {
await fetch( url );
} catch {
console.log( "请求出错了" );
}
// 或
fetch( url ).catch( () => {
console.log( "请求出错了" );
} )

应该使用 Response.statusResponse.ok 来判断是否请求成功

1
2
3
4
5
6
7
8
const res = await fetch( url );
if ( res.status >= 200 && res.status < 300 ) {
// 响应成功
}
// 或是
if ( res.ok ) {
// 响应成功
}

Response.headers 属性

Response.headers 属性指向一个 Headers 对象,可对其进行遍历、取值、删除、设置等操作

不过对于 Response 对象来说,由于得到的是响应数据,对 headers 进行删除/设置操作意义并不大。

fetch 读取流

Response.body 返回一个 ReadableStream 对象,可以用来分块读取内容。

我们可以借助 NodeJS 来实现一个类似进度条的案例

后端负责每间隔 0.5s - 2s 增加当前进度,并向前端发送,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
express.Router().get( "/stream", ( req, res ) => {
const total = 100;
let cur = 0;

const randomTimout = () => {
setTimeout( () => {
cur ++;
res.write( cur.toString() )
if ( cur >= total ) {
res.end();
return;
}
randomTimout();
}, getRandomNumber( 500, 2000 ) );
}

randomTimout();
} );

前端通过 Response.body 流式读取进度,并将其打印出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
( async () => {
const res = await fetch( "http://localhost:20408/test/fetch/stream" );

if ( !res.body ) {
return;
}
const reader = res.body.getReader();

while ( true ) {
const { done, value } = await reader.read();
if ( done ) break;

// value 得到的是一个 Uint8Array 无符号8位字节数组,需要通过 TextDecoder 来将其读取为文本类型
const decoder = new TextDecoder( "utf8" );
console.log( decoder.decode( value ) );
}
} )()

RequestInit 对象

fetch() 除了第一个 url 参数外,还存在第二个定制参数 init,其类型接口为 RequestInit

fetch 所发送请求的请求方法、请求头、数据体等属性均可以在其中设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface RequestInit {
body?: BodyInit | null;
cache?: RequestCache;
credentials?: RequestCredentials;
headers?: HeadersInit;
integrity?: string;
keepalive?: boolean;
method?: string;
mode?: RequestMode;
redirect?: RequestRedirect;
referrer?: string;
referrerPolicy?: ReferrerPolicy;
signal?: AbortSignal | null;
window?: null;
}

该参数与 Request对象 的实例属性一致,各属性详情可查看 Request对象 的实例属性文档。

fetch 同样支持直接传递一个 Request对象

1
2
3
4
fetch( "https://xxx.top/timer", { cache: "no-cache" } )
// 等同于
const req = new Request( "https://xxx.top/timer", { cache: "no-cache" } );
fetch( req );

后台保持请求连接

默认情况下,当进行关闭页面或跳转等离开页面的操作时,向后台发送的请求将会被直接中断,状态显示为 cancel

RequestInit 对象中提供了一个 keepalive 属性来解决这个问题,当设置为 keepalive: true 时,请求的存活时间将不再被页面存活时间限制,
浏览器会在页面卸载后继续在后台完成请求。

例如在页面卸载事件中像后端发送请求,若不设置 keepalive 则请求有很大可能无法发送成功

1
2
3
window.onbeforeunload = () => {
fetch( "https://xxx.top/timer", { keepalive: true } )
}

取消 fetch 请求

默认情况下请求一经发送会一直等到服务器响应后才会完成请求。

而在 RequestInit 对象中,存在一个 single 属性,它的值为一个 AbortSignal 实例,
可以让我们按自己需要来手动中断请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const controller = new AbortController();
const signal = controller.signal;

// controller.abort() 手动中断请求时会触发这个事件
signal.onabort = () => {
console.log( "请求中断了" );
}

// 可获取当前 signal 实例是否已经被中止过
console.log( signal.aborted ); // false

// 请求一个延时 5000ms 响应的 api
fetch( "/dev-online/test/simple/delay?delay=5000", { signal } )
.then( res => res.text() )
.then( res => console.log( res ) )
.catch( err => {
console.log( err ); // DOMException: The user aborted a request.
} );;

// 两秒后中断请求
setTimeout( () => {
controller.abort();
console.log( signal.aborted ); // true
}, 2000 );

中断请求后,network 中显示的请求状态为 cancel

对 fetch 进行超时处理

我们知道,默认情况下 fetch 是没有超时时间这个设置的,其超时时间依照不同浏览器设置来定。

tips: Chrome 浏览器中为 300s,Firefox 为 90s

而依照 signal 的特性,我们可以手动来为 fetch 实现超时效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function mariFetch( input: string | URL | Request, init?: RequestInit & { timeout?: number } ) {
const controller = new AbortController();
const signal = controller.signal;

// 默认 30s 超时
const timeout = init?.timeout || 30000;
const timer = setTimeout( () => {
controller.abort();
}, timeout );

return fetch( input, { ...init, signal } ).then( res => {
clearTimeout( timer );
return res;
} ).catch( err => {
// 当因为手动中断导致报错时,基于超时报错提示
if ( signal.aborted ) {
const mariErr = new Error( `timeout of ${ timeout }ms exceeded` );
mariErr.name = "MariTimeoutError";
throw mariErr;
}
throw err;
} );
}

备注: 之所以手动抛出超时错误,是因为 controller.abort() 的 reason 参数定义后未能正常生效。具体怎么回事我暂切蒙在崛川雷鼓里。

Iterator 迭代器

ES6 为统一遍历方式所提供的一种接口,主要提供给 for...of 循环使用。
ES6 规定该接口部署在数据结构的 Symbol.iterator 属性上,诸如 ArrayMapSetString 等均内部部署了该接口。

1
""[ Symbol.iterator ]; // [Function: [Symbol.iterator]]

迭代器的结构

迭代器是一个方法,这个方法返回一个拥有三个属性的对象,他们均为方法,分别为 nextreturnthrow

next()

迭代器中最重要也是必须要部署的方法,用于返回每次遍历结果。其返回值为一个对象,包含两个属性:

  • value: 当前遍历的值,为 undefined 时可以省略
  • done: 布尔值,表示遍历是否结束,为 false 时可以省略
1
2
const iter = [ 1, 2 ][ Symbol.iterator ]();
iter.next(); // { value: 1, done: false }

在我们使用诸如 for...of 循环遍历时,其内部会自动不停的调用迭代器的 next 方法,直到 donetrue 为止。
我们可以获取到数组的迭代器来查看其工作方式。

1
2
3
4
5
6
7
const arr = [ 1, 2 ];
const iter = arr[ Symbol.iterator ]();

iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: undefined, done: true }
iter.next(); // { value: undefined, done: true }

next() 方法没有返回正确格式的对象,for...of 等使用到迭代器的地方会直接报错。

1
2
3
4
5
6
7
8
const obj = Object.create( null );
obj[ Symbol.iterator ] = () => ( {
next() {
return 1;
}
} );

[...obj]; // TypeError: Iterator result 1 is not an object

return()

可选方法,当 for...of 循环提前退出(出现错误或触发 break 语句)时,会调用该方法。可以用来在退出时释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 定义一个对象,其迭代器只能遍历 2 次
const obj = {
[ Symbol.iterator ]() {
let index = 0;
return {
next() {
if ( index < 2 ) {
return { value: index++ };
}
return { done: true };
},
return() {
console.log( "释放资源" );
return { done: true };
}
}
}
}

// 手动中断
for ( const item of obj ) {
console.log( item );
break;
}
// 0
// 释放资源

// 抛错
for ( const item of obj ) {
console.log( item );
throw new Error( "出错了" );
}
// 0
// 释放资源
// Uncaught Error: 出错了

// 正常结束
for ( const item of obj ) {
console.log( item );
}
// 0
// 1

可以看到,当正常结束时,return 方法不会被调用,而当提前退出时,return 方法才会被调用。
即使是抛出错误,也依旧会先调用 return 方法,再抛出错误。

需要注意的是,return 必须返回一个对象,不返回或返回非对象都会抛出错误:

1
TypeError: Iterator result xxx is not an object

迭代器的辅助方法

迭代器有一些辅助方法,他们与 Array 的一些方法非常类似,例如 forEachmapfilterreduce 等。
详情可以参考 Iterator 对象的方法

目前这些方法的兼容性并不怎么样

自行编写迭代器

对于 ArrayString 等内置迭代器的数据结构,我们可以直接使用 for...of 循环进行遍历。
但对于自定义的数据类型,例如普通对象,由于其内部并不存在 Symbol.iterator 属性,因此无法直接使用 for...of 进行遍历。

1
2
3
4
5
6
7
8
9
const author = {
name: "Mari",
age: 17
}

for ( const item of author ) {
console.log( item );
}
// TypeError: author is not iterable

此时我们可以尝试通过在对象上部署 Symbol.iterator 属性,使其成为可遍历对象,然后再使用 for...of 进行遍历。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const author = {
name: "Mari",
age: 17
}

Reflect.defineProperty( author, Symbol.iterator, {
value() {
const keys = Object.keys( this );
let index = 0;
return {
next: () => {
const key = keys[ index ];
return {
value: [ key, this[ key ] ],
done: index++ === keys.length
}
}
}
}
} );

for ( const item of author ) console.log( item );

// [ "name", "Mari" ]
// [ "age", 17 ]

上面我们通过自定义迭代器,来实现了让普通对象可以被 for...of 循环遍历,并类似数组那样输出每个属性的值。

迭代器不需要必须为当前对象的自有属性,也可以是原型链上的属性,因此我们可以修改上面的代码,允许所有的对象都可以被遍历。

1
2
3
4
5
6
7
8
9
Reflect.defineProperty( Object.prototype, Symbol.iterator, {
value() {
// 相同代码
}
} );

for ( const item of { name: "Mari" } ) console.log( item ); // [ "name", "Mari" ]

for ( const item of { age: 17 } ) console.log( item ); // [ "age", 17 ]

当然这里只是演示作用,实际上并不建议这样做。

使用到迭代器的场景

除了 for...of 循环外,还有一些场景会用到迭代器。下面的实例对象默认经过了我们上文中的迭代器部署。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const author = {
name: "Mari",
age: 17
}

// 解构赋值
[ a, b ] = author;
console.log( a, b ); // [ "name", "Mari" ] [ "age", 17 ]

// 扩展运算符
[ ...author ]; // [ [ "name", "Mari" ], [ "age", 17 ] ]

// Array.from()
Array.from( author ); // [ [ "name", "Mari" ], [ "age", 17 ] ]

// Map、WeakMap
new Map( { mari: [ "age", 17 ] } ); // Map(2) { "name" => "mari", "age" => 17 }

// Set、WeakSet
new Set( author ); // Set(2) { [ "name", "Mari" ], [ "age", 17 ] }

由于这些场景的内部都是调用的迭代器接口,因此这意味着,他们对目标自身进行操作等同于对目标的迭代器进行操作。

1
2
3
[ ...author ];
// 等同于
[ ...author[ Symbol.iterator ]() ];

还有 Promise.allPromise.race 等方法也会用到迭代器,这里不再赘述。

需要注意的是,在绝大多数场景下,当 next() 函数的返回值的 donetrue 时,迭代器会自动停止遍历。此时即使 value 有值,也不会被遍历到。

Generator 函数

Generator 函数即生成器,ES6 提供的一种异步编程解决方案,其内部可以通过 yield 关键字来暂停函数执行,通过 next() 方法来恢复函数执行。
详见 Generator 函数的语法

我们可以使用 Generator 函数改进自行编写迭代器中的案例:

1
2
3
4
5
6
7
Reflect.defineProperty( Object.prototype, Symbol.iterator, {
value: function* () {
for( let key in this ) {
yield [ key, this[ key ] ];
}
}
} );

Generator 函数的返回值

ES6 规定,Generator 函数返回的迭代器对象是 Generator 函数的实例,也继承了 Generator 函数的 prototype 对象上的方法。

1
2
3
4
5
6
7
function* gen() {}

gen.prototype.hello = "world";
const g = gen();

g instanceof gen; // true
g.hello; // "world"

与迭代器实例的关系

在 ES6 中,迭代器的遍历长这个样子:

1
2
3
for ( const key of Object.keys( obj ) ) {
console.log( key, obj[key] );
}

这种迭代器的语法糖之所以能够实现,就是因为内置了生成器(generator)
生成器可以说实现了一半的协程,因为它不能指定要让步的线程,只能让步给生成器的调用者或回复者

因此,有迭代器就要有生成器,这俩是分不开的一对关系。

与 new 关键字的关系

Generator 函数不能使用 new 关键字,因为 Generator 函数返回的是一个迭代器对象,而不是一个普通对象。
若对 Generator 函数使用 new 关键字,会报错。

1
2
function* gen() {}
new gen(); // TypeError: gen is not a constructor

结合上面 Generator 函数与迭代器实例的关系,我们可以将 Generator 函数内部的 this 指向 Generator 函数的的原型。
这样 Generator 函数内部的 this 就可以为 Generator 函数返回的迭代器实例设置属性,手动实现 new 关键字的效果并不丢失迭代器的特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* gen() {
this.name = "mari";
yield this.age = 17;
}

function Gen() {
return gen.call( gen.prototype );
}

const g = new Gen();
g.next(); // { value: 17, done: false }
g.name; // "mari"
g.age; // 17

async await

async 函数是 Generator 函数的语法糖,其内部通过 Promise 对象来实现异步编程。

其本质是将 Generator 和自动执行器,包装在一个函数内部。

1
2
3
4
5
6
7
8
9
async function foo() {
// ...
}
// 等同于
function foo() {
return spawn( function* () {
// ...
} );
}

至于 spawn 函数,其基本就是 Generator 函数那边的的自动执行器的翻版。

与 Generator 函数的比较

async - await 更像是针对以往使用 co 模块实现异步编程的一种语法糖

使用 co 模块实现异步编程时:

1
2
3
4
5
6
7
8
9
10
import co from "co";

function* foo () {
const res = yield Promise.resolve( 1 );
return res;
}

co( foo ).then( res => {
console.log( res ); // 1
} );

而使用 async 函数时:

1
2
3
4
5
6
7
8
async function foo() {
const res = await Promise.resolve( 1 );
return res;
}

foo().then( res => {
console.log( res ); // 1
} );

await

正常情况下,await 后面跟着的是一个 Promise 对象,但实际上 await 后面可以跟任意值,
包括 Promise 对象、thenable 对象(定义了 then 方法的对象)、原始类型的值(数值、字符串、布尔值,但这时等同于同步操作)。

当跟随普通值类型时,await 会将其转为 Promise,即 Promise.resolve( value )

1
2
3
4
async function foo() {
const res = await 1; // 等同于 Promise.resolve( 1 )
return res;
}

当为 thenable 对象时,await 会将等同于 Promise 对象,并执行其 then 方法。

1
2
3
4
5
6
7
8
9
10
const thenable = {
then( resolve, reject ) {
resolve( 1 );
}
}

async function foo() {
const res = await thenable;
return res; // 1
}

async 内部循环依次执行

当我们有一个异步任务数组,需要循环依次执行而不是并发执行时。通常会使用 for...of 循环来实现。

1
2
3
4
5
6
async function foo() {
const arr = [ 1, 2, 3 ];
for ( const item of arr ) {
await asyncTask( item );
}
}

但这种方式不方便直接获取遍历项的索引,如果使用 for...in 循环,会遍历到数组的原型链上的属性。此时我们还可以使用 reduce 方法来实现。

1
2
3
4
5
6
7
async function foo() {
const arr = [ 1, 2, 3 ];
await arr.reduce( async ( prev, cur ) => {
await prev;
await asyncTask( cur );
}, Promise.resolve() );
}

顶层 await

在以往,await 仅允许出现在 async 函数内部,这对于引入模块中需要异步赋值的变量存在一定的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// module.js
let name;
new Promise( resolve => setTimeout( () => {
name = "module1.js";
console.log( "赋值完成" );
resolve();
}, 3000 ) );
export { name };

// main.js
import { name } from "./module.js";
console.log( name );

// 运行结果
// undefined
// (3s 后)
// 赋值完成

上面的示例中,由于 name 的赋值是异步的,在 3000ms 后才会为其赋值,而导出的运行是不会等待这段时间的,因此在 main.js 中得到nameundefined

而在 ES2022 中,await 被允许在模块顶层使用,这样就可以解决上面的问题。

还是上面的例子,这里我们仅在 new Promise 的前面加上一个 await 关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// module.js
let name;
// 顶层 await
await new Promise( resolve => setTimeout( () => {
name = "module1.js";
console.log( "赋值完成" );
resolve();
}, 3000 ) );
export { name };

// main.js
import { name } from "./module.js";
console.log( name ); // "module1.js"

// 运行结果
// (3s 后)
// 赋值完成
// module1.js

可以看到,一直到 3s 后赋值 name 的操作完成后,main.js 才会输出 module1.js

加载多个异步模块

当存在多个异步模块时,他们的加载动作是并发执行的,我们可以编写两个异步模块来模拟这种场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// module1.js
console.log( "开始执行 module1.js" );

let name;
await new Promise( resolve => setTimeout( () => {
name = "module1.js";
resolve();
}, 3000 ) );
console.log( "module1.js 执行完毕" );

export { name };

// module2.js
console.log( "开始执行 module2.js" );

let name;
await new Promise( resolve => setTimeout( () => {
name = "module2.js";
resolve();
}, 5000 ) );
console.log( "module2.js 执行完毕" );

export { name };

// main.js
import { name as name1 } from "./module1.js";
console.log( "导入 module1: ", name1 );

import { name as name2 } from "./module2.js";
console.log( "导入 module2: ", name2 );

// 运行结果
// 开始执行 module1.js
// 开始执行 module2.js
// (3s 后)
// module1.js 执行完毕
// (2s 后)
// module2.js 执行完毕
// 导入 module1: module1.js
// 导入 module2: module2.js

可以看到,两个模块中在异步操作之前的部分(开始执行 xxx),均在一开始就被打印。
而当两个模块的异步操作全部执行完毕时,才会执行 main.js 中的导入操作后续的代码。

import()

上面有个小现象,即两条 导入 moduleX 的打印同时被执行了。这是因为 import..form 语句是发生在编译阶段的,会早于运行阶段执行的代码。
main.js 的写法的运行结果,实际与下面的写法是等价的。

1
2
3
4
5
import { name as name1 } from "./module1.js";
import { name as name2 } from "./module2.js";

console.log( "导入 module1: ", name1 );
console.log( "导入 module2: ", name2 );

若希望在运行时执行导入模块语句,可以使用 import() 函数。

1
2
3
4
5
// main.js
const { name: name1 } = await import( "./module1.js" );
console.log( "导入 module1: ", name1 );
const { name: name2 } = await import( "./module2.js" );
console.log( "导入 module2: ", name2 );

这样会严格遵守异步模块的加载顺序,即先完全加载 module1.js 之后,再加载 module2.js,得到下面这样的打印结果:

1
2
3
4
5
6
7
8
// 开始执行 module1.js
// (3s 后)
// module1.js 执行完毕
// 导入 module1: module1.js
// 开始执行 module2.js
// (5s 后)
// module2.js 执行完毕
// 导入 module2: module2.js

若想用 import() 方法来实现 import...from 语句的效果,需要结合 Promise.all 来实现。

1
2
3
4
5
6
7
// main.js
const [ { name: name1 }, { name: name2 } ] = await Promise.all( [
import( "./module1.js" ),
import( "./module2.js" )
] );
console.log( "导入 module1: ", name1 );
console.log( "导入 module2: ", name2 );

class

ES6 中引入了 class 关键字,其内部默认使用严格模式。

基本可以将 class 看作是一个语法糖,它就是用来简化 ES5 中操作原型链的写法的

为什么说基本?请继续往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ES5
function Person( name: string ) {
this.name = name;
}

Person.prototype.say = function ( msg: string ) {
console.log( this.name + " say: " + msg );
}

new Person( "Mari" ).say( "Hello" );

// ES6
class Person {
constructor( name: string ) {
this.name = name;
}

say( msg: string ) {
console.log( this.name + " say: " + msg );
}
}

new Person( "Mari" ).say( "Hello" );

它的本质是一个构造函数,因此很多函数的特性都被 class 继承了,比如 name 属性。

1
2
3
4
5
6
class Author {}

typeof Author; // "function"
Object.getPrototypeOf( Author ) === Function.prototype; // true

Author.name; // "Author"

因此事实上,class 的所有方法都定义在它的 prototype 原型对象上。

与 ES5 构造函数的区别

尽管 class 基本可以说是语法糖,但 class 内部自动进行了一些处理,使得它与 ES5 构造函数有一些区别。

class 不存在变量提升,必须先定义后使用

1
2
new Person(); // ReferenceError: Cannot access 'Person' before initialization
class Person {};

ES5 中通过 XXX.prototype.xxx 定义的方法是可以枚举的,即 enumerabletrue,而 class 中定义的方法是不可枚举的。

1
2
3
4
5
6
7
8
9
10
function Person() {}
Person.prototype.say = function () {};
Object.getOwnPropertyDescriptor( Person.prototype, "say" );
// { value: [Function: say], writable: true, enumerable: true, configurable: true }

class Person {
say() {}
}
Object.getOwnPropertyDescriptor( Person.prototype, "say" );
// { value: [Function: say], writable: true, enumerable: false, configurable: true }

class 定义的类,必须要通过 new 调用,否则会报错。而 ES5 中的构造函数则不会。

1
2
class Person {}
Person(); // TypeError: Class constructor Person cannot be invoked without 'new'

直接定义实例属性

ES2022 中允许直接在类内部的最顶层定义实例属性,而不再需要在 constructor 中通过 this 定义。

1
2
3
4
5
6
7
class Person {
// 直接定义实例属性
name = "Mari";
say() {
console.log( this.name );
}
}

存取值函数

class 中的 getset 函数是设置在属性的 Descriptor 对象上的,这与 ES5 完全一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
_name = "Mari";

get name() {
return this._name;
}

set name( value ) {
this._name = value;
}
}

Object.getOwnPropertyDescriptor( Person.prototype, "name" );
// { get: [Function: get name], set: [Function: set name], enumerable: false, configurable: true }

静态方法的 this 指向

普通方法的 this 指向实例对象,而静态方法的 this 指向类本身。

静态属性

ES6 规定,Class 内部只有静态方法,没有静态属性。因此只能在类的外部定义静态属性。

1
2
class Person {}
Person.name = "Mari";

但是,现在有一个提案,为 class 加了静态属性的支持,目前已经有部分浏览器提供了支持。

1
2
3
class Person {
static name = "Mari";
}

私有属性

ES2022 中引入了私有属性的概念,通过 # 来定义私有属性。在此之前没有真正可以定义私有属性的方法。

1
2
3
4
5
6
7
8
9
10
11
class Person {
#name = "Mari";

getName() {
return this.#name;
}
}

const p = new Person();
p.getName(); // "Mari"
p.#name; // SyntaxError: Private field '#name' must be declared in an enclosing class

这个 # 可以理解成 TypeScript 或 Java 中的 private 关键字,只能在类的内部访问,外部无法访问,即使是继承类也无法访问。

#xxxx 是两个不同的属性。

需要注意的是,从 Chrome 111 开始,F12 开发者工具中允许直接读写私有属性,理由是 Chrome 团队认为这样可以方便调试。

同时,不论是在类内部还是外部,都不允许访问不存在的私有属性,否则会报错。这有别于公开属性,公开属性是不会报错的。

1
2
3
4
5
class Person {
getName() {
return this.#name; // SyntaxError: Private field '#name' must be declared in an enclosing class
}
}

上面这个报错不会等到 getName 方法被调用时才报错,而是在类被解析时就会报错。

也可以通过 # 来设置私有方法、gettersetter

1
2
3
4
5
6
7
8
9
10
11
class Person {
#value = "Mari";

get #name() {
return this.#value;
}

getName() {
return this.#name;
}
}

实例对象访问私有属性

默认情况下,实例对象是无法访问到自己的私有属性的。但私有属性存在一个特性,即:只要在类的内部,就可以访问私有属性

这个特性甚至允许类的实例访问到自己的私有属性,只要语句处于类的内部。

1
2
3
4
5
6
7
8
9
class Person {
#name = "Mari";

getPrivateValue( obj ) {
return obj.#name;
}
}

new Person().getPrivateValue( new Person() ); // "Mari"

静态私有属性

通过 static 可以使得私有属性变为静态私有属性。

1
2
3
4
5
6
7
class Person {
static #name = "Mari";

static getName() {
return Person.#name;
}
}

这种静态私有属性依旧是只能在类的内部访问,外部无法访问。

1
2
Person.getName(); // "Mari"
Person.#name; // SyntaxError: Private field '#name' must be declared in an enclosing class

静态块

ES2022 中引入了静态块的概念,用来在类加载时执行一些初始化操作。

1
2
3
4
5
6
7
8
9
10
11
class Person {
static age;

static {
try {
this.age = calcAge();
} catch ( e ) {
this.age = 114514;
}
}
}

静态块中的 this 指向类本身,静态块仅会在类加载时执行一次,后续实例化对象时不再执行。

还可以干一些歪门邪道的事,比如将类的私有属性分享给外部代码:

1
2
3
4
5
6
7
8
9
10
let getAge;
class Person {
#age = 17;

static {
getAge = obj => obj.#age;
}
}

getAge( new Person() ); // 17

静态块的引入避免了在构造函数中进行复杂的初始化操作,又或是跟在类的外部进行写法杂乱的初始化操作。

继承

ES6 中通过 extends 关键字来实现继承,子类可以继承父类的所有属性和方法。

1
2
class Person {}
class Son extends Person {}

这个继承的机制如下:

  1. 创建一个空对象,通过父类的构造函数,将父类的属性和方法添加到这个空对象上面
  2. 将这个对象作为子类自己的 this 对象
  3. 对这个对象进行加工,添加子类自己的属性和方法

在这个机制的运作下,每次实例化子类时,都会调用一次父类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
constructor() {
console.log( "Person" );
}
}

class Son extends Person {
constructor() {
super();
console.log( "Son" );
}
}

new Son();
// Person
// Son

super 关键字

super 关键字有两种用法:作为函数使用、作为对象使用。

作为函数使用

此时 super 只允许出现在子类的构造函数中使用。

在上面的实例中,super() 用来调用父类的构造函数,即用来生成一个继承父类的子类自己的 this 对象。
因此,super() 必须在子类的 this 对象被使用之前调用,否则会报错。所以用不到 this 的语句是可以放在 super() 之前的

1
2
3
4
5
6
7
8
class Son extends Person {
constructor( age ) {
this.age = age;
super();
}
}

new Son( 17 ); // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

当子类没有显示定义 constructor 方法时,会默认添加,且内部会自动调用 super() 方法。

super 内部的 this 指向

super() 虽然代表调用父类的构造函数,但因为其返回的是子类的 this 对象,因此其内部的 this 指向的是子类的实例对象。

1
2
3
4
5
6
7
class Son extends Person {
constructor() {
super();
// 等同于
Person.prototype.constructor.call( this );
}
}

不过由于子类的构造方法在执行 super() 时,子类的实例对象还未生成,因此如果存在同名属性,拿到的将是父类的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
pName = "Mari";

constructor() {
console.log( this );
}
}

class Son extends Person {
pName = "Marry";

constructor() {
super();
}
}

new Son(); // Son { pName: "Mari" }

可以看到,this 打印结果为 Son 实例对象,但 pName 属性却是父类的属性 Mari

作为对象使用

此时分为两种情况:

  • 在静态方法中使用:super 指向父类,若调用父类静态方法,则父类方法中的 this 指向子类
  • 在普通方法中使用:super 指向父类的原型对象,若调用父类普通方法,则父类方法中的 this 指向子类实例

另外需要注意,由于通过 {} 定义的对象总是继承自 Object.prototype,因此可以在任意使用 {} 定义的对象的方法中使用 super 关键字。

1
2
3
4
5
6
const obj = {
say() {
console.log( super.toString() );
}
}
obj.say();

改良后的 in 运算符与私有属性的继承

私有属性中我们提到两点:

  • 允许实例对象在类的内部获取自身的私有属性
  • 当私有属性不存在时,尝试获取会报错

根据这两点我们可以编写一个方法,用来判断对象是否为当前类的实例对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
#name = "Mari";

static isInstance( obj ) {
try {
obj.#name;
return true;
} catch {
return false;
}
}
}

Person.isInstance( new Person() ); // true
Person.isInstance( {} ); // false

这样虽然实现了需求,但也是相当麻烦的。ES2022 改良了 in 运算符,使其可以直接用来判断私有属性,写法如下:

1
#name in obj;

语句依旧是返回一个布尔值,有两个注意事项:

  • 依旧必须要在类的内部使用
  • 私有属性 #name 不需要使用 "" 包裹,但要求该私有属性必须被显式的生命在 class 的内部,否则会报错。

为此我们可以重新编写 isInstance 方法:

1
2
3
4
5
6
7
8
9
10
class Person {
#name = "Mari";

static isInstance( obj ) {
return #name in obj;
}
}

Person.isInstance( new Person() ); // true
Person.isInstance( {} ); // false

而当我们尝试为 isInstance 方法提供 Son 子类实例对象时,会发现一个有趣的现象:判断结果为 true

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
#name = "Mari";

static isInstance( obj ) {
return #name in obj;
}
}

class Son extends Person {}

Person.isInstance( new Son() ); // true
new Son() instanceof Person; // true

由此我们可以得到私有属性继承的本质:子类创建的实例对象的原型链上依旧存在父类的私有属性,只是无法在父类以外的地方访问而已。

因此在此处场景下,当子类的实例对象被传入 isInstance 方法时,同样可以获取到 #name,导致判断结果为 true
所以我们大费周章搞这么半天最后效果和 instanceof 关键词一个德行

而当我们尝试直接修改原型链的方式去继承对象时,却得到了不一样的结果。

1
2
3
4
5
6
7
8
9
class Person {
#name = "Mari";

static isInstance( obj ) {
return #name in obj;
}
}

Person.isInstance( Object.create( new Person() ) ); // false

可以得到一个结论:私有属性是可以通过 extends 关键字来传递的,之所以私有仅仅是因为它无法在类自身以外的地方使用。
而 ES5 中修改原型链的继承方式则直接无法传递私有属性。

这里的原因是因为 ES5 和 ES6 继承的本质区别:

  • ES6:先新建父类的实例对象 this,此时完整的拿到了父类的私有属性,再用子类的构造函数修改 this
  • ES5:先创建子类的实例对象 this,再将父类的属性添加到 this 上,但由于无法从外部获取父类的私有属性,导致无法子类实例对象中无法继承父类私有属性

静态属性的继承

仅注意一点:静态属性是通过浅拷贝来实现继承的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
static pName = "Mari";
static action = ["eat", "sleep"];
}

class Son extends Person {
static {
this.pName = "Marry";
this.action.push( "play" );
}
}

console.log( Person.pName, Son.pName ); // "Mari" "Marry"
console.log( Person.action, Son.action ); // [ "eat", "sleep", "play" ] [ "eat", "sleep", "play" ]

可以看到,子类和父类的静态属性 action 结果完全一致。

getPrototypeOf

对子类使用 getPrototypeOf 方法时,会返回子类的父类。

1
2
3
Reflect.getPrototypeOf( Son ); // [class: Person]
// 不标准属性 __proto__ 同理
Son.__proto__; // [class: Person]

new.target

new.target 是一个元属性,用来返回 new 命令作用于的构造函数,如果构造函数不是通过 new 命令调用的,则返回 undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person() {
console.log( new.target, new.target === Person );
}

new Person(); // [Function: Person] true
Person(); // undefined false

// 类写法
class Person {
constructor() {
console.log( new.target, new.target === Person );
}
}

new Person(); // [class: Person] true

该表达式仅允许在函数或类的内部使用,在 JS 模块顶层使用会报错。

1
new.target; // SyntaxError: new.target expression is not allowed here

当子类继承父类后,对子类实例化时,new.target 会指向子类。

1
2
3
4
5
6
7
8
class Person {
constructor() {
console.log( new.target );
}
}

class Son extends Person {}
new Son(); // [class: Son]

module 导入导出

多次对同一个模块进行导入操作,只会执行一次模块代码。

1
2
3
4
import { name } from "./module.js";
import { age } from "./module.js";
// 等同于
import { name, age } from "./module.js";

export 复合写法

可以直接使用 export 从一个模块导出多个变量。

1
2
3
4
export { name, age } from "./module.js";
// 类似于
import { name, age } from "./module.js";
export { name, age };

不过需要注意的是,此处的 nameage 并没有被导入到当前模块中,因此无法直接使用。

1
2
export { name } from "./module.js";
console.log( name ); // ReferenceError: name is not defined

由于默认导出实际为导出一个名为 default 的变量,结合这点可以将某一个具名导入改为当前模块的默认导出。

1
2
3
4
export { name as default } from "./module.js";
// 类似于
import { name } from "./module.js";
export default name;

ES2020 支持了将整体导入快速导出的复合写法。

1
2
3
4
export * as md from "./module.js";
// 类似于
import * as md from "./module.js";
export { md };

模块的继承

模块之间可以使用 export * 的方式继承

1
2
3
4
5
6
7
8
9
10
11
// module1.js
export const name = "Mari";
export default 114514;

// module2.js
export * from "./module1.js";
export const age = 17;

// main.js
import * as data from "./module2.js";
console.log( data ); // { name: "Mari", age: 17 }

上面的案例可以发现一个问题:module1.js 中的 default 导出没有被 main.js 接收到。

造成这种情况的原因是:export * 会无视目标模块的 default 导出,只会导出具名导出。

export * as 不会出现这种情况,default 会作为对象的一个属性一同被导出,可以放心食用

静态连接

import 会静态链接待导入的模块,当模块中的导出变量发生变化时,被导入的变量会同步更新变化。

1
2
3
4
5
6
7
8
9
10
11
12
// module.js
export let name = "Mari";
setTimeout( () => {
name = "Marry";
}, 3000 );

// main.js
import { name } from "./module.js";
console.log( name ); // "Mari"
setTimeout( () => {
console.log( name ); // "Marry"
}, 4000 );

import() 则没有这种特性,他更像是一个异步的 require

1
2
3
4
5
6
7
// main.js
import( "./module.js" ).then( ( { name } ) => {
console.log( name ); // "Mari"
setTimeout( () => {
console.log( name ); // "Mari"
}, 4000 );
} );

ES6 模块化加载

在 ES6 之前,JS 模块有三种加载方式:

1
2
3
4
5
<!-- 同步加载 -->
<script src="module.js"></script>
<!-- 异步加载 -->
<script src="module.js" defer></script>
<script src="module.js" async></script>
  • 默认情况: 同步加载,遇到 script 标签即中断页面渲染等待其加载,加载完成后执行脚本,执行完毕后再恢复页面渲染
  • defer: 异步加载,等待整个页面渲染完毕后,按照所有的带有 defer 属性脚本在页面中出现的顺序,依次执行
  • async: 异步加载,当模块被加载完成时,立刻中断页面渲染,前去执行模块,模块执行完毕后再恢复页面渲染。

async 外,另外两种均可以保证 script 标签的执行顺序。

而 ES6 通过 type="module" 属性来实现模块化加载,此时该标签的加载行为与打开了 defer 属性类似。

1
<script type="module" src="module.js"></script>

type="module 同样允许使用 async 属性,此时符合 async 的加载行为。

1
<script type="module" src="module.js" async></script>

对于 type="module"script 标签(后面统称为模块脚本),有几点特殊事项:

  • 默认采用严格模式
  • 代码在模块作用域下运行,而非全局作用域,模块内的顶层变量外部不可见
  • 模块中的顶层 this 指向 undefined,而非 window
  • 同一个模块如果被引入加载多次,只会执行一次

与 CommonJS 的区别

除了编译时与运行时的区别外,还有两点比较重要的区别:

  • CommonJS 模块输出的是一个值的拷贝,而 ESModule 输出的是值的引用
  • require 是同步执行,import 是异步引用

关于第一点,ESM 在运行时,如果遇到模块加载语句 import,会生成一个只读引用,当脚本真正执行时,会根据这个只读引用,去被加载的模块中取值。
由于这个特性,import 引入的变量是不能被重新赋值的,因为它是只读的。

CommonJS 加载原理

CommonJS 加载完一个模块后,会在内存中缓存一个对象,可以通过 require.cache 查看。
例如对于 /usr/local/mari-test/test.js 这个对象的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cache = {
"/usr/local/mari-test/test.js": {
id: "/usr/local/mari-test/test.js",
exports: {},
filename: "/usr/local/mari-test/test.js",
loaded: true,
children: [],
paths: [
"/usr/local/mari-test/node_modules",
"/usr/local/node_modules",
"/usr/node_modules",
"/node_modules"
]
}
}

其中 id 是模块名称,exports 是模块导出的各种接口,filename 是模块文件名,loaded 表示是否加载完成,paths 是模块搜索路径。

在加载一次被存入缓存后,以后每次用到这个模块,就会直接到 exports 中取值,即使再次执行 require 也不会再次加载。

NodeJS 的使用

NodeJS 在 13.2 版本后默认打开了 ESM 支持,其要求 ESM 模块使用 .mjs 后缀名重命名,这些模块会被默认启用严格模式。

若不希望修改后缀名,也可以在 package.json 中添加 "type": "module" 字段,这样 NodeJS 会默认将所有 JS 文件视为 ESM 模块。
此时如果还想要使用 CommonJS 模块,需要使用 .cjs 后缀名重命名。

一句话总结就是:需要使用 ESM 就采用 .mjs 后缀名,需要使用 CommonJS 就采用 .cjs 后缀名。
至于 .js 后缀名的加载方式则取决于 package.json 中的 "type" 字段。

package.json 的相关字段

package.json 文件有两个字段可以指定模块的入口文件:mainexports

下面以模块 mari-test 为例,其 package.json 文件位于 ./node_modules/mari-test/package.json

main

比较简单的模块可以只使用 main 字段,指定模块的入口文件。

1
2
3
{
"main": "index.js"
}

根据 type 字段的不同,使用以下方式加载该模块。

1
2
3
4
// type 为 "module"
import { data } from "mari-test";
// type 不设置或为 "commonjs"
const { data } = require( "mari-test" );

此时导入的模块文件为 ./node_modules/mari-test/index.js,与 main 字段指定的对应。

若使用的导入方式不符合 type 字段的设置,会出现不可预期的情况,参考后文 ESM 与 CommonJS 模块的互相加载

虽然 CommonJS 模块不能使用 require() 导入 ESM 模块,但 import() 方法是允许使用的。

exports

exports 值为一个对象或字符串,其优先级高于 main 字段。
当使用支持 ESM 的 NodeJS 版本时,若同时存在 exportsmain,会优先使用 exports 字段。

其键值对均需要为符合路径匹配规则的字符串,即:

  1. . 开头
  2. 使用 * 通配符指代任意内容

例如以下几种情况:

1
2
3
4
5
6
7
{
"exports": {
".": "./index.js",
"./timer": "./src/timer.js",
"./utils/*": "./src/utils/*"
}
}

相对 main 来说,它有多种用法。

子目录别名

可以通过 exports 指定脚本和目录的别名。

1
2
3
4
5
{
"exports": {
"./timer": "./src/timer.js"
}
}

上面为 src/timer.js 指定了一个别名 timer,可以通过以下方式引入该脚本文件。

1
2
// 访问 ./node_modules/mari-test/src/timer.js
import timer from "mari-test/timer";

也可以指定目录的别名,其中 ./utils/* 中的 * 代表在使用 importrequire 引入时,可以在路径 mar-test/utils/ 后面加上任意文件名。
而值 ./src/utils/* 代表为 ./node_modules/mari-test/src/utils/ 目录下的任意文件均指定别名。

1
2
3
4
5
{
"exports": {
"./utils/*": "./src/utils/*"
}
}

此时可以快捷引入该目录下的脚本文件。

1
2
3
4
// 访问 ./node_modules/mari-test/src/utils/random.js
import random from "mari-test/utils/random.js";
// 访问 ./node_modules/mari-test/src/utils/async/sleep.js
import sleep from "mari-test/utils/async/sleep.js";

需要注意的是,只有指定了别名后才能通过 “模块名/脚本路径名” 的方式加载脚本,否则会报错。

1
2
3
// 尝试直接通过路径访问 random.js
import random from "mari-test/src/utils/random.js";
// Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/utils/random.js' is not defined by "exports" in xxx/node_modules/mari-test/package.json imported from xxx\index.js

main 的别名

exports 键值对的键值为 . 时,指代模块的入口文件,即起到 main 字段的功能。

1
2
3
4
5
6
{
"main": "index.js",
"exports": {
".": "./src/index.js"
}
}

也可以直接简写为 exports 字段的值:

1
2
3
{
"exports": "./src/index.js"
}

由于 exports 只有支持 ESM 的 NodeJS 版本才支持,因此可以通过同时指定 exportsmain 字段来兼容不同版本的 NodeJS。

1
2
3
4
5
6
{
"main": "index.js",
"exports": {
".": "./index.js"
}
}

条件加载

exports 键值对的值不止可以为一个字符串,还可以设置为一个拥有 importrequire 字段的对象,例如:

1
2
3
4
5
6
7
8
{
"exports": {
".": {
"import": "./index.js",
"require": "./index.cjs"
}
}
}

其中 import 指定了 ESM 的加载文件,require 指定了 CommonJS 的加载文件。

exports 中只有 . 路径别名时,可以简写:

1
2
3
4
5
6
{
"exports": {
"import": "./index.js",
"require": "./index.cjs"
}
}

ESM与CommonJS模块的互相加载

ESM 和 CommonJS 模块互相并不能完全兼容加载,存在一些注意事项。

CommonJS 加载 ESM

CommonJS 模块是不能正常通过 require 加载 ESM 模块的,会报错。

import() 方法则被允许在 CommonJS 模块中加载 ESM 模块。

1
2
3
4
5
6
7
// module.mjs
export const name = "Mari";

// module.cjs
import( "./module.mjs" ).then( ( { name } ) => {
console.log( name ); // "Mari"
} );

之所以 require 不支持 ESM 模块,是因为 require 是同步加载,而 ESM 模块存在异步模块的情况,两者做不到兼容。

ESM 加载 CommonJS

CommonJS 模块存在两种导出方式:module.exportsexports.xx,ESM 对他们分别进行了相应的兼容处理。

module.exports

现在有一个 cjs 模块:

1
2
// module.cjs
module.exports = { name: "Mari" };

尝试对其整体引入,得到如下结果:

1
2
3
4
// index.mjs
import * as data from "./module.cjs";

console.log( data ); // { default: { name: "Mari" } }

可以看出,module.exports 被 ESM 视为一个默认导出。

既然是默认导出而非导出一个 name 变量 ,而且众所周知 import 不能使用具名导入去导入一个未导出的对象,因此下面这种情况会抛出错误。

1
2
// index.mjs
import { name } from "./module.cjs"; // SyntaxError: Named export 'name' not found

此时只能按照导入默认导出的方式来处理:

1
2
3
// index.mjs
import data from "./module.cjs";
const { name } = data;

exports.xx

同样有一个 cjs 模块:

1
2
// module.cjs
exports.name = "Mari";

我们再次通过完整导入查看其被解析结果:

1
2
3
// index.mjs
import * as data from "./module.cjs";
console.log( data ); // { default: { name: 'mari' }, name: 'mari' }

可以看出,ESM 不仅将其是为一个具名导出,同时还为其生成了一个默认导出,因此使用下面两种方式均可以获取到 name 变量。

1
2
3
4
5
6
// index.mjs
// 具名导入
import { name } from "./module.cjs";
// 默认导入后解构
import data from "./module.cjs";
const { name } = data;

内部变量相关

ESM 应该是通用的,即能够同时为浏览器环境和服务器环境服务。为此,NodeJS 禁止在 ESM 中使用一些 CommonJS 的内置变量。

  • __dirname
  • __filename
  • module
  • exports
  • require

若直接使用这些变量,则均会提示:xx is not defined in ES module scope

循环加载

实际开发中不可避免的出现循环加载的情况,即: a 导入 b,b 导入 a

而对于循环加载,CommonJS 和 ESM 有不同的处理方式。

CommonJS

CommonJS 模块由于是在加载中执行,当脚本代码发现 require 时,会先放下当前模块手中的工作,去全部加载目标模块。
此时若目标模块发生循环加载,则只能得到当前模块中已执行的部分的内容。

现在提供两个模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.cjs
exports.done = false;
const b = require( "./b.cjs" );
console.log( "a.js: b.done = %j", b.done );
exports.done = true;
console.log( "a.js: done" );

// b.cjs
exports.done = false;
const a = require( "./a.cjs" );
console.log( "b.js: a.done = %j", a.done );
exports.done = true;
console.log( "b.js: done" );

下面运行 a.cjs 模块,可以得到结果:

1
2
3
4
b.js: a.done = false
b.js: done
a.js: b.done = true
a.js: done

不难理解,模块 a.cjs 在加载 b.cjs 时,只执行了第一行 exports.done = false;,导致此时在 b.cjs 中加载 a.cjs 时,只能得到第一行的内容,因此 b.donefalse

鉴于这种运作方式,即循环加载中返回的是模块已执行的值而不是全部执行完毕的值。因此尽量需要保持对象的引用特性,尽可能避免如下的写法。

1
2
3
4
5
6
7
// 危险写法
const name = require( "./target.cjs" ).name;
exports.getName = () => name; // 使用的部分加载的值

// 正确写法
const data = require( "./target.cjs" );
exports.getName = () => data.name; // 使用的最新值

ESM

ESM 与 CommonJS 有本质不同。ESM 是动态引用,从一个模块中引入的变量不会被缓存,而是变成一个指向被加载模块的引用。

也就是说,ESM 只负责生成引用,至于这个变量等真正使用的时候能不能取到值,这个得靠开发者自己来保证了。

例如下面有两个模块:

1
2
3
4
5
6
7
8
9
// a.mjs
import { name } from "./b.mjs";
console.log( "a.mjs: name = %j", name );
export const age = 17;

// b.mjs
import { age } from "./a.mjs";
console.log( "b.mjs: age = %j", age );
export const name = "Mari";

此时运行 a.mjs 模块,会得到如下报错:

1
ReferenceError: Cannot access 'age' before initialization

由于 a.mjs 中使用 import 引入了 b.mjs 的变量。ESM 判断需要先去加载 b.mjs,而当执行到 console.log( "b.mjs: age = %j", age ); 时,发现 age 变量还未初始化,因此报错。

此案例中的解决方式有两种:将 age 使用 var 声明,或将 age 改为 function。因为这两种声明方式存在变量提升,使得 b.mjs 在取值时,变量已经被定义。

首先尝试改为使用 var 声明:

1
2
3
// a.mjs
// ...
export var age = 17;

此时会得到如下执行结果:

1
2
b.mjs: age = undefined
a.mjs: name = "Mari"

之所以 ageundefined,是因为 var 仅做了变量提升,但赋值语句还未执行。

接着尝试将 age 改为 function

1
2
3
4
5
6
7
8
9
10
// a.mjs
// ...
export function age() {
return 17;
}

// b.mjs
// ...
console.log( "b.mjs: age = %j", age() );
// ...

可以得到如下执行结果:

1
2
b.mjs: age = 17
a.mjs: name = "Mari"

得到正确结果,而事实上这也是常用来解决 ESM 循环加载问题的方式。

编程风格优化

JavaScript 编译器会对 const 进行优化,相对 let 来说,建议多使用 const

对象定义后避免新增属性,若需要新增,则使用 Object.assign 方法。

1
2
3
4
5
6
const obj = { name: "Mari" };

// 不推荐
obj.age = 17;
// 推荐
Object.assign( obj, { age: 17 } );

布尔值尽量不要直接用作函数参数,带吗语义会较差,也不利于将来增加新的配置项。

1
2
3
4
// 不推荐
function foo( flag = false ) {}
// 推荐
function foo( { flag = false } = {} ) {}

如果模块默认输出一个对象,对象名的首字母应该大写,表示是一个配置对象。

1
2
3
4
5
const Config = {
name: "Mari",
age: 17
};
export default Config;