JavaScript 数据类型和数据结构

编程语言都具有内建的数据结构,但各种编程语言的数据结构常有不同之处。本文尝试列出 JavaScript 语言中内建的数据结构及其属性,它们可以用来构建其他的数据结构。

JavaScript 语言概述提供了对常见数据类型的类似总结,但是更倾向于和其他语言进行比较。

动态和弱类型

JavaScript 是一种有着动态类型动态语言。JavaScript 中的变量与任何特定值类型没有任何关联,任何变量都可以被赋予(和重新赋予)各种类型的值:

js
let foo = 42; // foo 现在是一个数值
foo = "bar"; // foo 现在是一个字符串
foo = true; // foo 现在是一个布尔值

JavaScript 也是一个弱类型语言,这意味着当操作涉及不匹配的类型时,它允许隐式类型转换,而不是抛出类型错误。

js
const foo = 42; // foo 现在是一个数值
const result = foo + "1"; // JavaScript 将 foo 强制转换为字符串,因此可以将其与另一个操作数连接起来
console.log(result); // 421

隐式强制转换是非常方便的,但当转换发生在预期之外的地方,或发生在预期的另一个方向(例如,字符串转换为数值,而不是数值转换为字符串)时,就会产生一些微妙的错误。对于 symbolBigInt,JavaScript 有意禁止了某些隐式类型转换。

原始值

除了 Object 以外,所有类型都定义了表示在语言最低层面的不可变值。我们将这些值称为原始值

除了 null,所有原始类型都可以使用 typeof 运算符进行测试。typeof null 返回 "object",因此必须使用 === null 来测试 null

除了 nullundefined,所有原始类型都有它们相应的对象包装类型,这为处理原始值提供可用的方法。例如,Number 对象提供像 toExponential() 这样的方法。当在原始值上访问属性时,JavaScript 会自动将值包装到相应的包装对象中,并访问对象上的属性。然而,在 nullundefined 上访问属性时,会抛出 TypeError 异常,这需要采用可选链运算符。

类型 typeof 返回值 对象包装器
Null "object" 不适用
Undefined "undefined" 不适用
Boolean "boolean" Boolean
Number "number" Number
BigInt "bigint" BigInt
String "string" String
Symbol "symbol" Symbol

对象包装器类的参考页面包含关于每个类型可用方法和属性类型的更多用法,以及原始类型本身的详细描述。

Null 类型

Null 类型只有一个值:null

undefined 类型

Undefined 类型只有一个值:undefined

从概念上讲,undefined 表示的缺失,null 表示对象的缺失(这也可以说明 typeof null === "object" 的原因)。当某些东西没有值时,该语言通常默认为 undefined

  • 没有值(return;)的 return 语句,隐式返回 undefined
  • 访问不存在的对象属性(obj.iDontExist),返回 undefined
  • 变量声明时没有初始化(let x;),隐式初始化为 undefined
  • 许多像 Array.prototype.find()Map.prototype.get() 的方法,当没有找到元素时,返回 undefined

null 在核心语言中使用频率少得多。最重要的地方是原型链的末端,其次是与原型交互的方法,如 Object.getPrototypeOf()Object.create() 等,接受或返回 null 而不是 undefined

null 是一个关键字,但是 undefined 是一个普通的标识符,恰好是一个全局属性。在实践中,这两个差异很小,因为 undefined 不应该被重新定义或者遮蔽。

Boolean 类型

Boolean 类型表示一个逻辑实体并且包括两个值:truefalse

布尔值通常用于条件运算,包括三元运算符if...elsewhile 等。

Number 类型

Number 类型是一种基于 IEEE 754 标准的双精度 64 位二进制格式的值。它能够存储 2-1074Number.MIN_VALUE)和 21024Number.MAX_VALUE)之间的正浮点数,以及 -2-1074 和 -21024 之间的负浮点数,但是它仅能安全地存储在 -(253 − 1)(Number.MIN_SAFE_INTEGER)到 253 − 1(Number.MAX_SAFE_INTEGER)范围内的整数。超出这个范围,JavaScript 将不能安全地表示整数;相反,它们将由双精度浮点近似表示。你可以使用 Number.isSafeInteger() 检查一个数是否在安全的整数范围内。

±(2-1074 ~ 21024) 范围之外的值会自动转换:

+Infinity-Infinity 行为类似于数学上的无穷大,但是有一些细微的区别;更多细节,参见 Number.POSITIVE_INFINITYNumber.NEGATIVE_INFINITY

Number 类型仅有一个具有多个表现形式的值:0 同时表示为 -0+0(其中 0+0 的别名)。实际上,这两者之间几乎没有区别;例如,+0 === -0true。然而,当你除以 0 的时候,你要注意到这一点:

js
console.log(42 / +0); // Infinity
console.log(42 / -0); // -Infinity

NaN(“Not a Number”)是一个特殊种类的数值,当算术运算的结果不表示数值时,通常会遇到它。它也是 JavaScript 中唯一不等于自身的值。

虽然 number 在概念上是一个“数学的值”,并且总是隐式的编码为浮点类型,但是 JavaScript 提供了位运算符。当应用位运算符时,number 首先转换为 32 位整数。

备注: 尽管位运算符可以使用位掩码来表示单个数值中的几个布尔值,但通常这不是一个好的做法。JavaScript 提供了表示一组布尔值的其他方法(如布尔数组,或将布尔值分配给命名属性的对象)。位掩码也往往会使代码更难读取、理解和维护。

可能有必要在非常受限的环境中使用此类技术,例如在试图应对本地存储的限制时,或在极端情况下(例如当网络上的每个位计数时)。只有当这项技术是优化尺寸的最后一项措施时,才应考虑这项技术。

BigInt 类型

BigInt 类型在 Javascript 中是一个数字的原始值,它可以表示任意大小的整数。使用 BigInt,你可以安全地存储和操作巨大的整数,甚至超过 Number 的安全整数限制(Number.MAX_SAFE_INTEGER)。

BigInt 是通过将 n 附加到整数末尾或调用 BigInt() 函数来创建的。

本例演示了递增 Number.MAX_SAFE_INTEGER 会返回预期结果:

js
// BigInt
const x = BigInt(Number.MAX_SAFE_INTEGER); // 9007199254740991n
x + 1n === x + 2n; // false,因为 9007199254740992n 和 9007199254740993n 不相等

// Number
Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2; // true,因为都等于 9007199254740992

你可以使用大多数运算符处理 BigInt,包括 +*-**%。——唯一被禁止的是 >>>。BigInt 并不是严格等于有着相同数学值的 Number,而是宽松的相等。

BigInt 值并不总是更精确的,也不总是比 number 精确,因为 BigInt 不能表示小数,但可以更精确地表示大整数。这两种类型都不能相互替代。如果 BigInt 值在算术表达式中与常规 number 值混合,或者它们相互隐式转换,则抛出 TypeError

String 类型

String 类型表示文本数据并编码为 UTF-16 码元的 16 位无符号整数值序列。字符串中的每个元素在字符串中占据一个位置。第一个元素的索引为 0,下一个是索引 1,依此类推。字符串的长度是它的元素的数量。字符串的长度是其中的 UTF-16 代码单元的数量,这可能与 Unicode 字符的实际数量不符;更多细节参见 String 参考页面。

JavaScript 字符串是不可变的。这意味着一旦字符串被创建,就不可能修改它。字符串方法基于当前字符串的内容创建一个新的字符串——例如:

  • 使用 substring() 获取原始的子字符串。
  • 使用串联运算符(+)或 concat() 将两个字符串串联。

注意代码中的“字符串类型”!

使用字符串来表示复杂的数据可能很诱人。这样做会带来短期的好处:

  • 容易通过字符串拼接来构造复杂的字符串。
  • 字符串容易被调试(你看到的打印的内容始终是字符串)。
  • 字符串通常是许多 API 的常见标准(input 字段local storage 中的值,以及 XMLHttpRequest 使用 responseText 等作为响应,等等)而且这些 API 可能只能与字符串一同使用。

使用约定,字符串一般可以用来表示任何数据结构。但这并不总是一个好主意。例如,使用一个分隔符,可以模拟一个列表(而 JavaScript 数组可能更适合)。不幸的是,当分隔符用于列表中的元素时,列表就会被破坏。这时可以选择转义字符,等等。所有这些都需要约定,并造成不必要的维护负担。

表示文本数据时候推荐使用字符串。当需要表示复杂的数据时,使用字符串解析并使用适当的抽象。

Symbol 类型

Symbol唯一并且不可变的原始值并且可以用来作为对象属性的键(如下)。在某些程序语言当中,Symbol 也被称作“原子(atom)类型”。symbol 的目的是去创建一个唯一属性键,保证不会与其他代码中的键产生冲突。

Object

在计算机科学中,对象(object)是指内存中的可以被标识符引用的一块区域。在 JavaScript 中,对象是唯一可变的值。事实上,函数也是具有额外可调用能力的对象。

属性

在 JavaScript 中,对象可以被看作是一组属性的集合。用对象字面量语法来定义一个对象时,会自动初始化一组有限的属性;然后,这些属性还可以被添加和移除。对象属性等价于键值对。属性键要么是字符串类型,要么是 symbol。属性值可以是任何类型的值,包括其他对象,从而可以构建复杂的数据结构。

有两种对象属性的类型:数据属性访问器属性。每个属性都有对应的特性(attribute)。JavaScript 引擎可在内部访问每个属性,但是你可以通过 Object.defineProperty() 设置它们,或通过 Object.getOwnPropertyDescriptor() 读取它们。你可以在 Object.defineProperty() 页面阅读更多有关各种细微差别的信息。

数据属性

数据属性将键与值相关联。它可以通过以下属性来描述:

value

通过属性访问器获取值。可以是任意的 JavaScript 值。

writable

一个布尔值,表示是否可以通过赋值来改变属性。

enumerable

一个布尔值,表示是否可以通过 for...in 循环来枚举属性。另请参阅枚举性和属性所有权,以了解枚举属性如何与其他函数和语法交互。

configurable

一个布尔值,表示该属性是否可以删除,是否可以更改为访问器属性,并可以更改其特性。

访问器属性

将键与两个访问器函数(getset)相关联,以获取或者存储值。

备注: 重要的是,意识到它是访问器属性——而不是访问器方法。我们可以将函数作为值来提供给 JavaScript 对象的访问器,使得对象表现得像一个类——但这并不能使该对象成为类。

一个访问器属性有着以下的特性:

get

该函数使用一个空的参数列表,以便有权对值执行访问时,获取属性值。参见 getter。可能是 undefined

set

使用包含分配值的参数调用的函数。每当尝试更改指定属性时执行。参见 setter。可能是 undefined

enumerable

一个布尔值,表示是否可以通过 for...in 循环来枚举属性。另请参阅枚举性和属性所有权,以了解枚举属性如何与其他函数和语法交互。

configurable

一个布尔值,表示该属性是否可以删除,是否可以更改为访问器属性,并可以更改其特性。

对象的原型(prototype)指向另一个对象或者 null——从概念上讲,它是对象的隐藏属性,通常表示为 [[Prototype]]。对象的 [[Prototype]] 属性也可以在对象自身访问。

对象是临时的键值对,因此经常被用作映射。不过,这可能存在人体工程学、安全性和性能方面的问题。请使用 Map 来存储任意数据。Map 参考页面更详细地讨论了使用普通对象和使用 map 存储键值之间的利弊。

Date

当表示日期时,最好的选择是使用在 JavaScript 内置的 Date 工具类。

索引类集合:数组和类型化数组

数组是一种以整数为键(integer-keyed)的属性并与长度(length)属性关联的常规对象。

此外,数组对象还继承了 Array.prototype 的一些操作数组的便捷方法。例如,indexOf()(搜索数组中的一个值)或 push()(向数组中添加一个元素),等等。这使得数组成为表示有序列表的理想选择。

类型化数组表示底层二进制缓冲区的类数组视图,并且提供了与数组相对应的类似语义的方法。“类型化数组”是一系列数据结构的总话术语,包括 Int8ArrayFloat32Array 等等。获取更多细节,请查看类型化数组页。类型化数组通常与 ArrayBufferDataView 一起使用。

带键的集合:Map、Set、WeakMap、WeakSet

这些数据结构把对象的引用当作键。SetWeakSet 表示唯一值的集合,而 MapWeakMap 表示键值相关联的集合。

你也可以自己实现 MapSet。然而,因为对象不能被比较(例如,在 <“小于”的意义上),另一方面,引擎也没有暴露出它的哈希函数,因此查找性能必定是线性的。它们的原生实现(包括 WeakMap)可以达到近似对数到常数时间的查找性能。

通常,要将数据绑定到 DOM 节点,可以直接在对象上设置属性,或使用 data-* 属性。这样做的缺点是,在同一上下文中运行的任何脚本都可以使用这些数据。而 MapWeakMap 则可以轻松地将数据隐蔽地绑定到对象上。

WeakMapWeakSet 只允许将可垃圾回收的值作为键,这些键要么是对象,要么是未注册的 symbol,即使键仍在集合中,也可能被收集。它们专门用于优化内存使用

结构化数据:JSON

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,来源于 JavaScript,同时也被多种语言所使用。JSON 构建了通用数据结构,可以在不同环境之间传输,甚至可以跨语言传输。更多细节,请参见 JSON

标准库中的更多对象

JavaScript 有一个内置对象的标准库。请阅读参考页面,了解有关内置对象的更多信息。

强制类型转换

如上所述,JavaScript 是一个弱类型语言。这意味着你经常可以使用与预期类型不同类型的值,并且该语言将为你转换它为正确的类型。为此,JavaScript 定义了少数强制转换规则。

原始值强制转换

原始值强制转换用于得到一个期望的原始值,但对实际类型应该是什么并没有强烈的偏好。通常情况下可以接受字符串数值BigInt。例如:

  • Date() 构造函数,当它收到一个不是 Date 实例的参数时——字符串表示日期字符串,而数值表示时间戳。
  • + 运算符——如果运算对象是字符串,执行字符串串联;否则,执行数值相加。
  • == 运算符——如果一个运算对象是原始值,而另一个运算对象是对象(object),则该对象将转换为没有首选类型的原始值。

如果值已经是原始值,则此操作不会进行任何转换。对象将依次调用它的 [@@toPrimitive]()(将 default 作为 hint 值)、valueOf()toString() 方法,将其转换为原始值。注意,原始值转换会在 toString() 方法之前调用 valueOf() 方法,这与数字类型强制转换的行为相似,但与字符串类型强制转换不同。

[@@toPrimitive]() 方法,如果存在,则必须返回原始值——返回对象,会导致 TypeError。对于 valueOf()toString(),如果其中一个返回对象,则忽略其返回值,从而使用另一个的返回值;如果两者都不存在,或者两者都没有返回一个原始值,则抛出 TypeError。例如,以下代码:

js
console.log({} + []); // "[object Object]"

{}[] 都没有 [@@toPrimitive]() 方法。{}[] 都从 Object.prototype.valueOf 继承 valueOf(),其返回对象自身。因为返回值是一个对象,因此它被忽略。因此,调用 toString() 方法。{}.toString() 返回 "[object Object]",而 [].toString() 返回 "",因此这个结果是它们的串联:"[object Object]"

在强制转换为任意的原始类型时,[@@toPrimitive]() 方法总是优先调用。原始值的强制转换的行为通常与 number 类型的强制转换类似,因为优先调用了 valueOf();然而,有着自定义 [@@toPrimitive]() 方法的对象可以选择返回任意的原始值。DateSymbol 对象是唯一重写 [@@toPrimitive]() 方法的对象。Date.prototype[@@toPrimitive]()"string" 视为 "default" hint,而 Symbol.prototype[@@toPrimitive]() 忽略 hint 并始终返回一个 symbol。

数字类型强制转换

有两种数字类型:numberBigInt。有时候,该语言期望使用 number 或 BigInt(例如 Array.prototype.slice(),其中索引必须是一个数字);其他时候,它可能容忍并且根据运算对象的类型不同执行不同的运算。有关不允许从其他类型隐式转换的严格强制转换过程,请参阅 number 强制转换BigInt 强制转换

数字类型强制转换与 number 类型强制转换几乎相同,只是 BigInt 会按原样返回,而不是引起 TypeError。强制数字类型转换用于所有算术运算,因为它们重载了 number 和 BigInt 类型。唯一例外的是一元加,它总是实施 number 强制类型转换。

其他类型强制转换

所有除了 null、undefined 以及 Symbol 的数据类型,都有它们各自的强制转换过程。更多细节,请参见字符串强制转换布尔值强制转换以及对象强制转换

你可能已经注意到,有三种不同的路径可以将对象转换为原始值:

在所有情况下,[@@toPrimitive]() 如果存在,必须可调用并返回原始值,而如果它们不可调用或返回对象,valueOftoString 将被忽略。在过程结束时,如果成功,结果保证是原始值。然后,由此产生的原始值会进一步强制类型转换,具体取决于上下文。

参见