如果你写过几天 JavaScript,大概率经历过这样的无语瞬间:只是想给购物车数量加个 1,结果页面弹出了'100'这种匪夷所思的值。我在某个线上协作项目里排查过一条诡异数据,后台返回的'12'被前端代码+2之后变成了'122',当时整个会议室都沉默了。
这个问题的根源不在数据,而在 JavaScript 的加法运算符。+在绝大多数语言里就是"做加法",但在 JavaScript 里,它更像一场大型相亲会:左右两侧的值就像两个性格完全不同的相亲对象,被硬凑到一块,能不能好好"过日子",完全取决于那套藏在引擎底层的隐式类型转换规则。这篇文章想把这些规则彻底拆开——哪些组合会产生离谱结果、引擎内部到底按什么流程决定是加法还是字符串拼接、对象类型为什么经常"演戏"、以及你在实际业务里怎么防御这些坑。适合所有写业务代码时被类型问题坑过的人。
1. 先围观几场"掺和式"相亲:加法符号的迷惑行为大赏
在摸规则之前,先看一些可以直接粘贴到控制台跑一遍的结果。我按直觉分了三组,每一组都值得细品。
字符串和数字混在一起
'2' + 3; // '23' 2 + '3'; // '23' '2' - 1; // 1 '2' * 3; // 6 1 + 1 + '1'; // '21' '1' + 1 + 1; // '111'同样一个+,只要有一边是字符串,结果就往字符串方向跑;但换成-或*,程序又变得"讲数学"了。这其实不是 JS 精神分裂,而是+的特殊身份:它既要承担加法,又要承担字符串拼接,运算到一半必须看两边"脸色"决定走哪条路。
布尔值、空值和数字
true + true; // 2 null + 1; // 1 undefined + 1; // NaN false + 1; // 1true能当1用,null能当0用,undefined却直接给你NaN。这一组已经能看出隐式转换的不对称性:有的值能"将就",有的值直接掀桌子。
数组、对象和空值
[] + []; // '' [] + 1; // '1' [1, 2] + [3, 4]; // '1,23,4' ({}) + 1; // '[object Object]1' ({}) + []; // '[object Object]'空数组相加得到空字符串,数组加数组变成逗号分隔的字符串,普通对象直接露出[object Object]的原形。这一组已经不只是"反常",而是完全进入"行为艺术"的范畴了。
把这些案例放在一起,你大概能感受到一个规律:加法运算符并不是无脑转数字,也不是无脑拼接,而是有一套优先级判断流程。在深入流程之前,有个经典谜题必须单独拆一下,因为它误导过很多人。
1.1 同一个表达式,换个姿势结果就变了
在浏览器的控制台里输入下面两行,结果完全不同:
{} + []; // 0 ({}) + []; // '[object Object]'这跟类型转换没关系,纯粹是语法解析的锅。第一行里,行首的{}被 JavaScript 解析器当成一个独立的代码块(Block Statement),而不是对象字面量。代码块执行完没有任何结果,真正参与运算的只剩+ [],也就是一元正号把空数组转成数字0。第二行加了括号,({})就是明确的表达式,于是{}作为对象参与加法,最后走对象转字符串的分支,得到'[object Object]'。
这种"看起来一样,结果不一样"的问题,在面试题里出现概率特别高。它真正想考的不是你会不会背结果,而是你知不知道:同一段代码所处的上下文(语句上下文还是表达式上下文),会直接影响解析结果。工作中遇到{} + xxx这种写法时,我基本直接判为代码坏味道,老老实实加括号或者换个写法,因为别人读代码时第一眼根本看不出这里埋着雷。
2. 相亲中介的底牌:隐式类型转换的内部流程
前面看到的现象只是表象,真正决定录用的"相亲中介"是 ECMAScript 引擎里的一套抽象操作。加法运算符在运算时,并不是直接"两个值相加",而是按下面这套流程走:
- 对左侧操作数执行
ToPrimitive,得到一个原始值; - 对右侧操作数执行
ToPrimitive,得到一个原始值; - 如果其中任意一侧的原始值是字符串,就把两侧都转成字符串,做字符串拼接;
- 否则,把两侧都转成数值(Number 或 BigInt),做数值加法;
- 如果两侧数值类型不兼容(一个 Number 一个 BigInt),直接抛出 TypeError。
用伪代码表达会更清楚:
function add(left, right) { const l = toPrimitive(left); // hint: default const r = toPrimitive(right); // hint: default if (isString(l) || isString(r)) { return toString(l) + toString(r); // 字符串拼接 } const ln = toNumeric(l); const rn = toNumeric(r); if (isBigInt(ln) && isBigInt(rn)) { return ln + rn; // BigInt 加法 } if (isBigInt(ln) || isBigInt(rn)) { throw new TypeError('Cannot mix BigInt and other types'); } return ln + rn; // Number 加法 }这里最关键的一点是:加法永远不会跳过 ToPrimitive 直接决定走数值还是走字符串。它先把两边都"掰成"原始值,再判断是否有字符串。这就是为什么数组加数字会得到字符串:空数组经过 ToPrimitive 变成空字符串'',一侧是字符串,于是'' + 1就拼接成'1'。
2.1 ToPrimitive:把复杂值"打回原形"
ToPrimitive(input, hint)是整场相亲的"形象包装环节",它的目标是把对象转换成原始值。hint 有三种:
default:加法运算符使用,不明确倾向转字符串还是转数字;string:模板字符串、String()等场景使用;number:一元正号、Number()、比较运算符等场景使用。
对于绝大多数原始类型(数字、字符串、布尔值、null、undefined),ToPrimitive 不做任何事,原样返回。真正的戏份都在对象身上,这个下一节会展开。这里先看原始类型的ToNumber和ToString,因为 Step 3 和 Step 4 用到的就是它们。
ToNumber 对常见原始值的转换结果
| 输入 | 结果 |
|---|---|
undefined | NaN |
null | 0 |
true | 1 |
false | 0 |
''(空字符串) | 0 |
'0x1F' | 31 |
'12.5px' | NaN |
' 12 '(前后空格) | 12 |
Symbol() | 抛出 TypeError |
ToString 对常见值的转换结果
| 输入 | 结果 |
|---|---|
null | 'null' |
undefined | 'undefined' |
true | 'true' |
1e21 | '1e+21' |
0.1 | '0.1' |
Symbol() | 抛出 TypeError |
注意几个容易忽略的细节。空字符串转数字是0,所以'' + 1先变成'' + 1再拼接为'1',但+''单独执行时是0。带十六进制前缀的字符串能被解析成数字,但带着单位的字符串只能得到NaN。另外,1e21这种超出常规展示范围的大数,转成字符串会出现指数形式,这在拼接时特别容易让看日志的人懵圈。
2.2 为什么加号和减号"性格"不同
想通加号内部的隐性流程,就能理解为什么'2' + 3和'2' - 1行为完全不同。减号没有"字符串拼接"分支,规范里减法直接就走ToNumeric,所以'2' - 1会把两侧都转成数字,得到1。加号多了一个分支:只要任何一侧在 ToPrimitive 之后是字符串,加法立刻转向拼接。
这个差异是大量无意 bug 的来源。我见过的最典型例子是:
const count = '10'; const result = count + 5; // '105',不是 15 const difference = count - 5; // 5,这行反而没问题新手经常在+上踩坑,却觉得-很靠谱,正因为这个不对称的设计。遇到+时多留个心眼,遇到-、*、/时反而可以放松一点警惕,这是第一句经验。
3. 对象型选手的层层伪装:valueOf、toString 与 Symbol.toPrimitive
对象参与加法时,ToPrimitive 会执行一套"层层剥开"的动作。默认情况下(没有自定义的 Symbol.toPrimitive),引擎从下面的流程依次尝试:
- 如果存在
Symbol.toPrimitive方法,调用它,把 hint 传进去,直接采用它的返回值; - 否则根据 hint 决定顺序:
- hint 是
string或default:先调toString,如果返回原始值则采用;否则再调valueOf; - hint 是
number:先调valueOf,如果返回原始值则采用;否则再调toString;
- hint 是
- 如果两个方法都没返回原始值,抛出 TypeError(最常见的报错是 "Cannot convert object to primitive value")。
这里有个容易混淆的点:加法走的是 hint 为default的路径,而为了让日常的String(obj)能正常工作,规范里 default 对普通对象来说通常优先尝试toString。这就是为什么{} + 1得到字符串'[object Object]1':普通对象的valueOf返回它自身(不是原始值),于是退回toString,得到'[object Object]'。
3.1 数组、日期和自定义对象的"人设"
不同对象类型的toString和valueOf行为差异巨大,可以用下面这张表直观感受:
| 对象类型 | valueOf 返回值 | toString 返回值 |
|---|---|---|
普通对象{} | 对象自身 | '[object Object]' |
空数组[] | 数组自身 | '' |
数组[1,2] | 数组自身 | '1,2' |
new Date() | 毫秒时间戳(数字) | 可读日期字符串 |
正是这种差异造成了前面那组匪夷所思的结果。
[] + []:两边都先走 toString,空数组的 toString 是'',所以结果'';[1,2] + [3]:数组的 toString 等价于join(','),于是'1,2' + '3',结果是'1,23';({}) + []:普通对象 toString 是'[object Object]',空数组 toString 是'',拼接起来正好是'[object Object]';new Date() + 1:日期对象对加法来说优先走 toString,得到日期字符串,再和数字拼接,所以是字符串结果。
Date 是很多人遇到的第一例"对象类型在不同运算符下判若两人"的活教材:
const now = new Date(); now + 0; // 'Thu Sep 12 2024 10:00:00 GMT+0800 (...)0' now - 0; // 1726113600000(数字毫秒值) +now; // 1726113600000(一元正号触发 number hint)同一个now,加号走到字符串拼接,减号和一元正号走到数值。你要是在代码里用date + 间隔秒数这种写法,结果一定会让你怀疑人生。实际业务里,日期运算要么用时间戳,要么用专门的日期库,千万别指望加法运算符。
3.2 自定义对象的"人设操控"
对象参与加法的行为其实是可以完全掌控的,这能给调试带来很大方便。比如想让一个对象参与加法时表现得像数字,同时在其他场景呈现为字符串,可以这样设计:
const order = { amount: 42, valueOf() { return this.amount; // 参与数值运算时优先走这里 }, toString() { return `order-${this.amount}`; // 参与字符串场景时走这里 }, }; order + 1; // 43,因为 valueOf 返回了原始值数字 `${order}`; // 'order-42',模板字符串 hint=string,走 toString如果想更精细地控制不同"相亲场合"的表现,可以用Symbol.toPrimitive。这个方法的优先级最高,一旦定义了,valueOf和toString都会被忽略:
const price = { v: 10, [Symbol.toPrimitive](hint) { if (hint === 'number') return this.v; // 数值场景 if (hint === 'string') return `$${this.v}`; // 字符串场景 return this.v; // default,加法场景 }, }; price + 5; // 15 String(price); // '$10' Number(price); // 10自定义对象参与隐式转换,核心套路就一句话:hint 告诉对象"这次相亲你要展示什么形象",返回原始值才有效,返回对象会被忽略并继续下一步。掌握这个点之后,你不仅能看懂任何对象的加法行为,还能在调试时故意重写对象的这两个方法,让它的转换结果变得可预期。
4. 容易误判的第二现场:数组、日期、空值与边界值
除了对象的基础转换令人头疼,还有一批 "第二现场" 的边界值经常在真实业务里突然抛给你。这些场景不是冷门考点,而是每天都可能遇到的输入数据。
4.1 null、undefined、Symbol、BigInt 的"极端人设"
null + 1; // 1,null 被转成 0 undefined + 1; // NaN,undefined 转数字是 NaN null + ''; // 'null' undefined + ''; // 'undefined' Symbol() + 1; // TypeError Symbol() + ''; // TypeError 1n + 1; // TypeError:BigInt 不能和 Number 直接相加 '1' + 1n; // '11':有一侧是字符串,走拼接,BigInt 转字符串null 和 undefined 的区别非常容易踩。null在数值语境下是0,在字符串语境下是'null';undefined在数值语境下直接 NaN,在字符串语境下是'undefined'。所以接口返回一个空字段时,如果代码里写的是value + 1,null 不会报错但结果可能是错的,undefined 会直接污染整个数值链。
Symbol 和 BigInt 是 ES6 之后加入的原始类型,它们的规则更严苛。Symbol 根本不能参与任何隐式转换,连接字符串都不行,必须显式调用String()。BigInt 则不能和 Number 混着加,除非其中一侧是字符串走拼接分支。这些不是语法糖,是规范层面强制保护的类型隔离。
4.2 浮点数的"物理缺陷":0.1 加 0.2
0.1 + 0.2不等于0.3是 JavaScript 世界最著名的"明谋"之一。原因是计算机用二进制浮点数表示小数,而0.1和0.2在二进制里都是无限循环小数,存进内存时已经被截断,相加后再转回十进制,就成了0.30000000000000004。
这不是 JavaScript 独有的问题,几乎所有用 IEEE 754 标准的语言都有。只是 JavaScript 不区分整数和浮点数,导致它出现在业务代码里的概率特别高。最直接的教训是:
// 别这样比较 if (a + b === 0.3) { ... } // 要么用容差比较 const EPSILON = Number.EPSILON || 2.220446049250313e-16; Math.abs(a + b - 0.3) < EPSILON; // 要么先转成整数再算,要么用库 parseFloat((a + b).toFixed(2));顺带提醒:'0.1' + '0.2'的结果是'0.10.2',因为这是字符串拼接不是数值加法。类似错误我在报表模块见过好几次——从接口拿到字符串,直接拼了个假小数出来。
4.3 大数与安全整数的陷阱
JavaScript 的数字是双精度浮点,能精确表示的整数范围是Number.MAX_SAFE_INTEGER,也就是9007199254740991。超过这个范围,加法就开始"四舍五入":
9007199254740991 + 1; // 9007199254740992,看着对 9007199254740991 + 2; // 9007199254740992,还是这个数!精度丢了 1e21 + 1; // 1e21,+1 完全没效果如果业务数据里有超过 2^53 的 ID(比如某些系统用雪花 ID),千万别拿 Number 类型做加法或比较,老老实实用BigInt或字符串处理。这里还有一个隐式转换的坑:'9007199254740991' + 1由于字符串拼接,直接变成'90071992547409911',看似没问题,实际是拼字符串,不是数学运算。
4.4 表单和接口返回是重灾区
前面这些案例看着像脑筋急转弯,但在真实系统里,我遇到最多的情况是把外部数据直接扔进加法表达式。表单输入永远返回字符串,URL 参数永远返回字符串,接口字段经常在 number 和 string 之间来回横跳。下面这段代码就是事故现场:
// 用户输入了 "2",length 从接口拿到字符串 "10" const total = userInput + length; // 用户以为 12,实际得到 '210'只要有一个字段没经过转换,+就会自动走拼接分支,而且不会报错,页面显示的结果也不像 NaN 那样一眼就能看出来问题,它默默给你一个看起来"挺像话"的字符串。这类 bug 最阴险的地方就在这里——它静默地错了,但结果长得像对的。
5. 实践中的防御姿势:如何避免加法背刺你
看完底层原理和各类边界情况,接下来是更重要的部分:工程上怎么防。我从三个层面来讲:代码写法、工具配置、排查路径。
5.1 从源头限定加法语义
第一条纪律:代码里的+只能有一个语义。要么是明确的数值加法,要么是明确的字符串拼接,不要在同一个项目里放任它摇摆。
数值加法,先显式转换再运算:
const total = Number(userInput) + Number(length);字符串拼接,用模板字符串,别用+:
const message = `订单 ${orderId} 的数量是 ${count}`;这样做最大的收益是代码审查时一眼就能看出意图。看到Number(...)就知道这行要算数;看到模板字符串就知道要拼文案;再看到裸的a + b,心里就得打一个问号:这俩货类型确定吗?
第二条纪律:从不可信来源取值时,不要直接用原始值参与二元加法。表单、URL 参数、localStorage、接口字段,全都要走一次"净化"。
5.2 写一个防御函数,别裸奔
我习惯在项目里放一个toFiniteNumber这样的防御函数,它负责把所有可疑输入统一净化成可靠的数字:
const toFiniteNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number') { return Number.isFinite(value) ? value : fallback; } if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed === '') return fallback; const num = Number(trimmed); return Number.isFinite(num) ? num : fallback; } if (typeof value === 'boolean') { return value ? 1 : 0; } if (value === null) { return 0; } return fallback; }; const safeAdd = (...values: unknown[]): number => values.reduce((sum, v) => sum + toFiniteNumber(v), 0);这个函数的策略很直白:能转就转,转不了给兜底值,永远不抛错、永远不返回 NaN。注意我对undefined的处理是走 fallback,而不是跟着原生 ToNumber 走 NaN,因为在业务里一个被遗忘的字段远没有0更安全。空字符串同样走 fallback,因为表单提交的空值按0处理通常比按NaN合理。
同样的防御思维也可以延伸到"加法后过一遍":
const normalized = toFiniteNumber(untrustedValue, 0); const result = price * normalized + shipping; // 全部基于净化后的数5.3 借助编译器和 Lint 规则卡住隐患
TypeScript 不是万能的,但确实能挡住不少低级错误。打开strict模式后,null和undefined会被强约束,很多"隐式转数字"的路径会被类型检查拦下。真正干活的是这两个配置:
{ "compilerOptions": { "strict": true, "noImplicitAny": true }, "rules": { "no-implicit-coercion": ["error", { "allow": ["!!"] }], "@typescript-eslint/restrict-plus-operands": "error" } }@typescript-eslint/restrict-plus-operands这条规则非常值得用:它会禁止string + number这种"类型暧昧"的加法表达式。开了之后,想拼接必须显式写模板字符串,想做加法必须显式转换,从根本上把+的模糊地带给堵死。最初我团队接入这条规则时,光存量代码就报了上百个错,但修完之后整个"不知道为什么结果会这样"的问题少了一个量级。
不过要清醒一点:TypeScript 检查的是编译时的静态类型,它挡不住any、JSON.parse出来的数据、as unknown as number这种强转。所以编译期防护只是第一道防线,运行时防御函数仍然要保留。
5.4 排查加法问题的一套"体检流程"
如果你还是遇到加法结果诡异的问题,按这个顺序排查,比肉眼盯代码高效得多:
- 先看这个值从哪来。是用户输入、接口字段、URL 参数还是计算中间变量?外部数据一律先假设它是字符串;
- 打印
typeof和Object.prototype.toString,不要只打印值本身,否则"10"和10在 console 里很难分清; - 在表达式前临时加断点,把两边的值在不做任何转换的情况下单独看一遍;
- 检查是否有对象参与了运算。
Object.prototype.toString.call(x)能准确告诉你对象类型,比typeof区分不了数组和普通对象的情况强太多; - 把加法拆开验证。比如
a + b不对,就分别跑a + 0、b + 0、a + ''、b + '',看哪一步暴露问题; - 最后检查运算顺序。
'1' + 1 + 1和1 + 1 + '1'结果不同,是因为左结合律让前两步先算,只要第一步碰到字符串,整条链就变成拼接。
5.5 代码审查时要问的一句话
我自己的习惯,也是我到现在为止最想安利给大家的一个小技巧:代码审查时碰到+,停下来问一句"这个运算符左右两边,你预期是什么类型?"如果对方能秒答,说明类型在他的心智模型里是清晰的;如果支支吾吾,那这里基本就埋着一颗雷。
一次评审中,某位同事写了value + 1,我问他要的是数值加还是字符串拼,他想了想说"应该是数值加,但 value 可能从接口来"——好,答案已经浮出水面了。这行代码在单元测试里永远正确,一上线就随机出现'21'这种结果。改成Number(value) + 1之后,世界安静了。
这些年下来,我对 JavaScript 加法的感受其实已经从"愤怒"变成了"敬畏"。它不是故意整人,而是设计当初为了同时服务"数学计算"和"字符串展示"两个需求,埋下了一整套优先级规则。理解这套规则,不是为了记住所有匪夷所思的结果,而是为了在自己的代码里明确表达意图,让+没有机会自作主张。规范化、防御函数、Lint 规则,本质上都在做同一件事:把这个最不稳定的运算符,限制在最不容易出错的使用姿势里。