首页 阅读DAY6 JavaScript高级程序设计 6章上 高级引用类型
文章
取消

阅读DAY6 JavaScript高级程序设计 6章上 高级引用类型

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《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 个空位

空位不等于 undefinedundefined 是一个真实的值,空位什么都没有:

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)startendvalue 填充,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) 从数组中复制 startend 范围的内容,插入到 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,默认使用逗号分隔。

注意:数组中的 nullundefined 在上述方法输出中以空字符串表示。

栈方法:

栈是一种 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():删除数组第一项并返回,长度减 1
  • unshift():在数组开头添加任意数量参数,返回新长度

标准队列: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 前面
0ab 相等,保持顺序
正数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()方法不够直观,下表列出了常见的排序模式:

image-20260423142849244

操作方法:

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) 返回从 startend不含)的片段,不改变原数组

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()truefalse
some()truetrue

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()最后一项 → 第一项

两个方法都接收两个参数:

  1. 归并函数(必传)
  2. 初始值(可选)

归并函数接收 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

执行过程:

轮次prevcur返回值
1011
2123
3336
46410
510515

不传初始值:第一次迭代从第二项开始,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 才能把 45 完全取出来。如果不确定嵌套了多少层,直接传 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() 就是最自然的选择。

本文由作者按照 CC BY 4.0 进行授权

阅读DAY6 JavaScript高级程序设计 5章下 基本引用类型

阅读DAY7 JavaScript高级程序设计 6章中 高级引用类型