前言
如果把 JavaScript 比作一门语言,变量是名词,函数是句子,那么运算符就是动词------它决定了值与值之间发生什么:相加、比较、赋值、取反。我们每天都在写 + 和 ===,但很少有人能说清楚 0 || 10 和 0 ?? 10 到底差在哪,[] == ![] 为什么是 true,typeof null 为什么返回 'object'。
这些"灵异事件"没有一个是 bug。它们全部是 ECMAScript 规范里白纸黑字定义好的行为,只是与人的直觉不一致。而直觉不一致的地方,就是线上事故的高发区:一个用 || 设置默认值的音量配置,把用户精心调好的 0 悄悄改回了 50;一个用 delete 删数组元素的循环,让后续的 forEach 莫名其妙跳了一格。
一、地基:类型转换机制
JS 是动态弱类型语言,几乎所有运算符在执行前都会对操作数做隐式转换。不理解转换规则,就只能靠背"经典面试题";理解了规则,任何诡异表达式都可以拿纸笔一步步推导出来。
规范定义了几个核心的抽象操作(Abstract Operations),它们不是可以调用的函数,而是规范描述行为用的记号。
1.1 ToPrimitive:对象变原始值
ToPrimitive(input, hint) 接收一个 hint 参数,取值有三种:number、string、default。转换流程是:
- 如果对象定义了
Symbol.toPrimitive方法,直接调用它,hint 作为参数传入,结束; - 否则,按顺序尝试
valueOf()和toString()------但 hint 为string时顺序对调,先试toString; - 第一个返回原始值的方法,其结果就是答案;两个都不返回原始值,抛 TypeError。
js
const obj = {
valueOf() { return 42; },
toString() { return 'str'; }
};
obj + 1; // 43。+ 用 default hint,优先 valueOf
`${obj}`; // 'str'。模板字符串用 string hint
Number(obj); // 42
String(obj); // 'str'
hint 为 default 时按 number 的顺序处理,所以 +、==、< 这些运算符触发的转换里,valueOf 排在前面。
日期对象是唯一的特例。Date.prototype[Symbol.toPrimitive] 在 default hint 下返回字符串而不是数字,这就是为什么:
js
new Date(0) + 1;
// 'Thu Jan 01 1970 ...1'------字符串拼接,不是时间戳加一
Number(new Date(0)); // 0,number hint 下老老实实返回时间戳
数组的 valueOf 返回自身(不是原始值),于是落到 toString,也就是 join(','):
js
[] + []; // '':两个空数组各自变成 '',拼接还是 ''
[1, 2] + [3, 4]; // '1,23,4'
1.2 ToNumber:一切变数字
| 输入 | 结果 |
|---|---|
undefined |
NaN |
null |
0 |
true / false |
1 / 0 |
| 数字字符串 | 对应数值:'0x1A' → 26,' 12 ' → 12,'1e3' → 1000 |
| 非法数字字符串 | NaN:'12px' → NaN,'1,2' → NaN |
空字符串 '' |
0 |
| Symbol | 抛 TypeError |
| 对象 | 先 ToPrimitive(number),再对结果递归 ToNumber |
对象转数字这条递归路径解释了一堆现象:
js
+[]; // 0:[] → '' → 0
+['5']; // 5:['5'] → '5' → 5
+[1, 2]; // NaN:[1,2] → '1,2' → NaN
+{}; // NaN:{} → '[object Object]' → NaN
+null; // 0
+undefined; // NaN
1.3 ToBoolean:只有八个 falsy
转布尔的规则最简单,也最需要背下来------falsy 值一共只有八个:
false、0、-0、0n、''、null、undefined、NaN
其余一切都是 truthy。高频踩坑的"意外 truthy":
js
Boolean([]); // true!空数组
Boolean({}); // true!空对象
Boolean('0'); // true!非空字符串
Boolean('false'); // true
Boolean(' '); // true!空格也是非空
Boolean(new Boolean(false)); // true!包装对象是对象
最后一条尤其阴险:new Boolean(false) 创建的是对象,对象一律 truthy,尽管它"包着" false。永远不要用 new Boolean/Number/String,要转换就用函数形式的 Boolean(x)。
1.4 ToInt32 与 ToNumeric
位运算专用:ToInt32 把值转成 32 位有符号整数,null、undefined、NaN 都变 0,小数向零截断,超出 ±2³¹ 的数按模回绕。BigInt 的位运算则走 ToBigInt。
算术运算的总入口是 ToNumeric:BigInt 走 BigInt 路径,其余走 ToNumber 路径。BigInt 和 Number 不能直接混算,1n + 1 抛 TypeError,这是规范故意设置的防线------两种数字的精度语义不同,混算的结果几乎必然不符合任何一方的预期,不如强制开发者显式转换。
有了这四组规则,前文提过的 [] + {} 已经可以完整推导:
[] → ToPrimitive(default) → valueOf 返回自身(非原始值) → toString → join → ''
{} → ToPrimitive(default) → '[object Object]'
两侧有字符串 → 走拼接分支 → '[object Object]'
没有任何玄学。下面进入运算符正题。
二、算术运算符
算术运算符包括 +、-、*、/、%、**、++、--,以及一元 + 和一元 -。
2.1 加法 +:一身兼两职
+ 是 JS 中唯一具有双重语义的二元运算符。求值分两步:先对左右操作数各执行一次 ToPrimitive(hint 为 default),然后只要有一方的原始类型是字符串,就走字符串拼接;否则双方 ToNumber 后数值相加。
注意判断发生在转换之后,这是理解所有怪异加法的关键:
js
1 + 2; // 3
1 + '2'; // '12','2' 本身就是字符串
1 + true; // 2,true → 1
1 + null; // 1,null → 0
1 + undefined; // NaN,undefined → NaN,NaN 传染一切
1 + NaN; // NaN
'' + 0; // '0',最常用的"数字转字符串"手段
{} + [] 是流传最广的整活题。在浏览器控制台把它当语句敲进去,结果是 0------因为行首的 {} 被解析为空代码块 ,实际执行的是 +[],一元加号把空数组转成数字 0。但写成 ({} + [])、放在赋值右侧、或在 Node REPL 里执行,{} 就是对象字面量了,结果是 '[object Object]'。差异出在语法解析层,与运算符本身无关。这个例子的教训是:同一串字符在语句位置和表达式位置的解析可能完全不同。
2.2 一元加号:最短的类型转换
一元 + 的语义就是对操作数执行 ToNumber:
js
+'42'; // 42
+''; // 0
+'12px'; // NaN
+true; // 1
它是所有数字转换手段里最短的,处理 DOM 输入值(+input.value)时很常见。缺点是意图不够显眼------一个前缀加号容易被看漏,也容易和二元加号混淆。团队项目中更推荐 Number(x),语义一目了然,行为完全等价(都走 ToNumber)。
顺带澄清一个常见误解:Number() 与 parseInt() 不等价。Number('12px') 是 NaN,parseInt('12px') 是 12------parseInt 从左往右解析到第一个非法字符为止,Number 要求整个字符串是合法数字。解析用户输入的自由文本用 parseInt(记得传基数 10),做严格校验用 Number 或正则。
2.3 减法、乘法、除法 - * /
这三个没有歧义:操作数各自 ToNumber 后运算,结果永远是数字。字符串参与时会被强制转数字,与 + 形成鲜明对比:
js
'10' - 5; // 5
'10' * '2'; // 20
'a' - 1; // NaN
除法要单独讲浮点精度。JS 只有一种数字类型------IEEE 754 双精度浮点数,于是:
js
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
这不是 JS 的锅,Java、Python、C# 全都一样,是二进制无法精确表示 0.1 的固有问题。工程对策分场景:金额计算用整数(以分为单位)、用字符串保存小数、或用 decimal.js 这类库;普通业务里比较浮点数用误差范围 Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b)),简单场景 Math.abs(a - b) < 1e-9 也够用。整数在 Number.MAX_SAFE_INTEGER(2⁵³−1,约 9007 万亿)以内是精确的,超出的整数运算用 BigInt。
除零是合法操作,不抛错:
js
1 / 0; // Infinity
-1 / 0; // -Infinity
0 / 0; // NaN
2.4 取余 %
取余结果的符号跟随左操作数(被除数),这与数学上的"模运算"不同:
js
7 % 3; // 1
-7 % 3; // -1,不是 2
7 % -3; // 1
-7 % -3; // -1
需要数学意义的非负模时用 ((a % b) + b) % b 修正。BigInt 也支持 %,且 BigInt 除法是向零取整(与 Number 的浮点除法不同,BigInt 的 / 直接丢弃小数部分:7n / 2n === 3n)。
另一个细节:n % 2 === 1 判断奇数对负数是错的-------3 % 2 得 -1。稳妥写法是 n % 2 !== 0。
2.5 幂运算 **(ES2016)
js
2 ** 10; // 1024,等价于 Math.pow(2, 10)
2 ** -1; // 0.5
4 ** 0.5; // 2,开平方
两个规范细节。第一,** 是右结合的:
js
2 ** 3 ** 2; // 512,即 2 ** (3 ** 2) = 2 ** 9,不是 (2 ** 3) ** 2 = 64
第二,一元运算符不能直接贴在底数前:
js
-2 ** 2; // SyntaxError!
-(2 ** 2); // -4 ✅
(-2) ** 2; // 4 ✅
这条禁令是 ES2016 讨论时有意为之的------Python 里 -2 ** 2 是 -4,有些语言是 4,与其替开发者选边站,不如直接报错强制表态。
2.6 自增自减 ++ --
前置与后置的区别在于表达式返回新值还是旧值:
js
let a = 1;
let b = a++; // b = 1,a = 2。后置:先取旧值,再自增
let c = 1;
let d = ++c; // d = 2,c = 2。前置:先自增,再取新值
它们内部先执行 ToNumber,所以字符串也能用,且变量类型会改变:
js
let s = '5';
s++; // s 现在是数字 6,不再是字符串
由此可知 ++x 与 x = x + 1 并不严格等价:前者做 ToNumber('5' 变 6),后者走完整的 + 逻辑('5' + 1 得 '51')。对 const 变量、对象字面量、或任何非法赋值目标使用会直接报错。
还有一类必须远离的写法:在同一表达式里对同一变量多次读写,比如 arr[i++] = i++。规范虽然定义了求值顺序,但这种代码的可读性是灾难,且不同语言的顺序规则不同,迁移时必炸。一行只做一次修改,是底线。
三、赋值运算符
3.1 基本赋值 =
赋值表达式的值是被赋的那个值,且赋值右结合,链式赋值由此成立:
js
let a, b;
a = b = 5; // 解析为 a = (b = 5),a 和 b 都是 5
一个历史包袱:a = b = 5 中如果 b 从未声明,非严格模式下会悄悄创建全局变量;严格模式下抛 ReferenceError。ES Module 默认严格模式,现代项目踩不到,但读遗留代码时需要心里有数------这也正是当年无数全局变量污染的来源。
对象赋值传的是引用,这是"数据被莫名改掉"类问题的头号源头:
js
const o1 = { n: 1 };
const o2 = o1; // o2 和 o1 指向同一个对象
o2.n = 2;
o1.n; // 2
注意 const 只锁定绑定不锁定内容:const 对象的属性照样可以改,o1 = {} 才会报错。
3.2 复合赋值
算术版:+=、-=、*=、/=、%=、**=;位运算版:&=、|=、^=、<<=、>>=、>>>=。都是 a = a op b 的简写。
+= 继承 + 的双重语义,是唯一需要小心的:
js
let total = '1';
total += 2; // '12',字符串拼接
let sum = 1;
sum += 2; // 3
一个很少人知道的规范细节:a += b 只对 a 求值一次 ,而 a = a + b 求值两次。当左值是带副作用的表达式(函数调用、getter、arr[i++])时,两者行为不同。日常代码几乎不会踩到,但读复杂左值时值得留意。
3.3 逻辑赋值(ES2021):||= &&= ??=
这三个运算符的共同核心是短路:条件不满足时,右侧表达式根本不会执行。它们与展开形式的对应关系是:
js
a ||= b; // 等价于 a || (a = b),不是 a = (a || b)
a &&= b; // 等价于 a && (a = b)
a ??= b; // 等价于 a ?? (a = b)
写法上的差别不只是少敲几个字符------右侧是昂贵计算或有副作用的调用时,短路版本根本不执行它:
js
config.theme ||= computeExpensiveTheme(); // theme 存在时,函数不调用
??= 是三者中最实用的,处理"给缺失的配置项补默认值"干净利落:
js
const settings = { volume: 0 };
settings.volume ??= 50; // 保持 0------用户明确设置的 0 是合法值 ✅
settings.muted ??= false; // 补上 false
settings.lang ??= 'zh-CN'; // 补上 'zh-CN'
如果换成 ||=,volume 会被错误覆盖成 50,用户的静音设置凭空丢失。这个例子值得反复强调:凡是 0、空串、false 属于合法业务值的字段,默认值逻辑必须走 ?? 系。分页页码、计数器、开关状态、允许为空的文本框,全都属于此类。
&&= 的典型场景是"存在才修改":
js
let user = null;
user &&= { ...user, lastLogin: Date.now() }; // user 为 null 时右侧不求值,不报错
四、比较运算符
这是规范中最庞大的一章,也是实际项目事故率最高的区域。
4.1 关系运算符 < > <= >=
比较逻辑分两条路径:双方 ToPrimitive 后都是字符串 时,按 Unicode 码元逐字符比较(字典序);其他情况,双方 ToNumeric 后按数值比较(BigInt 与 Number 混合比较在这四个运算符下是合法的,规范专门定义了跨类型数值比较)。
js
'a' < 'b'; // true
'Z' < 'a'; // true,'Z'(90) 在 'a'(97) 前面------大写整体排在小写前
'10' < '9'; // true!双方都是字符串,逐字符比,'1'(49) < '9'(57)
'10' < 9; // false,右侧是数字,'10' 转数值 10
'10' < '9' 坑过无数对字符串排序掉以轻心的人------数据库里 VARCHAR 类型的数字字段排序、版本号比较、文件名排序,全是重灾区。凡是需要数值语义,先确保两边真的是数字;版本号这种 1.9.0 < 1.10.0 的场景则要拆段比较。
null 和 undefined 参与大小比较时行为分裂,是高频面试点,也是真实的坑:
js
null >= 0; // true
null > 0; // false
null <= 0; // true
undefined >= 0; // false
undefined > 0; // false
undefined < 0; // false
前两条看似矛盾,其实完全自洽。规范里 >= 的定义是"先做小于比较,若为 false 且结果不是 undefined 则返回 true",即 null >= 0 等价于 !(null < 0);null < 0 走 ToNumber 得 0 < 0 为 false,取反就是 true。而 null > 0 直接算 0 > 0,false。undefined 转数字是 NaN,NaN 参与任何大小比较都是 false,所以三行全 false。
结论很实际:大小比较前先判空,不要让 null/undefined 混进来。
4.2 宽松相等 ==:抽象相等算法
== 的行为由 Abstract Equality Comparison 定义。规则可以压缩成六条,按判断顺序:
- 类型相同 :完全按
===的规则走; - null 与 undefined :互相相等,且不与任何别的值相等------包括 0、''、false;
- 数字对字符串:字符串 ToNumber 后重新比较;
- 有布尔参与:布尔先 ToNumber(true→1,false→0),重新比较;
- 对象对原始值(bigint 对 number 也在此列):对象先 ToPrimitive,重新比较;
- 其余组合(如 Symbol 对数字):一律 false。
js
null == undefined; // true(规则2)
null == 0; // false(规则2:null 绝不转数字)
'' == 0; // true(规则3:'' → 0)
'0' == false; // true(规则4:false → 0;规则3:'0' → 0)
[] == false; // true(同上:[] → '' → 0)
[] == ![]; // true(见下方推导)
NaN == NaN; // false,NaN 参与任何相等比较都是 false
[] == ![] 值得完整推一遍,它串起了三条规则:
第一步:! 的优先级高于 ==,先算 ![]
[] 是对象 → ToBoolean 为 true → 取反得 false
表达式变为 [] == false
第二步:规则4,布尔转数字 → [] == 0
第三步:规则5,对象转原始值 → '' == 0
第四步:规则3,字符串转数字 → 0 == 0
第五步:类型相同值相同 → true
每一步都严格遵循规范。背结论没有意义,会推导才能以不变应万变。
== 在工程上唯一被广泛认可的用法,是一行同时判 null 和 undefined:
js
if (value == null) {
// 当且仅当 value 是 null 或 undefined 时进入
}
它精确等价于 value === null || value === undefined,更短且不引入任何歧义------因为规则 2 保证了 null/undefined 不会和其他任何值宽松相等。ESLint 的 eqeqeq 规则开 "smart" 或 ["always", {"null": "ignore"}] 选项时专门放行这一种写法。除此之外,没有理由使用 ==。
4.3 严格相等 ===
类型不同直接 false;类型相同再比值。对象比引用:
js
1 === '1'; // false,类型不同
null === undefined; // false
NaN === NaN; // false ⚠️
+0 === -0; // true ⚠️
{} === {}; // false,两个不同对象
const a = {}; a === a; // true,同一引用
两个 ⚠️ 是 === 仅有的"不完美"处,都源于 IEEE 754。
NaN 的判断用 Number.isNaN(x):
js
Number.isNaN(NaN); // true
Number.isNaN('abc'); // false ✅ 不转换,只对真正的 NaN 返回 true
isNaN('abc'); // true ⚠️ 全局 isNaN 先做 ToNumber,'abc' → NaN,语义完全不同
全局 isNaN 的实际含义是"这个值能不能转成数字",和名字表达的"这个值是不是 NaN"是两回事。永远用 Number.isNaN。另一个技巧 x !== x 利用了 NaN 不等于自身的特性,功能正确但可读性差,只配在压缩产物里出现。
+0 与 -0 在 === 眼里相等,绝大多数场景无差别。区别只在:1/0 得 Infinity 而 1/-0 得 -Infinity;Object.is 能区分它们;(-0).toString() 是 '0',负号会悄悄消失,这在需要保号的数学库中是要专门处理的问题。
4.4 Object.is:同值相等(SameValue)
ES2015 引入,对应规范的 SameValue 算法,与 === 只有两处分歧------恰好修复上面两个 ⚠️:
js
Object.is(NaN, NaN); // true
Object.is(+0, -0); // false
Object.is(1, '1'); // false,与 === 一致
Object.is({}, {}); // false,与 === 一致
业务代码直接用它的机会不多(判 NaN 用 Number.isNaN 更语义化),但它在库层面无处不在------React 的 useState 内部就是用 Object.is 比较新旧 state 来决定是否触发重渲染。你给 state 连续赋两个 NaN,React 不会无限重渲,就是它的功劳。
4.5 第四种相等:SameValueZero
规范里还有一种相等算法值得知道:SameValueZero,与 Object.is 的唯一区别是认为 +0 和 -0 相等 (NaN 等于自身这点与 Object.is 相同)。它是 Map/Set 的键去重、Array.prototype.includes 的判断标准:
js
[NaN].includes(NaN); // true!includes 用 SameValueZero
[NaN].indexOf(NaN); // -1!indexOf 用严格相等
new Set([NaN, NaN]).size; // 1,NaN 被视为同一个键
new Set([0, -0]).size; // 1,±0 也被视为同一个键
includes 能找到 NaN 而 indexOf 找不到,这个差异的根源就在算法选型。四种相等性对照:
| 比较 | == |
=== |
Object.is (SameValue) |
SameValueZero |
|---|---|---|---|---|
| NaN vs NaN | false | false | true | true |
| +0 vs -0 | true | true | false | true |
| null vs undefined | true | false | false | false |
| 1 vs '1' | true | false | false | false |
| 使用者 | 宽松相等 | 严格相等 | React state、Object.is | Map/Set/includes |
选型原则一句话:默认 ===;判空可用 == null 或 ??;需要精确处理 NaN 或 ±0 时用 Object.is;集合类 API 的行为由 SameValueZero 决定,知道即可。
五、逻辑运算符
5.1 短路与返回值:&& 和 || 不返回布尔
这是 JS 与 Java、C 的重要差异。&& 和 || 返回的是决定结果的那个操作数本身:
a && b:a 为 falsy 返回 a,否则返回 b;a || b:a 为 truthy 返回 a,否则返回 b。
js
'hi' && 'there'; // 'there'
0 && 'there'; // 0,左侧已经决定结果
null || 'fallback'; // 'fallback'
'a' || 'b'; // 'a'
1 && 2 && 3; // 3,链式取最后一个
null && 2 && 3; // null,短路在第一环
求值从左往右,一旦结果可确定立即停止,右侧完全不会执行------这就是短路。它带来两种经典用法。
一是防御性访问,可选链出现之前的标准写法:
js
if (response && response.data && response.data.items) { ... }
二是 React 条件渲染:
jsx
{isLogin && <Profile />}
第二种用法藏着前端最著名的事故之一:如果 isLogin 是数字,比如购物车数量 count,那么 count && <Cart /> 在 count 为 0 时返回 0 ,而 React 会把数字 0 渲染到页面上------用户看到一个孤零零的"0"挂在导航栏。根因就是 && 返回操作数本身而非布尔。修复方式任选其一:
jsx
{count > 0 && <Cart />} // 让左侧是真正的布尔表达式
{!!count && <Cart />} // 显式转布尔
{count ? <Cart /> : null} // 三元
5.2 逻辑非 ! 与双重取反 !!
! 先对操作数做 ToBoolean 再取反,返回值一定是布尔。!! 于是成为把任意值规范化为布尔的惯用法,与 Boolean(x) 完全等价:
js
!0; // true
![]; // false------空数组是 truthy
!!'abc'; // true
!!x 和 Boolean(x) 选哪个纯属风格问题,团队统一即可。需要精确控制转换语义时(比如配合 getter 或 Proxy)用 Boolean() 更显式。
5.3 空值合并 ??(ES2020)
?? 只在左侧为 null 或 undefined 时返回右侧,其余所有 falsy 值(0、''、false、NaN)一律原样保留:
js
0 ?? 'default'; // 0
'' ?? 'default'; // ''
false ?? true; // false
null ?? 'default'; // 'default'
undefined ?? 'x'; // 'x'
NaN ?? 'default'; // NaN
它同样短路:
js
'ok' ?? expensive(); // expensive 不执行
null ?? expensive(); // 执行
?? 与 || 的选型,用一个真实场景就能说清。假设做表单回显:
js
// 用户的 nickname 可能故意设置为空字符串
const display = user.nickname || '匿名用户'; // ⚠️ 空昵称被顶替成"匿名用户"
const display = user.nickname ?? '匿名用户'; // 仅 null/undefined 才用兜底
|| 的语义是"左侧不可用就换右侧",?? 的语义是"左侧缺失才补右侧"。取默认值几乎总是后者的语义 。只有当业务确实认为 0/''/false 等同于"未提供"时(比较少见),才用 ||。
一条硬性语法限制:?? 不能与 &&、|| 不加括号地混用:
js
a || b ?? c; // SyntaxError
(a || b) ?? c; // ✅
a || (b ?? c); // ✅
这是规范有意设计------三种逻辑的短路语义各不相同,裸混写出的表达式连作者自己都未必说得清结果,不如强制加括号表态。&& 与 || 混用没有这个限制(历史上太多存量代码),但 lint 会提醒你加括号。
六、位运算符
位运算把操作数经 ToInt32 转成 32 位有符号整数后按位运算,结果转回 64 位浮点数。这意味着它只适用于 ±2³¹−1 范围内的整数,更大的数会被截断回绕。
6.1 七个运算符
| 运算符 | 名称 | 示例 | 说明 |
|---|---|---|---|
& |
按位与 | 13 & 7 → 5 |
1101 & 0111 = 0101 |
| ` | ` | 按位或 | `13 |
^ |
按位异或 | 13 ^ 7 → 10 |
相同为 0,不同为 1 |
~ |
按位非 | ~13 → -14 |
规律:~n === -(n + 1) |
<< |
左移 | 5 << 1 → 10 |
低位补 0,相当于 ×2 |
>> |
有符号右移 | -8 >> 1 → -4 |
高位补符号位,相当于除 2 向下取整 |
>>> |
无符号右移 | -1 >>> 28 → 15 |
高位补 0,JS 特有 |
>>> 把 32 位补码当作无符号数解读,所以负数经它会变成很大的正数:
js
-1 >>> 0; // 4294967295(-1 的补码是全 1,按无符号读)
-5 >>> 1; // 2147483645
它的正经用途是把任意数字规范化为无符号 32 位整数------处理 WebGL 索引、哈希值、Math.imul 结果时会用到。
ToInt32 对输入的宽容也值得知道:null → 0,undefined/NaN → 0,布尔 → 1/0,字符串先 ToNumber。所以 null & 1 得 0,不报错;'5' | 0 得 5。
6.2 实用场景与技巧的边界
位运算在现代前端代码里的正当用途集中在两处。
一是颜色与图形处理,RGB 通道打包在 24 位整数里:
js
const color = 0xFF8800;
(color >> 16) & 0xFF; // 255,红
(color >> 8) & 0xFF; // 136,绿
color & 0xFF; // 0,蓝
(r << 16) | (g << 8) | b; // 反向打包
二是标志位(flags)管理,权限系统、状态机的经典手法:
js
const READ = 1, WRITE = 2, EXEC = 4;
let perm = READ | WRITE; // 组合:3
perm & WRITE; // 检测:非 0 表示有写权限
perm & ~WRITE; // 清除写权限:1
perm ^ READ; // 翻转读权限
至于网上流传的那些技巧,逐个说清楚为什么不推荐:
~~x取整:向零截断,对负数与Math.floor行为不同(~~-4.7是 -4,Math.floor(-4.7)是 -5),且超出 32 位就回绕出错。要取整用Math.trunc(向零)或Math.floor(向下),意图明确且无范围限制。x | 0取整:同上,多一层"这是在干嘛"的阅读成本。a ^= b; b ^= a; a ^= b交换变量:没有性能优势(V8 对const t = a; a = b; b = t优化得很好),对浮点数还会因精度丢失出错,且a ^= b在 a、b 是同一变量时结果为 0。用解构[a, b] = [b, a]。n & 1判奇偶:功能正确且确实比%快,但在业务代码里n % 2 !== 0的表达力完胜。
BigInt 支持 &、|、^、~、<<、>>------注意没有 >>>,因为 BigInt 是任意精度有符号数,"无符号右移"没有意义。BigInt 与 Number 不能混合位运算,会抛 TypeError。
七、条件(三元)运算符
condition ? expr1 : expr2 是 JS 唯一的三目运算符,也是唯一能以表达式身份嵌入其他表达式的条件结构。它的价值在于把"根据条件选值"从四行 if/else 压缩成一行,且结果可以直接参与赋值、传参、返回、渲染:
js
const label = score >= 60 ? '及格' : '不及格';
return isVIP ? price * 0.8 : price;
render(isLoading ? <Spinner /> : <Content />);
三元是右结合的,链式写法等价于右嵌套:
js
const level = score >= 90 ? 'A'
: score >= 80 ? 'B'
: score >= 60 ? 'C'
: 'D';
// 解析为 score >= 90 ? 'A' : (score >= 80 ? 'B' : (...))
像上面这样每档一行、缩进对齐的排版下,三四档的链式三元是可读的,本质上是 switch 的表达式版。再复杂就该换 if/else、switch 或查表(const map = { ... }; map[key] ?? fallback 往往比长链条更清晰)。
三条使用纪律:两个分支必须是表达式 ------break、continue、let 声明这些语句进不来,这既是限制也是保护,它把三元约束在"选值"这个它真正擅长的场景;分支里有副作用(赋值、调用)时读起来非常费劲,副作用交给 if/else;三元嵌套在另一个三元的分支 里勉强可读,嵌套在条件里没人能读懂,永远别写。
八、成员访问、可选链与属性检查
8.1 . 与 []
点访问要求属性名是合法标识符;方括号接受任意表达式,动态属性名只能用它:
js
obj.prop;
obj['prop'];
obj[key]; // key 是变量
obj[fields[i]]; // 任意表达式
对象键最终都是字符串或 Symbol,数字键会被转成字符串,所以 obj[1] 和 obj['1'] 访问同一个属性。这一点对数组式对象(比如后端返回的 { "0": ..., "1": ... })尤其重要。
8.2 可选链 ?.(ES2020)
可选链把"访问前层层判空"的样板代码压缩成一个符号,三种形态:
js
user?.profile?.address?.city; // 属性访问
items?.[index]?.id; // 索引访问
onSuccess?.(); // 方法调用,方法不存在时返回 undefined 不报错
核心语义是短路 :链条中任何一环求值为 null 或 undefined,整个表达式立即返回 undefined,后续部分完全不执行------包括副作用:
js
let a = null;
a?.b.c.d; // undefined,.b.c.d 整段跳过,不抛错
null?.[console.log('never')]; // 'never' 不会打印
使用上有四个容易误解的点。
第一,?. 只保护紧挨着它的那一环 在"起点为空"时的情形,链条中断后整段短路;但如果链条没断、中间某环恰好缺失,后面的裸 . 照样抛错。a?.b.c 中若 a 存在而 a.b 是 undefined,访问 .c 时 TypeError。拿不准就每环都写:a?.b?.c。
第二,短路发生在 ?. 处,但整个"链段"作为一个单元------a?.b.c.d 在 a 为 null 时,b.c.d 全部不求值。所以 a?.b.c 与 a?.b?.c 的差异只体现在"a 存在但 b 缺失"这一种情况。
第三,可选链不能出现在赋值左侧:a?.b = 1 是语法错误。它的定位是安全读取,不是条件写入。需要条件写入就老实写 if。
第四,区分不了"值为 undefined"和"对象不存在"。user?.name 返回 undefined 时,可能是 user 为 null,也可能是 name 本来就没设置。需要区分时自己判。
?.() 处理可选回调特别顺手,在组件库和 SDK 代码里高频出现:
js
function submit(options = {}) {
const result = doSubmit();
options?.onSuccess?.(result); // 回调可能存在也可能不存在,一行搞定
}
8.3 in 与 Object.hasOwn
in 判断属性存在于对象自身或原型链上:
js
const obj = { a: 1 };
'a' in obj; // true
'toString' in obj; // true!继承自 Object.prototype
'b' in obj; // false
0 in [10, 20]; // true,数组的"属性"就是索引
2 in [10, 20]; // false
'length' in [10, 20]; // true
只想检查自身属性 ,用 ES2022 的 Object.hasOwn(obj, key):
js
Object.hasOwn(obj, 'toString'); // false
Object.hasOwn(obj, 'a'); // true
它等价于 Object.prototype.hasOwnProperty.call(obj, key),但更安全------对象可能通过 Object.create(null) 没有原型(调 obj.hasOwnProperty 直接 TypeError),也可能恶意覆盖了 hasOwnProperty 属性。新代码一律用 Object.hasOwn。
in 与直接访问比较的关键差异:能识别"属性存在但值为 undefined":
js
const o = { x: undefined };
'x' in o; // true
o.x === undefined; // true,但 o.y === undefined 也是 true,分不清
Object.hasOwn(o, 'x'); // true ✅
序列化、对象 diff、表单"用户是否动过这个字段"的判断,都依赖这个区分能力。
8.4 instanceof
instanceof 沿对象的原型链查找构造器的 prototype 是否出现:
js
[] instanceof Array; // true
[] instanceof Object; // true,原型链包含 Object.prototype
new Date() instanceof Date; // true
function f() {}
f instanceof Function; // true
f instanceof Object; // true,函数也是对象
'str' instanceof String; // false!原始值不是对象
它有四个必须知道的局限。
跨 realm 失效 。iframe、Worker、Node 的 vm 各有独立的全局对象,iframe 里创建的数组与主窗口的 Array 构造器不是同一个,instanceof 直接失手:
js
iframeArray instanceof Array; // false
Array.isArray(iframeArray); // true ✅
这正是"判断数组必须用 Array.isArray"的根本原因------它内部走 Object.prototype.toString 标签机制,跨 realm 可靠。jQuery 时代无数 bug 源于此。
原型可修改 。Object.setPrototypeOf(obj, X.prototype) 之后,instanceof 结果随之改变。它反映的是"当前原型链状态",不是"出生时的血统"。
Symbol.hasInstance 可接管。ES2015 起类可以自定义 instanceof 行为:
js
class Even {
static [Symbol.hasInstance](n) {
return Number.isInteger(n) && n % 2 === 0;
}
}
2 instanceof Even; // true
3 instanceof Even; // false
冷门,但读库源码时会遇到。
继承链上的语义 。class B extends A 后 new B() instanceof A 为 true------这通常是你想要的,但做精确类型判断时就是干扰项。
需要精确的运行时类型标签时,用 Object.prototype.toString.call(x):
js
Object.prototype.toString.call([]); // '[object Array]'
Object.prototype.toString.call(new Date()); // '[object Date]'
Object.prototype.toString.call(/re/); // '[object RegExp]'
Object.prototype.toString.call(null); // '[object Null]'(连 null 都能精确识别)
实践中各类判断的推荐手段:数组用 Array.isArray;null 用 === null;日期用 x instanceof Date(单 realm)或 Object.prototype.toString(跨 realm);普通对象用 typeof x === 'object' && x !== null;"是不是纯对象"(排除数组、日期等)则需要 Object.prototype.toString 或递归原型检查,视严格程度选型。
九、typeof、void、delete
9.1 typeof
返回类型的字符串标签,一共只有八种可能:
js
typeof 42; // 'number'
typeof 3.14; // 'number'
typeof NaN; // 'number'!NaN 是数字
typeof 'str'; // 'string'
typeof true; // 'boolean'
typeof undefined; // 'undefined'
typeof Symbol(); // 'symbol'
typeof 10n; // 'bigint'
typeof function(){}; // 'function'
typeof (() => {}); // 'function',箭头函数也是
typeof class C {}; // 'function'!类在 typeof 眼里就是函数
typeof {}; // 'object'
typeof []; // 'object'
typeof null; // 'object' ⚠️
typeof undeclaredVar; // 'undefined',对未声明变量不抛错(独有的安全性)
逐个说坑。
typeof null === 'object' 是 JS 第一版实现留下的 bug------当年值用"类型标签 + 数据"表示,对象的标签位是 000,而 null 在底层是全零地址,标签位恰好也是 000。后来规范讨论过修复提案,因破坏存量代码太多而放弃,它将永远存在。判 null 只认 x === null。
无法区分对象子类型 :数组、日期、正则、Map、普通对象统统 'object'。这是 typeof 最大的盲区,对策见上一节(Array.isArray、Object.prototype.toString)。
函数独享 'function' 是规范特例------函数本质是对象,但 typeof 给它单开一个返回值。typeof x === 'function' 是判断可调用性的标准手段。
typeof 对 TDZ 变量会抛错。"typeof 永远不抛错"这句老话在 ES6 之后不严谨了:
js
typeof x; // ReferenceError: Cannot access 'x' before initialization
let x = 1;
let/const 声明的变量在声明语句之前处于暂时性死区,typeof 访问也会触发 ReferenceError。而 var 声明的变量被提升为 undefined,typeof 安全。对完全未声明 的变量 typeof 依然安全返回 'undefined'------早年特性检测就靠这个:typeof window.someLegacyAPI !== 'undefined'。
9.2 void
对操作数求值,返回 undefined。操作数照常执行,只是结果被丢弃。现代代码中它有两个仍然活跃的用途:
丢弃 Promise,显式表达"故意不等待":
js
void doAsync(); // 发起异步调用,明确不处理结果
TypeScript 生态的 @typescript-eslint/no-floating-promises 规则把 void 前缀作为"开发者已知晓并故意忽略"的官方逃生门。不写 void 的话,裸调用的 floating promise 会被 lint 拦下。
获取保证纯净的 undefined 。远古代码里 undefined 是全局对象的普通属性、可被覆盖(undefined = 'hacked' 在非严格模式的老浏览器真的可行),void 0 永远返回真 undefined。严格模式下 undefined 已不可写,这个用途过时了,但库代码和压缩产物里仍大量存在(void 0 比 undefined 少三个字节)。
至于 <a href="javascript:void(0)">,历史用法,现代做法是用 <button> 或 event.preventDefault()。另外注意 void 是一元运算符,优先级很高,void a + b 解析为 (void a) + b,得 NaN------又一个"不确定就加括号"的例子。
9.3 delete
移除对象的自身属性,返回布尔值:
js
const obj = { a: 1 };
delete obj.a; // true,obj 变为 {}
delete obj.nonexist; // true!删不存在的东西也算"成功"
delete Math.PI; // false,configurable: false 的属性删不掉
delete someVar; // 删变量:严格模式 SyntaxError,非严格模式静默失败返回 false
delete obj?.a; // ES2020 起合法,obj 为 null 时短路返回 true
五个要点:
-
只影响自身属性 。原型上有同名属性时,删除自身属性后访问会"穿透"到原型------删除不等于屏蔽:
jsObject.prototype.x = 'inherited'; const o = { x: 'own' }; delete o.x; o.x; // 'inherited' -
删数组元素别用 delete 。它留下空洞而非缩短长度:
jsconst arr = [1, 2, 3]; delete arr[1]; // [1, <1 empty item>, 3],length 仍是 3空洞会让
forEach/map/filter跳过该位置,让arr[1]返回 undefined 但1 in arr为 false。原地删用splice(i, 1),生成新数组用filter。 -
性能 。V8 里 delete 会让对象退出隐藏类(hidden class)优化路径,后续属性访问退化为字典模式。只想"清空值"且属性会保留时,赋
null或undefined对引擎更友好------属性还在,对象形状不变。 -
返回值语义。true 不代表删掉了什么(本来不存在也 true);真正的失败(不可配置属性)返回 false,严格模式下直接抛 TypeError。
-
不可删除的东西 :变量声明、函数声明、
configurable: false的属性、用Object.defineProperty显式定义且不可配置的属性。
十、逗号、展开/剩余、分组及其他语法级运算符
10.1 逗号运算符 ,
从左到右依次求值,返回最后一个表达式的值:
js
let x = (1, 2, 3); // x = 3,前两个被求值后丢弃
let y = (sideEffect(), 'result'); // y = 'result',副作用先执行
必须区分:let a = 1, b = 2 里的逗号是声明分隔符 ,f(x, y) 里的是实参分隔符 ,[1, 2] 里的是数组元素分隔符------这些都不是逗号运算符。只有出现在表达式语境、把多个表达式串起来的那个逗号才是。
它如今几乎唯一的正当用途是 for 循环同步维护多个变量:
js
for (let i = 0, j = n - 1; i < j; i++, j--) {
[arr[i], arr[j]] = [arr[j], arr[i]]; // 双指针相向而行
}
逗号运算符优先级全场最低,a = 1, 2 解析为 (a = 1), 2 而非 a = (1, 2)。
10.2 展开与剩余 ...
严格说是语法而非运算符,但与运算符体系紧密相关,且同一符号在不同位置语义相反,放在一起讲最清楚。
展开(Spread)位置------把可迭代对象或对象属性"摊开":
js
// 数组字面量
const merged = [...arr1, ...arr2];
const copy = [...original]; // 浅拷贝
const withNew = [...arr, newItem];
// 函数调用实参
Math.max(...nums);
new Date(...parts);
// 对象字面量(ES2018)
const config = { ...defaults, ...userConfig }; // 后者覆盖前者
// 字符串可迭代
[...'abc']; // ['a', 'b', 'c'],正确处理 Unicode 代理对
剩余(Rest)位置------把多个元素"收集":
js
function sum(...nums) { // 收集实参为数组
return nums.reduce((a, b) => a + b, 0);
}
const [first, ...others] = [1, 2, 3, 4]; // others = [2, 3, 4]
const { a, ...restObj } = { a: 1, b: 2, c: 3 }; // restObj = { b: 2, c: 3 }
对象剩余有个贴心的规范细节:剩余变量自动排除已解构的键,这正是"从 props 里剥离个别属性、其余透传"模式的基础:
js
const { onClick, ...restProps } = props;
<Button {...restProps} />; // onClick 已被剥离
三条注意事项:展开是浅 的,嵌套对象仍共享引用,深拷贝用 structuredClone()(现代浏览器与 Node 17+ 原生支持);对象展开会触发 getter ,{ ...obj } 拿到的是 getter 的返回值而非 getter 本身;剩余参数必须是函数的最后一个 参数,function f(...a, b) 是语法错误(但解构里 rest 同样必须在最后,规则一致)。
10.3 分组 ()
括号在运算符体系中扮演四种角色:
- 提升优先级、消除歧义------优先级焦虑的万能解药;
- 把对象字面量从语句位置拯救出来 :行首裸写
{ a: 1 }是带标签a的代码块,({ a: 1 })才是对象表达式。箭头函数返回对象字面量必须包裹:() => ({ a: 1 }),写成() => { a: 1 }会被解析为函数体加标签语句; - 包裹箭头函数参数 :
(a, b) => a + b,单参数可省a => a,无参数写(); - IIFE :
(function () { ... })()。
10.4 new 与 new.target
new Constructor(args) 执行构造流程:创建对象、链接原型、以新对象为 this 执行构造器、返回对象(构造器返回原始值时忽略之)。优先级上,带参数的 new 与函数调用同级且左结合,new Foo().bar() 解析为 (new Foo()).bar();不带参数时括号可省略(new Date 合法),但风格指南普遍要求写全 new Date()。
new.target 是函数内的元属性:被 new 调用时指向实际使用的构造器,普通调用时为 undefined。两个经典用途------防御性检查和抽象基类:
js
function Foo() {
if (!new.target) throw new Error('Foo 必须用 new 调用');
}
class Shape {
constructor() {
if (new.target === Shape) throw new Error('抽象类不可直接实例化');
}
}
class Circle extends Shape {} // new Circle() 时 new.target 是 Circle,放行
10.5 await 与 yield
await 暂停 async 函数执行直到 Promise 落定,返回兑现值;Promise 被拒绝时异常在 await 处抛出,可以被 try/catch 捕获。它对非 Promise 值也合法,等效 Promise.resolve(value) 包一层------但这会多花微任务周期,性能敏感的循环里别对纯同步值 await。
使用位置限制:只能在 async 函数体内,或 ES Module 的顶层(Top-level await,ES2022)。
它的优先级坑前文提过,这里给出完整对策:
js
await a + b; // (await a) + b ------ 几乎必然不是你想要的
await (a + b); // ✅ 需要整体等待就加括号
// 并发 vs 串行
const [x, y] = await Promise.all([fetchA(), fetchB()]); // ✅ 并发,总耗时 = max
const m = await fetchA();
const n = await fetchB(); // ⚠️ 串行,总耗时 = 相加
yield 暂停生成器并产出值,yield* 委托给另一个可迭代对象或生成器:
js
function* inner() { yield 1; yield 2; }
function* outer() {
yield* inner(); // 委托生成器
yield* [3, 4]; // 委托任意可迭代对象
}
[...outer()]; // [1, 2, 3, 4]
yield 还能接收 值------const v = yield x 中 v 是下次 next(v) 传入的参数,这是生成器双向通信的基础,也是早年 co 库实现 async/await 的原理。
10.6 其他值得点名的
动态 import() :函数形式(不是运算符,虽然长得像调用),返回 Promise,是代码分割的入口:const module = await import('./heavy.js')。
标签语句 :label: for (...) 配合 break label / continue label 跳出多层循环。冷门但合法,是处理嵌套循环搜索的最直接手段。
in 在 for 循环里的形态 :for (const k in obj) 遍历可枚举属性(含原型链,通常要配 hasOwn 过滤);遍历数组用 for...of(按值)或普通索引循环,for...in 遍历数组拿到的是字符串索引且顺序无保证,是明确的反模式。
十一、优先级与结合性
11.1 完整优先级表
从高到低(MDN 编号体系,同级别从左到右结合,标注"右"的除外):
| 级别 | 运算符 | 说明 | 结合性 |
|---|---|---|---|
| 21 | () |
分组 | --- |
| 20 | obj.prop obj?.prop obj[expr] new F(args) f(args) |
成员访问与调用 | 左 |
| 19 | new F(不带参数) |
--- | |
| 18 | a++ a-- |
后缀自增减 | --- |
| 17 | ! ~ ++a --a +a -a typeof void delete await |
一元 | 右 |
| 16 | ** |
幂 | 右 |
| 15 | * / % |
左 | |
| 14 | + - |
左 | |
| 13 | << >> >>> |
左 | |
| 12 | < <= > >= in instanceof |
关系 | 左 |
| 11 | == != === !== |
相等 | 左 |
| 10 | & |
位与 | 左 |
| 9 | ^ |
位异或 | 左 |
| 8 | ` | ` | 位或 |
| 7 | && |
逻辑与 | 左 |
| 6 | ` | ` | |
| 5 | ?? |
空值合并 | 左 |
| 4 | cond ? a : b |
条件 | 右 |
| 3 | = += **= &&= ` |
= ??=...yield yield*` |
|
| 2 | ...(对象/数组字面量中的展开) |
--- | |
| 1 | , |
逗号 | 左 |
几个结构性观察:一元运算符(17 级)整体高于一切二元运算;乘除模(15)高于加减(14)符合数学习惯;相等(11)低于关系(12),所以 a < b === c < d 碰巧符合直觉;逻辑三兄弟 &&(7) > ||(6) > ??(5) 依次递减;赋值(3)几乎垫底,所以 x = a + b * c 永远不用操心。
11.2 实际会踩的坑
typeof 先于加法:
js
typeof 1 + 2; // 'number2'!先 typeof 1,再字符串拼接
typeof (1 + 2); // 'number' ✅
一元负号撞上幂运算:
js
-2 ** 2; // SyntaxError,规范直接禁止
-(2 ** 2); // -4
(-2) ** 2; // 4
&& 先于 ||:
js
a || b && c; // a || (b && c)
(a || b) && c; // 意图不同时必须显式括号
await 先于一切二元运算:
js
await fetch(a) + await fetch(b);
// (await fetch(a)) + (await fetch(b))
// Response + Response → '[object Response][object Response]'
// 不报错,静默产出垃圾字符串------最难查的那类 bug
三元右结合:
js
true ? 'a' : true ? 'b' : 'c';
// 解析为 true ? 'a' : (true ? 'b' : 'c'),结果 'a'
幂右结合:
js
2 ** 3 ** 2; // 512,不是 64
赋值右结合:
js
a = b = c = 1; // a = (b = (c = 1))
in/instanceof 与比较同级:
js
'x' in obj === true; // ('x' in obj) === true,碰巧符合直觉,但别依赖
11.3 工程态度
优先级表没人应该背,也没人需要背。正确的态度是三句话:
- 不确定就加括号 。
(a && b) || c永远好于a && b || c,哪怕两者等价------括号是给下一个读代码的人(包括三个月后的你)的礼物。 - 开 lint 兜底 。
no-mixed-operators强制混合运算符加括号;no-void、no-bitwise、no-plusplus等规则按团队口味取舍;@typescript-eslint系列对 await 优先级也有专项规则。 - 复杂就拆 。一个表达式复杂到需要查优先级表,说明它应该被拆成几个命名良好的中间变量。变量名本身就是文档,
const isAdmin = user?.role === 'admin'永远好过把这段逻辑内联进三层三元里。
十二、陷阱总清单
把全文的坑收拢成一张速查表:
| # | 陷阱 | 表现 | 对策 |
|---|---|---|---|
| 1 | + 意外拼接 |
1 + '2' 得 '12' |
运算前确认类型;显式 Number() / String() |
| 2 | == 隐式转换 |
[] == ![] 为 true |
一律 ===;判空专用 == null |
| 3 | typeof null |
'object' |
判 null 用 x === null |
| 4 | typeof 分不清对象子类型 | 数组/日期都是 'object' |
Array.isArray、Object.prototype.toString |
| 5 | typeof 撞 TDZ | typeof x 抛 ReferenceError |
let/const 声明前不访问 |
| 6 | NaN 不等于自身 | NaN === NaN 为 false |
Number.isNaN(),不是全局 isNaN |
| 7 | indexOf 找不到 NaN |
[NaN].indexOf(NaN) 为 -1 |
用 includes |
| 8 | ` | ` 吞掉合法 falsy | |
| 9 | 数组是 truthy | if ([]) 为真,![] 为假 |
判数组内容用 length |
| 10 | && 渲染出 0 |
{count && <C/>} 页面出现 0 |
左侧先转布尔或用三元 |
| 11 | 浮点精度 | 0.1 + 0.2 !== 0.3 |
金额用整数分 / BigInt / decimal 库 |
| 12 | '10' < '9' |
字符串按码元比较 | 数值比较前显式转数字 |
| 13 | delete arr[i] |
产生空洞,length 不变 | 用 splice / filter |
| 14 | instanceof 跨 realm | iframe 数组判 false | Array.isArray |
| 15 | 可选链只护一段 | a?.b.c 在 b 缺失时抛错 |
拿不准就每环 ?. |
| 16 | a?.b = 1 |
SyntaxError | 可选链不能做赋值目标 |
| 17 | await a + b |
await 优先级高于 + | await (a + b) 或分别 await |
| 18 | null 参与大小比较 | null > 0 false 但 null >= 0 true |
比较前先判空 |
| 19 | parseInt 缺基数 |
遗留环境八进制歧义 | 永远 parseInt(str, 10) |
| 20 | -2 ** 2 |
SyntaxError | 显式括号表态 |
| 21 | typeof 1 + 2 |
'number2' |
typeof (1 + 2) |
| 22 | {} + [] 结果看环境 |
控制台 0,表达式里是字符串 | 理解语句/表达式位置的解析差异 |
| 23 | ** 右结合 |
2 ** 3 ** 2 是 512 不是 64 |
拿不准加括号 |
| 24 | 展开是浅拷贝 | 嵌套对象仍共享引用 | 深拷贝用 structuredClone |
十三、实践清单
最后落到日常开发的操作层面。
默认选择 。相等用 ===;判 null/undefined 用 x == null 或 x ?? fallback;默认值赋值用 ??=;防御访问用 ?.;布尔规范化用 Boolean() 或 !!;数字转换用 Number() 或 parseInt(s, 10);数组判断用 Array.isArray;自身属性判断用 Object.hasOwn;NaN 判断用 Number.isNaN。
ESLint 配置建议 。eqeqeq: ["error", "smart"](放行 == null);no-mixed-operators: "error";TypeScript 项目加 @typescript-eslint/no-floating-promises(配合 void 前缀逃生门);no-bitwise 按团队情况选择------图形、权限位项目关掉,纯业务项目开启防炫技。
BigInt 备忘 。字面量后缀 n(9007199254740993n);与 Number 不能混算(1n + 1 抛 TypeError),显式转换 Number(bigintValue) / BigInt(numberValue);除法向零取整(7n / 2n === 3n);不支持 >>>;typeof 10n === 'bigint'。超过 2⁵³−1 的 ID(后端返回的雪花 ID 就该用字符串或 BigInt 接,用 Number 接必丢精度)。
Code review 重点扫描位 。任何出现 ==(非 null 判断)、|| 做默认值、+ 混合类型、delete、位运算、多环属性访问的地方,都值得多看一眼。这五类覆盖了绝大多数运算符相关事故。