本文参考自电子书《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 除了添加let
和const
命令,后面章节还会提到,另外两种声明变量的方法: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
上面最后一个表达式之所以会报错,是因为x
用y
做默认值时,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
,因为null
与undefined
不严格相等,所以是个有效的赋值,导致默认值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
都能取到值。
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined
和null
无法转为对象,所以对它们进行解构赋值,都会报错。
let { prop: x } = undefined; // TypeErrorlet { prop: y } = null; // TypeError
5. 函数参数的解构赋值
函数的参数也可以使用解构赋值。
function add([x, y]){return x + y;}add([1, 2]); // 3
上面代码中,函数add
的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x
和y
。对于函数内部的代码来说,它们能感受到的参数就是x
和y
。
下面是另一个例子。
[[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
的参数是一个对象,通过对这个对象进行解构,得到变量x
和y
的值。如果解构失败,x
和y
等于默认值。
注意,下面的写法会得到不一样的结果。
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
的参数指定默认值,而不是为变量x
和y
指定默认值,所以会得到与前一种写法不同的结果。
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];
上面代码交换变量x
和y
的值,这样的写法不仅简洁,而且易读,语义非常清晰。
(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_INTEGER
和Number.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()
的参数是一个对象时,变量x
和y
才会通过解构赋值生成。如果函数foo()
调用时没提供参数,变量x
和y
就不会生成,从而报错。通过提供函数参数的默认值,就可以避免这种情况。
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
上面代码中,a3
和a4
是用两种不同方法合并而成的新数组,但是它们的成员都是对原数组成员的引用,这就是浅拷贝。如果修改了引用指向的值,会同步反映到新数组。
(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 则是明确将空位转为undefined
。Array.from()
方法、扩展运算符(...
)、keys()
、values()
等都会将空位转为undefined
。for...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...in
、Object.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"
由于undefined
和null
无法转成对象,所以如果它们作为参数,就会报错。
Object.assign(undefined) // 报错Object.assign(null) // 报错
如果非对象参数出现在源对象的位置(即非首参数),那么处理规则有所不同。首先,这些参数都会转成对象,如果无法转成对象,就会跳过。这意味着,如果undefined
和null
不在首参数,就不会报错。
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
上面代码中,源对象obj1
的a
属性的值是一个对象,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()
方法将DEFAULTS
和options
合并成一个新对象,如果两者有同名属性,则options
的属性值会覆盖DEFAULTS
的属性值。
注意,由于存在浅拷贝的问题,DEFAULTS
对象和options
对象的所有属性的值,最好都是简单类型,不要指向另一个对象。否则,DEFAULTS
对象的该属性很可能不起作用。
const DEFAULTS = {url: {host: 'example.com',port: 7070},};processContent({ url: {port: 8000} })// {// url: {port: 8000}// }
上面代码的原意是将url.port
改成 8000
,url.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 判断运算符
??
:它的行为类似||
,但是只有运算符左侧的值为null
或undefined
时,才会返回右侧的值。例如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
运算符的结果,表明变量s
是 Symbol
数据类型,而不是字符串之类的其他类型。
注意,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)"
上面代码中,s1
和s2
是两个 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
上面代码中,s1
和s2
都是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]
而且,数组的map
和filter
方法也可以间接用于 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 结构实现。如果你需要“键值对”的数据结构,Map
比 Object
更合适。
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
实例时,就指定了两个键name
和title
。
Map
构造函数接受数组作为参数,实际上执行的是下面的算法。
const items = [['name', '张三'],['title', 'Author']];const map = new Map();items.forEach(([key, value]) => map.set(key, value));
事实上,不仅仅是数组,任何具有 Iterator
接口、且每个成员都是一个双元素的数组的数据结构都可以当作Map
构造函数的参数。这就是说,Set
和Map
都可以用来生成新的 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
上面代码的set
和get
方法,表面是针对同一个键,但实际上这是两个不同的数组实例,内存地址是不一样的,因此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
上面代码中,变量k1
和k2
的值是一样的,但是它们在 Map 结构中被视为两个键。
如果 Map
的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map
将其视为一个键,比如0
和-0
就是一个键,布尔值true
和字符串true
则是两个不同的键。另外,undefined
和null
也是两个不同的键。虽然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
本身没有map
和filter
方法)。
const map0 = new Map().set(1, 'a').set(2, 'b').set(3, 'c');const map1 = new Map([...map0].filter(([k, v]) => k < 3));// 产生 Map 结构 {1 => 'a', 2 => 'b'}const map2 = new Map([...map0].map(([k, v]) => [k * 2, '_' + v]));// 产生 Map 结构 {2 => '_a', 4 => '_b', 6 => '_c'}
此外,Map 还有一个forEach
方法,与数组的forEach
方法类似,也可以实现遍历。
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
方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value
和done
两个属性的对象。其中,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
方法返回一个对象,表示当前数据成员的信息。这个对象具有value
和done
两个属性,value
属性返回当前位置的成员,done
属性是一个布尔值,表示遍历是否结束,即是否还有必要再一次调用next
方法。
总之,调用指针对象的next
方法,就可以遍历事先给定的数据结构。
对于遍历器对象来说,done: false
和value: 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
方法,都会返回一个代表当前成员的信息对象,具有value
和done
两个属性。
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
是一个数组,原生就具有遍历器接口,部署在arr
的Symbol.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"
上面代码中,字符串 str
的Symbol.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属性,结果obj
的for...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
循环不会返回数组arr
的foo
属性。
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、continue
和return
配合使用。 - 提供了遍历所有数据结构的统一操作接口。
下面是一个使用 break
语句,跳出for...of
循环的例子。
for (var n of fibonacci) {if (n > 1000)break;console.log(n);}
上面的例子,会输出斐波纳契数列小于等于 1000 的项。如果当前项大于 1000,就会使用break
语句跳出for...of
循环。