本文参考自电子书《ECMAScript 6 入门》:https://es6.ruanyifeng.com/

let 和 const 命令

1. let 命令

  • 尽量使用 let 声明变量,而不是 var
  • let 声明的变量是块级作用域var 声明的变量是全局作用域
  • 使用 let 变量必须先声明再使用,否则报错,不存在变量提升。相对的 var 声明的变量如果提前使用,不会报错,只会提示为undefined
  • let不允许在相同作用域内,重复声明同一个变量。
  • ES6的块级作用域允许使用多层 {} 无限嵌套,只有位于同一层的 let 变量相互影响,跨层之间的 let 变量即便是同名也无影响,只要在同一层内使用自己层的变量即可。
  • ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。(if语句后面最好必须跟上大括号)

2. const 命令

  • const声明一个只读的常量。一旦声明,常量的值就不能改变。
  • 必须在声明时指定初始值。
  • const的作用域与let命令相同:只在声明所在的块级作用域内有效。同样不存在变量提升,必须先声明后使用,不可重复声明。

ES6 声明变量的六种方法

ES5 只有两种声明变量的方法:var命令和function命令。ES6 除了添加letconst命令,后面章节还会提到,另外两种声明变量的方法:import命令和class命令。所以,ES6 一共有 6 种声明变量的方法。

变量的解构赋值

1. 数组的解构赋值

let [a, b, c] = [1, 2, 3];

上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。

本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。

let [foo, [[bar], baz]] = [1, [[2], 3]];foo // 1bar // 2baz // 3let [ , , third] = ["foo", "bar", "baz"];third // "baz"let [x, , y] = [1, 2, 3];x // 1y // 3let [head, ...tail] = [1, 2, 3, 4];head // 1tail // [2, 3, 4]let [x, y, ...z] = ['a'];x // "a"y // undefinedz // []

如果解构不成功,变量的值就等于undefined

let [foo] = [];let [bar, foo] = [1];

以上两种情况都属于解构不成功,foo的值都会等于undefined

另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。

let [x, y] = [1, 2, 3];x // 1y // 2let [a, [b], d] = [1, [2, 3], 4];a // 1b // 2d // 4

上面两个例子,都属于不完全解构,但是可以成功。

如果等号的右边不是数组(或者严格地说,不是可遍历的结构,参见《Iterator》一章),那么将会报错。

// 报错let [foo] = 1;let [foo] = false;let [foo] = NaN;let [foo] = undefined;let [foo] = null;let [foo] = {};

上面的语句都会报错,因为等号右边的值,要么转为对象以后不具备 Iterator 接口(前五个表达式),要么本身就不具备 Iterator 接口(最后一个表达式)。

对于 Set 结构,也可以使用数组的解构赋值。

let [x, y, z] = new Set(['a', 'b', 'c']);x // "a"

默认值

解构赋值允许指定默认值。

let [foo = true] = [];foo // truelet [x, y = 'b'] = ['a']; // x='a', y='b'let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'

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

let [x = 1] = [undefined];x // 1let [x = 1] = [null];x // null

上面代码中,如果一个数组成员是null,默认值就不会生效,因为null不严格等于undefined

如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。

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

上面代码中,因为x能取到值,所以函数f根本不会执行。上面的代码其实等价于下面的代码。

let x;if ([1][0] === undefined) {x = f();} else {x = [1][0];}

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

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

上面最后一个表达式之所以会报错,是因为xy做默认值时,y还没有声明。

2. 对象的解构赋值

解构不仅可以用于数组,还可以用于对象。

let { foo, bar } = { foo: 'aaa', bar: 'bbb' };foo // "aaa"bar // "bbb"

对象的解构与数组有一个重要的不同:

  • 数组的元素是按次序排列的,变量的取值由它的位置决定
  • 而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
let { bar, foo } = { foo: 'aaa', bar: 'bbb' };foo // "aaa"bar // "bbb"let { baz } = { foo: 'aaa', bar: 'bbb' };baz // undefined

上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined

如果解构失败,变量的值等于undefined

let {foo} = {bar: 'baz'};foo // undefined

上面代码中,等号右边的对象没有foo属性,所以变量foo取不到值,所以等于undefined

对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。

// 例一let { log, sin, cos } = Math;// 例二const { log } = console;log('hello') // hello

上面代码的例一将Math对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。例二将console.log赋值到log变量。

如果变量名与属性名不一致,必须写成下面这样。

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' };baz // "aaa"foo // error: foo is not defined

上面代码中,foo是匹配的模式,baz才是变量。真正被赋值的是变量baz,而不是模式foo

默认值

对象的解构也可以指定默认值。

var {x = 3} = {};x // 3var {x, y = 5} = {x: 1};x // 1y // 5var {x: y = 3} = {};y // 3var {x: y = 3} = {x: 5};y // 5var { message: msg = 'Something went wrong' } = {};msg // "Something went wrong"

默认值生效的条件是,对象的属性值严格等于undefined

var {x = 3} = {x: undefined};x // 3var {x = 3} = {x: null};x // null

上面代码中,属性x等于null,因为nullundefined不严格相等,所以是个有效的赋值,导致默认值3不会生效。

3. 字符串的解构赋值

字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。

const [a, b, c, d, e] = 'hello';a // "h"b // "e"c // "l"d // "l"e // "o"

类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值。

let {length : len} = 'hello';len // 5

4. 数值和布尔值的解构赋值

解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。

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

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

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

let { prop: x } = undefined; // TypeErrorlet { prop: y } = null; // TypeError

5. 函数参数的解构赋值

函数的参数也可以使用解构赋值。

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

上面代码中,函数add的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量xy。对于函数内部的代码来说,它们能感受到的参数就是xy

下面是另一个例子。

[[1, 2], [3, 4]].map(([a, b]) => a + b);// [ 3, 7 ]

函数参数的解构也可以使用默认值。

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

上面代码中,函数move的参数是一个对象,通过对这个对象进行解构,得到变量xy的值。如果解构失败,xy等于默认值。

注意,下面的写法会得到不一样的结果。

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]

上面代码是为函数move参数指定默认值,而不是为变量xy指定默认值,所以会得到与前一种写法不同的结果。

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

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

6. 圆括号问题

原则:尽量不要在模式中使用圆括号。

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

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

上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p,而不是d;第三行语句与第一行语句的性质一致。

7. 用途

变量的解构赋值用途很多。

(1)交换变量的值

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

上面代码交换变量xy的值,这样的写法不仅简洁,而且易读,语义非常清晰。

(2)从函数返回多个值

函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。

// 返回一个数组function example() {return [1, 2, 3];}let [a, b, c] = example();// 返回一个对象function example() {return {foo: 1,bar: 2};}let { foo, bar } = example();

(3)函数参数的定义

解构赋值可以方便地将一组参数与变量名对应起来。

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

(4)提取 JSON 数据

解构赋值对提取 JSON 对象中的数据,尤其有用。

let jsonData = {id: 42,status: "OK",data: [867, 5309]};let { id, status, data: number } = jsonData;console.log(id, status, number);// 42, "OK", [867, 5309]

上面代码可以快速提取 JSON 数据的值。

(5)函数参数的默认值

jQuery.ajax = function(url, {async = true,beforeSend = function () {},cache = true,complete = function () {},crossDomain = false,global = true,// ... more config} = {}) {// ... do stuff};

指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || 'default foo';这样的语句。

(6)遍历 Map 结构

任何部署了 Iterator 接口的对象,都可以用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) {// ...}

(7)输入模块的指定方法

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

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

字符串

模板字符串

ES6 引入了模板字符串,使用反引号 “ 括起来的字符串,它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。

// 普通字符串`In JavaScript '\n' is a line-feed.`// 多行字符串`In JavaScript this is not legal.`console.log(`string text line 1string text line 2`);// 字符串中嵌入变量let name = "Bob", time = "today";`Hello ${name}, how are you ${time}?`

上面代码中的模板字符串,都是用反引号表示。如果在模板字符串中需要使用反引号,则前面要用反斜杠转义。

let greeting = `\`Yo\` World!`;

如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中。

$('#list').html(`
  • first
  • second
`
);

上面代码中,所有模板字符串的空格和换行,都是被保留的,比如

    标签前面会有一个换行。如果你不想要这个换行,可以使用trim方法消除它。

    $('#list').html(`
    • first
    • second
    `
    .trim());

    模板字符串中嵌入变量,需要将变量名写在${}之中。

    function authorize(user, action) {if (!user.hasPrivilege(action)) {throw new Error(// 传统写法为// 'User '// + user.name// + ' is not authorized to do '// + action// + '.'`User ${user.name} is not authorized to do ${action}.`);}}

    大括号内部可以放入任意的 JavaScript 表达式,可以进行运算,以及引用对象属性。

    let x = 1;let y = 2;`${x} + ${y} = ${x + y}`// "1 + 2 = 3"`${x} + ${y * 2} = ${x + y * 2}`// "1 + 4 = 5"let obj = {x: 1, y: 2};`${obj.x + obj.y}`// "3"

    模板字符串之中还能调用函数。

    function fn() {return "Hello World";}`foo ${fn()} bar`// foo Hello World bar

    如果大括号中的值不是字符串,将按照一般的规则转为字符串。比如,大括号中是一个对象,将默认调用对象的toString方法。

    如果模板字符串中的变量没有声明,将报错。

    // 变量place没有声明let msg = `Hello, ${place}`;// 报错

    由于模板字符串的大括号内部,就是执行 JavaScript 代码,因此如果大括号内部是一个字符串,将会原样输出。

    `Hello ${'World'}`// "Hello World"

    字符串的遍历

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

    for (let codePoint of 'foo') {console.log(codePoint)}// "f"// "o"// "o"

    除了遍历字符串,这个遍历器最大的优点是可以识别大于0xFFFF的码点,传统的for循环无法识别这样的码点。

    let text = String.fromCodePoint(0x20BB7);for (let i = 0; i < text.length; i++) {console.log(text[i]);}// " "// " "for (let i of text) {console.log(i);}// ""

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

    标签模板

    模板字符串的功能,不仅仅是上面这些。它可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串。这被称为“标签模板”功能(tagged template)。

    alert`hello`// 等同于alert(['hello'])

    标签模板其实不是模板,而是函数调用的一种特殊形式。“标签”指的就是函数,紧跟在后面的模板字符串就是它的参数。

    但是,如果模板字符里面有变量,就不是简单的调用了,而是会将模板字符串先处理成多个参数,再调用函数。

    let a = 5;let b = 10;tag`Hello ${ a + b } world ${ a * b }`;// 等同于tag(['Hello ', ' world ', ''], 15, 50);

    上面代码中,模板字符串前面有一个标识名tag,它是一个函数。整个表达式的返回值,就是tag函数处理模板字符串后的返回值。

    函数tag依次会接收到多个参数。

    function tag(stringArr, value1, value2){// ...}// 等同于function tag(stringArr, ...values){// ...}

    字符串的新增方法

    https://es6.ruanyifeng.com/#docs/string-methods

    • String.fromCodePoint()
    • String.raw()
    • 实例方法:codePointAt()
    • 实例方法:normalize()
    • 实例方法:includes(), startsWith(), endsWith()
    • 实例方法:repeat()
    • 实例方法:padStart(),padEnd()
    • 实例方法:trimStart(),trimEnd()
    • 实例方法:matchAll()
    • 实例方法:replaceAll()
    • 实例方法:at()

    数值的扩展

    • 二进制 0b 和八进制 0o表示法
    • 数值分隔符1_000_000_000_000;
    • Number.isFinite() 检查是否为有限的,即不是Infinity, Number.isNaN()检查一个值是否为NaN
    • Number.parseInt(), Number.parseFloat() 解析字符串
    • Number.isInteger() 判断一个数值是否为整数。
    • Number.EPSILON 它表示 1 与大于 1 的最小浮点数之间的差。等于 2 的 -52 次方Math.pow(2, -52)Number.EPSILON的实质是一个可以用来设置“能够接受的最小误差范围”
    • 安全整数和 Number.isSafeInteger() :JavaScript 能够准确表示的整数范围在-2^53到2^53之间(不含两个端点),超过这个范围,无法精确表示这个值。ES6 引入了Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER这两个常量,用来表示这个范围的上下限。Number.isSafeInteger()则是用来判断一个整数是否落在这个范围之内。
    • Math 对象的扩展:ES6 在 Math 对象上新增了 17 个与数学相关的方法。所有这些方法都是静态方法,只能在 Math 对象上调用。
    • BigInt 数据类型:JS 数值的精度只能到 53 个二进制位,大于这个范围的整数,无法精确表示,另外JS也无法表示大于或等于2的1024次方的数值,会返回Infinity。ES2020 引入了一种新的数据类型 BigInt(大整数),来解决这个问题,这是 ECMAScript 的第八种数据类型。BigInt 只用来表示整数,没有位数的限制,任何位数的整数都可以精确表示。表示方法是在数字后面加上后缀n,如15346349309n;

    函数的扩展

    1. 函数参数的默认值

    ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。

    function log(x, y = 'World') {console.log(x, y);}log('Hello') // Hello Worldlog('Hello', 'China') // Hello Chinalog('Hello', '') // Hello

    可以看到,ES6 的写法比 ES5 简洁许多,而且非常自然。下面是另一个例子。

    function Point(x = 0, y = 0) {this.x = x;this.y = y;}const p = new Point();p // { x: 0, y: 0 }

    与解构赋值默认值结合使用

    参数默认值可以与解构赋值的默认值,结合起来使用。

    function foo({x, y = 5}) {console.log(x, y);}foo({}) // undefined 5foo({x: 1}) // 1 5foo({x: 1, y: 2}) // 1 2foo() // TypeError: Cannot read property 'x' of undefined

    上面代码只使用了对象的解构赋值默认值,没有使用函数参数的默认值。只有当函数foo()的参数是一个对象时,变量xy才会通过解构赋值生成。如果函数foo()调用时没提供参数,变量xy就不会生成,从而报错。通过提供函数参数的默认值,就可以避免这种情况。

    function foo({x, y = 5} = {}) {console.log(x, y);}foo() // undefined 5

    上面代码指定,如果没有提供参数,函数foo的参数默认为一个空对象

    下面是另一个解构赋值默认值的例子。

    function fetch(url, { body = '', method = 'GET', headers = {} }) {console.log(method);}fetch('http://example.com', {})// "GET"fetch('http://example.com')// 报错

    上面代码中,如果函数fetch()的第二个参数是一个对象,就可以为它的三个属性设置默认值。这种写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。

    function fetch(url, { body = '', method = 'GET', headers = {} } = {}) {console.log(method);}fetch('http://example.com')// "GET"

    上面代码中,函数fetch没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量method才会取到默认值GET

    注意,函数参数的默认值生效以后,参数解构赋值依然会进行。

    function f({ a, b = 'world' } = { a: 'hello' }) {console.log(b);}f() // world

    上面示例中,函数f()调用时没有参数,所以参数默认值{ a: 'hello' }生效,然后再对这个默认值进行解构赋值,从而触发参数变量b的默认值生效。

    参数默认值的位置

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

    // 例一function f(x = 1, y) {return [x, y];}f() // [1, undefined]f(2) // [2, undefined]f(, 1) // 报错f(undefined, 1) // [1, 1]// 例二function f(x, y = 5, z) {return [x, y, z];}f() // [undefined, 5, undefined]f(1) // [1, 5, undefined]f(1, ,2) // 报错f(1, undefined, 2) // [1, 5, 2]

    上面代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数,而不省略它后面的参数,除非显式输入undefined

    如果传入undefined,将触发该参数等于默认值,null则没有这个效果。

    function foo(x = 5, y = 6) {console.log(x, y);}foo(undefined, null)// 5 null

    上面代码中,x参数对应undefined,结果触发了默认值,y参数等于null,就没有触发默认值。

    应用

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

    function throwIfMissing() {throw new Error('Missing parameter');}function foo(mustBeProvided = throwIfMissing()) {return mustBeProvided;}foo()// Error: Missing parameter

    上面代码的foo函数,如果调用的时候没有参数,就会调用默认值throwIfMissing函数,从而抛出一个错误。

    另外,可以将参数默认值设为undefined,表明这个参数是可以省略的。

    function foo(optional = undefined) { ··· }

    2. rest 参数

    ES6 引入 rest 参数(形式为...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

    function add(...values) {let sum = 0;for (var val of values) {sum += val;}return sum;}add(2, 5, 3) // 10

    上面代码的add函数是一个求和函数,利用 rest 参数,可以向该函数传入任意数目的参数。

    下面是一个 rest 参数代替arguments变量的例子。

    // arguments变量的写法function sortNumbers() {return Array.from(arguments).sort();}// rest参数的写法const sortNumbers = (...numbers) => numbers.sort();

    上面代码的两种写法,比较后可以发现,rest 参数的写法更自然也更简洁。

    注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。

    // 报错function f(a, ...b, c) {// ...}

    函数的length属性,不包括 rest 参数。

    (function(a) {}).length// 1(function(...a) {}).length// 0(function(a, ...b) {}).length// 1

    3. 严格模式

    从 ES5 开始,函数内部可以设定为严格模式。

    function doSomething(a, b) {'use strict';// code}

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

    4. name 属性

    函数的name属性,返回该函数的函数名。

    function foo() {}foo.name // "foo"

    这个属性早就被浏览器广泛支持,但是直到 ES6,才将其写入了标准。

    需要注意的是,ES6 对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5 的name属性,会返回空字符串,而 ES6 的name属性会返回实际的函数名。

    var f = function () {};// ES5f.name // ""// ES6f.name // "f"

    上面代码中,变量f等于一个匿名函数,ES5 和 ES6 的name属性返回的值不一样。

    如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的name属性都返回这个具名函数原本的名字。

    const bar = function baz() {};// ES5bar.name // "baz"// ES6bar.name // "baz"

    Function构造函数返回的函数实例,name属性的值为anonymous

    (new Function).name // "anonymous"

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

    function foo() {};foo.bind({}).name // "bound foo"(function(){}).bind({}).name // "bound "

    5. 箭头函数

    基本用法

    ES6 允许使用“箭头”(=>)定义函数。

    var f = v => v;// 等同于var f = function (v) {return v;};

    如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。

    var f = () => 5;// 等同于var f = function () { return 5 };var sum = (num1, num2) => num1 + num2;// 等同于var sum = function(num1, num2) {return num1 + num2;};

    如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return语句返回。

    var sum = (num1, num2) => { return num1 + num2; }

    由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。

    // 报错let getTempItem = id => { id: id, name: "Temp" };// 不报错let getTempItem = id => ({ id: id, name: "Temp" });

    如果箭头函数只有一行语句,且不需要返回值,就不用写大括号了。箭头函数可以与变量解构结合使用。

    const full = ({ first, last }) => first + ' ' + last;// 等同于function full(person) {return person.first + ' ' + person.last;}

    箭头函数使得表达更加简洁。

    const isEven = n => n % 2 === 0;const square = n => n * n;

    箭头函数的一个用处是简化回调函数。

    // 普通函数写法[1,2,3].map(function (x) {return x * x;});// 箭头函数写法[1,2,3].map(x => x * x);

    另一个例子是

    // 普通函数写法var result = values.sort(function (a, b) {return a - b;});// 箭头函数写法var result = values.sort((a, b) => a - b);

    下面是 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对象。
    • (2)不可以当作构造函数,也就是说,不可以对箭头函数使用new命令,否则会抛出一个错误。
    • (3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
    • (4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

    上面四点中,最重要的是第一点。对于普通函数来说,内部的this指向函数运行时所在的对象,但是这一点对箭头函数不成立。它没有自己的this对象,内部的this就是定义时上层作用域中的this。也就是说,箭头函数内部的this指向是固定的,相比之下,普通函数的this指向是可变的。

    function foo() {setTimeout(() => {console.log('id:', this.id);}, 100);}var id = 21;foo.call({ id: 42 });// id: 42

    上面代码中,setTimeout()的参数是一个箭头函数,这个箭头函数的定义生效是在foo函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时this应该指向全局对象window,这时应该输出21。但是,箭头函数导致this总是指向函数定义生效时所在的对象(本例是{id: 42}),所以打印出来的是42

    下面例子是回调函数分别为箭头函数和普通函数,对比它们内部的this指向。

    function Timer() {this.s1 = 0;this.s2 = 0;// 箭头函数setInterval(() => this.s1++, 1000); // 这里的this指向setInterval所在的对象,即Timer对象// 普通函数setInterval(function () {this.s2++; // 这里的this指向window对象}, 1000);}var timer = new Timer(); // 这句执行后,内部的s1就会每隔1秒自增一次,但是s2不会setTimeout(() => console.log('s1: ', timer.s1), 3100);setTimeout(() => console.log('s2: ', timer.s2), 3100);// s1: 3// s2: 0

    上面代码中,Timer函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this绑定定义时所在的作用域(即Timer函数),后者的this指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1被更新了 3 次,而timer.s2一次都没更新。

    箭头函数实际上可以让this指向固定化,绑定this使得它不再可变,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。

    var handler = {id: '123456',init: function() {document.addEventListener('click',event => this.doSomething(event.type), false);},doSomething: function(type) {console.log('Handling ' + type+ ' for ' + this.id);}};

    上面代码的init()方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。如果回调函数是普通函数,那么运行this.doSomething()这一行会报错,因为此时this指向document对象。

    总之,箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。正是因为它没有this,所以也就不能用作构造函数。

    下面是 Babel 转箭头函数产生的 ES5 代码,就能清楚地说明this的指向。

    // ES6function foo() {setTimeout(() => {console.log('id:', this.id);}, 100);}// ES5function foo() {var _this = this;setTimeout(function () {console.log('id:', _this.id);}, 100);}

    上面代码中,转换后的 ES5 版本清楚地说明了,箭头函数里面根本没有自己的this,而是引用外层的this

    除了this,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:arguments、super、new.target

    不适用场合

    由于箭头函数使得this从“动态”变成“静态”,下面两个场合不应该使用箭头函数。

    第一个场合是定义对象的方法,且该方法内部包括this

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

    上面代码中,cat.jumps()方法是一个箭头函数,这是错误的。调用cat.jumps()时,如果是普通函数,该方法内部的this指向cat;如果写成上面那样的箭头函数,使得this指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致jumps箭头函数定义时的作用域就是全局作用域。

    再看一个例子。

    globalThis.s = 21;const obj = {s: 42,m: () => console.log(this.s)};obj.m() // 21

    上面例子中,obj.m()使用箭头函数定义。JavaScript 引擎的处理方法是,先在全局空间生成这个箭头函数,然后赋值给obj.m,这导致箭头函数内部的this指向全局对象,所以obj.m()输出的是全局空间的21,而不是对象内部的42。上面的代码实际上等同于下面的代码。

    globalThis.s = 21;globalThis.m = () => console.log(this.s);const obj = {s: 42,m: globalThis.m};obj.m() // 21

    由于上面这个原因,对象的属性建议使用传统的写法定义,不要用箭头函数定义。

    第二个场合是需要动态this的时候,也不应使用箭头函数。

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

    上面代码运行时,点击按钮会报错,因为button的监听函数是一个箭头函数,导致里面的this就是全局对象。如果改成普通函数,this就会动态指向被点击的按钮对象。

    另外,如果函数体很复杂,有许多行,或者函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性。

    其他

    • 尾调用优化(尾递归优化,原理是转成循环实现)
    • 函数参数的尾逗号(函数最后一个参数的后面允许写一个逗号)
    • Function.prototype.toString()
    • catch 命令的参数省略(以前要求try...catch结构的catch命令后面必须跟参数,现在不要求了)

    数组的扩展

    1. 扩展运算符

    扩展运算符(spread)是三个点(...)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。

    console.log(...[1, 2, 3])// 1 2 3console.log(1, ...[2, 3, 4], 5)// 1 2 3 4 5[...document.querySelectorAll('div')]// [, , ]

    该运算符主要用于函数调用。

    function push(array, ...items) {array.push(...items);}function add(x, y) {return x + y;}const numbers = [4, 38];add(...numbers) // 42

    上面代码中,array.push(...items)add(...numbers)这两行,都是函数的调用,它们都使用了扩展运算符。该运算符将一个数组,变为参数序列。

    扩展运算符与正常的函数参数可以结合使用,非常灵活。

    function f(v, w, x, y, z) { }const args = [0, 1];f(-1, ...args, 2, ...[3]);

    扩展运算符后面还可以放置表达式。

    const arr = [...(x > 0 ? ['a'] : []),'b',];

    如果扩展运算符后面是一个空数组,则不产生任何效果。

    [...[], 1]// [1]

    下面是扩展运算符取代apply()方法的一个实际的例子,应用Math.max()方法,简化求出一个数组最大元素的写法。

    // ES5 的写法Math.max.apply(null, [14, 3, 77])// ES6 的写法Math.max(...[14, 3, 77])// 等同于Math.max(14, 3, 77);

    扩展运算符的应用

    (1)复制数组

    数组是复合的数据类型,直接复制的话,只是复制了指向底层数据结构的指针,而不是克隆一个全新的数组。

    const a1 = [1, 2];const a2 = a1;a2[0] = 2;a1 // [2, 2]

    上面代码中,a2并不是a1的克隆,而是指向同一份数据的另一个指针。修改a2,会直接导致a1的变化。

    扩展运算符提供了复制数组的简便写法。

    const a1 = [1, 2];// 写法一const a2 = [...a1];// 写法二const [...a2] = a1;

    上面的两种写法,a2都是a1的克隆。

    (2)合并数组

    扩展运算符提供了数组合并的新写法。

    const arr1 = ['a', 'b'];const arr2 = ['c'];const arr3 = ['d', 'e'];// ES5 的合并数组arr1.concat(arr2, arr3);// [ 'a', 'b', 'c', 'd', 'e' ]// ES6 的合并数组[...arr1, ...arr2, ...arr3]// [ 'a', 'b', 'c', 'd', 'e' ]

    不过,这两种方法都是浅拷贝,使用的时候需要注意。

    const a1 = [{ foo: 1 }];const a2 = [{ bar: 2 }];const a3 = a1.concat(a2);const a4 = [...a1, ...a2];a3[0] === a1[0] // truea4[0] === a1[0] // true

    上面代码中,a3a4是用两种不同方法合并而成的新数组,但是它们的成员都是对原数组成员的引用,这就是浅拷贝。如果修改了引用指向的值,会同步反映到新数组。

    (3)与解构赋值结合

    扩展运算符可以与解构赋值结合起来,用于生成数组。

    // ES5a = list[0], rest = list.slice(1)// ES6[a, ...rest] = list

    下面是另外一些例子。

    const [first, ...rest] = [1, 2, 3, 4, 5];first // 1rest// [2, 3, 4, 5]const [first, ...rest] = [];first // undefinedrest// []const [first, ...rest] = ["foo"];first// "foo"rest // []

    如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。

    const [...butLast, last] = [1, 2, 3, 4, 5];// 报错const [first, ...middle, last] = [1, 2, 3, 4, 5];// 报错

    (4)字符串

    扩展运算符还可以将字符串转为真正的数组。

    [...'hello']// [ "h", "e", "l", "l", "o" ]

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

    'x\uD83D\uDE80y'.length // 4[...'x\uD83D\uDE80y'].length // 3

    上面代码的第一种写法,JavaScript 会将四个字节的 Unicode 字符,识别为 2 个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数,可以像下面这样写。

    function length(str) {return [...str].length;}length('x\uD83D\uDE80y') // 3

    凡是涉及到操作四个字节的 Unicode 字符的函数,都有这个问题。因此,最好都用扩展运算符改写。

    let str = 'x\uD83D\uDE80y';str.split('').reverse().join('')// 'y\uDE80\uD83Dx'[...str].reverse().join('')// 'y\uD83D\uDE80x'

    上面代码中,如果不用扩展运算符,字符串的reverse()操作就不正确。

    (5)实现了 Iterator 接口的对象

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

    let nodeList = document.querySelectorAll('div');let array = [...nodeList];

    上面代码中,querySelectorAll()方法返回的是一个NodeList对象。它不是数组,而是一个类似数组的对象。这时,扩展运算符可以将其转为真正的数组,原因就在于NodeList对象实现了 Iterator。

    Number.prototype[Symbol.iterator] = function*() {let i = 0;let num = this.valueOf();while (i < num) {yield i++;}}console.log([...5]) // [0, 1, 2, 3, 4]

    上面代码中,先定义了Number对象的遍历器接口,扩展运算符将5自动转成Number实例以后,就会调用这个接口,就会返回自定义的结果。

    对于那些没有部署 Iterator 接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。

    let arrayLike = {'0': 'a','1': 'b','2': 'c',length: 3};// TypeError: Cannot spread non-iterable object.let arr = [...arrayLike];

    上面代码中,arrayLike是一个类似数组的对象,但是没有部署 Iterator 接口,扩展运算符就会报错。这时,可以改为使用Array.from方法将arrayLike转为真正的数组。

    (6)Map 和 Set 结构,Generator 函数

    扩展运算符内部调用的是数据结构的 Iterator 接口,因此只要具有 Iterator 接口的对象,都可以使用扩展运算符,比如 Map 结构。

    let map = new Map([[1, 'one'],[2, 'two'],[3, 'three'],]);let arr = [...map.keys()]; // [1, 2, 3]

    Generator 函数运行后,返回一个遍历器对象,因此也可以使用扩展运算符。

    const go = function*(){yield 1;yield 2;yield 3;};[...go()] // [1, 2, 3]

    上面代码中,变量go是一个 Generator 函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值,转为一个数组。

    如果对没有 Iterator 接口的对象,使用扩展运算符,将会报错。

    const obj = {a: 1, b: 2};let arr = [...obj]; // TypeError: Cannot spread non-iterable object

    2. Array.from()

    Array.from()方法用于将两类对象转为真正的数组类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。

    下面是一个类似数组的对象,Array.from()将它转为真正的数组。

    let arrayLike = {'0': 'a','1': 'b','2': 'c',length: 3};// ES5 的写法var arr1 = [].slice.call(arrayLike); // ['a', 'b', 'c']// ES6 的写法let arr2 = Array.from(arrayLike); // ['a', 'b', 'c']

    实际应用中,常见的类似数组的对象是 DOM 操作返回的 NodeList 集合,以及函数内部的arguments对象。Array.from()都可以将它们转为真正的数组。

    // NodeList 对象let ps = document.querySelectorAll('p');Array.from(ps).filter(p => {return p.textContent.length > 100;});// arguments 对象function foo() {var args = Array.from(arguments);// ...}

    上面代码中,querySelectorAll()方法返回的是一个类似数组的对象,可以将这个对象转为真正的数组,再使用filter()方法。

    只要是部署了 Iterator 接口的数据结构,Array.from()都能将其转为数组。

    Array.from('hello')// ['h', 'e', 'l', 'l', 'o']let namesSet = new Set(['a', 'b'])Array.from(namesSet) // ['a', 'b']

    上面代码中,字符串和 Set 结构都具有 Iterator 接口,因此可以被Array.from()转为真正的数组。

    如果参数是一个真正的数组,Array.from()会返回一个一模一样的新数组。(因此可以用于数组的拷贝?)

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

    值得提醒的是,扩展运算符(...)也可以将某些数据结构转为数组。

    // arguments对象function foo() {const args = [...arguments];}// NodeList对象[...document.querySelectorAll('div')]

    扩展运算符背后调用的是遍历器接口(Symbol.iterator),如果一个对象没有部署这个接口,就无法转换。Array.from()方法还支持类似数组的对象。所谓类似数组的对象,本质特征只有一点,即必须有length属性。因此,任何有length属性的对象,都可以通过Array.from()方法转为数组,而此时扩展运算符就无法转换。

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

    上面代码中,Array.from()返回了一个具有三个成员的数组,每个位置的值都是undefined。扩展运算符转换不了这个对象。

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

    Array.from(arrayLike, x => x * x);// 等同于Array.from(arrayLike).map(x => x * x);Array.from([1, 2, 3], (x) => x * x)// [1, 4, 9]

    下面的例子是取出一组 DOM 节点的文本内容。

    let spans = document.querySelectorAll('span.name');// map()let names1 = Array.prototype.map.call(spans, s => s.textContent);// Array.from()let names2 = Array.from(spans, s => s.textContent)

    下面的例子将数组中布尔值为false的成员转为0

    Array.from([1, , 2, , 3], (n) => n || 0)// [1, 0, 2, 0, 3]

    另一个例子是返回各种数据的类型。

    function typesOf () {return Array.from(arguments, value => typeof value)}typesOf(null, [], NaN)// ['object', 'object', 'number']

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

    3. Array.of()

    Array.of()方法用于将一组值,转换为数组。

    Array.of(3, 11, 8) // [3,11,8]Array.of(3) // [3]Array.of(3).length // 1

    这个方法的主要目的,是弥补数组构造函数Array()的不足。因为参数个数的不同,会导致Array()的行为有差异。

    Array() // []Array(3) // [, , ,]Array(3, 11, 8) // [3, 11, 8]

    上面代码中,Array()方法没有参数、一个参数、三个参数时,返回的结果都不一样。只有当参数个数不少于 2 个时,Array()才会返回由参数组成的新数组。参数只有一个正整数时,实际上是指定数组的长度。

    Array.of()基本上可以用来替代Array()new Array(),并且不存在由于参数不同而导致的重载。它的行为非常统一。

    Array.of() // []Array.of(undefined) // [undefined]Array.of(1) // [1]Array.of(1, 2) // [1, 2]

    Array.of()总是返回参数值组成的数组。如果没有参数,就返回一个空数组。

    4. 实例方法:copyWithin()

    在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法,会修改当前数组。

    Array.prototype.copyWithin(target, start = 0, end = this.length)

    它接受三个参数。

    • target(必需):从该位置开始替换数据。如果为负值,表示倒数。
    • start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
    • end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。

    这三个参数都应该是数值,如果不是,会自动转为数值。

    [1, 2, 3, 4, 5].copyWithin(0, 3)// [4, 5, 3, 4, 5]

    上面代码表示将从 3 号位直到数组结束的成员(4 和 5),复制到从 0 号位开始的位置,结果覆盖了原来的 1 和 2。

    5. 实例方法:find(),findIndex(),findLast(),findLastIndex()

    数组实例的find()方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined

    [1, 4, -5, 10].find((n) => n < 0)// -5

    上面代码找出数组中第一个小于 0 的成员。

    [1, 5, 10, 15].find(function(value, index, arr) {return value > 9;}) // 10

    上面代码中,find()方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。

    数组实例的findIndex()方法的用法与find()方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1。

    [1, 5, 10, 15].findIndex(function(value, index, arr) {return value > 9;}) // 2

    这两个方法都可以接受第二个参数,用来绑定回调函数的this对象。

    find()findIndex()都是从数组的0号位,依次向后检查。ES2022 新增了两个方法findLast()findLastIndex(),从数组的最后一个成员开始,依次向前检查,其他都保持不变。

    6. 实例方法:fill()

    fill方法使用给定值,填充一个数组。

    ['a', 'b', 'c'].fill(7)// [7, 7, 7]new Array(3).fill(7)// [7, 7, 7]

    上面代码表明,fill方法用于空数组的初始化非常方便。数组中已有的元素,会被全部抹去。

    fill方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置。

    ['a', 'b', 'c'].fill(7, 1, 2)// ['a', 7, 'c']

    上面代码表示,fill方法从 1 号位开始,向原数组填充 7,到 2 号位之前结束。

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

    let arr = new Array(3).fill({name: "Mike"});arr[0].name = "Ben";arr// [{name: "Ben"}, {name: "Ben"}, {name: "Ben"}]let arr = new Array(3).fill([]);arr[0].push(5);arr// [[5], [5], [5]]

    7. 实例方法:entries(),keys() 和 values()

    ES6 提供三个新的方法——entries(),keys()values()——用于遍历数组。它们都返回一个遍历器对象(详见《Iterator》一章),可以用for...of循环进行遍历,唯一的区别是keys()是对键名的遍历、values()是对键值的遍历,entries()是对键值对的遍历。

    for (let index of ['a', 'b'].keys()) {console.log(index);}// 0// 1for (let elem of ['a', 'b'].values()) {console.log(elem);}// 'a'// 'b'for (let [index, elem] of ['a', 'b'].entries()) {console.log(index, elem);}// 0 "a"// 1 "b"

    8. 实例方法:includes()

    Array.prototype.includes方法返回一个布尔值,表示某个数组是否包含给定的值,与字符串的includes方法类似。ES2016 引入了该方法。

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

    该方法的第二个参数表示搜索的起始位置,默认为0。如果第二个参数为负数,则表示倒数的位置,如果这时它大于数组长度(比如第二个参数为-4,但数组长度为3),则会重置为从0开始。

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

    没有该方法之前,我们通常使用数组的indexOf方法,检查是否包含某个值。

    if (arr.indexOf(el) !== -1) {// ...}

    indexOf方法有两个缺点,一是不够语义化,它的含义是找到参数值的第一个出现位置,所以要去比较是否不等于-1,表达起来不够直观。二是,它内部使用严格相等运算符(===)进行判断,这会导致对NaN的误判。

    [NaN].indexOf(NaN)// -1

    includes使用的是不一样的判断算法,就没有这个问题。

    [NaN].includes(NaN)// true

    9. 实例方法:flat(),flatMap()

    数组的成员有时还是数组,Array.prototype.flat()用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。

    [1, 2, [3, 4]].flat()// [1, 2, 3, 4]

    上面代码中,原数组的成员里面有一个数组,flat()方法将子数组的成员取出来,添加在原来的位置。

    flat()默认只会“拉平”一层,如果想要“拉平”多层的嵌套数组,可以将flat()方法的参数写成一个整数,表示想要拉平的层数,默认为1

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

    上面代码中,flat()的参数为2,表示要“拉平”两层的嵌套数组。

    如果不管有多少层嵌套,都要转成一维数组,可以用Infinity关键字作为参数。

    [1, [2, [3]]].flat(Infinity)// [1, 2, 3]

    flatMap()方法对原数组的每个成员执行一个函数(相当于执行Array.prototype.map()),然后对返回值组成的数组执行flat()方法。该方法返回一个新数组,不改变原数组。

    // 相当于 [[2, 4], [3, 6], [4, 8]].flat()[2, 3, 4].flatMap((x) => [x, x * 2])// [2, 4, 3, 6, 4, 8]

    flatMap()只能展开一层数组。

    10. 实例方法:at()

    接受一个整数作为参数,返回对应位置的成员,并支持负索引。这个方法不仅可用于数组,也可用于字符串和类型数组(TypedArray)。

    const arr = [5, 12, 8, 130, 44];arr.at(2) // 8arr.at(-2) // 130

    如果参数位置超出了数组范围,at()返回undefined

    const sentence = 'This is a sample sentence';sentence.at(0); // 'T'sentence.at(-1); // 'e'sentence.at(-100) // undefinedsentence.at(100) // undefined

    其他

    • 实例方法:toReversed(),toSorted(),toSpliced(),with()
    • 实例方法:group(),groupToMap()
    • 数组的空位:Array(3)返回一个具有 3 个空位的数组。空位不是undefined。ES6 则是明确将空位转为undefinedArray.from()方法、扩展运算符(...)、keys()values() 等都会将空位转为undefinedfor...of循环也会遍历空位。
    • Array.prototype.sort() 的排序稳定性

    对象的扩展

    1. 属性的简洁表示法

    ES6 允许在大括号里面,直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。

    const foo = 'bar';const baz = {foo};baz // {foo: "bar"}// 等同于const baz = {foo: foo};

    上面代码中,变量foo直接写在大括号里面。这时,属性名就是变量名, 属性值就是变量值。下面是另一个例子。

    function f(x, y) {return {x, y};}// 等同于function f(x, y) {return {x: x, y: y};}f(1, 2) // Object {x: 1, y: 2}

    除了属性简写,方法也可以简写。

    下面是一个实际的例子。

    let birth = '2000/01/01';const Person = {name: '张三',//等同于birth: birthbirth,// 等同于hello: function ()...hello() { console.log('我的名字是', this.name); }};

    这种写法用于函数的返回值,将会非常方便。

    function getPoint() {const x = 1;const y = 10;return {x, y};}getPoint()// {x:1, y:10}

    CommonJS 模块输出一组变量,就非常合适使用简洁写法。

    let ms = {};function getItem (key) {return key in ms ? ms[key] : null;}function setItem (key, value) {ms[key] = value;}function clear () {ms = {};}module.exports = { getItem, setItem, clear };// 等同于module.exports = {getItem: getItem,setItem: setItem,clear: clear};

    属性的赋值器(setter)和取值器(getter),事实上也是采用这种写法。

    简洁写法在打印对象时也很有用。

    let user = {name: 'test'};let foo = {bar: 'baz'};console.log(user, foo)// {name: "test"} {bar: "baz"}console.log({user, foo})// {user: {name: "test"}, foo: {bar: "baz"}}

    上面代码中,console.log直接输出user和foo两个对象时,就是两组键值对,可能会混淆。把它们放在大括号里面输出,就变成了对象的简洁表示法,每组键值对前面会打印对象名,这样就比较清晰了。

    注意,简写的对象方法不能用作构造函数,会报错。

    const obj = {f() {this.foo = 'bar';}};new obj.f() // 报错

    上面代码中,f是一个简写的对象方法,所以obj.f不能当作构造函数使用。

    2. 属性名表达式

    JavaScript 定义对象的属性,有两种方法。

    // 方法一obj.foo = true;// 方法二obj['a' + 'bc'] = 123;

    上面代码的方法一是直接用标识符作为属性名,方法二是用表达式作为属性名,这时要将表达式放在方括号之内。

    但是,如果使用字面量方式定义对象(使用大括号),在 ES5 中只能使用方法一(标识符)定义属性。

    var obj = {foo: true,abc: 123};

    ES6 允许字面量定义对象时,用方法二(表达式)作为对象的属性名,即把表达式放在方括号内。

    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

    注意,属性名表达式与简洁表示法,不能同时使用,会报错。

    3. 方法的 name 属性

    函数的name属性,返回函数名。对象方法也是函数,因此也有name属性。

    const person = {sayName() {console.log('hello!');},};person.sayName.name // "sayName"

    上面代码中,方法的name属性返回函数名(即方法名)。

    如果对象的方法使用了取值函数(getter)和存值函数(setter),则name属性不是在该方法上面,而是该方法的属性的描述对象的get和set属性上面,返回值是方法名前加上get和set。

    const obj = {get foo() {},set foo(x) {}};obj.foo.name// TypeError: Cannot read property 'name' of undefinedconst descriptor = Object.getOwnPropertyDescriptor(obj, 'foo');descriptor.get.name // "get foo"descriptor.set.name // "set foo"

    有两种特殊情况:bind方法创造的函数,name属性返回bound加上原函数的名字;Function构造函数创造的函数,name属性返回anonymous。

    (new Function()).name // "anonymous"var doSomething = function() {// ...};doSomething.bind().name // "bound doSomething"

    其他

    • 属性的可枚举性和遍历:for...inObject.keys(obj)Object.getOwnPropertyNames(obj) 等方法可以遍历对象的属性
    • super 关键字:我们知道,this关键字总是指向函数所在的当前对象,ES6 又新增了另一个类似的关键字super,指向当前对象的原型对象。
    • 对象的扩展运算符:《数组的扩展》一章中,已经介绍过扩展运算符(...)。ES2018 将这个运算符引入了对象。
    • AggregateError 错误对象
    • Error 对象的 cause 属性

    对象的新增方法

    1. Object.is()

    S5 比较两个值是否相等,只有两个运算符:相等运算符(==)和严格相等运算符(===)。它们都有缺点,前者会自动转换数据类型,后者的NaN不等于自身,以及+0等于-0。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。

    ES6 提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。

    Object.is('foo', 'foo')// trueObject.is({}, {})// false

    不同之处只有两个:一是+0不等于-0,二是NaN等于自身。

    +0 === -0 //trueNaN === NaN // falseObject.is(+0, -0) // falseObject.is(NaN, NaN) // true

    2. Object.assign()

    基本用法

    Object.assign()方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

    const target = { a: 1 };const source1 = { b: 2 };const source2 = { c: 3 };Object.assign(target, source1, source2);target // {a:1, b:2, c:3}

    Object.assign()方法的第一个参数是目标对象,后面的参数都是源对象。

    注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。

    const target = { a: 1, b: 1 };const source1 = { b: 2, c: 2 };const source2 = { c: 3 };Object.assign(target, source1, source2);target // {a:1, b:2, c:3}

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

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

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

    typeof Object.assign(2) // "object"

    由于undefinednull无法转成对象,所以如果它们作为参数,就会报错。

    Object.assign(undefined) // 报错Object.assign(null) // 报错

    如果非对象参数出现在源对象的位置(即非首参数),那么处理规则有所不同。首先,这些参数都会转成对象,如果无法转成对象,就会跳过。这意味着,如果undefinednull不在首参数,就不会报错。

    let obj = {a: 1};Object.assign(obj, undefined) === obj // trueObject.assign(obj, null) === obj // true

    其他类型的值(即数值、字符串和布尔值)不在首参数,也不会报错。但是,除了字符串会以数组形式,拷贝入目标对象,其他值都不会产生效果。

    const v1 = 'abc';const v2 = true;const v3 = 10;const obj = Object.assign({}, v1, v2, v3);console.log(obj); // { "0": "a", "1": "b", "2": "c" }

    上面代码中,v1、v2、v3分别是字符串、布尔值和数值,结果只有字符串合入目标对象(以字符数组的形式),数值和布尔值都会被忽略。这是因为只有字符串的包装对象,会产生可枚举属性。

    Object(true) // {[[PrimitiveValue]]: true}Object(10)//{[[PrimitiveValue]]: 10}Object('abc') // {0: "a", 1: "b", 2: "c", length: 3, [[PrimitiveValue]]: "abc"}

    上面代码中,布尔值、数值、字符串分别转成对应的包装对象,可以看到它们的原始值都在包装对象的内部属性[[PrimitiveValue]]上面,这个属性是不会被Object.assign()拷贝的。只有字符串的包装对象,会产生可枚举的实义属性,那些属性则会被拷贝。

    Object.assign()拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false)。

    Object.assign({b: 'c'},Object.defineProperty({}, 'invisible', {enumerable: false,value: 'hello'}))// { b: 'c' }

    上面代码中,Object.assign()要拷贝的对象只有一个不可枚举属性invisible,这个属性并没有被拷贝进去。

    属性名为 Symbol 值的属性,也会被Object.assign()拷贝。

    Object.assign({ a: 'b' }, { [Symbol('c')]: 'd' })// { a: 'b', Symbol(c): 'd' }

    注意点

    (1)浅拷贝

    Object.assign()方法实行的是浅拷贝,而不是深拷贝。也就是说,如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用。

    const obj1 = {a: {b: 1}};const obj2 = Object.assign({}, obj1);obj1.a.b = 2;obj2.a.b // 2

    上面代码中,源对象obj1a属性的值是一个对象,Object.assign()拷贝得到的是这个对象的引用。这个对象的任何变化,都会反映到目标对象上面。

    (2)同名属性的替换

    对于这种嵌套的对象,一旦遇到同名属性,Object.assign()的处理方法是替换,而不是添加。

    const target = { a: { b: 'c', d: 'e' } }const source = { a: { b: 'hello' } }Object.assign(target, source)// { a: { b: 'hello' } }

    上面代码中,target对象的a属性被source对象的a属性整个替换掉了,而不会得到{ a: { b: 'hello', d: 'e' } }的结果。这通常不是开发者想要的,需要特别小心。

    一些函数库提供Object.assign()的定制版本(比如 Lodash 的_.defaultsDeep()方法),可以得到深拷贝的合并。

    (3)数组的处理

    Object.assign()可以用来处理数组,但是会把数组视为对象。

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

    上面代码中,Object.assign()把数组视为属性名为 0、1、2 的对象,因此源数组的 0 号属性4覆盖了目标数组的 0 号属性1。

    (4)取值函数的处理

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

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

    上面代码中,source对象的foo属性是一个取值函数,Object.assign()不会复制这个取值函数,只会拿到值以后,将这个值复制过去。

    常见用途

    Object.assign()方法有很多用处。

    (1)为对象添加属性

    class Point {constructor(x, y) {Object.assign(this, {x, y});}}

    上面方法通过Object.assign()方法,将x属性和y属性添加到Point类的对象实例。

    (2)为对象添加方法

    Object.assign(SomeClass.prototype, {someMethod(arg1, arg2) {···},anotherMethod() {···}});// 等同于下面的写法SomeClass.prototype.someMethod = function (arg1, arg2) {···};SomeClass.prototype.anotherMethod = function () {···};

    上面代码使用了对象属性的简洁表示法,直接将两个函数放在大括号中,再使用assign()方法添加到SomeClass.prototype之中。

    (3)克隆对象

    function clone(origin) {return Object.assign({}, origin);}

    上面代码将原始对象拷贝到一个空对象,就得到了原始对象的克隆。

    不过,采用这种方法克隆,只能克隆原始对象自身的值,不能克隆它继承的值。如果想要保持继承链,可以采用下面的代码。

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

    (4)合并多个对象

    将多个对象合并到某个对象。

    const merge =(target, ...sources) => Object.assign(target, ...sources);

    如果希望合并后返回一个新对象,可以改写上面函数,对一个空对象合并。

    const merge =(...sources) => Object.assign({}, ...sources);

    (5)为属性指定默认值

    const DEFAULTS = {logLevel: 0,outputFormat: 'html'};function processContent(options) {options = Object.assign({}, DEFAULTS, options);console.log(options);// ...}

    上面代码中,DEFAULTS对象是默认值,options对象是用户提供的参数。Object.assign()方法将DEFAULTSoptions合并成一个新对象,如果两者有同名属性,则options的属性值会覆盖DEFAULTS的属性值。

    注意,由于存在浅拷贝的问题,DEFAULTS对象和options对象的所有属性的值,最好都是简单类型,不要指向另一个对象。否则,DEFAULTS对象的该属性很可能不起作用。

    const DEFAULTS = {url: {host: 'example.com',port: 7070},};processContent({ url: {port: 8000} })// {// url: {port: 8000}// }

    上面代码的原意是将url.port改成 8000url.host不变。实际结果却是options.url覆盖掉DEFAULTS.url,所以url.host就不存在了。

    其他

    • Object.getOwnPropertyDescriptors()
    • __proto__属性,Object.setPrototypeOf(),Object.getPrototypeOf()
    • Object.keys(),Object.values(),Object.entries()
    • Object.fromEntries():Object.entries()的逆操作,用于将一个键值对数组转为对象。
    • Object.hasOwn():判断是否为自身的属性。

    运算符的扩展

    • 指数运算符**:如 2 ** 3 表示 2 的 3 次方
    • 链判断运算符?.:例如const firstName = message?.body?.user?.firstName || 'default';
    • Null 判断运算符??:它的行为类似||,但是只有运算符左侧的值为nullundefined时,才会返回右侧的值。例如 const headerText = response.settings.headerText ?? 'Hello, world!';
    • 逻辑赋值运算符:||=、&&=、??=
    • #!命令

    Symbol

    ES5 的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的方法(mixin 模式),新方法的名字就有可能与现有方法产生冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是 ES6 引入Symbol的原因。

    ES6 引入了一种新的原始数据类型Symbol表示独一无二的值。它属于 JavaScript 语言的原生数据类型之一,其他数据类型是:undefined、null、布尔值(Boolean)、字符串(String)、数值(Number)、大整数(BigInt)、对象(Object)

    Symbol 值通过Symbol()函数生成。这就是说,对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的 Symbol 类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。

    let s = Symbol();typeof s// "symbol"

    上面代码中,变量s就是一个独一无二的值。typeof运算符的结果,表明变量sSymbol 数据类型,而不是字符串之类的其他类型。

    注意,Symbol()函数前不能使用new命令,否则会报错。这是因为生成的 Symbol 是一个原始类型的值,不是对象,所以不能使用new命令来调用。另外,由于 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)"

    上面代码中,s1s2是两个 Symbol 值。如果不加参数,它们在控制台的输出都是Symbol(),不利于区分。有了参数以后,就等于为它们加上了描述,输出的时候就能够分清,到底是哪一个值。

    如果 Symbol 的参数是一个对象,就会调用该对象的toString()方法,将其转为字符串,然后才生成一个 Symbol 值。

    const obj = {toString() {return 'abc';}};const sym = Symbol(obj);sym // Symbol(abc)

    注意,Symbol()函数的参数只是表示对当前 Symbol 值的描述,因此相同参数的Symbol函数的返回值是不相等的。

    // 没有参数的情况let s1 = Symbol();let s2 = Symbol();s1 === s2 // false// 有参数的情况let s1 = Symbol('foo');let s2 = Symbol('foo');s1 === s2 // false

    上面代码中,s1s2都是Symbol()函数的返回值,而且参数相同,但是它们是不相等的。事实上,如果调用100次Symbol(),会得到100个互不相等的值。

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

    let sym = Symbol('My symbol');"your symbol is " + sym// TypeError: can't convert symbol to string`your symbol is ${sym}`// TypeError: can't convert symbol to string

    但是,Symbol 值可以显式转为字符串。

    let sym = Symbol('My symbol');String(sym) // 'Symbol(My symbol)'sym.toString() // 'Symbol(My symbol)'

    另外,Symbol 值也可以转为布尔值,但是不能转为数值

    let sym = Symbol();Boolean(sym) // true!sym// falseif (sym) {// ...}Number(sym) // TypeErrorsym + 2 // TypeError

    Set 和 Map 数据结构

    1. Set

    基本用法

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

    Set本身是一个构造函数,用来生成 Set 数据结构。

    const s = new Set();[2, 3, 5, 4, 5, 2, 2].forEach(x => s.add(x));for (let i of s) {console.log(i);}// 2 3 5 4

    上面代码通过add()方法向 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

    上面代码中,例一和例二都是Set函数接受数组作为参数,例三是接受类似数组的对象作为参数。

    上面代码也展示了一种去除数组重复成员的方法。

    // 去除数组的重复成员[...new Set(array)]

    上面的方法也可以用于,去除字符串里面的重复字符。

    [...new Set('ababbc')].join('')// "abc"

    Set 加入值的时候,不会发生类型转换,所以5"5"是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(===),主要的区别是向 Set 加入值时认为NaN等于自身,而精确相等运算符认为NaN不等于自身。

    let set = new Set();let a = NaN;let b = NaN;set.add(a);set.add(b);set // Set {NaN}

    上面代码向 Set 实例添加了两次NaN,但是只会加入一个。这表明,在 Set 内部,两个NaN是相等的。

    另外,两个对象总是不相等的。

    let set = new Set();set.add({});set.size // 1set.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():清除所有成员,没有返回值。

    上面这些属性和方法的实例如下。

    s.add(1).add(2).add(2);// 注意2被加入了两次s.size // 2s.has(1) // trues.has(2) // trues.has(3) // falses.delete(2) // trues.has(2) // false

    下面是一个对比,判断是否包括一个键,Object结构和Set结构写法的不同。

    // 对象的写法const properties = {'width': 1,'height': 1};if (properties[someName]) {// do something}// Set的写法const properties = new Set();properties.add('width');properties.add('height');if (properties.has(someName)) {// do something}

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

    const items = new Set([1, 2, 3, 4, 5]);const array = Array.from(items);

    这就提供了去除数组重复成员的另一种方法。

    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方法返回的都是遍历器对象(详见《Iterator 对象》一章)。由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以keys方法和values方法的行为完全一致。

    let set = new Set(['red', 'green', 'blue']);for (let item of set.keys()) {console.log(item);}// red// green// bluefor (let item of set.values()) {console.log(item);}// red// green// bluefor (let item of set.entries()) {console.log(item);}// ["red", "red"]// ["green", "green"]// ["blue", "blue"]

    上面代码中,entries方法返回的遍历器,同时包括键名和键值,所以每次输出一个数组,它的两个成员完全相等。

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

    Set.prototype[Symbol.iterator] === Set.prototype.values// true

    这意味着,可以省略values方法,直接用for...of循环遍历 Set

    let set = new Set(['red', 'green', 'blue']);for (let x of set) {console.log(x);}// red// green// blue

    (2)forEach()

    Set 结构的实例与数组一样,也拥有forEach方法,用于对每个成员执行某种操作,没有返回值。

    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 结构。

    let set = new Set(['red', 'green', 'blue']);let arr = [...set];// ['red', 'green', 'blue']

    扩展运算符和 Set 结构相结合,就可以去除数组的重复成员。

    let arr = [3, 5, 2, 2, 5, 5];let unique = [...new Set(arr)];// [3, 5, 2]

    而且,数组的mapfilter方法也可以间接用于 Set 了。

    let set = new Set([1, 2, 3]);set = new Set([...set].map(x => x * 2));// 返回Set结构:{2, 4, 6}let set = new Set([1, 2, 3, 4, 5]);set = new Set([...set].filter(x => (x % 2) == 0));// 返回Set结构:{2, 4}

    因此使用 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方法。

    // 方法一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

    上面代码提供了两种方法,直接在遍历操作中改变原来的 Set 结构。

    2. Map

    含义和基本用法

    JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。

    const data = {};const element = document.getElementById('myDiv');data[element] = 'metadata';data['[object HTMLDivElement]'] // "metadata"

    上面代码原意是将一个 DOM 节点作为对象data的键,但是由于对象只接受字符串作为键名,所以element被自动转为字符串[object HTMLDivElement]

    为了解决这个问题,ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,MapObject 更合适。

    const m = new Map();const o = {p: 'Hello World'};m.set(o, 'content')m.get(o) // "content"m.has(o) // truem.delete(o) // truem.has(o) // false

    上面代码使用 Map 结构的set方法,将对象o当作m的一个键,然后又使用get方法读取这个键,接着使用delete方法删除了这个键。

    上面的例子展示了如何向 Map 添加成员。作为构造函数,Map 也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。

    const map = new Map([['name', '张三'],['title', 'Author']]);map.size // 2map.has('name') // truemap.get('name') // "张三"map.has('title') // truemap.get('title') // "Author"

    上面代码在新建 Map 实例时,就指定了两个键nametitle

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

    const items = [['name', '张三'],['title', 'Author']];const map = new Map();items.forEach(([key, value]) => map.set(key, value));

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

    const set = new Set([['foo', 1],['bar', 2]]);const m1 = new Map(set);m1.get('foo') // 1const m2 = new Map([['baz', 3]]);const m3 = new Map(m2);m3.get('baz') // 3

    上面代码中,我们分别使用 Set 对象和 Map 对象,当作Map构造函数的参数,结果都生成了新的 Map 对象。

    如果对同一个键多次赋值,后面的值将覆盖前面的值。

    const map = new Map();map.set(1, 'aaa').set(1, 'bbb');map.get(1) // "bbb"

    上面代码对键1连续赋值两次,后一次的值覆盖前一次的值。

    如果读取一个未知的键,则返回undefined

    new Map().get('asfddfsasadf')// undefined

    注意,只有对同一个对象的引用,Map 结构才将其视为同一个键。这一点要非常小心。

    const map = new Map();map.set(['a'], 555);map.get(['a']) // undefined

    上面代码的setget方法,表面是针对同一个键,但实际上这是两个不同的数组实例,内存地址是不一样的,因此get方法无法读取该键,返回undefined

    同理,同样的值的两个实例,在 Map 结构中被视为两个键。

    const map = new Map();const k1 = ['a'];const k2 = ['a'];map.set(k1, 111).set(k2, 222);map.get(k1) // 111map.get(k2) // 222

    上面代码中,变量k1k2的值是一样的,但是它们在 Map 结构中被视为两个键。

    如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键,比如0-0就是一个键,布尔值true和字符串true则是两个不同的键。另外,undefinednull也是两个不同的键。虽然NaN不严格相等于自身,但 Map 将其视为同一个键。

    let map = new Map();map.set(-0, 123);map.get(+0) // 123map.set(true, 1);map.set('true', 2);map.get(true) // 1map.set(undefined, 3);map.set(null, 4);map.get(undefined) // 3map.set(NaN, 123);map.get(NaN) // 123

    实例的属性和操作方法

    Map 结构的实例有以下属性和操作方法。

    (1)size 属性

    size属性返回 Map 结构的成员总数。

    const map = new Map();map.set('foo', true);map.set('bar', false);map.size // 2

    (2)Map.prototype.set(key, value)

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

    const m = new Map();m.set('edition', 6)// 键是字符串m.set(262, 'standard') // 键是数值m.set(undefined, 'nah')// 键是 undefined

    set方法返回的是当前的Map对象,因此可以采用链式写法。

    let map = new Map().set(1, 'a').set(2, 'b').set(3, 'c');

    (3)Map.prototype.get(key)

    get方法读取key对应的键值,如果找不到key,返回undefined

    const m = new Map();const hello = function() {console.log('hello');};m.set(hello, 'Hello ES6!') // 键是函数m.get(hello)// Hello ES6!

    (4)Map.prototype.has(key)

    has方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。

    const m = new Map();m.set('edition', 6);m.set(262, 'standard');m.set(undefined, 'nah');m.has('edition') // truem.has('years') // falsem.has(262) // truem.has(undefined) // true

    (5)Map.prototype.delete(key)

    delete()方法删除某个键,返回true。如果删除失败,返回false

    const m = new Map();m.set(undefined, 'nah');m.has(undefined) // truem.delete(undefined)m.has(undefined) // false

    (6)Map.prototype.clear()

    clear()方法清除所有成员,没有返回值。

    let map = new Map();map.set('foo', true);map.set('bar', false);map.size // 2map.clear()map.size // 0

    遍历方法

    Map 结构原生提供三个遍历器生成函数和一个遍历方法。

    • Map.prototype.keys():返回键名的遍历器。
    • Map.prototype.values():返回键值的遍历器。
    • Map.prototype.entries():返回所有成员的遍历器。
    • Map.prototype.forEach():遍历 Map 的所有成员。

    需要特别注意的是,Map 的遍历顺序就是插入顺序

    const map = new Map([['F', 'no'],['T','yes'],]);for (let key of map.keys()) {console.log(key);}// "F"// "T"for (let value of map.values()) {console.log(value);}// "no"// "yes"for (let item of map.entries()) {console.log(item[0], item[1]);}// "F" "no"// "T" "yes"// 或者for (let [key, value] of map.entries()) {console.log(key, value);}// "F" "no"// "T" "yes"// 等同于使用map.entries()for (let [key, value] of map) {console.log(key, value);}// "F" "no"// "T" "yes"

    上面代码最后的那个例子,表示 Map 结构的默认遍历器接口(Symbol.iterator属性),就是entries方法。

    map[Symbol.iterator] === map.entries// true

    Map 结构转为数组结构,比较快速的方法是使用扩展运算符(...)。

    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 本身没有mapfilter方法)。

    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方法类似,也可以实现遍历。

    map.forEach(function(value, key, map) {console.log("Key: %s, Value: %s", key, value);});

    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']]])// Map {// true => 7,// Object {foo: 3} => ['abc']// }

    (3)Map 转为对象

    如果所有 Map 的键都是字符串,它可以无损地转为对象。

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

    如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。

    (4)对象转为 Map

    对象转为 Map 可以通过Object.entries()

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

    此外,也可以自己实现一个转换函数。

    function objToStrMap(obj) {let strMap = new Map();for (let k of Object.keys(obj)) {strMap.set(k, obj[k]);}return strMap;}objToStrMap({yes: true, no: false})// Map {"yes" => true, "no" => false}

    (5)Map 转为 JSON

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

    function strMapToJson(strMap) {return JSON.stringify(strMapToObj(strMap));}let myMap = new Map().set('yes', true).set('no', false);strMapToJson(myMap)// '{"yes":true,"no":false}'

    另一种情况是,Map 的键名有非字符串,这时可以选择转为数组 JSON。

    function mapToArrayJson(map) {return JSON.stringify([...map]);}let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']);mapToArrayJson(myMap)// '[[true,7],[{"foo":3},["abc"]]]'

    (6)JSON 转为 Map

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

    function jsonToStrMap(jsonStr) {return objToStrMap(JSON.parse(jsonStr));}jsonToStrMap('{"yes": true, "no": false}')// Map {'yes' => true, 'no' => false}

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

    function jsonToMap(jsonStr) {return new Map(JSON.parse(jsonStr));}jsonToMap('[[true,7],[{"foo":3},["abc"]]]')// Map {true => 7, Object {foo: 3} => ['abc']}

    其他

    • WeakSet
    • WeakMap
    • WeakRef
    • FinalizationRegistry

    类似Java中的弱引用,这几个主要是为了配合垃圾回收机制提出的。

    Iterator 和 for…of 循环

    1. Iterator(遍历器)的概念

    JavaScript 原有的表示“集合”的数据结构,主要是数组(Array)和对象(Object),ES6 又添加了Map和Set。这样就有了四种数据集合,用户还可以组合使用它们,定义自己的数据结构,比如数组的成员是Map,Map的成员是对象。这样就需要一种统一的接口机制,来处理所有不同的数据结构。

    遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

    Iterator 的作用有三个:

    • 一是为各种数据结构,提供一个统一的、简便的访问接口;
    • 二是使得数据结构的成员能够按某种次序排列;
    • 三是 ES6 创造了一种新的遍历命令for…of循环,Iterator 接口主要供for...of消费。

    Iterator 的遍历过程是这样的。

    (1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。

    (2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。

    (3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。

    (4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。

    每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含valuedone两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。

    下面是一个模拟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};}};}

    上面代码定义了一个makeIterator函数,它是一个遍历器生成函数,作用就是返回一个遍历器对象。对数组['a', 'b']执行这个函数,就会返回该数组的遍历器对象(即指针对象)it

    指针对象的next方法,用来移动指针。开始时,指针指向数组的开始位置。然后,每次调用next方法,指针就会指向数组的下一个成员。第一次调用,指向a;第二次调用,指向b

    next方法返回一个对象,表示当前数据成员的信息。这个对象具有valuedone两个属性,value属性返回当前位置的成员,done属性是一个布尔值,表示遍历是否结束,即是否还有必要再一次调用next方法。

    总之,调用指针对象的next方法,就可以遍历事先给定的数据结构。

    对于遍历器对象来说,done: falsevalue: undefined属性都是可以省略的,因此上面的makeIterator函数可以简写成下面的形式。

    function makeIterator(array) {var nextIndex = 0;return {next: function() {return nextIndex < array.length ?{value: array[nextIndex++]} :{done: true};}};}

    由于 Iterator 只是把接口规格加到数据结构之上,所以,遍历器与它所遍历的那个数据结构,实际上是分开的,完全可以写出没有对应数据结构的遍历器对象,或者说用遍历器对象模拟出数据结构。下面是一个无限运行的遍历器对象的例子。

    var it = idMaker();it.next().value // 0it.next().value // 1it.next().value // 2// ...function idMaker() {var index = 0;return {next: function() {return {value: index++, done: false};}};}

    上面的例子中,遍历器生成函数idMaker,返回一个遍历器对象(即指针对象)。但是并没有对应的数据结构,或者说,遍历器对象自己描述了一个数据结构出来。

    如果使用 TypeScript 的写法,遍历器接口(Iterable)、指针对象(Iterator)和next方法返回值的规格可以描述如下。

    interface Iterable {[Symbol.iterator]() : Iterator,}interface Iterator {next(value?: any) : IterationResult,}interface IterationResult {value: any,done: boolean,}

    2. 默认 Iterator 接口

    Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即for...of循环(详见下文)。当使用for...of循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。

    一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是“可遍历的”(iterable)。

    ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内(参见《Symbol》一章)。

    const obj = {[Symbol.iterator] : function () {return {next: function () {return {value: 1,done: true};}};}};

    上面代码中,对象obj是可遍历的(iterable),因为具有Symbol.iterator属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有next方法。每次调用next方法,都会返回一个代表当前成员的信息对象,具有valuedone两个属性。

    ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被for...of循环遍历。原因在于,这些数据结构原生部署了Symbol.iterator属性(详见下文),另外一些数据结构没有(比如对象)。凡是部署了Symbol.iterator属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。

    原生具备 Iterator 接口的数据结构如下。

    • Array
    • Map
    • Set
    • String
    • TypedArray
    • 函数的 arguments 对象
    • NodeList 对象

    下面的例子是数组的Symbol.iterator属性。

    let arr = ['a', 'b', 'c'];let iter = arr[Symbol.iterator]();iter.next() // { value: 'a', done: false }iter.next() // { value: 'b', done: false }iter.next() // { value: 'c', done: false }iter.next() // { value: undefined, done: true }

    上面代码中,变量arr是一个数组,原生就具有遍历器接口,部署在arrSymbol.iterator属性上面。所以,调用这个属性,就得到遍历器对象。

    对于原生部署 Iterator 接口的数据结构,不用自己写遍历器生成函数,for...of循环会自动遍历它们。除此之外,其他数据结构(主要是对象)的 Iterator 接口,都需要自己在Symbol.iterator属性上面部署,这样才会被for...of循环遍历。

    对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作 Map 结构使用,ES5 没有 Map 结构,而 ES6 原生提供了。

    一个对象如果要具备可被for...of循环调用的 Iterator 接口,就必须在Symbol.iterator的属性上部署遍历器生成方法(原型链上的对象具有该方法也可)。

    class RangeIterator {constructor(start, stop) {this.value = start;this.stop = stop;}[Symbol.iterator]() { return this; }next() {var value = this.value;if (value < this.stop) {this.value++;return {done: false, value: value};}return {done: true, value: undefined};}}function range(start, stop) {return new RangeIterator(start, stop);}for (var value of range(0, 3)) {console.log(value); // 0, 1, 2}

    上面代码是一个类部署 Iterator 接口的写法。Symbol.iterator属性对应一个函数,执行后返回当前对象的遍历器对象。

    下面是通过遍历器实现“链表”结构的例子。

    function Obj(value) {this.value = value;this.next = null;}Obj.prototype[Symbol.iterator] = function() {var iterator = { next: next };var current = this;function next() {if (current) {var value = current.value;current = current.next;return { done: false, value: value };}return { done: true };}return iterator;}var one = new Obj(1);var two = new Obj(2);var three = new Obj(3);one.next = two;two.next = three;for (var i of one){console.log(i); // 1, 2, 3}

    上面代码首先在构造函数的原型链上部署Symbol.iterator方法,调用该方法会返回遍历器对象iterator,调用该对象的next方法,在返回一个值的同时,自动将内部指针移到下一个实例。

    下面是另一个为对象添加 Iterator 接口的例子。

    let obj = {data: [ 'hello', 'world' ],[Symbol.iterator]() {const self = this;let index = 0;return {next() {if (index < self.data.length) {return {value: self.data[index++],done: false};}return { value: undefined, done: true };}};}};

    对于类似数组的对象(存在数值键名和length属性),部署 Iterator 接口,有一个简便方法,就是Symbol.iterator方法直接引用数组的 Iterator 接口。

    NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];// 或者NodeList.prototype[Symbol.iterator] = [][Symbol.iterator];[...document.querySelectorAll('div')] // 可以执行了

    NodeList 对象是类似数组的对象,本来就具有遍历接口,可以直接遍历。上面代码中,我们将它的遍历接口改成数组的Symbol.iterator属性,可以看到没有任何影响。

    下面是另一个类似数组的对象调用数组的Symbol.iterator方法的例子。

    let iterable = {0: 'a',1: 'b',2: 'c',length: 3,[Symbol.iterator]: Array.prototype[Symbol.iterator]};for (let item of iterable) {console.log(item); // 'a', 'b', 'c'}

    注意,普通对象部署数组的Symbol.iterator方法,并无效果。

    let iterable = {a: 'a',b: 'b',c: 'c',length: 3,[Symbol.iterator]: Array.prototype[Symbol.iterator]};for (let item of iterable) {console.log(item); // undefined, undefined, undefined}

    如果Symbol.iterator方法对应的不是遍历器生成函数(即会返回一个遍历器对象),解释引擎将会报错。

    var obj = {};obj[Symbol.iterator] = () => 1;[...obj] // TypeError: [] is not a function

    上面代码中,变量obj的Symbol.iterator方法对应的不是遍历器生成函数,因此报错。

    有了遍历器接口,数据结构就可以用for...of循环遍历(详见下文),也可以使用while循环遍历。

    var $iterator = ITERABLE[Symbol.iterator]();var $result = $iterator.next();while (!$result.done) {var x = $result.value;// ...$result = $iterator.next();}

    上面代码中,ITERABLE代表某种可遍历的数据结构,$iterator是它的遍历器对象。遍历器对象每次移动指针(next方法),都检查一下返回值的done属性,如果遍历还没结束,就移动遍历器对象的指针到下一步(next方法),不断循环。

    3. 调用 Iterator 接口的场合

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

    (1)解构赋值

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

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

    (2)扩展运算符

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

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

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

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

    let arr = [...iterable];

    (3)yield*

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

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

    (4)其他场合

    由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。

    • for…of
    • Array.from()
    • Map(), Set(), WeakMap(), WeakSet()(比如new Map([[‘a’,1],[‘b’,2]]))
    • Promise.all()
    • Promise.race()

    4. 字符串的 Iterator 接口

    字符串是一个类似数组的对象,也原生具有 Iterator 接口。

    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方法返回一个遍历器对象,在这个遍历器上可以调用 next 方法,实现对于字符串的遍历。

    可以覆盖原生的Symbol.iterator方法,达到修改遍历器行为的目的。

    var str = new String("hi");[...str] // ["h", "i"]str[Symbol.iterator] = function() {return {next: function() {if (this._first) {this._first = false;return { value: "bye", done: false };} else {return { done: true };}},_first: true};};[...str] // ["bye"]str // "hi"

    上面代码中,字符串 strSymbol.iterator方法被修改了,所以扩展运算符(...)返回的值变成了bye,而字符串本身还是hi

    5. Iterator 接口与 Generator 函数

    Symbol.iterator()方法的最简单实现,还是使用 Generator 函数。

    let myIterable = {[Symbol.iterator]: function* () {yield 1;yield 2;yield 3;}};[...myIterable] // [1, 2, 3]// 或者采用下面的简洁写法let obj = {* [Symbol.iterator]() {yield 'hello';yield 'world';}};for (let x of obj) {console.log(x);}// "hello"// "world"

    上面代码中,Symbol.iterator()方法几乎不用部署任何代码,只要用 yield 命令给出每一步的返回值即可。

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

    遍历器对象除了具有next()方法,还可以具有return()方法和throw()方法。如果你自己写遍历器对象生成函数,那么next()方法是必须部署的,return()方法和throw()方法是否部署是可选的。

    return()方法的使用场合是,如果for…of循环提前退出(通常是因为出错,或者有break语句),就会调用return()方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署return()方法。

    function readLinesSync(file) {return {[Symbol.iterator]() {return {next() {return { done: false };},return() {file.close();return { done: true };}};},};}

    上面代码中,函数readLinesSync接受一个文件对象作为参数,返回一个遍历器对象,其中除了next()方法,还部署了return()方法。下面的两种情况,都会触发执行return()方法。

    // 情况一for (let line of readLinesSync(fileName)) {console.log(line);break;}// 情况二for (let line of readLinesSync(fileName)) {console.log(line);throw new Error();}

    上面代码中,情况一输出文件的第一行以后,就会执行return()方法,关闭这个文件;情况二会在执行return()方法关闭文件之后,再抛出错误。

    注意,return()方法必须返回一个对象,这是 Generator 语法决定的。

    throw()方法主要是配合 Generator 函数使用,一般的遍历器对象用不到这个方法。

    7. for…of 循环

    ES6 借鉴 C++、Java、C# 和 Python 语言,引入了for...of循环,作为遍历所有数据结构的统一的方法。

    一个数据结构只要部署了Symbol.iterator属性,就被视为具有 iterator 接口,就可以用for...of循环遍历它的成员。也就是说,for...of循环内部调用的是数据结构的Symbol.iterator方法。

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

    数组

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

    const arr = ['red', 'green', 'blue'];for(let v of arr) {console.log(v); // red green blue}const obj = {};obj[Symbol.iterator] = arr[Symbol.iterator].bind(arr);for(let v of obj) {console.log(v); // red green blue}

    上面代码中,空对象obj部署了数组arr的Symbol.iterator属性,结果objfor...of循环,产生了与arr完全一样的结果。

    for...of循环可以代替数组实例的forEach方法。

    const arr = ['red', 'green', 'blue'];arr.forEach(function (element, index) {console.log(element); // red green blueconsole.log(index); // 0 1 2});

    JavaScript 原有的for...in循环,只能获得对象的键名,不能直接获取键值。ES6 提供for...of循环,允许遍历获得键值。

    var arr = ['a', 'b', 'c', 'd'];for (let a in arr) {console.log(a); // 0 1 2 3}for (let a of arr) {console.log(a); // a b c d}

    上面代码表明,for...in循环读取键名,for...of循环读取键值。如果要通过for...of循环,获取数组的索引,可以借助数组实例的entries方法和keys方法(参见《数组的扩展》一章)。

    for...of循环调用遍历器接口,数组的遍历器接口只返回具有数字索引的属性。这一点跟for...in循环也不一样。

    let arr = [3, 5, 7];arr.foo = 'hello';for (let i in arr) {console.log(i); // "0", "1", "2", "foo"}for (let i of arr) {console.log(i); //"3", "5", "7"}

    上面代码中,for...of循环不会返回数组arrfoo属性。

    Set 和 Map 结构

    Set 和 Map 结构也原生具有 Iterator 接口,可以直接使用for...of循环。

    var engines = new Set(["Gecko", "Trident", "Webkit", "Webkit"]);for (var e of engines) {console.log(e);}// Gecko// Trident// Webkitvar es6 = new Map();es6.set("edition", 6);es6.set("committee", "TC39");es6.set("standard", "ECMA-262");for (var [name, value] of es6) {console.log(name + ": " + value);}// edition: 6// committee: TC39// standard: ECMA-262

    上面代码演示了如何遍历 Set 结构和 Map 结构。值得注意的地方有两个,首先,遍历的顺序是按照各个成员被添加进数据结构的顺序。其次,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']

    类似数组的对象

    类似数组的对象包括好几类。下面是for...of循环用于 字符串、DOM NodeList 对象、arguments对象的例子。

    // 字符串let str = "hello";for (let s of str) {console.log(s); // h e l l o}// DOM NodeList对象let paras = document.querySelectorAll("p");for (let p of paras) {p.classList.add("test");}// arguments对象function printArgs() {for (let x of arguments) {console.log(x);}}printArgs('a', 'b');// 'a'// 'b'

    对于字符串来说,for...of循环还有一个特点,就是会正确识别 32 位 UTF-16 字符。

    for (let x of 'a\uD83D\uDC0A') {console.log(x);}// 'a'// '\uD83D\uDC0A'

    并不是所有类似数组的对象都具有 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循环依然可以用来遍历键名。

    let es6 = {edition: 6,committee: "TC39",standard: "ECMA-262"};for (let e in es6) {console.log(e);}// edition// committee// standardfor (let e of es6) {console.log(e);}// TypeError: es6[Symbol.iterator] is not a function

    上面代码表示,对于普通的对象,for...in循环可以遍历键名,for...of循环会报错。

    一种解决方法是,使用Object.keys方法将对象的键名生成一个数组,然后遍历这个数组。

    for (var key of Object.keys(someObject)) {console.log(key + ': ' + someObject[key]);}

    另一个方法是使用 Generator 函数将对象重新包装一下。

    const obj = { a: 1, b: 2, c: 3 }function* entries(obj) {for (let key of Object.keys(obj)) {yield [key, obj[key]];}}for (let [key, value] of entries(obj)) {console.log(key, '->', value);}// a -> 1// b -> 2// c -> 3

    与其他遍历语法的比较

    以数组为例,JavaScript 提供多种遍历语法。最原始的写法就是for循环。

    for (var index = 0; index < myArray.length; index++) {console.log(myArray[index]);}

    这种写法比较麻烦,因此数组提供内置的forEach方法。

    myArray.forEach(function (value) {console.log(value);});

    这种写法的问题在于,无法中途跳出forEach循环,break命令或return命令都不能奏效。

    for...in循环可以遍历数组的键名。

    for (var index in myArray) {console.log(myArray[index]);}

    for...in循环有几个缺点。

    • 数组的键名是数字,但是for...in循环是以字符串作为键名“0”、“1”、“2”等等。
    • for...in循环不仅遍历数字键名,还会遍历手动添加的其他键,甚至包括原型链上的键。
    • 某些情况下,for...in循环会以任意顺序遍历键名。

    总之,for...in循环主要是为遍历对象而设计的,不适用于遍历数组。

    for...of循环相比上面几种做法,有一些显著的优点。

    for (let value of myArray) {console.log(value);}

    有着同for...in一样的简洁语法,但是没有for...in那些缺点。

    • 不同于forEach方法,它可以与break、continuereturn配合使用。
    • 提供了遍历所有数据结构的统一操作接口。

    下面是一个使用 break 语句,跳出for...of循环的例子。

    for (var n of fibonacci) {if (n > 1000)break;console.log(n);}

    上面的例子,会输出斐波纳契数列小于等于 1000 的项。如果当前项大于 1000,就会使用break语句跳出for...of循环。