类型隐式转换精算
# 03.类型隐式转换精算
📍 上接第 02 篇《隐藏类与分代垃圾回收》。对象的内存模型已了然。本文攻破 JS 的「类型黑洞」——typeof null === 'object' 根源在哪?0.1+0.2 为什么不等于 0.3?[] == ![] 为什么是 true?
# 目录介绍
- 1. 案例与疑问引入
- [1.1 [] ==
- 1.2 顺藤摸到根因
- 1.3 我们要回答什么
- [1.1 [] ==
- 2. 架构全景概览
- 3. typeof标签位
- 4. ToPrimit
- 5. 类型转换算法
- 6. 等号全推导
- 7. 浮点精度详解
- 8. BigInt深度
- 9. Intl 国际化
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 [] == ![
在 StackOverflow 上有一个被浏览超过 300 万次的问题——一句话就能让初学 JS 的人怀疑人生:
console.log([] == ![]); // → true
第一次看到这一行的人反应通常是:"等等,一个空数组等于它自己的取反?这怎么可能?"
但这在 JS 里不仅是可能的,而且是严格遵循规范算法推导出来的必然结果。更令人吃惊的是,这个等式背后调用了 ECMA-262 规范中的 四条抽象操作——每一条都是 JS 类型系统的核心构件。
# 1.2 顺藤摸到根因
一步步推导 [] == ![] 的完整执行过程(每条推导都标注对应规范操作):
[] == ![]
步骤 1:先算右侧的 ![] —— 逻辑非运算符
![] → 根据 ToBoolean 规则
→ [] 是 truthy(因为不是 falsy 八值之一)
→ !truthy → false
现在等式变成:[] == false
步骤 2:根据 == 规则(ECMA-262 §7.2.15 规则 7):
"如果 Type(y) 是 Boolean → y = ToNumber(y)"
→ [] == ToNumber(false)
→ [] == 0
步骤 3:根据 == 规则 9:
"如果 Type(x) 是 Object 且 Type(y) 是 Number/String/Symbol"
"→ x = ToPrimitive(x)"
→ ToPrimitive([]) == 0
步骤 4:ToPrimitive([])
→ hint = "default"(因为 == 运算符对对象用 default hint)
→ 调用 [].valueOf() → 返回 [] 自身,不是基本类型
→ 再调用 [].toString()
→ [].toString() → ""
现在等式变成:"" == 0
步骤 5:根据 == 规则 5:
"如果 Type(x) 是 String 且 Type(y) 是 Number"
"→ x = ToNumber(x)"
→ ToNumber("") == 0
→ 0 == 0
步骤 6:同类型→严格比较 → true ✓
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
五步转换把一个看似荒谬的等式论证为完全符合规范的结果。 这个推导过程包含了 JS 类型系统的四块核心积木——ToBoolean、ToNumber、ToPrimitive 和 == 抽象相等算法——本文会把每一块都拆开看。
# 1.3 我们要回答什么
本文围绕 JS 类型系统的"隐式转换黑洞",追问六个问题:
- ① typeof 遗产:
typeof null === 'object'——这个二十多年的 bug 为什么修不了? - ② ToPrimitive:为什么
1 + {}和{} + 1结果完全不同?一个对象怎么被"剥皮"成基本类型? - ③ ToNumber/ToString/ToBoolean:为什么
Number(" ")返回 0 而不是 NaN?falsy 到底有几个? - ④ == 全算法:规范里那 15 条抽象相等规则的完整推导——每条对应一个反直觉案例。
- ⑤ 浮点精度:
0.1 + 0.2 !== 0.3——从 IEEE 754 的 64 位位布局一路讲到二进制尾数截断。 - ⑥ BigInt + 数值极限:为什么
9999999999999999变成了10000000000000000?BigInt 内部和 Smi/HeapNumber 有什么区别?
最后,用一个能处理 NaN / -0 / 正则 / 循环引用 / BigInt 的 deepEqual 实现串起文中所有类型判断细节。
# 2. 架构全景概览
# 2.1 8 种类型
ECMA-262 定义了 7 种基本类型(Primitive) + 1 种对象类型(Object):
┌─────────────────────────────────────────────────────┐
│ JS 类型系统(ECMA-262 定义) │
├─────────────────────────────────────────────────────┤
│ │
│ 基本类型(Primitive)——存在栈上,不可变 │
│ ┌─────────┬──────────┬───────────────────────────┐ │
│ │ Number │ typeof→"number"│ 64位IEEE754,含NaN/±Inf││
│ │ BigInt │ typeof→"bigint"│ 任意精度整数 │ │
│ │ String │ typeof→"string"│ UTF-16 编码,不可变 │ │
│ │ Boolean │ typeof→"boolean"│ true / false │ │
│ │ Undefined│typeof→"undefined"│ 变量未赋值时的默认值 │ │
│ │ Null │ typeof→"object"│ ← 著名的 bug │ │
│ │ Symbol │ typeof→"symbol"│ 唯一标识符 │ │
│ └─────────┴──────────┴───────────────────────────┘ │
│ │
│ 对象类型(Object)——存在堆上,可变 │
│ ┌─────────┬──────────┬───────────────────────────┐ │
│ │ Object │ typeof→"object"或"function"│ 一切非基本│ │
│ │ │ │ 类型都是对象 │ │
│ │ ┌───────────┐ │ Array/RegExp/Date/Map/ │ │
│ │ │ 函数是特殊的 │ │ Set/WeakMap/Promise... │ │
│ │ │ 可调用对象 │ │ typeof fn → "function" │ │
│ │ └───────────┘ │ │ │
│ └─────────┴──────────┴───────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 2.2 类型系统的三条转
JS 的隐式类型转换不是"随便转"——它有三条固定的抽象操作通道:
┌─────────────────────────────────────────────────┐
│ 三条抽象转换通道(规范内部操作) │
├─────────────────────────────────────────────────┤
│ │
│ ToPrimitive(input, hint) │
│ → 任何值 → 剥皮成基本类型 │
│ → 用于:==, +, 模板字面量, 属性键 │
│ → 这是"万物归基本类型"的唯一入口 │
│ │
│ ToNumber(argument) │
│ → 基本类型 → 数字 │
│ → 用于:算术运算(-*/%), ==, 一元 +, Number() │
│ │
│ ToString(argument) │
│ → 基本类型 → 字符串 │
│ → 用于:+, 模板字面量, String(), 属性键 │
│ │
│ ToBoolean(argument) │
│ → 任何值 → 布尔 │
│ → 用于:if/while/! / &&/|| / 三元运算符 │
│ → 最简单——只有 8 个值是 falsy │
│ │
└─────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这三个操作不是独立存在的——ToPrimitive 是入口,拆出基本类型后,ToNumber/ToString 接手完成最终的转换。本文的 §4、§5、§6 就是沿着这条链路——从"怎么剥"(ToPrimitive)到"怎么转"(ToNumber/ToString/ToBoolean)到"== 怎么用"(抽象相等算法)——一层层解构。
# 3. typeof标签位
# 3.1 V8 内部类型标
疑惑:V8 在底层怎么区分"这个值是什么类型"?不可能每次都做字符串比较。
论证:V8(以及所有主流 JS 引擎)使用**指针标签位(Pointer Tagging)**技术——在 64 位架构上,内存地址的最低 2~3 位总是 0(因为内存对齐到 4 或 8 字节),引擎利用这几位嵌入类型标签:
V8 64 位架构的表示策略(简化):
┌─────────────────────────────────────────┐
│ Smi(小整数): │
│ ┌────────────────────────────────────┐ │
│ │ 31 位整数值 (补码) │ │0│ │ ← 最低位 = 0
│ └────────────────────────────────────┘ │
│ 例:值 1 → 0x00000002 (= 1<<1 + 0) │
│ 值 -1 → 0xFFFFFFFE │
│ │
│ HeapObject(堆对象指针): │
│ ┌────────────────────────────────────┐ │
│ │ 堆地址 (62 bits) │ │1│ │ ← 最低位 = 1
│ └────────────────────────────────────┘ │
│ 例:obj → 0x1234567890AB1 │
│ 最低位 = 1 表示这是"堆对象指针" │
│ → V8 读出地址时右移一位获得真实指针 │
│ │
│ 特殊值: │
│ undefined → 0x000000000000000A (特殊常量) │
│ null → 0x0000000000000006 (特殊常量) │
│ true → 0x000000000000000E │
│ false → 0x0000000000000002 │
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
关键:typeof 的实现不是一个查表函数——它直接读变量的标签位:
// 伪代码(V8 简化版)
function typeof_internal(value) {
if (isSmi(value)) return 'number';
if (isHeapObject(value)) {
if (value == NullValue) return 'object'; // ← bug 在这里!
if (isFunction(value)) return 'function';
// ... 其他堆对象
return 'object';
}
if (value == UndefinedValue) return 'undefined';
if (value == TrueValue || value == FalseValue) return 'boolean';
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
# 3.2 为什么 null
疑惑:typeof null === 'object' 这个著名的 bug 二十多年了——为什么没人修?
论证:
回到 1995 年,JavaScript 在 Netscape 中用 10 天写出。在当时的实现中,值的类型标签存在 32 位值的低 1~3 位:
1995 年 JS 引擎的类型标签(32 位):
┌──────────────────────────────────────┐
│ 类型标签(低 3 位) │ 对应的值 │
├──────────────────────────────────────┤
│ 000 │ 对象 (Object) │
│ 001 │ 整数 (Int) │
│ 010 │ 浮点 (Double) │
│ 100 │ 字符串 (String) │
│ 110 │ 布尔 (Boolean) │
│ 000 │ ... │
│ │ │
│ null 被表示为: 0x00000000 (全零) │
│ 最低 3 位 → 000 → typeof 判定为 Object! │
└──────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
null 在 32 位体系下被表示为零地址(全 0 的机器字)——这恰好和 Object 类型标签(000)重合。typeof 的实现用位掩码判断类型,结果 null 被误判为 Object。
为什么修不了?
// 假设 V8 有一天修复了这个 bug——typeof null → 'null'
// 所有依赖旧行为的代码全部崩溃:
// 老代码遍地都是:
if (typeof val === 'object' && val !== null) { ... }
// 一旦 typeof null 变成 'null',上面这种判断不会崩溃——
// 但 typeof null 从 'object' 变成 'null' 会导致:
// 1) 所有用 if (typeof x === 'object') 来检查"是否可以安全调用 x 的方法"的地方
// 需要重新考虑 null 是否也通过了检查
// 2) 反向后兼容——已经有无数 npm 包、CSS-in-JS 库、路由库做过了 null 特殊处理
//
// TC39 在 2015 年严肃讨论过"加 typeof null === 'null'"
// 结论:web 上已经挂了太多依赖 typeof null === 'object' 的代码
// 修复 bug 本身会引入更多的 bug → 不值得
2
3
4
5
6
7
8
9
10
11
12
13
14
结论:这不仅是"历史 bug",而是 "bug 已成为事实标准" 的经典案例。typeof null 永远不会被修复——不是因为开发者不在乎,而是因为修复本身的破坏性大于 bug 的困扰程度。
# 3.3 为什么 func
如果函数是 Object 的一个子类型,为什么 typeof 要单独给它返回 "function"?
答案是——不是因为规范规定的类型多出来一种,而是历史实用主义。Brendan Eich 在 1995 年实现 JS 时,为函数单独做了标签(110 或类似值),让 typeof fn 返回 "function"——因为"判断一个值是否是函数"是 JS 里最高频的操作之一。如果 typeof 统一返回 "object",每个调用前都要额外判断 key in obj ? fn() : obj[key] 这种模式会变得非常丑陋。
规范的态度:ECMA-262 规定 JS 有 7 种 + 1 种 = 8 种类型。"function" 不是一种独立的语言类型——它是 typeof 对"可调用对象"的实用主义特判。
# 4. ToPrimit
# 4.1 hint 的三种值
疑惑:1 + {} 和 {} + 1 为什么结果完全不同?
console.log(1 + {}); // → "1[object Object]"
console.log({} + 1); // → "[object Object]1" …等等,也可能是 NaN 或 1?
2
答案在 ToPrimitive 的 hint(暗示) 参数中:
ToPrimitive(input, hint) 的 hint 有三种值:
1) hint = "string"
触发场景:模板字面量 `${obj}`、String(obj)、obj[key](属性键)
目的:把对象转为字符串
→ obj.toString() 先调用,再 valueOf()
2) hint = "number"
触发场景:算术运算(除了 +)、Number(obj)、比较运算(< >)
目的:把对象转为数字
→ obj.valueOf() 先调用,再 toString()
3) hint = "default"
触发场景:+ 运算符、== 运算符(与基本类型比较时)
目的:不确定期望什么类型——"让对象自己决定"
→ 行为与 hint = "number" 相同(先 valueOf 后 toString)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
为什么 {} 和 + 在不同的位置结果不同?
1 + {}
// → 1 + ToPrimitive({}, "default") // hint = "default"
// → 1 + "[object Object]" // {}.valueOf()→{}(非基本类型)
// // {}.toString()→"[object Object]"
// → "1[object Object]" // + 运算符有一方是字符串 → 字符串拼接
{} + 1
// ← 这取决于上下文!{}
// 如果 {} 被解析为代码块(statement position)→ {} 是空代码块
// → +1 → 1
// 如果 {} 被解析为表达式(expression position,如 eval 中)→ {} 是空对象
// → "[object Object]1"
// Chrome DevTools 中直接输入:{} 被当作代码块 → 1
// 加上括号:( {} ) + 1 → "[object Object]1" ← 括号强制表达式上下文
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4.2 valueOf
每一种内置对象都有自己的 ToPrimitive 实现:
| 对象类型 | valueOf() 返回 | toString() 返回 | hint="number" 首先调 | hint="string" 首先调 |
|---|---|---|---|---|
| Object | 自身(非基本类型)→ 继续调 toString | "[object Object]" | valueOf → toString | toString → valueOf |
| Array | 自身 | join(",") —— [1,2].toString() = "1,2" | valueOf → toString | toString → valueOf |
| Date | 时间戳(毫秒数) | 完整日期字符串 | toString → valueOf(Date 特例!) | toString → valueOf |
| Number 对象 | 包装的数字值 | 数字的字符串形式 | valueOf 就返回了基本类型 | toString→valueOf |
| String 对象 | 包装的字符串值 | 包装的字符串 | valueOf 就返回了基本类型 | toString→valueOf |
Date 的特例:
// Date 的 ToPrimitive 中,hint="default" 时先调 toString
// 这是因为在绝大多数场景中,Date 期望被转为字符串
const now = new Date();
console.log(now + 1);
// → "Fri Jun 12 2026 ..." + "1" = 字符串拼接
// 而不是:now.valueOf() + 1 → 时间戳 + 1 → 一个巨大的数字
// Date 是唯一 hint="default" 时先调 toString 的内置对象
2
3
4
5
6
7
# 4.3 Symbol.t
ES6 引入的 Symbol.toPrimitive 完全绕过 valueOf/toString 的调用顺序——最高优先级:
const obj = {
valueOf() { return 100; },
toString() { return 'hello'; },
[Symbol.toPrimitive](hint) {
console.log('hint:', hint);
if (hint === 'number') return 42;
if (hint === 'string') return 'world';
return 'default_value';
}
};
console.log(+obj); // hint: number → 42
console.log(`${obj}`); // hint: string → "world"
console.log(obj + ''); // hint: default → "default_value"
// valueOf 和 toString 被完全忽略
2
3
4
5
6
7
8
9
10
11
12
13
14
15
结论:ToPrimitive 的调用链是:
对象 → 有 Symbol.toPrimitive ?
├─ 是 → 调用它,传入 hint → 返回(必须是基本类型,否则抛 TypeError)
└─ 否 → 根据 hint 决定先调 valueOf 还是 toString
→ 如果第一次调用返回基本类型 → 直接返回
→ 如果返回非基本类型 → 调另一个方法
→ 如果都没返回基本类型 → TypeError
2
3
4
5
6
# 5. 类型转换算法
# 5.1 ToNumber
疑惑:Number(" ") 为什么返回 0 而不是 NaN?
论证:ToNumber 的规则远比"看起来"复杂。ECMA-262 规定的完整映射:
| 输入值 | ToNumber 结果 | 为什么 |
|---|---|---|
undefined | NaN | 规范直接规定 |
null | 0 | 规范直接规定(+null → 0) |
true / false | 1 / 0 | 布尔→数字的数学直觉 |
""(空字符串) | 0 | 白空格全转为 0 |
" "(纯空格) | 0 | 先 TrimString,变成 "",再转 0 |
"123" | 123 | 数字字符串→对应数字 |
"123abc" | NaN | 包含非数字字符 |
"0x10" | 16 | 十六进制识别 |
"Infinity" | Infinity | 特殊字符串→无穷大 |
Symbol() | TypeError | Symbol 不能隐式转数字 |
{} 或 [] | NaN 或 0 | 先 ToPrimitive,再 ToNumber |
[1] | 1 | [1].toString() = "1" → 1 |
[1,2] | NaN | [1,2].toString() = "1,2" → NaN |
关键在于 StringToNumber 内部会先 Trim(去除首尾空格)再解析——这是 Number(" ") 返回 0 的原因:
Number(" ")
→ Trim 去掉前后空格 → ""
→ 空字符串 → 0
2
3
所有 ToNumber 特殊值速查:
// 这些会变成 0
+'' // → 0
+null // → 0
+false // → 0
+[] // → 0([].toString() = "" → 0)
// 这些会变成 NaN
+undefined // → NaN
+{} // → NaN(({}).toString() = "[object Object]" → NaN)
+'hello' // → NaN
+Symbol() // → TypeError
2
3
4
5
6
7
8
9
10
11
# 5.2 ToString
ToString 是所有转换中最直观的一个——大多数情况就是"加引号":
| 输入 | ToString 结果 | 注意 |
|---|---|---|
undefined | "undefined" | 直接映射 |
null | "null" | 直接映射 |
true / false | "true" / "false" | 直接映射 |
0 | "0" | 包括 -0 → "0" |
NaN | "NaN" | |
Infinity | "Infinity" | |
1e21 | "1e+21" | 大数字用科学计数法 |
Symbol() | TypeError | 不能隐式转字符串(可 Symbol().toString()) |
{} | "[object Object]" | 通过 ToPrimitive |
[1,2,3] | "1,2,3" | Array.prototype.toString 调用 join |
function(){} | "function(){}" | 函数源码(截断) |
# 5.3 ToBoolean
ToBoolean 是所有转换中最简单的——只有 8 个值是 falsy,其余所有值都是 truthy:
Falsy 八值:
┌──────────────┬──────────────────────┐
│ false │ 布尔假值 │
│ 0, -0 │ 数字零、负零 │
│ 0n │ BigInt 零 │
│ "" '' `` │ 空字符串(所有引号形式) │
│ null │ 空值 │
│ undefined │ 未定义 │
│ NaN │ 非数字 │
│ document.all │ ← 浏览器专属、HTML 规范 │
│ │ 为兼容旧代码添加的特殊 falsy │
└──────────────┴──────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
容易误判为 falsy 但其实是 truthy 的值:
// 这些都是 truthy!
!![] // → true(空数组是 truthy)
!!{} // → true(空对象是 truthy)
!!'false' // → true(非空字符串都是 truthy)
!!'0' // → true
!!new Boolean(false) // → true(Boolean 包装对象!)
!!' ' // → true(空格字符是非空字符串)
2
3
4
5
6
7
document.all 是唯一不是规范定义的 falsy 值——它是浏览器为兼容 if (document.all) 这类旧代码而特意做的例外。TC39 提案 IsHTMLDDA(host-defined falsy)允许宿主环境定义额外的 falsy 值。
# 6. 等号全推导
# 6.1 ECMA-262
疑惑:== 到底是怎么判断"抽象相等"的?
规范 ECMA-262 §7.2.15 定义了 Abstract Equality Comparison x == y。下面是逐条拆解(已按调用频率和重要性排序):
规则 1(同类型快速通道):
If Type(x) === Type(y) → return x === y
例: 5 == 5 → true; "a" == "a" → true
规则 2(null 和 undefined 互相豁免):
If x is null && y is undefined → true
If x is undefined && y is null → true
例: null == undefined → true
null == 0 → false (不触发规则 2!因为 0 不是 undefined)
规则 3(Number vs String):
If Type(x) is Number && Type(y) is String
→ return x == ToNumber(y)
例: 5 == "5" → 5 == 5 → true
规则 4(String vs Number —— 同规则 3 的对称版本):
同上,x 和 y 互换
规则 5(BigInt vs String):
If Type(x) is BigInt && Type(y) is String
→ let n = StringToBigInt(y)
→ if n is NaN → false; else x == n
例: 1n == "1" → 1n == 1n → true
规则 6(Boolean vs 任何 —— Boolean 先转 Number):
If Type(x) is Boolean → return ToNumber(x) == y
例: true == 1 → 1 == 1 → true
true == "1" → 1 == "1" → 1 == 1 → true
false == [] → 0 == ToPrimitive([])→0 → 0==0 → true ← 反直觉!
规则 7(String vs Symbol):
If Type(x) is String or Number or BigInt or Symbol && Type(y) is Object
→ return x == ToPrimitive(y)
例: "1" == [1] → "1" == ToPrimitive([1]) → "1" == "1" → true
42 == {valueOf(){return 42}} → 42 == 42 → true
规则 8(Object vs String/Number/BigInt/Symbol —— 同规则 7 的对称版本)
规则 9(BigInt vs Number —— 不允许!):
If Type(x) is BigInt && Type(y) is Number(或反过来)
→ 如果 x 或 y 是 NaN 或 ±Infinity → false
→ 如果 x 的数学值 === y 的数学值 → true
→ 否则 false
⚠ 1n == 1 → true; 1n == 1.5 → false
但 BigInt 和 Number 的 "==" 被允许——BigInt 和 Number 不能混用其他运算符(+ - *)
规则 10(Symbol 严格限制):
If Type(x) is Symbol → return false(除非同类型——已由规则 1 处理)
→ Symbol() == Symbol() → false(两个 Symbol 是不同的值)
规则 11(BigInt vs Undefined/Null):
规范中没有特殊规则 → 走通用路径 → false
→ 0n == undefined → false
→ 0n == null → false
兜底规则: return false
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 6.2 名人堂级反直觉案
把最有名的反直觉 == 案例逐个推演:
| 表达式 | 结果 | 推导链路 |
|---|---|---|
[] == ![] | true | ![]→false → []==0 → ""==0 → 0==0 |
[] == 0 | true | ToPrimitive([])=="" → ""==0 → 0==0 |
[] == "" | true | 同类型→[]不是""…不对——ToPrimitive([])="" → ""==""→true |
[0] == false | true | false→0 → ToPrimitive([0])="0" → "0"==0 → 0==0 |
[1] == true | true | true→1 → [1]→"1" → "1"==1 → 1==1 |
[2] == true | false | true→1 → [2]→"2" → "2"==1 → 2==1→false |
"" == false | true | false→0 → ""==0 → 0==0 |
"" == 0 | true | ""→0 → 0==0 |
"\t\n" == 0 | true | "\t\n"→Trim→""→0 → 0==0 |
null == 0 | false | null 不触发规则 2(因为 0 不是 undefined)→ 兜底→false |
null == undefined | true | 规则 2:null ↔ undefined 豁免 |
# 6.3 === 为什么比
疑惑:=== 是不是永远比 == 快?
论证:=== 的算法比 == 简单得多——只需一步:
=== (Strict Equality Comparison) 的算法:
1) 如果 Type(x) !== Type(y) → return false(直接拒绝!)
2) 如果 Type(x) 是 Number:
a) 如果 x 是 NaN 或 y 是 NaN → return false
b) 如果 x 和 y 是同一个数字值 → return true
(+0 和 -0 被认为是同一个值!)
c) 否则 false
3) 同类型非数字 → 按 SameValueNonNumber 比较
2
3
4
5
6
7
8
从规范角度看,=== 确实"更短"——它不需要 ToPrimitive/ToNumber/ToString 的任何转换。但实际上:
// 当两者类型相同且已经是基本类型时——
// === 和 == 走的是完全相同的 V8 内部路径(因为规则 1 直接跳到严格比较)
const a = 42, b = 42;
a === b; // 类型相同 → 直接逐位比较
a == b; // 类型相同 → 规则 1 → 跳到 === → 直接逐位比较
// ↑ 两者完全一样的机器码
// 当类型不同时——
'5' === 5; // 类型不同 → 直接 false(1 条指令)
'5' == 5; // 类型不同 → ToNumber → Trim → 比较(5+ 条指令)
// ↑ === 显著更快
// 当 "5" 已经 ToNumber 缓存在 Feedback Vector 里时——
// 热代码中,V8 可能已经把 == 编译为类似 === 的快速路径
// 所以在实际生产代码中,=== 和 == 的速度差异远小于理论分析
2
3
4
5
6
7
8
9
10
11
12
13
14
15
结论:在热代码中(被 TurboFan 优化后),== 和 === 的速度差异可以忽略不计。但在冷代码或类型不确定的路径上,=== 确实更简洁。=== 的真正优势不是性能,而是"你看代码时不需要在脑子里跑一遍 ToPrimitive 算法"——可读性才是 === 相比 == 最大的胜利。
# 7. 浮点精度详解
# 7.1 双精度 64 位
疑惑:JS 只有一种数字类型(Number),它在底层是怎么存的?
JS 的 Number 严格遵循 IEEE 754 双精度 64 位浮点数:
IEEE 754 双精度 64 位位布局:
┌──────┬─────────────────────────┬──────────────────────────────┐
│ Bit 63│ Bits 62~52 (11 bits) │ Bits 51~0 (52 bits) │
│ 符号位 │ 指数 (exponent) │ 尾数/有效数 (mantissa/fraction)│
│ S │ E (biased by 1023) │ M (implicit leading 1) │
└──────┴─────────────────────────┴──────────────────────────────┘
值 = (-1)^S × (1 + M/2^52) × 2^(E - 1023)
几个关键值在 64 位中的表示:
0: S=0, E=00000000000, M=全部0 → 0.0
-0: S=1, E=00000000000, M=全部0 → -0.0 (⚠ 0 === -0 → true)
NaN: S=0/1, E=11111111111, M ≠ 0 → NaN (⚠ NaN !== NaN → true)
Infinity: S=0, E=11111111111, M=0 → +∞
-Infinity: S=1, E=11111111111, M=0 → -∞
1.0: S=0, E=01111111111(1023), M=0 → 1.0
2.0: S=0, E=10000000000(1024), M=0 → 2.0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7.2 0.1+0.2
疑惑:0.1 + 0.2 为什么是 0.30000000000000004 而不是 0.3?
论证:根因在于 0.1 和 0.2 在二进制中是无限循环小数——类似于十进制中 1/3 = 0.333... 永远写不完。
0.1 转二进制的过程:
0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0 ← 循环开始!
0.4 × 2 = 0.8 → 0
...
0.1₁₀ = 0.00011001100110011001100110011...₂ (无限循环 0011)
IEEE 754 双精度只有 52 位尾数:
0.1 被截断为 53 位的近似值(含隐式的 leading 1)
0.2 同样被截断
0.1 ≈ 1.1001100110011001100110011001100110011001100110011010 × 2^(-4)
0.2 ≈ 1.1001100110011001100110011001100110011001100110011010 × 2^(-3)
相加时需要对齐指数 → 右移 → 最后一位被舍入
→ 结果 = 0.3000000000000000444089209850062616...
→ JS 打印时只显示 17 位 → "0.30000000000000004"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
更可怕的例子——这会产生连锁效应:
// 金融计算中,这些浮点误差会逐步累积
let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
console.log(total); // → 0.9999999999999999 不是 1.0 !
console.log(total === 1.0); // → false
2
3
4
5
# 7.3 四种解决方案与各
| 方案 | 代码 | 适用场景 | 代价 |
|---|---|---|---|
| 整数化 | (0.1*10 + 0.2*10) / 10 | 已知小数位数固定 | 只能处理整数范围的运算 |
toFixed | (0.1+0.2).toFixed(1) | 显示用(UI 输出) | 结果是字符串,不能继续运算 |
Number.EPSILON | Math.abs(a-b) < Number.EPSILON | 通用比较 | 只适用于比较,不适用于累积运算 |
| Decimal / BigInt | 0.1n + 0.2n ❌ BigInt 不支持小数 → 用库(decimal.js) | 金融/精确运算 | 引入外部依赖,运行速度慢 ~100 倍 |
通用浮点比较函数:
function approxEqual(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) < epsilon;
}
approxEqual(0.1 + 0.2, 0.3); // → true
// 注意:epsilon 只适用于绝对值在 1 附近的数
// 对于极大或极小的数值,需要按比例缩放 epsilon
2
3
4
5
6
# 8. BigInt深度
# 8.1 Smi vs H
疑惑:第 02 篇讲了 V8 的隐藏类和对象布局——数字在 V8 内部是怎么存的?
论证:V8 的数字有三种表示,按取值范围和精度分工:
V8 数值表示的三层架构:
┌─────────────────────────────────────────────────────┐
│ │
│ Smi(Small Integer,小整数): │
│ → 31 位有符号整数(64 位系统) │
│ → 范围:-2^30 ~ 2^30-1 │
│ → 例:1, 42, -100 │
│ → 存在栈上(指针标签位标识),无堆分配 │
│ → 最快:一次 ALU 操作完成加减 │
│ │
│ HeapNumber(堆数字): │
│ → 当数字超出 Smi 范围,或需要浮点精度时 │
│ → 在堆上分配一个 64 位 IEEE 754 双精度值 │
│ → 例:3.14, 2^53, NaN, Infinity │
│ → 有堆分配 + GC 开销 │
│ │
│ BigInt: │
│ → 任意精度整数(理论上无限位) │
│ → 在堆上分配,位数可动态增长 │
│ → 例:123456789012345678901234567890n │
│ → 存储为符号位 + 多个 64 位"数字"组成的数组 │
│ → 最慢:每次运算需要在"数字数组"上做多精度运算 │
│ │
└─────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Number 的安全边界:
// Number 的安全整数范围(由 IEEE 754 的 52 位尾数决定)
console.log(Number.MAX_SAFE_INTEGER); // → 9007199254740991 (2^53 - 1)
console.log(Number.MIN_SAFE_INTEGER); // → -9007199254740991
// 超过安全边界的后果——丢失精度:
console.log(9999999999999999); // → 10000000000000000 ← 变成了另一个数!
console.log(9999999999999999 === 10000000000000000); // → true!
// 为什么?
// 52 位尾数最多精确表示 2^53 = 9007199254740992 个整数
// 9007199254740992 ~ 18014398509481984 之间只有"偶数"能被精确表示
// 超过 18014398509481984 之后,间隔变成 4、8、16...
// 换句说:超过 2^53 后,不是所有整数都有自己的 IEEE 754 表示
2
3
4
5
6
7
8
9
10
11
12
13
# 8.2 BigInt
ES2020 引入 BigInt 后,刻意不提供 BigInt 和 Number 之间的隐式转换——因为这会丢精度:
// ❌ 不允许的混合运算
1n + 1; // → TypeError: Cannot mix BigInt and other types
Math.max(1n, 2n); // → TypeError(Math 函数需要 Number)
JSON.stringify(1n); // → TypeError(JSON 不支持 BigInt)
// ✅ 允许的混合比较
1n == 1; // → true(== 允许,§6.1 的规则 9)
1n === 1; // → false(=== 不允许跨类型)
1n < 2; // → true
1n > 0.5; // → true
// ✅ 显式转换
BigInt(42); // → 42n
Number(42n); // → 42(可能丢失精度!)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 8.3 数值操作的安全边
| 操作 | Smi(Number) | BigInt |
|---|---|---|
| 精确范围 | -2^53 ~ 2^53 | 无限 |
| 支持小数 | ✅ | ❌(整数除自动截断→3n/2n=1n) |
| 位运算 | 视为 32 位 | 无限位(~0n 返回负数补码) |
| 与 JSON 互转 | ✅ 原生支持 | ❌ TypeError |
与 + 运算符 | ✅ | ❌(不能与 Number 混合) |
| 性能 | 最快 (Smi → ALU) | 最慢 (多精度数组运算) |
# 9. Intl 国际化
# 9.1 DateTime
Intl API 是 ECMA-402 规范的一部分——独立于 ECMA-262,存在于所有现代浏览器和 Node.js(依赖 ICU 库):
// DateTimeFormat:日期格式化
const formatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: 'long', day: 'numeric',
weekday: 'long', hour: '2-digit', minute: '2-digit'
});
console.log(formatter.format(new Date()));
// → "2026年6月12日星期五 15:30"
// NumberFormat:数字本地化
new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' })
.format(12345.67); // → "¥12,345.67"
new Intl.NumberFormat('de-DE').format(12345.67); // → "12.345,67"(德国用逗号做小数点)
// RelativeTimeFormat:相对时间
new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
.format(-3, 'day'); // → "3天前"
new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
.format(1, 'week'); // → "下周"(auto 模式下用"下周"而非"1周后")
// ListFormat:列表连接
new Intl.ListFormat('zh-CN', { type: 'conjunction' })
.format(['苹果', '香蕉', '橘子']); // → "苹果、香蕉和橘子"
new Intl.ListFormat('en', { type: 'disjunction' })
.format(['apple', 'banana', 'orange']); // → "apple, banana, or orange"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 9.2 ICU 库依赖与
疑惑:Intl 凭什么能处理 400+ 种语言的格式差异?
答案在于它背后依赖的 ICU(International Components for Unicode)——一个由 Unicode 联盟维护的 C/C++ 库,包含所有语言的所有格式化规则。V8 打包了一个裁剪版的 ICU(icudtl.dat)。
数字系统的文化差异实例:
// 同一数字在不同语言中的格式完全不同
const num = 1234567.89;
// 阿拉伯数字(西方)→ "1,234,567.89"
new Intl.NumberFormat('en-US').format(num);
// 印度数字分组 → "12,34,567.89"(前三位一组,后面每两位一组)
new Intl.NumberFormat('en-IN').format(num);
// 阿拉伯语用不同的数字字形 → "١٬٢٣٤٬٥٦٧٫٨٩"
new Intl.NumberFormat('ar-EG').format(num);
// 中文大写数字 → "一百二十三万四千五百六十七点八九"
// (需要额外库,Intl 原生不直接支持中文大写)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 10. 综合案例串讲
# 10.1 案例真相揭晓
逐一回答 §1 的六个疑问:
① typeof 遗产:typeof null === 'object' 源于 1995 年 JS 引擎用全零位表示 null,恰好与 Object 类型标签(000)重合。修复这个 bug 会破坏 Web 上已有的大量依赖——"bug 已成标准"。
② ToPrimitive:{} + 1 在不同上下文中结果不同——{} 可能被解析为代码块(得 1)或对象(得 "[object Object]1")。关键在 ToPrimitive 的 hint 参数和括号是否强制表达式上下文。Date 是唯一 hint="default" 时先调 toString 的对象。
③ ToNumber:Number(" ") 返回 0 因为 StringToNumber 在解析前先 Trim 首尾空格。空字符串→0。falsy 总共只有 8 个值。
④ == 全算法:[] == ![] 经过五步推导:![]→false → Boolean→ToNumber→0 → Object→ToPrimitive→"" → String→ToNumber→0 → 0==0→true。每一步都有规可依。
⑤ 浮点精度:0.1+0.2 !== 0.3 —— 因为 0.1 和 0.2 在二进制中是无限循环小数,IEEE 754 的 52 位尾数不足以精确表示。比较时用 Number.EPSILON 或整数化。
⑥ BigInt:9999999999999999 被 Number 截断成 10000000000000000 因为超出 53 位精确范围。BigInt 可以存任意精度的整数,但性能代价更大,且与 Number 不能隐式转换。
# 10.2 一个能处理 Na
把本文的所有类型判断细节集成到一个生产级 deepEqual 中:
function deepEqual(a, b, visited = new WeakMap()) {
// 1) 同引用 → 直接 true(含 NaN 特判)
if (Object.is(a, b)) return true;
// Object.is 比 === 更精确:
// Object.is(NaN, NaN) → true (=== 返回 false)
// Object.is(+0, -0) → false (=== 返回 true)
// 2) 类型不同 → false
if (typeof a !== typeof b) return false;
// 3) 非对象(基本类型 + function + symbol)→ 值比较
if (typeof a !== 'object' || a === null || b === null) {
// NaN 已在步骤 1 通过 Object.is 处理
// -0 已通过 Object.is 处理
// BigInt: typeof BigInt === 'bigint'(被 typeof 分辨,走值比较)
// Symbol: typeof Symbol === 'symbol'(走值比较)
return false; // 如果到这里 typeof 相同但 Object.is 不同 → false
}
// 4) 循环引用检测
if (visited.has(a)) {
// a 已经在这个 deepEqual 调用链中见过 → 循环引用
return visited.get(a) === b; // b 是否就是同一个对象?
}
visited.set(a, b);
// 5) 数组
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i], visited)) return false;
}
return true;
}
// 6) 正则
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
// 7) Date
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// 8) Map
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false;
for (const [key, val] of a) {
if (!b.has(key) || !deepEqual(val, b.get(key), visited)) return false;
}
return true;
}
// 9) Set
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) return false;
const arrA = [...a], arrB = [...b];
// Set 无序 → 排序后比较
arrA.sort(); arrB.sort();
return deepEqual(arrA, arrB, visited);
}
// 10) 普通对象 / 类实例
const keysA = Object.keys(a), keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEqual(a[key], b[key], visited)) return false;
}
return true;
}
// 验证所有边缘情况:
console.log(deepEqual(NaN, NaN)); // → true ✅
console.log(deepEqual(+0, -0)); // → false ✅
console.log(deepEqual(1n, 1n)); // → true ✅
console.log(deepEqual(/abc/g, /abc/g)); // → true ✅
console.log(deepEqual(new Date(0), new Date(0)));// → true ✅
// 循环引用
const cyclicA = {}; cyclicA.self = cyclicA;
const cyclicB = {}; cyclicB.self = cyclicB;
console.log(deepEqual(cyclicA, cyclicB)); // → true ✅(WeakMap 防止无限递归)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# 10.3 设计哲学回扣
从类型系统和隐式转换中提炼三条设计哲学:
哲学一·「便捷优于正确」——JS 类型转换的设计原罪与挣扎
JS 诞生于"让非程序员也能写网页"的愿景之下。隐式类型转换(==、+ 的字符串拼接、ToBoolean)是这种愿景的直接产物——它们让一行 if (arr.length) 代替了 if (arr.length > 0),但也制造了 [] == ![] 这类经典陷阱。Brendan Eich 后来公开承认这是设计上的过度妥协。ES6+ 的 Symbol.toPrimitive、BigInt 的严格互操作限制、=== 的推广——都是 JS 在"为过去的便捷买单"的修正过程中。类型系统演进的本质不是"消除隐式转换",而是"给隐式转换加上清晰、可预测的规则"。
哲学二·「表示决定行为」——底层位布局塑造了上层语法
typeof null 的 bug、0.1+0.2 的精度问题、Smi/HeapNumber/BigInt 的三层表示,都与"这个值在内存中占据几位、怎么编码"直接相关。JS 不像 C 语言那样让你直接操作位——但它底层仍然受 IEEE 754、指针标签位、物理内存对齐的约束。理解类型系统不能只看 ECMA 规范,还要看到它跑在物理硬件上的真实表示。
哲学三·「容错性和精确性并存的紧张」——== vs === 的真正教训
== 和 === 的争论常常被简化为"永远用 ==="。但规范允许 ==(它不是 bug),给 == 定下了 15 条精确规则。真正的教训不是"== 是坏的"——而是隐式转换带来的认知负担: 看 x == y 时必须在脑子里跑一遍 ToPrimitive → ToNumber 链才能确定结果。=== 的优势不在于"更快"(实际差异微小),而在于"不需要额外的脑内推导"。一个好的类型系统设计应当让"看一眼就知道会发生什么"——而不是"看一眼规范才知道发生了什么"。
# 10.4 速查表:==
ToPrimitive 调用顺序决策树:
对象 → 有 [Symbol.toPrimitive]?
├─ 是 → 调用 fn(hint) → 须返回基本类型
└─ 否 → hint 是什么?
├─ "number" → valueOf() → 基本类型? → 返回
│ ↓ 非基本类型
│ toString() → 基本类型? → 返回
│ ↓ 非基本类型 → TypeError
├─ "string" → toString() → valueOf()
└─ "default" → 同 "number"(Date 除外 → 同 "string")
2
3
4
5
6
7
8
9
Falsy 八值速查:false, 0, -0, 0n, "", null, undefined, NaN(+ 浏览器 document.all)
Number 安全边界:
| 边界 | 值 | 说明 |
|---|---|---|
| MAX_SAFE_INTEGER | 9007199254740991 (2^53 - 1) | 最后一个"相邻相差 1"的整数 |
| MIN_SAFE_INTEGER | -9007199254740991 | |
| MAX_VALUE | ~1.7977e+308 | 超过就是 Infinity |
| MIN_VALUE | ~5e-324 | 最小的正非零数 |
| EPSILON | ~2.22e-16 | 1 和"比 1 大的最小浮点数"之差 |
下一步:类型系统通了。但真正让 JS 独一无二的是——执行上下文和闭包。为什么循环里的 var 是所有人踩过的坑?V8 怎么用 Context 链存闭包?进入 04.作用域链与闭包深度。