ES6
对象冻结
将对象冻结,应该使用Object.freeze
方法
const foo = Object.freeze({}); // 常规模式时,下面一行不起作用; // 严格模式时,该行会报错 foo.prop = 123;
顶层对象
JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。
- 浏览器里面,顶层对象是
window
,但 Node 和 Web Worker 没有window
。 - 浏览器和 Web Worker 里面,
self
也指向顶层对象,但是 Node 没有self
。 - Node 里面,顶层对象是
global
,但其他环境都不支持。
同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用this
关键字,但是有局限性。
ES2020 在语言标准的层面,引入globalThis
作为顶层对象。也就是说,任何环境下,globalThis
都是存在的,都可以从它拿到顶层对象,指向全局环境下的this
。
垫片库global-this
模拟了这个提案,可以在所有环境拿到globalThis
。
数组解构
只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。
解构赋值允许指定默认值。
let [foo = true] = []; foo // true let [x, y = 'b'] = ['a']; // x='a', y='b' let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'
ES6 内部使用严格相等运算符(===
),判断一个位置是否有值。所以,只有当一个数组成员严格等于undefined
,默认值才会生效。
对象解构
由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
let arr = [1, 2, 3]; let {0 : first, [arr.length - 1] : last} = arr; first // 1 last // 3
类似数组的对象都有一个length
属性,因此还可以对这个属性解构赋值。
let {length : len} = 'hello'; len // 5
省略某个属性,直接留空就行
for (let [key, value] of map) // 获取键名 for (let [key] of map) // 获取键值 for (let [,value] of map)
不要将一个已经声明的变量用于解构赋值,因为 JavaScript 引擎会将{x}
理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。
// 错误的写法 let x; {x} = {z: 1}; // SyntaxError: Identifier 'x' has already been declared"
// 正确的写法
({x} = {z: 1});
不要在模式中放置圆括号,有可能导致解构的歧义
解构赋值用途
从函数返回多个值
function example() { return { foo: 1, bar: 2 }; } let { foo, bar } = example();
函数参数的默认值
function (url, { async = true, beforeSend = function () {}, cache = true, complete = function () {}, crossDomain = false, global = true, // ... more config } = {}) { // ... do stuff };
提取 JSON 对象中的数据
jsonData = { id: 42, status: "OK", data: [867, 5309] }; let { id, status, data: number } = jsonData;
字符串遍历器
for (let codePoint of 'foo') { console.log(codePoint) }
for...of
循环遍历器最大的优点是可以识别大于0xFFFF
的码点,传统的for
循环无法识别这样的码点。
模板字符串
模板字符串是增强版的字符串,用反引号(`)标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。
// 普通字符串 `In JavaScript '\n' is a line-feed.` // 多行字符串 `In JavaScript this is not legal.` // 字符串中嵌入变量 let name = "Bob", time = "today"; `Hello ${name}, how are you ${time}?`
如果在模板字符串中需要使用反引号,则前面要用反斜杠转义。
let greeting = `\`Yo\` World!`;
如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中。如果你不想要这个换行,可以使用trim
方法消除它。
$('#list').html(`
<ul>
<li>first</li>
<li>second</li>
</ul>
`.trim());
标签模板
“标签”指的就是函数,紧跟在后面的模板字符串就是它的参数
let a = 5; let b = 10; tag`Hello ${ a + b } world ${ a * b }`; // 等同于 tag(['Hello ', ' world ', ''], 15, 50);
“标签模板”的一个重要应用,就是过滤 HTML 字符串;
let message = SaferHTML`<p>${sender} has sent you a message.</p>`;
function SaferHTML(templateData) {
这里,templateData 是 ["<p>", " has sent you a message.</p>"],arguments 是 templateData 和 ${sender} 组成的数组
ES6 还为原生的 String 对象,提供了一个raw()
方法。该方法返回一个斜杠都被转义。因此,templateData 参数有一个raw
属性,也指向一个数组。该数组的成员与templateData 数组完全一致。比如,templateData 数组是["First line\nSecond line"]
,那么templateData.raw
数组就是["First line\\nSecond line"]
。两者唯一的区别,就是字符串里面的斜杠都被转义了。
模板字符串默认会将字符串转义。ES2018 放松了对标签模板里面的字符串转义的限制。如果遇到不合法的字符串转义,就返回undefined
,而不是报错,并且从raw
属性上面可以得到原始字符串。
字符串对象的新增方法
JavaScript 只有indexOf
方法,可以用来确定一个字符串是否包含在另一个字符串中。ES6 又提供了三种新方法。
- includes():返回布尔值,表示是否找到了参数字符串。
- startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
- endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。
let s = 'Hello world!'; s.endsWith('Hello', 5) // true
这三个方法都支持第二个参数,表示开始搜索的位置。使用第二个参数n
时,endsWith
的行为与其他两个方法有所不同。它针对前n
个字符,而其他两个方法针对从第n
个位置直到字符串结束。
repeat
方法返回一个新字符串,表示将原字符串重复n
次。padStart()
用于头部补全,padEnd()
用于尾部补全。ES2019 对字符串实例新增了trimStart()
和trimEnd()
这两个方法。
'na'.repeat(0) // "" 'x'.padStart(4, 'ab') // 'abax' 'x'.padStart(4) // ' x' '09-12'.padStart(10, 'YYYY-MM-DD') // "YYYY-09-12"
历史上,字符串的实例方法replace()
只能替换第一个匹配。ES2021 引入了replaceAll()
方法,可以一次性替换所有匹配。
// 不报错 'aabbcc'.replace(/b/, '_') // 报错 'aabbcc'.replaceAll(/b/, '_')
如果searchValue
是一个不带有g
修饰符的正则表达式,replaceAll()
会报错。这一点跟replace()
不同。
replaceAll()
的第二个参数replacement
是一个字符串,表示替换的文本,其中可以使用一些特殊字符串。
$&
:匹配的字符串。$`
:匹配结果前面的文本。$'
:匹配结果后面的文本。$n
:匹配成功的第n
组内容,n
是从1开始的自然数。这个参数生效的前提是,第一个参数必须是正则表达式。$$
:指代美元符号$
。
// $& 表示匹配的字符串,即`b`本身 // 所以返回结果与原字符串一致 'abbc'.replaceAll('b', '$&') // 'abbc' // $` 表示匹配结果之前的字符串 // 对于第一个`b`,$` 指代`a` // 对于第二个`b`,$` 指代`ab` 'abbc'.replaceAll('b', '$`') // 'aaabc' // $' 表示匹配结果之后的字符串 // 对于第一个`b`,$' 指代`bc` // 对于第二个`b`,$' 指代`c` 'abbc'.replaceAll('b', `$'`) // 'abccc' // $1 表示正则表达式的第一个组匹配,指代`ab` // $2 表示正则表达式的第二个组匹配,指代`bc` 'abbc'.replaceAll(/(ab)(bc)/g, '$2$1') // 'bcab' // $$ 指代 $ 'abc'.replaceAll('b', '$$') // 'a$c'
replaceAll()
的第二个参数replacement
除了为字符串,也可以是一个函数,该函数的返回值将替换掉第一个参数searchValue
匹配的文本。
'aabbcc'.replaceAll('b', () => '_') // 'aa__cc'
字符串对象共有 4 个方法,可以使用正则表达式:match()
、replace()
、search()
和split()
。
at()
方法接受一个整数作为参数,返回参数指定位置的字符,支持负索引
const str = 'hello'; str.at(1) // "e" str.at(-1) // "o"
如果参数位置超出了字符串范围,at()
返回undefined
。
正则的扩展
ES6 还为正则表达式添加了y
修饰符,叫做“粘连”(sticky)修饰符。
y
修饰符的作用与g
修饰符类似,也是全局匹配,后一次匹配都从上一次匹配成功的下一个位置开始。不同之处在于,g
修饰符只要剩余位置中存在匹配就可,而y
修饰符确保匹配必须从剩余的第一个位置开始。
var s = 'aaa_aa_a'; var r1 = /a+/g; var r2 = /a+/y; r1.exec(s) // ["aaa"] r2.exec(s) // ["aaa"] r1.exec(s) // ["aa"] r2.exec(s) // null
实际上,y
修饰符号隐含了头部匹配的标志^
。y
修饰符的设计本意,就是让头部匹配的标志^
在全局匹配中都有效。
单单一个y
修饰符对match
方法,只能返回第一个匹配,必须与g
修饰符联用,才能返回所有匹配。
'a1a2a3'.match(/a\d/y) // ["a1"] 'a1a2a3'.match(/a\d/gy) // ["a1", "a2", "a3"]
y
修饰符的一个应用,是从字符串提取 token(词元),y
修饰符确保了匹配之间不会有漏掉的字符。
const TOKEN_Y = /\s*(\+|[0-9]+)\s*/y; const TOKEN_G = /\s*(\+|[0-9]+)\s*/g; tokenize(TOKEN_Y, '3x + 4') // [ '3' ] tokenize(TOKEN_G, '3x + 4') // [ '3', '+', '4' ] function tokenize(TOKEN_REGEX, str) { let result = []; let match; while (match = TOKEN_REGEX.exec(str)) { result.push(match[1]); } return result; }
上面代码中,g
修饰符会忽略非法字符,而y
修饰符不会,这样就很容易发现错误。
正则表达式中,点(.
)是一个特殊字符,代表任意的单个字符,但是有两个例外。一个是四个字节的 UTF-16 字符,这个可以用u
修饰符解决;另一个是行终止符(line terminator character)。
但是,很多时候我们希望匹配的是任意单个字符,ES2018 引入s
修饰符,使得.
可以匹配任意单个字符。
/foo.bar/s.test('foo\nbar') // true
/s
修饰符和多行修饰符/m
不冲突,两者一起使用的情况下,.
匹配所有字符,而^
和$
匹配每一行的行首和行尾。
正则表达式使用圆括号进行组匹配。
const RE_DATE = /(\d{4})-(\d{2})-(\d{2})/; const matchObj = RE_DATE.exec('1999-12-31'); const year = matchObj[1]; // 1999 const month = matchObj[2]; // 12 const day = matchObj[3]; // 31
ES2018 引入了具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用。
const RE_DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; const matchObj = RE_DATE.exec('1999-12-31'); const year = matchObj.groups.year; // "1999" const month = matchObj.groups.month; // "12" const day = matchObj.groups.day; // "31"
如果具名组没有匹配,那么对应的groups
对象属性会是undefined
。
有了具名组匹配以后,可以使用解构赋值直接从匹配结果上为变量赋值。
let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar'); one // foo two // bar
字符串替换时,使用$<组名>
引用具名组。
let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u; '2015-01-02'.replace(re, '$<day>/$<month>/$<year>') // '02/01/2015'
replace
方法的第二个参数也可以是函数,该函数的参数序列如下。
'2015-01-02'.replace(re, ( matched, // 整个匹配结果 2015-01-02 capture1, // 第一个组匹配 2015 capture2, // 第二个组匹配 01 capture3, // 第三个组匹配 02 position, // 匹配开始的位置 0 S, // 原字符串 2015-01-02 groups // 具名组构成的一个对象 {year, month, day} ) => { let {day, month, year} = groups; return `${day}/${month}/${year}`; });
如果要在正则表达式内部引用某个“具名组匹配”,可以使用\k<组名>
的写法。
const RE_TWICE = /^(?<word>[a-z]+)!\k<word>$/; RE_TWICE.test('abc!abc') // true RE_TWICE.test('abc!ab') // false
数字引用(\1
)依然有效。
const RE_TWICE = /^(?<word>[a-z]+)!\1$/;
正则实例的exec()
方法有一个index
属性,可以获取整个匹配结果的开始位置。但是,组匹配的每个组的开始位置,很难拿到。
ES2022 新增了d
修饰符,这个修饰符可以让exec()
、match()
的返回结果添加indices
属性,在该属性上面可以拿到匹配的开始位置和结束位置。
const text = 'zabbcdef'; const re = /ab/d; const result = re.exec(text); result.index // 1 result.indices // [ [1, 3] ]
注意,开始位置包含在匹配结果之中,相当于匹配结果的第一个字符的位置。但是,结束位置不包含在匹配结果之中,是匹配结果的下一个字符。比如,上例匹配结果的最后一个字符b
的位置,是原始字符串的2号位,那么结束位置3
就是下一个字符的位置。
indices
属性对应的数组就会包含多个成员,提供每个组匹配的开始位置和结束位置。const text = 'zabbcdef'; const re = /ab+(cd)/d; const result = re.exec(text); result.indices // [ [ 1, 6 ], [ 4, 6 ] ]
上面例子中,正则表达式re
包含一个组匹配(cd)
,那么indices
属性数组就有两个成员,第一个成员是整个匹配结果(abbcd
)的开始位置和结束位置,第二个成员是组匹配(cd
)的开始位置和结束位置。
indices
属性数组还会有一个groups
属性。该属性是一个对象,可以从该对象获取具名组匹配的开始位置和结束位置。const text = 'zabbcdef'; const re = /ab+(?<Z>cd)/d; const result = re.exec(text); result.indices.groups // { Z: [ 4, 6 ] }
如果一个正则表达式在字符串里面有多个匹配,现在一般使用g
修饰符或y
修饰符,在循环里面逐一取出。
ES2020 增加了String.prototype.matchAll()
方法,可以一次性取出所有匹配。不过,它返回的是一个遍历器(Iterator),而不是数组。
const string = 'test1test2test3'; const regex = /t(e)(st(\d?))/g; for (const match of string.matchAll(regex)) { console.log(match); } // ["test1", "e", "st1", "1", index: 0, input: "test1test2test3"] // ["test2", "e", "st2", "2", index: 5, input: "test1test2test3"] // ["test3", "e", "st3", "3", index: 10, input: "test1test2test3"]
上面代码中,由于string.matchAll(regex)
返回的是遍历器,所以可以用for...of
循环取出。相对于返回数组,返回遍历器的好处在于,如果匹配结果是一个很大的数组,那么遍历器比较节省资源。
遍历器转为数组是非常简单的,使用...
运算符和Array.from()
方法就可以了。
// 转为数组的方法一 [...string.matchAll(regex)] // 转为数组的方法二 Array.from(string.matchAll(regex))
数值的扩展
ES2021,允许 JavaScript 的数值使用下划线(_
)作为分隔符。
ES6 在Number
对象上,新提供了Number.isFinite()
和Number.isNaN()
两个方法。它们与传统的全局方法isFinite()
和isNaN()
的区别在于,传统方法先调用Number()
将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()
对于非数值一律返回false
, Number.isNaN()
只有对于NaN
才返回true
,非NaN
一律返回false
。
isFinite(25) // true isFinite("25") // true Number.isFinite(25) // true Number.isFinite("25") // false isNaN(NaN) // true isNaN("NaN") // true Number.isNaN(NaN) // true Number.isNaN("NaN") // false Number.isNaN(1) // false
ES6 将全局方法parseInt()
和parseFloat()
,移植到Number
对象上面,行为完全保持不变。
JavaScript 内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。
Number.isInteger(25) // true Number.isInteger(25.0) // true
ES6 在
Number.EPSILON === Math.pow(2, -52)Number
对象上面,新增一个极小的常量Number.EPSILON
。
Number.EPSILON
实际上是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了。引入一个这么小的量的目的,在于为浮点数计算,设置一个误差范围。我们知道浮点数计算是不精确的。
ES6 引入了Number.MAX_SAFE_INTEGER
和Number.MIN_SAFE_INTEGER
这两个常量,用来表示这个范围的上下限。
Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1 // true Number.MAX_SAFE_INTEGER === 9007199254740991 // true Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER // true Number.MIN_SAFE_INTEGER === -9007199254740991 // true
Number.isSafeInteger()
则是用来判断一个整数是否落在这个范围之内。
函数的扩展
函数参数的默认值生效以后,参数解构赋值依然会进行。
function f({ a, b = 'world' } = { a: 'hello' }) { console.log(b); } f() // world
上面示例中,函数f()
调用时没有参数,所以参数默认值{ a: 'hello' }
生效,然后再对这个默认值进行解构赋值,从而触发参数变量b
的默认值生效。
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。
let x = 1; function f(y = x) { let x = 2; console.log(y); } f() // 1
上面代码中,函数f
调用时,参数y = x
形成一个单独的作用域。这个作用域里面,变量x
本身没有定义,所以指向外层的全局变量x
。函数调用时,函数体内部的局部变量x
影响不到默认值变量x
。
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
function throwIfMissing() { throw new Error('Missing parameter'); } function foo(mustBeProvided = throwIfMissing()) { return mustBeProvided; } foo() // Error: Missing parameter
ES6 引入 rest 参数(形式为...变量名
),用于获取函数的多余参数,这样就不需要使用arguments
对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
// arguments变量的写法 function sortNumbers() { return Array.from(arguments).sort(); } // rest参数的写法 const sortNumbers = (...numbers) => numbers.sort();
ES2016 做了一点修改,规定只要函数参数使用了默认值、解构赋值、或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
这样规定的原因是,函数内部的严格模式,同时适用于函数体和函数参数。但是,函数执行的时候,先执行函数参数,然后再执行函数体。这样就有一个不合理的地方,只有从函数体之中,才能知道参数是否应该以严格模式执行,但是参数却应该先于函数体执行。
箭头函数
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
// 报错 let getTempItem = id => { id: id, name: "Temp" }; // 不报错 let getTempItem = id => ({ id: id, name: "Temp" });
如果箭头函数只有一行语句,且不需要返回值,可以采用下面的写法,就不用写大括号了。
let fn = () => void doesNotReturn();
对于普通函数来说,内部的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
,所以当然也就不能用call()
、apply()
、bind()
这些方法去改变this
的指向。
由于箭头函数使得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
。
第二个场合是需要动态this
的时候,也不应使用箭头函数。
var button = document.getElementById('press'); button.addEventListener('click', () => { this.classList.toggle('on'); });
上面代码运行时,点击按钮会报错,因为button
的监听函数是一个箭头函数,导致里面的this
就是全局对象。如果改成普通函数,this
就会动态指向被点击的按钮对象。
尾调用
尾调用是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是返回另一个函数。
function f(x){ return g(x); }
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。
注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
function addOne(a){ var one = 1; function inner(b){ return b + one; } return inner(a); }
目前只有 Safari 浏览器支持尾调用优化,Chrome 和 Firefox 都不支持。
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。
function factorial(n) { if (n === 1) return 1; return n * factorial(n - 1); } factorial(5) // 120 function factorial(n, total) { if (n === 1) return total; return factorial(n - 1, n * total); } factorial(5, 1) // 120
柯里化(currying),意思是将多参数的函数转换成单参数的形式。
function currying(fn, n) { return function (m) { return fn.call(this, m, n); }; } const factorial = currying(tailFactorial, 1); factorial(5) // 120
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
func.arguments
:返回调用时函数的参数。func.caller
:返回调用当前函数的那个函数。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
尾递归优化只在严格模式下生效,那么正常模式下,或者那些不支持该功能的环境中,有没有办法也使用尾递归优化呢?回答是可以的,就是自己实现尾递归优化。
它的原理非常简单。尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。怎么做可以减少调用栈呢?就是采用“循环”换掉“递归”。
蹦床函数(trampoline)可以将递归执行转为循环执行。
function tco(f) { var value; var active = false; var accumulated = []; return function accumulator() { accumulated.push(arguments); if (!active) { active = true; while (accumulated.length) { value = f.apply(this, accumulated.shift()); } active = false; return value; } }; } var sum = tco(function(x, y) { if (y > 0) { return sum(x + 1, y - 1) } else { return x } }); sum(1, 100000) // 100001
上面代码中,tco
函数是尾递归优化的实现,它的奥妙就在于状态变量active
。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum
返回的都是undefined
,所以就避免了递归执行;而accumulated
数组存放每一轮sum
执行的参数,总是有值的,这就保证了accumulator
函数内部的while
循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。
ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。这样的规定也使得,函数参数与数组和对象的尾逗号规则,保持一致了。
ES2019 做出了改变,允许catch
语句省略参数。
数组的扩展
扩展运算符(spread)是三个点(...
)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。该运算符主要用于函数调用
function add(x, y) { return x + y; } const numbers = [4, 38]; add(...numbers) // 42
由于扩展运算符可以展开数组,所以不再需要apply()
方法将数组转为函数的参数了。
// ES5 的写法 function f(x, y, z) { // ... } var args = [0, 1, 2]; f.apply(null, args); // ES6 的写法 function f(x, y, z) { // ... } let args = [0, 1, 2]; f(...args);
扩展运算符提供了复制数组的简便写法。
const a1 = [1, 2]; // 写法一 const a2 = [...a1]; // 写法二 const [...a2] = a1;
上面的两种写法,a2
都是a1
的克隆。
扩展运算符提供了数组合并的新写法。
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' ]
扩展运算符还可以将字符串转为真正的数组。
function length(str) { return [...str].length; } length('x\uD83D\uDE80y') // 3
上面的写法,有一个重要的好处,那就是能够正确识别四个字节的 Unicode 字符。
Array.from()
方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。
实际应用中,常见的类似数组的对象是 DOM 操作返回的 NodeList 集合,以及函数内部的arguments
对象。Array.from()
都可以将它们转为真正的数组。
所谓类似数组的对象,本质特征只有一点,即必须有length
属性。因此,任何有length
属性的对象,都可以通过Array.from()
方法转为数组,而此时扩展运算符就无法转换。
Array.from({ length: 3 }); // [ undefined, undefined, undefined ]
Array.from()
还可以接受一个函数作为第二个参数,作用类似于数组的map()
方法
Array.from()
可以将各种值转为真正的数组,并且还提供map
功能。这实际上意味着,只要有一个原始的数据结构,你就可以先对它的值进行处理,然后转成规范的数组结构,进而就可以使用数量众多的数组方法。
Array.of()
方法用于将一组值,转换为数组。这个方法的主要目的,是弥补数组构造函数Array()
的不足。因为参数个数的不同,会导致Array()
的行为有差异。
Array() // [] Array(3) // [, , ,] Array(3, 11, 8) // [3, 11, 8]
Array.of(3) // [3]
Array.of()
基本上可以用来替代Array()
或new Array()
,并且不存在由于参数不同而导致的重载。它的行为非常统一。
数组实例的copyWithin()
方法,在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法,会修改当前数组。
它接受三个参数。
- target(必需):从该位置开始替换数据。如果为负值,表示倒数。
- start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
- end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。
这三个参数都应该是数值,如果不是,会自动转为数值。
[1, 2, 3, 4, 5].copyWithin(0, 3) // [4, 5, 3, 4, 5]
上面代码表示将从 3 号位直到数组结束的成员(4 和 5),复制到从 0 号位开始的位置,结果覆盖了原来的 1 和 2。
find(),findIndex(),findLast(),findLastIndex()方法都可以接受第二个参数,用来绑定回调函数的this
对象。
function f(v){ return v > this.age; } let person = {name: 'John', age: 20}; [10, 12, 26, 15].find(f, person); // 26
另外,这些方法都可以发现NaN
,弥补了数组的indexOf()
方法的不足。
[NaN].indexOf(NaN) // -1 [NaN].findIndex(y => Object.is(NaN, y)) // 0
fill
方法使用给定值,填充一个数组。fill
方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置。
注意,如果填充的类型为对象,那么被赋值的是同一个内存地址的对象,而不是深拷贝对象。
let arr = new Array(3).fill([]); arr[0].push(5); arr // [[5], [5], [5]]
ES6 提供三个新的方法——entries()
,keys()
和values()
——用于遍历数组。它们都返回一个遍历器对象
for (let [index, elem] of ['a', 'b'].entries()) { console.log(index, elem); }
数组的成员有时还是数组,Array.prototype.flat()
用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。
如果原数组有空位,flat()
方法会跳过空位。
[1, 2, , 4, 5].flat() // [1, 2, 4, 5]
flat()
默认只会“拉平”一层,如果想要“拉平”多层的嵌套数组,可以将flat()
方法的参数写成一个整数,表示想要拉平的层数,如果不管有多少层嵌套,都要转成一维数组,可以用Infinity
关键字作为参数。
[1, [2, [3]]].flat(Infinity) // [1, 2, 3]
长久以来,JavaScript 不支持数组的负索引,如果要引用数组的最后一个成员,不能写成arr[-1]
,只能使用arr[arr.length - 1]
。
这是因为方括号运算符[]
在 JavaScript 语言里面,不仅用于数组,还用于对象。对于对象来说,方括号里面就是键名,比如obj[1]
引用的是键名为字符串1
的键,同理obj[-1]
引用的是键名为字符串-1
的键。由于 JavaScript 的数组是特殊的对象,所以方括号里面的负数无法再有其他语义了,也就是说,不可能添加新语法来支持负索引。
为了解决这个问题,ES2022 为数组实例增加了at()
方法,接受一个整数作为参数,返回对应位置的成员,并支持负索引。这个方法不仅可用于数组,也可用于字符串和类型数组(TypedArray)。
const arr = [5, 12, 8, 130, 44]; arr.at(2) // 8 arr.at(-2) // 130
ES5 对空位的处理,已经很不一致了,大多数情况下会忽略空位。ES6 则是明确将空位转为undefined
。
Array.from()
方法会将数组的空位,转为undefined。扩展运算符(
...
)也会将空位转为undefined
copyWithin()
会连空位一起拷贝。
fill()
会将空位视为正常的数组位置。
for...of
循环也会遍历空位
由于空位的处理规则非常不统一,所以建议避免出现空位。
Object 对象的新增方法
ES5 比较两个值是否相等,只有两个运算符:相等运算符(==
)和严格相等运算符(===
)。它们都有缺点,前者会自动转换数据类型,后者的NaN
不等于自身,以及+0
等于-0
。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。
ES6 提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is
就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。
+0 === -0 //true NaN === NaN // false Object.is(+0, -0) // false Object.is(NaN, NaN) // true
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}
如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。
如果该参数不是对象,则会先转成对象,然后返回。
如果非对象参数出现在源对象的位置(即非首参数),如果无法转成对象,就会跳过。这意味着,如果undefined
和null
不在首参数,就不会报错。
let obj = {a: 1}; Object.assign(obj, undefined) === obj // true Object.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" }
Object.assign()
拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性
Object.assign()
可以用来处理数组,但是会把数组视为对象。
Object.assign([1, 2, 3], [4, 5]) // [4, 5, 3]
上面代码中,Object.assign()
把数组视为属性名为 0、1、2 的对象,因此源数组的 0 号属性4
覆盖了目标数组的 0 号属性1
。
Object.assign()
只能进行值的复制,如果要复制的值是一个取值函数,那么将求值后再复制。
const source = { get foo() { return 1 } }; const target = {}; Object.assign(target, source) // { foo: 1 }
Object.assign()
方法有很多用处。
为对象添加属性
class Point { constructor(x, y) { Object.assign(this, {x, y}); } }
将x
属性和y
属性添加到Point
类的对象实例。
为对象添加方法
Object.assign(SomeClass.prototype, { someMethod(arg1, arg2) { ··· }, anotherMethod() { ··· } }); // 等同于下面的写法 SomeClass.prototype.someMethod = function (arg1, arg2) { ··· }; SomeClass.prototype.anotherMethod = function () { ··· };
克隆对象
采用这种方法克隆,只能克隆原始对象自身的值,不能克隆它继承的值。如果想要保持继承链,可以采用下面的代码。
function clone(origin) { let originProto = Object.getPrototypeOf(origin); return Object.assign(Object.create(originProto), origin); }
合并多个对象
const merge =
(target, ...sources) => Object.assign(target, ...sources);
为属性指定默认值
const DEFAULTS = { logLevel: 0, outputFormat: 'html' }; function processContent(options) { options = Object.assign({}, DEFAULTS, options); console.log(options); // ... }
注意,由于存在浅拷贝的问题,DEFAULTS
对象和options
对象的所有属性的值,最好都是简单类型,不要指向另一个对象。否则,DEFAULTS
对象的该属性很可能不起作用。
ES5 的Object.getOwnPropertyDescriptor()
方法会返回某个对象属性的描述对象(descriptor)。ES2017 引入了Object.getOwnPropertyDescriptors()
方法,返回指定对象所有自身属性(非继承属性)的描述对象。
该方法的引入目的,主要是为了解决Object.assign()
无法正确拷贝get
属性和set
属性的问题。这是因为Object.assign
方法总是拷贝一个属性的值,而不会拷贝它背后的赋值方法或取值方法。
const source = { set foo(value) { console.log(value); } }; const target2 = {}; Object.defineProperties(target2, Object.getOwnPropertyDescriptors(source)); Object.getOwnPropertyDescriptor(target2, 'foo') // { get: undefined, // set: [Function: set foo], // enumerable: true, // configurable: true }
Object.getOwnPropertyDescriptors()
方法的另一个用处,是配合Object.create()
方法,将对象属性克隆到一个新对象。这属于浅拷贝。
const clone = Object.create(Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj));
另外,Object.getOwnPropertyDescriptors()
方法可以实现一个对象继承另一个对象。以前,继承另一个对象,常常写成下面这样。
const obj = { __proto__: prot, foo: 123, };
ES6 规定__proto__
只有浏览器要部署,其他环境不用部署。如果去除__proto__
,上面代码就要改成下面这样。
const obj = Object.create(prot); obj.foo = 123; // 或者 const obj = Object.assign( Object.create(prot), { foo: 123, } );
有了Object.getOwnPropertyDescriptors()
,我们就有了另一种写法。
const obj = Object.create( prot, Object.getOwnPropertyDescriptors({ foo: 123, }) );
Object.getOwnPropertyDescriptors()
也可以用来实现 Mixin(混入)模式。
let mix = (object) => ({ with: (...mixins) => mixins.reduce( (c, mixin) => Object.create( c, Object.getOwnPropertyDescriptors(mixin) ), object) }); // multiple mixins example let a = {a: 'a'}; let b = {b: 'b'}; let c = {c: 'c'}; let d = mix(c).with(a, b); d.c // "c" d.b // "b" d.a // "a"
JavaScript 语言的对象继承是通过原型链实现的。ES6 提供了更多原型对象的操作方法。
用下面的Object.setPrototypeOf()
(写操作)、Object.getPrototypeOf()
(读操作)、Object.create()
(生成操作)代替。
Object.fromEntries()
方法是Object.entries()
的逆操作,用于将一个键值对数组转为对象。该方法的主要目的,是将键值对的数据结构还原为对象,因此特别适合将 Map 结构转为对象。
// 例一 const entries = new Map([ ['foo', 'bar'], ['baz', 42] ]); Object.fromEntries(entries) // { foo: "bar", baz: 42 } // 例二 const map = new Map().set('foo', true).set('bar', false); Object.fromEntries(map) // { foo: true, bar: false }
该方法的一个用处是配合URLSearchParams
对象,将查询字符串转为对象。
Object.fromEntries(new URLSearchParams('foo=bar&baz=qux')) // { foo: "bar", baz: "qux" }
JavaScript 对象的属性分成两种:自身的属性和继承的属性。对象实例有一个hasOwnProperty()
方法,可以判断某个属性是否为原生属性。ES2022 在Object
对象上面新增了一个静态方法Object.hasOwn()
,也可以判断是否为自身的属性。
Object.hasOwn()
的一个好处是,对于不继承Object.prototype
的对象不会报错,而hasOwnProperty()
是会报错的。
const obj = Object.create(null); obj.hasOwnProperty('foo') // 报错 Object.hasOwn(obj, 'foo') // false