开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。
红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否
Object:
显式地创建Object的实例有两种方式:
构造函数:
1
2
3
let person = new Object();
person.name = "Matt";
person.age = 29;
对象字面量(更常用):
1
2
3
4
let person = {
name: "Matt",
age: 29,
};
{} 和 new Object() 效果相同,但字面量写法更简洁,也有更强的”把所有相关数据封装在一起”的感觉。
表达式上下文和语句上下文:
对象字面量中的 { 之所以能被识别为表达式开始,是因为它出现在表达式上下文(期待返回值的地方,比如赋值右侧)。如果 {} 出现在语句上下文(比如 if 条件后),就会被解释为语句块:
1
2
3
4
5
// 语句上下文 — 这是代码块,不是对象
if (true) { let x = 1; }
// 表达式上下文 — 这是空对象
let obj = {};
属性名可以是字符串或数值:
1
2
3
4
5
let person = {
"name": "Matt", // 字符串属性名,加引号可省略
age: 29, // 标识符形式,可以不引号
5: true // 数值会自动转为字符串 "5"
};
age是这个对象的最后一个属性,所以没有后逗号,不过现代浏览器支持最后一个属性后加逗号。
给函数传大量可选参数:
使用对象字面量是传递可选参数的好方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
function displayInfo(args) {
let output = "";
if (typeof args.name === "string") {
output += "Name: " + args.name + "\n";
}
if (typeof args.age === "number") {
output += "Age: " + args.age + "\n";
}
alert(output);
}
displayInfo({ name: "Matt", age: 29 }); // 两个参数
displayInfo({ name: "Greg" }); // 只传 name
这种模式非常适合函数有大量可选参数的情况。一般来说,命名参数更直观,但在可选参数过多的时候就显得笨拙了。最好的方式是对必选参数使用命名参数,再通过一个对象字面量来封装多个可选参数,是很常见的设计模式。
因为如果可选参数很多,用命名参数会很乱:
1
2
3
4
5
// ❌ 参数太多,顺序容易搞错
function createUser(name, age, city, country, phone, email) {}
// 用的时候得记住位置,不想传的还得占位
createUser("Matt", undefined, undefined, "China", undefined, "x@y.com");
用对象传就清晰多了:
1
2
// ✅ 只传需要的,顺序不重要
createUser({ name: "Matt", country: "China", email: "x@y.com" });
一般用点语法或中括号进行属性访问:
1
2
3
4
5
6
7
person.name; // ✅ 点语法,最常用
person["name"]; // ✅ 中括号,等价
let key = "name";
person[key]; // ✅ 中括号支持变量访问
person["first name"]; // ✅ 属性名含空格,必须用中括号
通常优先用点语法,只有必须用变量或属性名含特殊字符时才用中括号。
Array:
除了Object,Array应该就是ECMAScript中最常用的类型了。ECMAScript数组跟其他编程语言的数组有很大区别。跟其他语言中的数组一样,ECMAScript数组也是一组有序的数据,但跟其他语言不同的是:数组中每个槽位可以存储任意类型的数据。这意味着可以创建一个数组,它的第一个元素是字符串,第二个元素是数值,第三个是对象。ECMAScript数组也是动态大小的,会随着数据添加而自动增长。
创建数组:
主要有两种创建方式。
方式一:Array 构造函数
1
let colors = new Array(); // 空数组
如果预先知道数组中元素的数量,可以传入一个数值,length 属性会自动被设置为该值:
1
let colors = new Array(20); // 创建一个 length 为 20 的空数组
也可以直接传入要保存的元素:
1
let colors = new Array("red", "blue", "green"); // ["red", "blue", "green"]
这里有一个容易出错的地方:构造函数只传一个值时,行为取决于值的类型。如果传的是数字,则创建该长度的空数组;如果传的是其他类型,则创建一个只包含该值的数组:
1
2
new Array(3); // ["empty × 3"],length 为 3 的空数组
new Array("Greg"); // ["Greg"],只有一个字符串元素的数组
也可以省略 new,效果完全一样:
1
2
let colors = Array(3); // 等价于 new Array(3)
let names = Array("Greg"); // 等价于 new Array("Greg")
方式二:数组字面量
数组字面量是在中括号中包含以逗号分隔的元素列表:
1
2
3
let colors = ["red", "blue", "green"]; // 三个元素的数组
let names = []; // 空数组
let values = [1, 2,]; // 两个元素的数组 [1, 2]
最后一个例子说明:数组字面量中最后一个值后面加逗号不会创建空位,现代浏览器都支持这种写法。
和对象字面量一样,使用 [] 创建数组不会调用 Array 构造函数。
实际开发中更倾向于使用数组字面量,因为代码更少,语义也更清晰,一眼就能看出数组包含哪些元素。
Array 静态方法:from() 和 of()
Array 构造函数有两个静态方法用于创建数组:from() 和 of()。
Array.from()— 将类数组结构转换为数组实例Array.of()— 将一组参数转换为数组实例
Array.from():
from() 的第一个参数是一个类数组对象,即任何可迭代的结构,或者有一个 length 属性和可索引元素的结构。
字符串会被拆分为单字符数组:
1
Array.from("Matt"); // ["M", "a", "t", "t"]
可以将集合和映射转换为数组:
1
2
3
4
5
const m = new Map().set(1, 2).set(3, 4);
const s = new Set().add(1).add(2).add(3).add(4);
Array.from(m); // [[1, 2], [3, 4]]
Array.from(s); // [1, 2, 3, 4]
可以对现有数组执行浅复制:
1
2
3
4
5
const a1 = [1, 2, 3, 4];
const a2 = Array.from(a1);
console.log(a1); // [1, 2, 3, 4]
console.log(a1 === a2); // false,两个不同的数组
任何可迭代对象都可以转换:
1
2
3
4
5
6
7
8
9
10
const iter = {
*[Symbol.iterator]() {
yield 1;
yield 2;
yield 3;
yield 4;
}
};
Array.from(iter); // [1, 2, 3, 4]
arguments 对象可以转换为数组:
1
2
3
4
5
function getArgsArray() {
return Array.from(arguments);
}
getArgsArray(1, 2, 3, 4); // [1, 2, 3, 4]
带有 length 和数字索引的自定义对象也可以转换:
1
2
3
4
5
6
7
8
9
const arrayLikeObject = {
0: 1,
1: 2,
2: 3,
3: 4,
length: 4
};
Array.from(arrayLikeObject); // [1, 2, 3, 4]
Array.from() 还可以接收第二个可选参数——映射函数。这个函数可以直接增强新数组的值,而无须先创建数组再调用 .map():
1
2
3
const a1 = [1, 2, 3, 4];
const a2 = Array.from(a1, x => x ** 2);
console.log(a2); // [1, 4, 9, 16]
还可以接收第三个参数,用于指定映射函数中 this 的值(注意箭头函数中不生效):
1
2
3
4
5
const a3 = Array.from(a1, function(x) {
return x ** this.exponent;
}, { exponent: 2 });
console.log(a3); // [1, 4, 9, 16]
Array.of():
Array.of() 把一组参数转换为数组。这个方法用于替代 ES6 之前常用的 Array.prototype.slice.call(arguments),一种笨拙的将 arguments 对象转换为数组的写法:
1
2
Array.of(1, 2, 3, 4); // [1, 2, 3, 4]
Array.of(undefined); // [undefined]
Array.of() 还能避免 Array 构造函数的一个坑,传单个数字时导致的行为不确定:
1
2
new Array(3); // ["empty × 3"],length 为 3 的空数组
Array.of(3); // [3],包含一个元素 3 的数组
Array.of() 始终把参数作为数组元素,行为一致可预测。
数组空位:
由于行为不一致和存在性能隐患,因此实践中要避免使用数组空位。如果确实需要空位,则可以显式地用undefined值代替。
使用数组字面量时,可以用一串逗号来创建空位。ECMAScript 会把逗号之间相应索引位置的值当作空位:
1
2
3
const options = [,,,,,]; // 创建包含 5 个元素的数组
console.log(options.length); // 5
console.log(options); // [empty × 5],即 5 个空位
空位不等于 undefined。undefined 是一个真实的值,空位什么都没有:
1
2
3
4
5
6
7
8
const a = [1, undefined, 3];
const b = [1, , 3];
console.log(a.length); // 3
console.log(b.length); // 3
console.log(1 in a); // true
console.log(1 in b); // false ← 索引 1 处是空位,不是 undefined
ES6 新增的方法普遍将空位视为存在的元素,只不过值为 undefined:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const options = [1, , , 5]; // 3 个空位
for (const option of options) {
console.log(option === undefined);
}
// false ← 1
// true ← 空位
// true ← 空位
// true ← 空位
// false ← 5
// Array.from() 也会把空位当元素
Array.from([,,,]); // [undefined, undefined, undefined]
// Array.of() 同样
Array.of([,,,]); // [undefined, undefined, undefined]
// entries() 迭代器也会遍历到空位
for (const [index, value] of options.entries()) {
console.log(value);
}
// 1, undefined, undefined, undefined, 5
ES6 之前的方法会忽略空位:
1
2
3
4
5
6
7
const options = [1, , , 5];
// map() 跳过空位
options.map(() => 6); // [6, undefined × 3, 6]
// join() 视空位为空字符串
options.join("-"); // "1----5"
数组索引:
可以通过使用中括号加数字索引来访问或设置数组元素的值:
1
2
3
4
let colors = ["red", "blue", "green"];
alert(colors[0]); // "red" ← 读取
colors[2] = "black"; // 修改第三项
colors[3] = "brown"; // 添加第四项
如果索引小于数组长度,返回该位置的值。如果设置一个超过当前最大索引的索引,数组会自动扩展到该索引加 1:
1
colors[3] = "brown"; // 设置索引3,数组长度从3变成4
数组的元素数量保存在 length 属性中,它始终返回 0 或大于 0 的值:
1
2
3
4
let colors = ["red", "blue", "green"];
let names = [];
colors.length; // 3
names.length; // 0
length 不是只读的,可以修改它来增减元素:
1
2
3
4
5
6
let colors = ["red", "blue", "green"];
colors.length = 2; // 删除最后一个元素
colors[2]; // undefined
colors.length = 4; // 扩展到 4,多出的位置以 undefined 填充
colors[3]; // undefined
利用 length 向末尾追加元素:数组最后一个元素的索引始终是 length - 1,下一个槽位的索引就是 length:
1
2
3
let colors = ["red", "blue", "green"];
colors[colors.length] = "black"; // 在位置3添加
colors[colors.length] = "brown"; // 在位置4添加
由于每次新增后 length 会自动更新,所以下一行会自动使用新的 length 作为索引。
这种规律也允许跳着设置索引:
1
2
3
let colors = ["red", "blue", "green"];
colors[99] = "black"; // 在位置99添加
colors.length; // 100
中间所有没有明确设置的位置(3~98)实际上不存在,访问它们会得到 undefined。
ES6 新增的 at() 方法更方便地访问数组元素,特别适合负索引:
1
2
3
4
5
6
7
let colors = ["red", "blue", "green"];
colors.at(0); // "red"
colors.at(-1); // "green" ← 倒数第一个
colors.at(-2); // "blue" ← 倒数第二个
colors.at(100); // undefined ← 越界
colors.at(-100); // undefined ← 越界
负索引从数组末尾开始计数:-1 是最后一个元素,-2 是倒数第二个,以此类推。
数组最多可以包含 4,294,967,295 个元素,这对绝大多数编程任务都足够了。如果尝试添加更多元素,会抛出错误。
检测数组:
在单个全局作用域的网页中,instanceof 就够用了:
1
2
3
if (value instanceof Array) { // 传入数组
// 操作数组
}
但如果网页里有多个 iframe,就会涉及两个不同的全局执行上下文,从而存在两个不同版本的 Array 构造函数。把数组从 A 框架传给 B 框架时,跨越了realm,这个数组的构造函数和 B 框架本地创建的数组构造函数不同,instanceof 会失效。
为解决这个问题,ECMAScript提供了Array.isArray()方法。不管数组是在哪个全局上下文中创建的,Array.isArray() 都能准确判断:
1
2
3
if (Array.isArray(value)) { // 传入数组
// 操作数组
}
这是跨框架传递数组时的首选判断方式。
instanceof比较的是 prototype 对象,跨 frame代表着不同的 prototype,所以判断失败。Array.isArray()不比较 prototype,而是检查对象内部标记,所以能跨 frame 准确判断。
Frame A 创建的数组,它的原型链上挂着的是 Frame A 的 Array.prototype。
Frame B 里的 Array 是另一个函数,它有自己独立的 Array.prototype。
所以当在 Frame B 里执行 arr instanceof Array 时,检查的是 Frame B 的 Array.prototype 是否在数组的原型链上。但数组的原型链上挂的是 Frame A 的 Array.prototype,两边根本不是同一个对象,判断就失败了。
迭代器方法:
Array.prototype 上有 3 个方法用于遍历数组内容:keys()、values() 和 entries()。
它们都返回迭代器,可以用 Array.from() 转换为数组:
1
2
3
4
5
6
const a = ["foo", "bar", "baz", "qux"];
// 因为这些方法都返回迭代器,所以可以将它们的内容
// 通过Array.from()直接转换为数组实例
Array.from(a.keys()); // [0, 1, 2, 3] ← 索引迭代器
Array.from(a.values()); // ["foo", "bar", "baz", "qux"] ← 元素迭代器
Array.from(a.entries()); // [[0, "foo"], [1, "bar"], [2, "baz"], [3, "qux"]] ← 键值对迭代器
还可以配合解构在循环中使用,用 entries() 配合解构可以方便地同时拿到索引和值:
1
2
3
4
5
6
7
for (const [idx, element] of a.entries()) {
console.log(idx, element);
}
// 0 foo
// 1 bar
// 2 baz
// 3 qux
迭代器(Iterator)是 ES6 引入的概念,简单说就是一个可以逐个取出元素的对象。
迭代器比数组更节省内存:
- 数组一次性把所有元素都加载进内存
- 迭代器只有”当前在哪”和”怎么取下一个”两个信息,不占用额外内存
所以当只需要遍历一遍时,迭代器比数组更高效。
复制和填充方法:
fill() 和 copyWithin()这两个方法都作用于既有数组的一个范围,范围是左闭右开区间 [start, end),即包含开始索引、不包含结束索引。它们不会改变数组的大小。
fill() 用指定值填充
fill(value, start, end) 从 start 到 end 用 value 填充,start 和 end 都可以是负数:
1
2
3
4
5
6
const zeroes = [0, 0, 0, 0, 0];
zeroes.fill(5); // [5, 5, 5, 5, 5] ← 全填充
zeroes.fill(6, 3); // [0, 0, 0, 6, 6] ← 从索引3开始填充
zeroes.fill(7, 1, 3); // [0, 7, 7, 0, 0] ← 索引1~3(左闭右开)
zeroes.fill(8, -4, -1); // [0, 8, 8, 8, 0] ← 负索引等价于 1~4
负索引的计算方式:数组长度加上负数得到正向索引。例如长度 5 的数组,-4 就是 5 + (-4) = 1,-1 就是 5 + (-1) = 4。
fill() 会静默忽略无效范围:
1
2
3
4
5
zeroes.fill(0); // 重置
zeroes.fill(1, -10, -6); // 索引过低,忽略
zeroes.fill(1, 10, 15); // 索引过高,忽略
zeroes.fill(2, 4, 2); // 开始 > 结束(反向),忽略
zeroes.fill(4, 3, 10); // 部分有效,填充可用部分 → [0, 0, 0, 4, 4]
copyWithin() 复制并插入到指定位置
copyWithin(target, start, end) 从数组中复制 start 到 end 范围的内容,插入到 target 索引开始的位置,复制的过程中不会发生数据被覆盖的风险(因为 JavaScript 引擎先完整复制完再插入):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let ints = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
ints.copyWithin(5); // [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
// 复制索引0~9,插入到索引5开始
ints.copyWithin(0, 5); // [5, 6, 7, 8, 9, 5, 6, 7, 8, 9]
// 复制索引5~9,插入到索引0开始
ints.copyWithin(4, 0, 3); // [0, 1, 2, 3, 0, 1, 2, 7, 8, 9]
// 复制索引0~2,插入到索引4开始
ints.copyWithin(-4, -7, -3); // [0, 1, 2, 3, 4, 5, 3, 4, 5, 6]
// -7 = 3, -3 = 7
// 复制索引3~6,插入到索引6开始
copyWithin() 同样静默忽略无效范围:
1
2
ints.copyWithin(2, 4, 2); // 反向,忽略
ints.copyWithin(4, 7, 10); // 部分有效 → [0, 1, 2, 3, 7, 8, 9, 7, 8, 9]
fill() 是改值,用同一个值替换范围内所有元素;copyWithin() 是搬移,把数组某一段内容复制到另一个位置。两者都不改变数组长度,无效范围都静默忽略。
扩展操作符:
...写在函数参数和解构左边 = 收集(rest),写在数组/对象字面量里 = 展开(spread)。
扩展操作符 ... 可以将数组的元素逐个展开,让数组的合并、复制、插入操作更简洁。
合并数组
1
2
3
4
5
let array1 = [1, 2, 3];
let array2 = [4, 5, 6];
let merged = [...array1, ...array2];
// [1, 2, 3, 4, 5, 6]
浅拷贝数组
1
2
3
let original = [1, 2, 3];
let copied = [...original];
// [1, 2, 3]
注意是浅拷贝,嵌套对象仍然共享引用。
灵活插入
扩展操作符可以和 slice() 配合,实现在任意位置插入元素:
1
2
3
4
5
let array1 = [1, 2, 4];
let array2 = [3];
let result = [...array1.slice(0, 2), ...array2, ...array1.slice(2)];
// [1, 2, 3, 4]
剩余操作符:
剩余操作符 ... 与扩展操作符语法相同,但用途相反:它将剩余元素收集成一个新数组。
收集函数参数
... 可以将不确定数量的实参收集为数组:
1
2
3
4
5
function sum(...numbers) {
return numbers.reduce((total, num) => total + num, 0);
}
sum(1, 2, 3, 4, 5); // 15
解构赋值中收集剩余元素
1
2
3
4
5
let [first, second, ...rest] = [1, 2, 3, 4, 5];
console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5]
...rest 会把解构后剩余的所有元素收集到一个数组中。
转换方法:
数组继承自 Object,拥有 toLocaleString()、toString() 和 valueOf() 三个方法。
toString() 和 valueOf()
valueOf()返回数组本身toString()返回逗号分隔的字符串,对每项调用其toString()
1
2
3
4
5
let colors = ["red", "blue", "green"];
colors.toString(); // "red,blue,green"
colors.valueOf(); // ["red", "blue", "green"](数组本身)
colors; // alert 时自动调用 toString() → "red,blue,green"
toLocaleString()
与 toString() 唯一的区别:对每项调用 toLocaleString() 而非 toString()。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let person1 = {
toLocaleString() { return "Matthew"; },
toString() { return "Matt"; }
};
let person2 = {
toLocaleString() { return "Grigorios"; },
toString() { return "Greg"; }
};
let people = [person1, person2];
people.toString(); // "Matt,Greg"
people.toLocaleString(); // "Matthew,Grigorios"
继承的方法toLocaleString()以及toString()都返回数组值的逗号分隔的字符串。如果想使用不同的分隔符,则可以使用join()方法。join() 可以自定义分隔符。
1
2
3
4
5
let colors = ["red", "green", "blue"];
colors.join(","); // "red,green,blue"
colors.join("||"); // "red||green||blue"
colors.join(); // "red,green,blue"(默认逗号)
不传参或传 undefined,默认使用逗号分隔。
注意:数组中的 null 或 undefined 在上述方法输出中以空字符串表示。
栈方法:
栈是一种 LIFO(Last-In-First-Out,后进先出)的数据结构,插入和删除都发生在栈顶。数组支持实现类似栈的行为。
push():接收任意数量参数,添加到数组末尾,返回新长度pop():删除数组最后一项,返回被删除的项
1
2
3
4
5
6
7
8
9
10
let colors = [];
let count = colors.push("red", "green"); // 推入两项
count; // 2
count = colors.push("black"); // 再推入一项
count; // 3
let item = colors.pop(); // 弹出最后一项
item; // "black"
colors.length; // 2
栈方法可以与数组的其他方法混用,push() 和直接索引赋值的项都能被 pop() 弹出:
1
2
3
4
5
let colors = ["red", "blue"];
colors.push("brown"); // push 添加
colors[3] = "black"; // 索引赋值添加
colors.pop(); // "black"
队列方法:
队列是一种 FIFO(First-In-First-Out,先进先出)的数据结构,从末尾添加、从开头取出。
shift():删除数组第一项并返回,长度减 1unshift():在数组开头添加任意数量参数,返回新长度
标准队列:push() + shift()
1
2
3
4
5
6
let colors = [];
colors.push("red", "green"); // 末尾添加
colors.push("black");
colors.shift(); // "red",从开头取出
// colors → ["green", "black"]
反向队列:unshift() + pop()
1
2
3
4
5
6
let colors = [];
colors.unshift("red", "green"); // 开头添加 → ["red", "green"]
colors.unshift("black"); // → ["black", "red", "green"]
colors.pop(); // "green",从末尾取出
// colors → ["black", "red"]
反转与排序方法:
reverse() 方法将数组元素原地反转。这很直观,但不够灵活:
1
2
let values = [1, 2, 3, 4, 5];
values.reverse(); // [5, 4, 3, 2, 1]
它直接翻转顺序,不会做任何智能处理。
sort() 默认按升序排列数组。但这里有个容易踩的坑:它会把所有元素先转成字符串,再比较字符串大小。
1
2
3
let values = [0, 1, 5, 10, 15];
values.sort();
// [0, 1, 10, 15, 5]
数值看起来明明是 0, 1, 5, 10, 15,但排序后 10 排到了 5 前面——因为 "10" 的字符串在 "5" 前面。这显然不是我们想要的结果。
不传参数时,sort() 使用内置的默认比较函数:
1
2
3
4
defaultCompare(a, b) {
return String(a) < String(b) ? -1 :
String(a) > String(b) ? 1 : 0;
}
要自定义排序规则,sort()需要传一个比较函数作为参数。比较函数接收两个参数 (a, b):
| 返回值 | 含义 |
|---|---|
| 负数 | a 应该排在 b 前面 |
| 0 | a 和 b 相等,保持顺序 |
| 正数 | a 应该排在 b 后面 |
1
2
3
4
5
6
7
8
9
function compare(value1, value2) {
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}
这个比较函数可以适用于大多数数据类型,可以把它当作参数传给sort()方法,如下所示:
1
2
3
let values = [0, 1, 5, 10, 15];
values.sort(compare);
alert(values); // 0,1,5,10,15
这个比较函数还可简写为一个箭头函数:
1
2
3
4
5
6
7
8
// 升序
let values = [0, 1, 5, 10, 15];
values.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
// [0, 1, 5, 10, 15]
// 降序(把返回值反过来)
values.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
// [15, 10, 5, 1, 0]
对于纯数值数组,直接用减法即可——a - b 为负则 a 在前,b - a 为负则 b 在前:
1
2
values.sort((a, b) => a - b); // 升序
values.sort((a, b) => b - a); // 降序
reverse() 和 sort() 都是原地修改数组,并返回数组本身的引用。
ECMAScript 的 sort() 是稳定排序,相等元素的相对顺序在排序后会保持不变。
很多开发人员觉得ECMAScript的sort()方法不够直观,下表列出了常见的排序模式:
操作方法:
concat() 创建当前数组的副本,再把参数添加到末尾,返回新数组。
- 如果参数是数组,会将其中每一项展开(打平)后加入
- 如果参数是普通值,直接添加
1
2
3
4
5
let colors = ["red", "green", "blue"];
let colors2 = colors.concat("yellow", ["black", "brown"]);
console.log(colors); // ["red", "green", "blue"] ← 原数组不变
console.log(colors2); // ["red", "green", "blue", "yellow", "black", "brown"]
Symbol.isConcatSpreadable 控制打平行为
这个 Symbol 可以覆盖默认的打平规则:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let newColors = ["black", "brown"];
newColors[Symbol.isConcatSpreadable] = false; // 强制不打平
// 不打平 → 整个数组作为一项被添加
colors.concat(newColors); // ["red", "green", "blue", ["black", "brown"]]
// 强制打平类数组对象
let moreColors = {
[Symbol.isConcatSpreadable]: true,
length: 2,
0: "pink",
1: "cyan"
};
colors.concat(moreColors); // ["red", "green", "blue", "pink", "cyan"]
slice(start, end) 返回从 start 到 end(不含)的片段,不改变原数组。
1
2
3
4
let colors = ["red", "green", "blue", "yellow", "purple"];
colors.slice(1); // ["green", "blue", "yellow", "purple"] ← 从索引1到末尾
colors.slice(1, 4); // ["green", "blue", "yellow"] ← 从索引1到3
负索引:等于 数组.length + 负值
1
2
colors.slice(-2, -1);
// 等同于 slice(3, 4)
注意:结束位置小于开始位置 → 返回空数组。
splice() 是最强大的数组方法,可删除、插入、替换。它返回被删除的元素数组。
① 删除:传入两个参数
splice(start, deleteCount):从 start 开始删除 deleteCount 项
1
2
3
4
5
let colors = ["red", "green", "blue"];
let removed = colors.splice(0, 1); // 从0开始删1项
colors; // ["green", "blue"]
removed; // ["red"]
② 插入:第三个参数起是要插入的值
splice(start, 0, ...items):在 start 位置插入,0 表示不删除
js复制
1
2
3
4
removed = colors.splice(1, 0, "yellow", "orange"); // 在位置1插入两项
colors; // ["green", "yellow", "orange", "blue"]
removed; // [] ← 没删除,返回空数组
③ 替换:删除和插入同时发生
splice(start, deleteCount, ...items):删除 deleteCount 项后插入新项,数量不必相等
1
2
3
4
removed = colors.splice(1, 1, "red", "purple"); // 在位置1删1项,插入2项
colors; // ["green", "red", "purple", "orange", "blue"]
removed; // ["yellow"]
| 方法 | 操作 | 返回值 | 原数组 |
|---|---|---|---|
concat() | 合并 | 新数组 | 不变 |
slice() | 截取 | 新数组 | 不变 |
splice() | 删除/插入/替换 | 被删元素数组 | 被修改 |
搜索和位置方法:
ECMAScript 提供两类搜索方法:按严格相等搜索 和 按断言函数搜索。
严格相等搜索:indexOf() / lastIndexOf() / includes():
这三个方法都接收两个参数:要查找的元素 + 可选的起始位置。在进行比较时使用全等 ===。
| 方法 | 搜索方向 | 返回值 |
|---|---|---|
indexOf() | 从前往后 | 索引或 -1 |
lastIndexOf() | 从后往前 | 索引或 -1 |
includes() | 从前往后 | 布尔值 |
1
2
3
4
5
6
7
8
9
let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
numbers.indexOf(4); // 3
numbers.lastIndexOf(4); // 5
numbers.includes(4); // true
numbers.indexOf(4, 4); // 5 ← 从索引4开始往后找
numbers.lastIndexOf(4, 4); // 3 ← 从索引4开始往前找
numbers.includes(4, 7); // false ← 从索引7开始,后面没有4
=== 比较的是引用,不是内容。两个结构相同的对象,引用不同就不算相等:
1
2
3
4
5
6
7
8
let person = { name: "Matt" };
let people = [{ name: "Matt" }]; // 新对象
let morePeople = [person]; // 同一引用
people.indexOf(person); // -1 ← 不是同一个对象
morePeople.indexOf(person); // 0 ← 同一引用
people.includes(person); // false
morePeople.includes(person); // true
断言函数搜索:find() / findIndex()
当比较逻辑复杂(比如”年龄小于28”),严格相等就不行了。这时用断言函数。
断言函数接收 3 个参数:
element— 当前元素index— 当前索引array— 数组本身
返回真值表示匹配。
| 方法 | 返回值 |
|---|---|
find() | 第一个匹配的元素 |
findIndex() | 第一个匹配的索引 |
1
2
3
4
5
6
7
const people = [
{ name: "Matt", age: 27 },
{ name: "Matt", age: 29 }
];
people.find(e => e.age < 28); // { name: "Matt", age: 27 }
people.findIndex(e => e.age < 28); // 0
两个方法都是找到第一个匹配项就停止搜索,不会遍历整个数组:
1
2
3
4
5
6
const evens = [2, 4, 6];
evens.find((element, index, array) => {
console.log(element); // 2, 4 ← 只打印到 4 就停了,6 没检查
return element === 4;
});
| 搜索类型 | 方法 | 适用场景 | 返回值 |
|---|---|---|---|
| 严格相等 | indexOf() | 找基本类型的位置 | 索引/-1 |
| 严格相等 | lastIndexOf() | 从后往前找 | 索引/-1 |
| 严格相等 | includes() | 只需知道”有没有” | 布尔值 |
| 断言函数 | find() | 按条件找元素 | 元素/undefined |
| 断言函数 | findIndex() | 按条件找位置 | 索引/-1 |
迭代方法:
ECMAScript 为数组定义了 5 个迭代方法,都不改变原数组。
每个方法接收一个函数作为参数,这个函数会收到 3 个参数:元素、索引、数组本身。
every()全部满足才返回 true
对每一项执行函数,所有都返回 true → 最终返回 true,有任何一个 false → 返回 false。
1
2
3
let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
numbers.every(item => item > 2); // false ← 1 和 2 不满足
some()有一个满足就返回 true
对每一项执行函数,只要任意一项返回 true → 最终返回 true,全部 false → 返回 false。
1
numbers.some(item => item > 2); // true ← 3、4、5 都满足
every() 和 some() 的区别:
| 全部满足 | 部分满足 | |
|---|---|---|
every() | true | false |
some() | true | true |
filter()筛选出符合条件的项
对每一项执行函数,返回让函数返回 true 的所有项组成的数组。
1
numbers.filter(item => item > 2); // [3, 4, 5, 4, 3] ← 保留所有大于2的
适合场景:查询数组中符合条件的全部元素。
map()对每一项做转换,返回新数组
对每一项执行函数,把每次的结果收集成新数组返回。
1
numbers.map(item => item * 2); // [2, 4, 6, 8, 10, 8, 6, 4, 2] ← 每个都×2
适合场景:一对一映射、批量处理每个元素。
forEach()遍历每一项,无返回值
对每一项执行函数,没有返回值,本质上等价于 for 循环。
1
2
3
numbers.forEach((item, index) => {
// 执行操作,比如打印
});
注意:用 forEach() 做数据转换是无意义的——它不返回值:
1
2
3
4
5
// ❌ 错误:返回 undefined
numbers.forEach(item => item * 2);
// ✅ 正确:用 map
numbers.map(item => item * 2);
| 方法 | 返回值 | 用途 |
|---|---|---|
every() | 布尔值 | 检查是否全部满足条件 |
some() | 布尔值 | 检查是否存在满足条件的项 |
filter() | 新数组 | 筛选出满足条件的所有项 |
map() | 新数组 | 对每项做转换,生成一一对应的新数组 |
forEach() | undefined | 单纯遍历执行操作 |
归并方法:
ECMAScript 提供两个归并方法,都是遍历所有项,最终产出一个值。
| 方法 | 遍历方向 |
|---|---|
reduce() | 第一项 → 最后一项 |
reduceRight() | 最后一项 → 第一项 |
两个方法都接收两个参数:
- 归并函数(必传)
- 初始值(可选)
归并函数接收 4 个参数:
prev— 上一次归并的返回值(累积值)cur— 当前项index— 当前索引array— 数组本身
函数的返回值会作为下一次调用的 prev。
归并函数最多可以接收 4 个参数,但不是必须写全。JavaScript 允许只写需要的参数。index和array大多数情况下不需要。
基本用法:累加
1
2
3
4
let values = [1, 2, 3, 4, 5];
let sum = values.reduce((prev, cur) => prev + cur, 0);
// 结果:15
执行过程:
| 轮次 | prev | cur | 返回值 |
|---|---|---|---|
| 1 | 0 | 1 | 1 |
| 2 | 1 | 2 | 3 |
| 3 | 3 | 3 | 6 |
| 4 | 6 | 4 | 10 |
| 5 | 10 | 5 | 15 |
不传初始值:第一次迭代从第二项开始,prev 是第一项,cur 是第二项:
1
2
3
values.reduce((prev, cur) => prev + cur);
// 第一次:prev=1, cur=2
// 结果:15(一样,因为是加法)
传初始值:第一次迭代从第一项开始,prev 是初始值,cur 是第一项。
空数组的问题:
1
2
[].reduce((prev, cur) => prev + cur); // ❌ 报错:TypeError
[].reduce((prev, cur) => prev + cur, 0); // ✅ 返回 0(初始值)
规则:
- 空数组 + 无初始值 → 报错
- 空数组 + 有初始值 → 直接返回初始值,回调不执行
- 单元素数组 + 无初始值 → 直接返回该元素,回调不执行
reduceRight():反向归并
1
2
3
4
5
let values = [1, 2, 3, 4, 5];
values.reduceRight((prev, cur) => prev + cur);
// 第一次:prev=5, cur=4
// 结果:15(加法交换律,结果相同)
方向相反,逻辑相同。选择哪个取决于业务逻辑是否依赖遍历顺序。
| 特性 | reduce() | reduceRight() |
|---|---|---|
| 遍历方向 | 从前往后 | 从后往前 |
| 参数 | (callback, 初始值) | 同左 |
| 回调参数 | (prev, cur, index, array) | 同左 |
| 返回值 | 累积结果 | 同左 |
总而言之,是把数组”压缩”成一个值,每一步的产出成为下一步的输入。
reduce()的核心价值:当目标结果不是数组时(对象、数值、分组),用它来”累积构建”。例如后端返回数组,前端可以将数组构建成对象,根据ID快速查找。
展开方法:
在 ES6 之前,要把嵌套数组展平成一维数组,只能靠递归或迭代来实现,代码写起来既啰嗦又容易出错。flat() 和 flatMap() 就是为了解决这个问题而出现的。
这两个方法只能展开嵌套数组,不能展开 Map、Set 等其他可迭代对象。
flat():按深度展开数组
Array.prototype.flat() 接收一个深度参数(默认为 1),返回一个将嵌套数组展开到指定深度的浅拷贝。
1
2
3
4
const arr = [[0], 1, 2, [3, [4, 5]], 6];
arr.flat(); // [0, 1, 2, 3, [4, 5], 6] ← 只展开一层(默认)
arr.flat(2); // [0, 1, 2, 3, 4, 5, 6] ← 展开两层
上面的数组 [3, [4, 5]] 本身嵌套了两层,所以需要 depth=2 才能把 4 和 5 完全取出来。如果不确定嵌套了多少层,直接传 Infinity 就能完全展开,不用自己算。
浅拷贝的行为:flat() 返回的是新数组,但元素本身仍然是引用。遇到循环引用时,它不会无限展开,而是从源数组中复制值:
1
2
3
4
5
6
const arr = [[0], 1, 2, [3, [4, 5]], 6];
arr.push(arr); // 把自己推入自己,形成循环引用
arr.flat();
// [0, 1, 2, 3, [4, 5], 6, [0], 1, 2, [3, [4, 5]], 6]
// ↑ 遇到自身的引用时,直接复制值,不再继续展开
在没有 flat() 之前:手动递归展开
理解 flat() 之前,先看没有它的时候是怎么做的。核心思路是把嵌套数组当成一棵树来遍历——数组是子节点,非数组元素是叶节点,展开的过程就是对叶节点的有序遍历:
1
2
3
4
5
6
7
8
9
10
11
12
13
function flatten(sourceArray, flattenedArray = []) {
for (const element of sourceArray) {
if (Array.isArray(element)) {
flatten(element, flattenedArray); // 递归进入子节点
} else {
flattenedArray.push(element); // 遇到叶节点,收集
}
}
return flattenedArray;
}
const arr = [[0], 1, 2, [3, [4, 5]], 6];
flatten(arr); // [0, 1, 2, 3, 4, 5, 6]
如果只想展开一层(类似 flat(1)),加一个深度参数就行:
1
2
3
4
5
6
7
8
9
10
11
12
function flatten(sourceArray, depth, flattenedArray = []) {
for (const element of sourceArray) {
if (Array.isArray(element) && depth > 0) {
flatten(element, depth - 1, flattenedArray);
} else {
flattenedArray.push(element);
}
}
return flattenedArray;
}
flatten(arr, 1); // [0, 1, 2, 3, [4, 5], 6] ← [4, 5] 没被展开
对比一下就能看出,flat() 就是把这个递归逻辑内置到了语言层面,一行代码搞定。
flatMap():先映射,再展开
Array.prototype.flatMap() 会在展开数组之前先执行一次映射操作,等价于 arr.map(fn).flat(),但只遍历一次,效率更高:
1
2
3
4
5
6
7
const arr = [[1], [3], [5]];
// map() 返回嵌套数组
arr.map(([x]) => [x, x + 1]); // [[1, 2], [3, 4], [5, 6]]
// flatMap() 映射完直接展平
arr.flatMap(([x]) => [x, x + 1]); // [1, 2, 3, 4, 5, 6]
函数签名与 map() 完全相同,回调接收三个参数 (item, index, array),每次回调返回的数组会被自动展开一层。
实用场景:字符串拆词 + 过滤
flatMap() 特别擅长处理 split() 这类返回数组的方法——拆完之后直接展平,一步到位:
1
2
3
4
const arr = ['Lorem ipsum dolor sit amet,', 'consectetur adipiscing elit.'];
arr.flatMap(x => x.split(/\W+/));
// ["Lorem", "ipsum", "dolor", "sit", "amet", "", "consectetur", "adipiscing", "elit", ""]
但注意结果里有空字符串——这是 split() 在分隔符出现在首尾位置时产生的副作用。
过滤空字符串的技巧:让回调返回空数组 [],展开时空数组会被直接跳过,相当于同时完成了 map + filter:
1
2
3
4
arr.flatMap(x => x.split(/\W+/))
.flatMap(x => x || []);
// ["Lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit"]
// 空字符串 "" → "" || [] 得到 [] → 展开时跳过 ✓
原理很简单:空字符串是 falsy 值,"" || [] 会返回 [],而空数组在展开时不产生任何元素,相当于被过滤掉了。
这个技巧会多一次遍历,数据量大时需要权衡性能。
| 方法 | 作用 | 深度 | 特点 |
|---|---|---|---|
flat(depth) | 展开嵌套数组 | 默认 1,可传 Infinity | 浅拷贝,循环引用安全 |
flatMap(fn) | 先 map 再 flat(1) | 固定 1 层 | 单次遍历,比 map().flat() 高效 |
两者的关系很清晰:flat() 负责”压扁”层级,flatMap() 把”变换 + 压扁”合为一步。如果映射函数本身会返回数组,flatMap() 就是最自然的选择。
