在JavaScript中,运算符是最基础的语法层,但线上问题往往不是不会写+或===,而是没看清隐式转换、返回值语义和短路求值。原文按ECMAScript抽象操作拆解了类型转换、算术、赋值、比较、逻辑和位运算。下面按“转换地基—运算符行为—相等性—默认值—排查清单”重组为可落地的代码实践。
一、四个转换入口决定运算符结果
规范里的ToPrimitive、ToNumber、ToBoolean、ToInt32/ToNumeric不是可调用函数,而是描述转换行为的抽象操作。对象转原始值时,先看Symbol.toPrimitive;没有则按hint尝试valueOf和toString,hint为string时顺序对调。第一个返回原始值的方法就是结果,两个都不返回原始值则抛TypeError。
- const obj = {
- valueOf() { return 42; },
- toString() { return 'str'; }
- };
- obj + 1; // 43,+ 用 default hint,优先 valueOf
- `${obj}`; // 'str',模板字符串用 string hint
- Number(obj); // 42
- String(obj); // 'str'
复制代码
Date是特例:default hint下返回字符串,所以new Date(0) + 1是字符串拼接;Number(new Date(0))才是时间戳。数组的valueOf返回自身,最终落到toString,也就是join(','),因此[] + []得空串,[1,2] + [3,4]得'1,23,4'。ToNumber方面,undefined为NaN,null为0,布尔为1/0,空串为0,非法数字字符串为NaN,Symbol抛TypeError。对象先ToPrimitive再递归ToNumber,所以+[]为0,+['5']为5,+[1,2]为NaN,+{}为NaN。
ToBoolean只需记住八个falsy:false、0、-0、0n、''、null、undefined、NaN。其余都是truthy。空数组、空对象、字符串'0'、字符串'false'、空格字符串都是true;new Boolean(false)也是true,因为它是对象。要转换就用Boolean(x),不要用new包装。位运算走ToInt32,null、undefined、NaN变0,小数向零截断;BigInt走ToBigInt,1n + 1会抛TypeError,不能和Number直接混算。
二、算术运算符的工程坑
+是唯一具有双重语义的二元运算符:先对左右操作数各做一次ToPrimitive,再判断原始类型;只要一方是字符串就拼接,否则双方ToNumber后相加。判断发生在转换之后,所以1 + true得2,1 + null得1,1 + undefined得NaN,'' + 0得'0'。{} + []在浏览器控制台语句位置会被解析为空代码块加+[],结果是0;写成({} + [])或放在赋值右侧,结果才是'[object Object]'。差异来自语法解析位置,不是运算符本身。
- +'42'; // 42
- +''; // 0
- +'12px'; // NaN
- Number('12px'); // NaN
- parseInt('12px', 10); // 12
复制代码
一元+等价于ToNumber,适合把DOM输入值转数字,但意图不如Number(x)显眼。Number和parseInt不等价:Number要求整个字符串合法,parseInt从左向右解析到第一个非法字符,自由文本用parseInt并传基数10,严格校验用Number或正则。减法、乘法、除法没有歧义,字符串会被强制转数字,'10' - 5得5,'a' - 1得NaN。浮点精度问题来自IEEE 754双精度:0.1 + 0.2得0.30000000000000004,0.1 + 0.2 === 0.3为false。金额可用整数分单位、字符串保存小数或decimal.js;普通比较用Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b)),简单场景也可用小于1e-9。除零合法:1/0为Infinity,0/0为NaN。
取余结果符号跟随左操作数:7 % 3为1,-7 % 3为-1。需要非负模时用((a % b) + b) % b修正。判断奇数不要写n % 2 === 1,因为-3 % 2得-1,稳妥写法是n % 2 !== 0。**是右结合,2 ** 3 ** 2等于512;一元运算符不能直接贴在底数前,-2 ** 2是SyntaxError,必须写-(2 ** 2)或(-2) ** 2。++和--的前置后置区别是返回新值还是旧值;它们内部先ToNumber,所以let s='5'; s++后s是数字6,而s = s + 1会得到'51'。同一表达式里对同一变量多次读写,如arr[i++] = i++,可读性差且迁移易错,应避免。
三、比较与相等:从推导到选型
关系运算符先做ToPrimitive:双方都是字符串时按Unicode码元逐字符比较,否则双方ToNumeric后按数值比较。'a' < 'b'为true,'Z' < 'a'为true,'10' < '9'为true,因为双方都是字符串,'1'的码元小于'9';'10' < 9为false,因为右侧是数字。null和undefined参与大小比较要特别小心:null >= 0为true,null > 0为false,null <= 0为true;undefined >= 0、undefined > 0、undefined < 0全为false,因为undefined转NaN。null >= 0可理解为!(null < 0),null < 0走ToNumber得0 < 0为false,取反为true;null > 0直接算0 > 0为false。
==由抽象相等算法定义。类型相同按===;null和undefined互相相等且不与别的值相等;数字对字符串时字符串ToNumber;有布尔参与时布尔先ToNumber;对象对原始值时对象先ToPrimitive;其余组合一律false。所以null == undefined为true,null == 0为false,'' == 0为true,'0' == false为true,[] == false为true,NaN == NaN为false。[] == ![]可逐步推导:![]先得false,表达式变为[] == false;布尔转数字得[] == 0;对象转原始值得'' == 0;字符串转数字得0 == 0;最后为true。工程上==唯一广泛认可的写法是value == null,用于一行同时判断null和undefined,等价于value === null || value === undefined。
- 1 === '1'; // false
- null === undefined; // false
- NaN === NaN; // false
- +0 === -0; // true
- Object.is(NaN, NaN); // true
- Object.is(+0, -0); // false
- Number.isNaN('abc'); // false,不转换
- isNaN('abc'); // true,全局isNaN先做ToNumber
- [NaN].includes(NaN); // true,SameValueZero
- [NaN].indexOf(NaN); // -1,严格相等
- new Set([NaN, NaN]).size; // 1
- new Set([0, -0]).size; // 1
复制代码
===类型不同直接false,类型相同再比值,对象比引用。它有两个不完美处:NaN === NaN为false,+0 === -0为true,都源于IEEE 754。判NaN用Number.isNaN(x),不要用全局isNaN;Object.is对应SameValue,修复了这两处分歧,React的useState内部就用它比较新旧state。SameValueZero与Object.is区别只在认为+0和-0相等,它是Map/Set键去重和Array.prototype.includes的判断标准。选型上:默认===;判空可用== null或??;精确处理NaN或±0用Object.is;集合类API行为由SameValueZero决定。
四、逻辑运算符与默认值赋值
&&和||返回决定结果的操作数本身,不返回布尔。'hi' && 'there'得'there',0 && 'there'得0,null || 'fallback'得'fallback',1 && 2 && 3得3。这个语义带来防御性访问,也带来React条件渲染事故:如果count是购物车数量,count && <Cart />在count为0时返回0,React会把数字0渲染到页面。修复可用count > 0 && <Cart />、!!count && <Cart />或count ? <Cart /> : null。!先ToBoolean再取反,!!x与Boolean(x)等价。
??只在左侧为null或undefined时取右侧。??=、||=、&&=都是短路赋值:条件不满足时右侧不执行。给配置补默认值时,0、空串、false属于合法业务值的字段必须走??系:settings.volume ??= 50会保持用户设置的0;如果换成||=,volume会被覆盖成50。&&=适合存在才修改,例如user &&= {...user, lastLogin: Date.now()}。复合赋值还有个规范细节:a += b只对a求值一次,a = a + b求值两次,左值带getter、函数调用或arr[i++]时行为不同。
- const settings = { volume: 0 };
- settings.volume ??= 50; // 保持0
- settings.muted ??= false;
- settings.lang ??= 'zh-CN';
- let user = null;
- user &&= { ...user, lastLogin: Date.now() }; // user为null时右侧不求值
复制代码
五、排查清单
默认值逻辑先判断字段是否允许0、空串、false,允许就用??或??=;判空优先value == null;日常比较默认===;判NaN用Number.isNaN;字符串数字比较前显式转数值,版本号要拆段比较;金额和浮点比较按场景选择整数、字符串、decimal库或误差范围;不要用new Boolean/Number/String;避免同一表达式多次自增;不要在循环里用delete处理数组元素,原文把它列为事故高发点,删除后留下的空洞会影响后续遍历,应改用语义明确的数组操作。理解抽象转换和短路返回值后,[] == ![]、null >= 0、typeof null等看似灵异的结果都能按规范一步步推出来,而不是靠背面试题。 |