第三章:内置对象与数据结构:从 API 到内部原理
第三章:内置对象与数据结构:从 API 到内部原理
Prorise第三章:内置对象与数据结构:从 API 到内部原理
摘要: 在掌握了语言的核心语法与函数之后,我们进入了数据处理的领域。本章将深入剖析 JavaScript 中最核心的内置对象与数据结构。我们将超越简单的 API 调用,探讨 Array
在 V8 引擎中的内部表示与性能特征,理解 String
的不可变性原理,并从时间复杂度的角度深度对比 Object
、Map
与 Set
的适用场景。学完本章,您将能够基于底层原理,为不同的业务需求选择最高效的数据结构,并写出更健壮、性能更优的代码。
在本章中,我们将深入探索数据的组织与处理:
- 首先,我们将从
Array
开始,不仅学习其丰富的 API,更要理解其作为动态集合的性能考量与函数式编程的应用。 - 接着,我们将探讨
String
,重点是理解其“不可变性”这一核心原理及其对程序性能的深远影响。 - 然后,我们将对
Object
、Map
和Set
进行深度对比,分析它们作为键值对集合的优劣与底层差异。 - 最后,我们将快速浏览
JSON
、Math
和Date
等实用工具对象,掌握它们在实际开发中的最佳实践。
3.1. 数组 (Array):动态集合与性能考量
在几乎所有的编程语言中,数组都是一种基础且重要的数据结构。然而,JavaScript 的 Array
与 C++/Java 等语言中的传统数组在底层实现上存在着根本性的区别。
3.1.1. 数组的本质:动态对象而非连续内存
核心原理:传统数组(如 C++ 中的 int arr[10]
)是一段 连续的、固定大小的内存空间,访问元素(arr[i]
)可以通过简单的指针运算实现,速度极快 (O(1))。
而 JavaScript 的 Array
本质上是一种特殊的对象,其键是整数索引,但它 并不保证内存的连续性。这赋予了它极大的灵活性(如动态增删、存储不同类型元素),但也带来了性能上的复杂性。
为了优化性能,V8 等现代 JavaScript 引擎内部会将数组分为两种主要模式:
- 密集数组: 当数组元素是连续的时(例如
[1, 2, 3]
),引擎会为其分配连续的内存,实现类似传统数组的快速访问。这是性能最优的模式。 - 稀疏数组: 当数组存在空位时(例如
const a = [1, , 3]; a[1000] = 5;
),引擎会将其降级为更慢的哈希表/字典结构来存储。对稀疏数组的操作会比密集数组慢得多。
3.1.2. 基础操作 API:用法与副作用分析
在对数组进行操作时,一个至关重要的考量是该操作是否会修改原数组。这种修改被称为 副作用 或 可变性。
产生副作用(修改原数组)的常用方法:
push()
: 在数组末尾添加一个或多个元素,返回新的长度。pop()
: 删除并返回数组的最后一个元素。shift()
: 删除并返回数组的第一个元素。unshift()
: 在数组开头添加一个或多个元素,返回新的长度。splice(start, deleteCount, ...items)
: 万能方法,可以从start
索引开始,删除deleteCount
个元素,并插入items
。它返回被删除元素的数组。
1 | const numbers = [10, 20, 30, 40]; |
1
2
3
4
5
6
7
8
9
10
11
12
13
--- push ---
更新后的数组: [ 10, 20, 30, 40, 50 ]
--- pop ---
弹出的元素为: 50
弹出后的数组为: [ 10, 20, 30, 40 ]
--- shift ---
弹出的元素为: 10
弹出后的数组为: [ 20, 30, 40 ]
--- unshift ---
更新后的数组: [ 5, 20, 30, 40 ]
--- splice ---
删除的元素为: [ 20, 30 ]
更新后的数组为: [ 5, 40 ]
不产生副作用(返回新数组)的常用方法:
slice(start, end)
: 提取从start
到end
(不含end
)的元素,返回一个 新数组,原数组不变。concat(...items)
: 连接多个数组或值,返回一个 新数组。
1 | const original = [1,2,3,4] |
1
2
3
4
5
6
7
8
9
10
console.log("---slice---");
console.log('Sliced:', sliced); // [2, 3]
console.log('Original:', original); // [1, 2, 3, 4] (原数组未被修改)
const original_2 = [5,6,7,8]
const concat_arry = original.concat(original_2)
console.log("---concat---");
console.log('Concat:', concat_arry); // [1, 2, 3, 4, 5, 6, 7, 8]
console.log('Original:', original); // [1, 2, 3, 4] (原数组未被修改)
在现代 JavaScript 开发,特别是函数式编程和 React 等框架中,强烈推荐优先使用不产生副作用的方法,以保证数据的可预测性和程序的稳定性。
3.1.3. 高阶函数与函数式编程范式
核心思想: 所谓高阶函数,就是指可以接受函数作为参数,或者将函数作为返回值的函数。map
, filter
, reduce
是数组最重要的三个高阶函数,它们完美体现了 不变性 原则——即从不修改原数组,而是返回一个全新的、经过处理的数组。
map(callback)
作用: 遍历数组,对每个元素执行 callback
函数,并将每次执行的 返回值 收集起来,组成一个 新的数组 返回。
业务场景: 你有一个商品价格列表,需要生成一个包含税后价格的新列表。
1 | const prices = [100, 200, 350]; |
1
2
Prices with tax: [ 110, 220, 385 ]
Original prices: [ 100, 200, 350 ]
filter(callback)
作用: 遍历数组,对每个元素执行 callback
函数。如果 callback
返回 true
,则保留该元素,否则丢弃。最后将所有保留的元素组成一个 新的数组 返回。
业务场景: 你有一个用户列表,需要筛选出所有已成年的用户。
1 | const users = [ |
1
Adult users: [ { name: 'Alice', age: 25 }, { name: 'Cathy', age: 30 } ]
reduce(callback, initialValue)
作用: 数组的“聚合器”。它接收一个回调函数和一个可选的初始值。它会遍历数组,将上一次回调的返回值(accumulator
)和当前元素(currentValue
)传入下一次回调,最终将数组“减少”为一个单一的值。
业务场景: 计算购物车中所有商品的总价。
1 | const cart = [ |
1
Total price: 1350
3.1.4. 排序与搜索的陷阱
sort([compareFunction])
sort()
方法会 就地 对数组元素进行排序(即修改原数组)。它最大的陷阱在于其默认行为。
核心陷阱: 如果不提供 条件
,sort()
会将所有元素转换为 字符串,然后按 UTF-16 编码顺序进行排序。
1 | const numbers = [1, 10, 2, 21, 5]; |
1
[ 1, 10, 2, 21, 5 ]
看到结果 [ 1, 10, 2, 21, 5 ]
了吗?因为 “10” 在字符串比较中排在 “2” 的前面。对数字数组排序时,必须提供比较函数。
解决方案: 提供一个比较函数 (a, b)
。
- 如果返回
a - b
,则为升序排序。 - 如果返回
b - a
,则为降序排序。
1 | const numbers = [1, 10, 2, 21, 5]; |
1
[ 1, 2, 5, 10, 21 ]
find()
vs indexOf()
indexOf(value)
: 返回指定value
在数组中首次出现的 索引,如果不存在则返回-1
。它只能用于查找原始类型值。find(callback)
: 返回数组中满足callback
函数的 第一个元素的值,如果不存在则返回undefined
。它非常适合用来查找对象数组中的特定对象。
1 | const users = [ |
3.1.5. 迭代协议:for...of
背后的原理
核心原理: for...of
循环之所以能遍历数组、字符串、Map、Set 等,是因为这些对象都遵守了 Iterable 协议。
一个对象要成为“可迭代”的,它必须实现一个 [Symbol.iterator]
方法。这个方法返回一个 迭代器 (Iterator) 对象,该对象有一个 next()
方法。每次调用 next()
,它会返回一个形如 { value: ..., done: boolean }
的对象,直到遍历结束 done
变为 true
。
for...of
循环就是这个过程的语法糖。理解这一点有助于你明白为什么 for...of
不能用于遍历普通对象(因为它们默认不是可迭代的)。
1 | const arr = ['a', 'b']; |
3.2. 字符串 (String):不可变性与高效处理
3.2.1. 字符串的不可变性原理
核心原理: 与数组不同,JavaScript 中的字符串是 不可变的。这意味着一旦一个字符串被创建,它的内容就不能被改变。所有看起来像在修改字符串的方法(如 replace()
, toUpperCase()
),实际上都是在 返回一个全新的字符串,而原始字符串保持不变。
1 | let str = "hello"; |
1
2
3
upperStr: HELLO
original str: hello
After modification attempt: hello
性能影响: 字符串的不可变性意味着在循环中用 +
或 +=
来拼接大量字符串时,性能会很差。因为每次拼接都会创建一个新的中间字符串,并可能引发垃圾回收。
痛点场景:
1 | let longString = ""; |
解决方案: 在需要拼接大量字符串时,更高效的做法是先将各部分放入一个数组,最后用 join('')
方法一次性合并。
1 | const parts = []; |
3.2.2. 现代字符串操作
模板字符串 (Template Literals)
ES6 引入的模板字符串(使用反引号 `
)是处理动态文本的最佳方式,它支持内嵌表达式 ${...}
和多行文本,完全取代了传统的 +
拼接方式。
1 | const user = { name: 'Prorise', plan: 'Premium' }; |
常用 API
很多时候,我们不需要记忆所有的 API,API 永远都只是一个方法,想要什么值就往里传,在学习过很多编程语言之后我相信您就能理解,再多的方法都不如一个场景使用到的思路来的实在。
方法/属性 | 说明 | 返回值 |
---|---|---|
length | 属性,获取字符串的长度。 | Number |
toLowerCase() / toUpperCase() | 转换为全小写或全大写。常用于不区分大小写的比较。 | String |
trim() | 移除字符串两端的空白字符(空格、制表符、换行符等)。 | String |
includes(substring) | 检查是否包含子字符串。 | Boolean |
startsWith(str) / endsWith(str) | 检查字符串是否以指定子字符串开头或结尾。 | Boolean |
indexOf(substring) | 查找子字符串首次出现的位置索引,如果不存在则返回 -1。 | Number |
slice(startIndex, endIndex) | 提取子字符串,返回一个新字符串。 | String |
split(separator) | 按分隔符将字符串分割成数组。 | Array |
replace(searchValue, newValue) | 替换 第一个 匹配的子字符串或模式。 | String |
replaceAll(searchValue, newValue) | 替换 所有 匹配的子字符串或模式。 | String |
padStart(len, str) / padEnd(len, str) | 用指定字符串在开头或末尾进行填充,直到达到目标长度。常用于格式化数字(如补零)。 | String |
1 | // 字符串常用方法演示 |
3.2.3. 正则表达式 (RegExp) 入门
业务场景: 假设你需要验证用户输入的邮箱地址是否合法,或者从一段文本中提取所有电话号码。用 if/else
等传统方法处理会非常繁琐且极易出错。这正是正则表达式的用武之地。
重要提示: 正则表达式是一种独立于任何编程语言的、强大的 文本模式匹配工具。它有自己的一套语法规则。本节的重点不是深入讲解正则表达式本身的每一个规则,而是 介绍如何在 JavaScript 中创建和使用它。如果您对元字符不熟悉,可以查阅 MDN 或其他专业教程进行学习,当然,现在 Ai 对于正则表达式的编写已经比人类强大得多得多了,我强烈建议如果有需求,可以直接找 AI 写正则表达式,完全没有问题
在 JavaScript 中创建正则表达式
字面量创建 (推荐): 语法为
/pattern/flags
,在脚本加载时编译,性能更高。1
const regex = /[a-z]+/g; // 匹配所有小写字母组合
构造函数创建: 语法为
new RegExp("pattern", "flags")
,在运行时编译,适用于模式是动态的(例如,来自用户输入)场景。1
2let userInput = "abc";
const dynamicRegex = new RegExp(userInput, "i"); // 根据用户输入动态创建,忽略大小写
核心概念速览
分类 | 符号 | 说明 |
---|---|---|
常用元字符 | \d | 匹配一个数字 (等同于 [0-9] ) |
\w | 匹配字母、数字、下划线 (等同于 [A-Za-z0-9_] ) | |
\s | 匹配一个空白字符(空格、制表符、换行符等) | |
. | 匹配除换行符外的任意单个字符 | |
量词 | + | 匹配前面的表达式 一个或多个 |
* | 匹配前面的表达式 零个或多个 | |
? | 匹配前面的表达式 零个或一个 | |
边界 | ^ | 匹配输入的 开头 |
$ | 匹配输入的 结尾 | |
字符集 | [] | 匹配方括号内的任意一个字符,如 [aeiou] |
修饰符 (Flags) | g | 全局 (Global) 匹配,查找所有匹配项而非第一个 |
i | 忽略大小写 (Ignore case) 匹配 | |
m | 多行 (Multiline) 匹配,使 ^ 和 $ 匹配行的开头/结尾 |
在 JavaScript 中的实战方法
regexp.test(string)
: 验证。检查字符串是否匹配模式,返回true
或false
。这是最常用的验证方法。string.match(regexp)
: 提取。返回一个包含匹配结果的数组。如果正则有g
标志,返回所有匹配的子串;否则只返回第一个匹配项及其捕获组的详细信息。string.replace(regexp, newValue)
: 替换。查找匹配项并用新值替换。结合g
标志可以实现全局替换。
1 | // --- 1. 使用 .test() 进行验证 --- |
3.3. 键值对集合:Object, Map, Set 的深度对比
在 JavaScript 中,存储键值对数据最常用的方式是 Object
。但 ES6 引入的 Map
和 Set
提供了更专业、更高效的解决方案。
3.3.1. 作为字典的 Object
优势: 语法简洁,易于创建和访问。
原生缺陷:
- 键类型限制: 对象的键只能是
String
或Symbol
类型。任何非字符串的键都会被隐式转换为字符串。 - 原型链污染风险: 如果不小心使用了
__proto__
等内置属性名作为键,可能会覆盖原型链上的方法,导致意外行为或安全漏洞。 - 迭代不便: 遍历对象的属性需要
for...in
或Object.keys()
,不如Map
和Set
的直接迭代方便。
3.3.2. Map:为“字典”场景而生的数据结构
Map
是一个真正的哈希表,它的设计完全是为了高效地存储和检索键值对。
与 Object 的核心对比:
特性 | Map | Object |
---|---|---|
键的类型 | 任意类型 (包括对象、函数) | String 或 Symbol |
键的顺序 | 按插入顺序 | 历史上无序,现代引擎大多实现有序,但不保证 |
大小获取 | .size 属性 (O(1)) | Object.keys().length (O(n)) |
迭代 | 直接可迭代 (for...of , forEach ) | 需要辅助方法 (Object.keys , values , entries ) |
性能 | 在频繁增删键值对的场景下,通常 更优 | 在仅有少量固定属性时性能极佳 |
原型 | 无原型链,不会与内置属性冲突 | 有原型链,可能存在键名冲突 |
业务场景: 当你需要一个键不是字符串的字典,或者需要频繁地对集合进行增删操作时,Map
是不二之选。
1 | const user1 = { id: 1 }; |
1
2
Role of user1: Admin
Map size: 2
3.3.3. Set:唯一值的数学集合
Set
对象允许你存储任何类型的 唯一值,无论是原始值或者是对象引用。
与 Array 的核心对比:
业务场景: 数组去重、检查一个值是否存在于一个大集合中。
场景 | Set | Array |
---|---|---|
保证唯一性 | 天生如此 | 需要手动去重(如 filter + indexOf ) |
成员检查 | set.has(value) (O(1)) | array.includes(value) (O(n)) |
性能差异是关键。检查一个元素是否存在于一个有 100 万个元素的 Set
中,速度几乎和检查 10 个元素一样快。而对于数组,则需要遍历整个数组,速度会慢 10 万倍。
1 | const numbers = [1, 2, 2, 3, 4, 4, 5]; |
1
2
3
4
Unique numbers: Set(5) { 1, 2, 3, 4, 5 }
Back to array: [ 1, 2, 3, 4, 5 ]
Does Set have 3? true
Does Set have 6? false
3.4. 其他实用内置对象
3.4.1. JSON:通用数据交换格式
JSON (JavaScript Object Notation) 是一种轻量级的数据交换格式,已成为 Web API 通信的事实标准。
JSON.stringify(JavaScript值[, 转换函数, 缩进])
: 将 JavaScript 的值(如对象、数组)转换为 JSON 字符串。JavaScript值
: 必需,要被转换成 JSON 字符串的对象、数组或其他值。转换函数
: 可选,一个函数,用于在序列化过程中改变值的行为。缩进
: 可选,用于美化输出的 JSON 字符串,可以是数字(代表空格数)或字符串(如'\t'
)。
JSON.parse(JSON字符串[, 还原函数])
: 将一个 JSON 字符串 解析为对应的 JavaScript 值。JSON字符串
: 必需,一个符合 JSON 格式规范的字符串。还原函数
: 可选,一个函数,用于在解析后对生成的值进行转换。
核心规范与安全:
- JSON 格式比 JS 对象字面量更严格:键必须是双引号包裹的字符串。
- JSON 不能表示
undefined
,Symbol
, 函数等。 - 在解析来自不受信任来源的 JSON 字符串时要小心,
JSON.parse
本身是安全的,但解析后的数据可能被恶意利用。reviver
参数可用于在解析过程中对数据进行处理和校验。
1 | const user = { name: "Prorise", id: 1, joined: new Date() }; |
1
2
3
4
5
6
{
"name": "Prorise",
"id": 1,
"joined": "2025-08-27T12:48:08.066Z"
}
Prorise
3.4.2. Math 与 Date
- Math: 一个静态对象,提供了常用的数学常数和函数,如
Math.random()
(生成 0-1 之间的随机数),Math.max()
,Math.floor()
等。 - Date: 用于处理日期和时间。创建一个
new Date()
实例来表示特定时间点。
实践建议: Date
对象的 API 在处理时区等复杂场景时显得笨拙且易出错。在生产环境中,强烈推荐使用成熟的第三方库,如 date-fns
或 Day.js
,它们提供了更友好、更可靠的 API,同样的 Math 也是一样,对于这些对象的 Api 我们无需细扣,用到什么我们查什么即可
3.5. 本章核心原理速查与高频面试题
核心原理速查
概念 | 核心原理 | 关键影响 |
---|---|---|
JS 数组 | 本质是特殊对象,非连续内存 | 动态灵活,但需警惕稀疏数组带来的性能下降。 |
副作用/不变性 | 操作是否修改原数据 | 不可变操作更利于代码的可预测性和调试。 |
高阶函数 | 接受或返回函数的函数 | map , filter , reduce 是函数式编程的核心,遵循不变性。 |
字符串不可变性 | 字符串一旦创建无法更改 | 频繁拼接字符串时,应使用 Array.join() 以避免性能问题。 |
Map vs Object | Map 键类型任意,性能更优 | 在非字符串键或频繁增删的字典场景,应使用 Map。 |
Set vs Array | Set 保证唯一性,has 检查 | 在去重和成员检查场景,Set 性能远超 Array。 |
3.6 课后小结
假设有一个包含上千个用户对象的数组,我需要频繁地检查某个特定用户(通过其对象引用)是否存在于这个数组中。你会选择什么数据结构,为什么?
我会选择 Set
而不是 Array
。
具体解释一下。
原因是时间复杂度。如果使用数组,每次检查都需要调用 array.includes(userObject)
,这是一个 O(n) 操作,意味着在最坏的情况下,它需要遍历整个数组来找到该用户。对于上千个元素,这会越来越慢。
而 Set
内部使用类似哈希表的结构来存储数据。检查一个成员是否存在,使用 set.has(userObject)
,其平均时间复杂度是 O(1),几乎是瞬时的,不受集合大小的影响。因此,对于频繁的成员检查场景,Set
的性能优势是压倒性的。
很好。看来你对于他们两的理解的还不错