~/ ?.log $
返回文章列表
8 min read
更新于 2026年3月4日

我不知道的 V8(10)— 1 + "2" 为什么等于 "12":类型转换的底层逻辑

1 + "2" 的结果是 "12" 而不是 3——大多数开发者知道这个结果,背后的理由通常是"JavaScript 会把数字转成字符串"。但这个说法不够精确。为什么是数字转成字符串,而不是字符串转成数字?+ 运算符究竟在什么时候做字符串拼接,什么时候做数字加法?ECMAScri…

1 + "2" 的结果是 "12" 而不是 3——大多数开发者知道这个结果,背后的理由通常是”JavaScript 会把数字转成字符串”。但这个说法不够精确。为什么是数字转成字符串,而不是字符串转成数字?+ 运算符究竟在什么时候做字符串拼接,什么时候做数字加法?ECMAScript 规范对这套逻辑有明确的定义,V8 的字节码忠实实现了它。

一、加法运算符的特殊性

+ 是 JavaScript 里唯一一个”重载”的运算符——它既能做数字加法,也能做字符串拼接。这和其他算术运算符不同:

console.log(1 * '2'); // 2,乘法只做数值运算,"2" 被转为数字
console.log(1 - '2'); // -1,减法同上
console.log(1 + '2'); // "12",加法有字符串拼接模式

为什么加法如此特殊?历史原因:早期 JavaScript 用 + 承担字符串拼接的职责(那时还没有模板字符串),这个行为被固定进了规范。

二、ECMAScript 规范:加法的决策树

ECMAScript 规范(Section 13.15.3,Abstract Relational Comparison 以外的 +)定义了加法的执行顺序。V8 严格按这套逻辑实现:

步骤一:对两个操作数分别调用 ToPrimitive()
  → 数字、字符串、布尔值、null、undefined 直接通过
  → 对象需要转换(见下一节)

步骤二:判断是否有字符串
  → 如果 lprim 或 rprim 中有字符串
      对两者都调用 ToString(),然后做字符串拼接
  → 如果都没有字符串
      对两者都调用 ToNumber(),然后做数值加法

用这个规则解析几个例子:

1 + '2';
// ToPrimitive(1) = 1, ToPrimitive("2") = "2"
// 右操作数是字符串 → 字符串拼接模式
// ToString(1) = "1", ToString("2") = "2"
// 结果:"12"

1 + true;
// ToPrimitive(1) = 1, ToPrimitive(true) = true
// 没有字符串 → 数值加法模式
// ToNumber(1) = 1, ToNumber(true) = 1
// 结果:2

null + undefined;
// ToPrimitive(null) = null, ToPrimitive(undefined) = undefined
// 没有字符串 → 数值加法模式
// ToNumber(null) = 0, ToNumber(undefined) = NaN
// 结果:NaN

'' + null;
// ToPrimitive("") = "", ToPrimitive(null) = null
// 左操作数是字符串 → 字符串拼接模式
// ToString("") = "", ToString(null) = "null"
// 结果:"null"

三、对象的 ToPrimitive:valueOf 优先

当操作数是对象时,ToPrimitive 会按顺序尝试两个方法:

  1. 调用 valueOf(),如果返回原始值(非对象),使用它
  2. 否则调用 toString()
// 自定义 valueOf 的对象
const obj1 = { valueOf: () => 3, toString: () => 'obj' };
console.log(obj1 + '2'); // "32"
// ToPrimitive(obj1) = 3(valueOf 返回数字 3,是原始值)
// 然后:ToPrimitive 结果是 3,有字符串 → 字符串拼接
// ToString(3) = "3", ToString("2") = "2" → "32"

// 没有 valueOf 的对象
const obj2 = { toString: () => 'hello' };
console.log(obj2 + ' world'); // "hello world"
// ToPrimitive(obj2):valueOf 返回 obj2 本身(非原始值) → 调用 toString → "hello"

// 普通对象(默认 toString 返回 "[object Object]")
console.log({} + '2'); // "[object Object]2"

// 数组的 toString 会把元素用逗号连接
console.log([1, 2] + '2'); // "1,22"
// ToPrimitive([1, 2]):valueOf 返回数组本身(非原始值) → toString → "1,2"
// "1,2" + "2" → "1,22"

console.log(1 + [1, 2]); // "11,2"
// ToPrimitive(1) = 1, ToPrimitive([1, 2]) = "1,2"
// 右操作数经转换后是字符串 → 字符串拼接
// ToString(1) = "1" → "1" + "1,2" → "11,2"

四、V8 的字节码实现

V8 的 Ignition 解释器把上述规则转换成字节码。以 1 + "2" 为例:

; function add() { return 1 + "2"; }

LdaSmi [1]          ; 加载小整数 1(Smi = Small Integer,V8 的优化表示)
ToString            ; 检测到有字符串操作数,先把 1 转为 "1"
LdaConstant ["2"]   ; 加载常量字符串 "2"
Add                 ; 字符串拼接,结果 "12"
Return

对比 1 + true(数值加法路径):

; function add() { return 1 + true; }

LdaSmi [1]
LdaTrue
ToNumber            ; 把 true 转为 1
Add                 ; 数值加法,结果 2
Return

对比含对象的加法:

; function add(obj) { return obj + "2"; }
; 其中 obj = { valueOf: () => 3 }

LdaNamedProperty [obj]
CallProperty0 [valueOf]   ; 调用 valueOf(),返回 3
ToString                  ; 3 转为 "3"
LdaConstant ["2"]
Add                       ; "3" + "2" = "32"
Return

用实际命令查看字节码:

node --print-bytecode --print-bytecode-filter=add script.js

五、性能影响

类型转换本身不是性能热点,但不一致的类型使用会影响 TurboFan 的优化:

// Bad Case:同一个函数分别传数字和字符串
function combine(a, b) {
  return a + b;
}
combine(1, 2); // 数值加法路径
combine('hello', ' world'); // 字符串拼接路径
// 两种类型 → 函数进入多态状态,TurboFan 难以优化

// Better:用专用函数处理不同类型
function addNumbers(a, b) {
  return a + b;
}
function concatStrings(a, b) {
  return a + b;
}

避免意外类型转换的常见建议:

// 不推荐:隐式转换,意图不明确
const result = someValue + ''; // 把某个值转成字符串

// 推荐:显式转换,意图清晰,也更利于 TurboFan 优化
const result = String(someValue);
// 或
const result = `${someValue}`;

六、总结

1 + "2" 等于 "12" 的完整逻辑:

  1. ECMAScript 规范:对两个操作数调用 ToPrimitive只要有一个是字符串,就走字符串拼接路径——不是”数字转字符串”,而是规范要求”任何非字符串操作数都要先用 ToString() 转换”
  2. V8 实现:Ignition 字节码中用 ToString 指令处理转换,然后执行字符串拼接
  3. 对象的处理:先调 valueOf(),返回非对象才使用;否则调 toString()

+ 运算符字符串优先的设计,是历史遗留。现代 JavaScript 开发里,明确的类型转换(String()Number()、模板字符串)比依赖隐式转换更可靠。


本系列其他文章:

share.ts

// 觉得这篇文章有帮助?

// 欢迎分享给更多人

export const  subscribe  =  "/rss.xml" ;