JavaScript · 类型转换与相等性

== 的每一步都写在规范里: ToPrimitive → 偏好拼接还是数字 → 逐分支可比 — 乱但不神秘

任一是 string 都是 number 类 a + b 的完整决策树 ToPrimitive(a/b) Symbol.toPrimitive 优先 然后 valueOf → toString 字符串拼接 concat '1' + 1 = '11' · [] + {} = '[object Object]' 数字相加 add '1' - 1 = 0 (减法只有数字一条路) 关键分歧: 加号偏爱字符串, 其余算术运算符只认数字 — 这就是 '1'+1 与 '1'-1 答案不同的全部原因 ToPrimitive(obj, hint): 对象 → 原始值 ① Symbol.toPrimitive ② valueOf() → 若仍对象 ③ toString() → 若仍对象 全部仍是对象 → TypeError: Cannot convert object to primitive value == 的简化算法 (宽松相等) 类型相同 → 按 === 比较 (对象比引用地址) null == undefined → true (这一对只彼此相等) number 与 string → 字符串转数字再比 有 boolean → 先转数字; 有对象 → 先 ToPrimitive 生产建议: 全用 === ; 唯一合法场景 x == null 判 null/undefined Boolean() 为 false 的只有 8 个值 false · 0 · -0 · 0n · '' · null · undefined · NaN 注意: 'false'/'0'/[]/{}/负数字符串 全是真值 — 布尔转换只看这张表, 不做"语义判断" // 经典谜题逐个击破 [] + [] // → '' 两个数组都 ToPrimitive 成 '' [] + {} // → '[object Object]' {} toString 的结果 0.1 + 0.2 // → 0.30000000000000004 IEEE-754 二进制浮点, 与转换无关 NaN === NaN // → false NaN 是唯一不等于自身的值; 判 NaN 用 Number.isNaN null >= 0 // → true 而 null == 0 → false: 比较转数字, 相等有特判 // 团队规约模板: 全 === · 显式 Number()/String()/Boolean() · 金额走整数分 · NaN 用 Number.isNaN 判

加法偏爱字符串

  • • + 有 string 参与就是拼接
  • • 其余算术符一律转数字
  • • ToPrimitive: Symbol.toPrimitive → valueOf → toString
  • • 全失败才 TypeError

== 规范可推导

  • • 同类型退化为 ===
  • • null/undefined 只彼此相等
  • • boolean 先转数字, 对象先 ToPrimitive
  • • 生产全用 ===, 只留 x == null

假值只有 8 个

  • • false 0 -0 0n '' null undefined NaN
  • • 'false'、[]、{} 都是真值
  • • ?? 与 || 的差异来自假值判定
  • • NaN 判等用 Number.isNaN

💡 一句话理解

JS 只有 7 种类型(6 原始 + object), 但运算符会自作主张地把类型掰到能算为止 — 这就是隐式转换。它不神秘: 每一步都写在规范里。ToPrimitive 负责"对象怎么变原始值"(自定义钩子 → valueOf → toString), + 看到字符串就拼接、否则转数字, 其他算术符只走数字, == 在类型不同时按固定优先级掰扯。

工程上的结论反而简单: 用 === 消灭歧义, 用显式转换表达意图。需要判空用 x == null 这一处约定俗成; 需要判 NaN 用 Number.isNaN; 涉及钱就别用浮点。真正的高手不是背下 []+{} 的答案, 而是让代码里根本不会出现这种表达式。

🧠 必知必会 必考 & 必会

typeof 全表
7 种类型 + 函数特例 + null 历史包袱; typeof 是唯一不抛错的"未定义检测"。
typeof null;        // → 'object'  历史遗留
typeof NaN;         // → 'number'
typeof undeclared;  // → 'undefined' 不抛错
ToPrimitive 顺序
对象转原始值: Symbol.toPrimitive 优先, 然后 valueOf, 再 toString; hint 是 number/string/default。
const o = {
  valueOf: () => 10, toString: () => 'ten' };
o + 1;      // → 11  default 偏向 valueOf
`${o}`;      // → 'ten'  string hint 偏向 toString
八假值
只有 false/0/-0/0n/''/null/undefined/NaN 是假值; 其余一切(含 [], {}, '0')都是真值。
Boolean('false');   // → true  非空字符串恒真
Boolean([]);         // → true
Boolean(0n);         // → false BigInt 零是假值
== 简化算法
同类型走 ===; null==undefined; boolean 转数字; number 与 string 互转; 对象先 ToPrimitive。
'1' == true;   // → true  true→1, '1'→1
'' == 0;       // → true  ''→0
null == 0;     // → false 特判: null 只等于 undefined
NaN 三特性
不等于自身; typeof 仍是 number; 产生于"算不出数"的场合。判定必须 Number.isNaN。
Number.isNaN(NaN);        // → true
Number('abc');            // → NaN  而不是报错
NaN === NaN;              // → false
+0 与 -0
=== 分不出正负零, 但 1/x 会暴露; Object.is 能区分且能判 NaN。
Object.is(-0, 0);    // → false
Object.is(NaN, NaN); // → true   === 都做不到
(1/-0) === -Infinity; // → true  探测 -0 的老办法
Number vs parseInt
Number 整串校验(允许空白, 不容垃圾); parseInt 从头解析吞前缀, 必带 radix。
Number('42px');    // → NaN  严格
parseInt('42px');  // → 42   宽松
parseInt('08');    // → 8   必须 parseInt(s, 10) 防意外
数组/对象转原始值
数组 toString=join(','); 普通对象 toString=[object Object]; 这就是 []+{} 之谜。
[1,2] + '';          // → '1,2'
({}) + '';           // → '[object Object]'
[[]] + [[]];         // → ''  两边都 join 成空串
?? 与 ||
|| 拦下一切假值(0/''/false 遭殃); ?? 只拦 null/undefined — 默认值场景的首选。
0 || 10;   // → 10   0 被误伤
0 ?? 10;   // → 0    只兜 null/undefined
'' ?? 'd'; // → ''
浮点本质
IEEE-754 二进制小数无法精确表示 0.1; 与类型转换无关, 钱用整数分或 decimal 库。
0.1 + 0.2;                       // → 0.30000000000000004
Math.round(0.1*100 + 0.2*100);   // → 30 分化思路
(0.1 + 0.2).toFixed(2);          // → '0.30' 展示层修
null vs undefined
undefined = "没值"(系统给); null = "有意为空"(程序给); 语义分工要守住。
JSON.stringify({ a: undefined, b: null });
// → {"b":null}  undefined 键直接消失!
Symbol/BigInt 特殊性
Symbol 不能与字符串隐式拼接; BigInt 与 number 不能混算 — 都必须显式转换。
1n + 1;            // → TypeError
1n + BigInt(1);    // → 2n
'' + Symbol();     // → TypeError

🏭 生产实战 real world

场景 1 · 表单数字的严格校验: Number 系一步到位

输入框拿到的永远是字符串, "0" 被当 falsy 跳过校验是高频事故。统一走"转数字 + Number.isNaN"管线:

function parseAmount(raw) {
  const n = Number(raw);            // 严格: '12a' 直接 NaN
  if (Number.isNaN(n) || n <= 0) {
    return { ok: false, msg: '金额必须为正数' };
  }
  return { ok: true, cents: Math.round(n * 100) };
}
parseAmount('0');    // → { ok:false }  "0" 不再被 falsy 误判跳过

场景 2 · 金额计算: 分整数化消灭浮点误差

订单合计 0.1+0.2 事故。前端只做展示层换算, 金额运算全部走"分":

const prices = [1990, 350, 100];          // 单位: 分
const totalCents = prices.reduce((s, p) => s + p, 0);
const display = (totalCents / 100).toFixed(2);   // '24.40'
// 折扣等乘法后必须取整回分:
const discounted = Math.round(totalCents * 0.85);  // 永远是整数分

场景 3 · 配置布尔解析: 'false' 字符串陷阱

环境变量/query 全是字符串, Boolean('false') 是 true。专门的解析函数:

function parseBool(v) {
  if (typeof v === 'boolean') return v;
  return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
}
parseBool(process.env.DEBUG);   // 'false' → false  ✓
// 反例: Boolean('false') → true, 线上 DEBUG 永远开着

场景 4 · x == null: 唯一推荐的宽松相等

可选参数"没传"与"显式传 null"都要兜住, 双重判断啰嗦, 约定俗成用 ==:

function create(name, opts) {
  if (opts == null) opts = {};          // 同时兜 undefined 与 null
  // 等价于 opts === undefined || opts === null, 但短
  // 现代写法: opts ??= {}  (逻辑空赋值)
}

场景 5 · Set 去重连 NaN 一起修好

filter+indexOf 去重遇到 NaN 全乱(NaN!==NaN), Set 的 SameValueZero 连 NaN 都能正确去重:

const dirty = [1, NaN, 2, NaN, 1];
[...new Set(dirty)];        // → [1, NaN, 2]  ✓ NaN 只留一个
dirty.filter((v, i) => dirty.indexOf(v) === i);
// → [1, NaN, NaN, 2]  indexOf 永远找不到第二个 NaN

场景 6 · Object.is 在 React 里的意义

组件依赖比较用 Object.is 语义: NaN 状态不再误判"没变", -0/0 切换也能触发渲染:

Object.is(NaN, NaN);   // → true   setState(NaN) 不再无限渲染
Object.is(-0, 0);      // → false  温度跨零点的场景能正确更新
// 自己写 deepEqual 时, 叶子节点比较也用 Object.is

场景 7 · 日期解析: ISO 与浏览器差异

new Date('2024-03-01') 按 UTC 解析, '2024/03/01' 按本地时区 — 差 8 小时的事故:

new Date('2024-03-01').getHours();   // 8 (UTC 零点 = 东八 8 点)
new Date('2024/03/01').getHours();   // 0 (本地零点)
// 对: 统一手动拆解或显式带时区
new Date('2024-03-01T00:00:00+08:00');   // 意图明确

场景 8 · 幂等键规范化: 1 与 '1' 撞键

同一笔订单从两条链路来, id 一个是 number 一个是 string, 幂等表出现两条记录。入口统一规范化:

const normId = (id) => String(id);       // 统一字符串键
const seen = new Set();
function handleOrder(o) {
  const k = normId(o.id);
  if (seen.has(k)) return { dup: true };
  seen.add(k);
  return process(o);
}
handleOrder({ id: 1001 }); handleOrder({ id: '1001' });
// → 第二次被识别为重复  ✓

场景 9 · 模板串里的对象: [object Object] 事故

日志/埋点把对象直接塞进模板串, 落库全是 [object Object]。显式序列化:

// 错: log(`payload=${payload}`);    // → payload=[object Object]
// 对: log(`payload=${JSON.stringify(payload)}`);
// 更稳: 结构化日志直接传对象, 由日志库序列化
logger.info('order_submit', { payload });

场景 10 · ?? 链式默认值: 配置合并的正确姿势

深度配置 { timeout: 0 } 被 || 默认值覆盖成 3000, 0 语义丢失。?? 保住合法假值:

const cfg = { timeout: 0, prefix: '', retry: null };
cfg.timeout ?? 3000;    // → 0      合法的"不等"被保留
cfg.prefix  ?? '/api';  // → ''     空前缀是合法配置
cfg.retry   ?? 3;       // → 3      null 才用默认
// 组合: a?.b?.c ?? default  一行完成深取值+默认

⚠️ 编码注意与常见坑 pitfalls

坑 1 · '1'+1 与 '1'-1 不一致 — + 偏字符串拼接, 其余算术只走数字. 正解: 显式 Number() 后再算。
'1' + 1;   // → '11'
'1' - 1;   // → 0
// 对: Number('1') + 1;  // → 2 意图明确
坑 2 · []+{} 谜题出现在真实日志 — 数组 join 与对象 toString 的组合产物. 正解: 拼接前显式 JSON.stringify。
[] + {};        // → '[object Object]'
{} + [];        // 语句开头 {} 被当块 → 0 (控制台差异)
坑 3 · typeof null 是 'object' — 判 null 要用 === null. 正解: v === null 或 Object.is。
typeof null === 'object';   // true, 但 null 不是对象
// 对: v === null;
坑 4 · NaN !== NaN — 去重/判等逻辑全部失灵. 正解: Number.isNaN / Object.is。
arr.indexOf(NaN);        // → -1 永远找不到
arr.includes(NaN);       // → true  includes 用 SameValueZero
坑 5 · Boolean('false') 为 true — 布尔转换只看八假值表, 不懂语义. 正解: 专用 parseBool。
Boolean('false');   // → true
Boolean('0');       // → true
坑 6 · 0.1+0.2 !== 0.3 — 二进制浮点表示问题. 正解: 整数分/Decimal/展示层 toFixed。
0.1 + 0.2 === 0.3;              // → false
Math.abs(0.1+0.2-0.3) < Number.EPSILON;  // → true 误差容忍比较
坑 7 · || 误伤 0 与 '' — 默认值覆盖了合法假值. 正解: 默认值场景一律 ??。
volume ?? 50;    // 0 音量被保留
timeout ?? 3000; // 0 超时被保留
坑 8 · new Number/Boolean 装箱对象 — 对象恒为真值且 typeof 变 object. 正解: 永远用原始值。
new Boolean(false) ? a : b;   // 走 a! 对象是真值
typeof new Number(1);          // → 'object'
坑 9 · parseInt 没写 radix — 前缀 0 的老环境八进制风险与宽容解析. 正解: 永远带基数。
parseInt('08');          // 多数现代引擎 8, 但别赌
parseInt('08', 10);     // → 8 明确十进制
坑 10 · == 跨类型黑洞 — ''/0/'0'/false/[] 两两相等, 分支走向失控. 正解: 全 ===, 只留 x == null。
'' == 0;  '0' == 0;  [] == 0;  [] == '';   // 全 true
// 生产代码里看到 == 就是 code smell
坑 11 · null >= 0 为 true 而 null == 0 为 false — 比较运算走数字转换, 相等有 null 特判. 正解: 别写 null 的算术比较。
null >= 0;   // → true  null→0
null == 0;   // → false 特判
null > 0;    // → false
坑 12 · JSON.stringify 丢 undefined 键 — 序列化后字段"凭空消失". 正解: 需要占位用 null。
JSON.stringify({ a: undefined });   // → '{}'
JSON.stringify({ a: null });        // → '{"a":null}'
坑 13 · 日期解析时区暗改 — '2024-03-01' 按 UTC, '2024/03/01' 按本地. 正解: ISO 带时区或手动拆解。
new Date('2024-03-01');              // UTC 零点
new Date(2024, 2, 1);                // 本地零点, 月份从 0 起!
坑 14 · Date 月份从 0 开始 — 3 月写成 3 变 4 月. 正解: 月-1 或用库。
new Date(2024, 2, 1);   // → 2024-03-01  2 表示三月
坑 15 · Symbol 拼接抛错 — Symbol 不能隐式转字符串. 正解: String(sym) 或 sym.description。
'' + Symbol('id');   // → TypeError
String(Symbol('id')); // → 'Symbol(id)'
坑 16 · BigInt 与 number 混算 — 直接 TypeError. 正解: 显式 BigInt() 或 Number() 收敛同一侧。
1n + 1;            // → TypeError
1n + BigInt(1);    // → 2n
坑 17 · 短路求值返回操作数 — a || b 的结果类型不一定是布尔. 正解: 真要布尔用 Boolean(a || b) 或 !!。
const has = user.tag || '';   // has 是 string 不是 boolean
// 对: const has = !!(user.tag);
坑 18 · isNaN 全局版先转数字 — isNaN('abc') 是 true, 语义是"转不成数". 正解: 判 NaN 值用 Number.isNaN。
isNaN('abc');        // → true  'abc'→NaN
Number.isNaN('abc'); // → false 不是 NaN 类型才 true
坑 19 · 模板串对象隐式 toString — 落库/上报全是 [object Object]. 正解: 显式 JSON.stringify 或结构化日志。
`data: ${obj}`;              // → data: [object Object]
`data: ${JSON.stringify(obj)}`;  // ✓
坑 20 · Array(3) 空洞与 map — map 跳过空洞, fill 未做时结果"看起来没执行". 正解: Array.from({length}, fn)。
new Array(3).map(fn);              // → [empty × 3] map 不跑
Array.from({ length: 3 }, fn);     // → [f(0), f(1), f(2)]