js04是我给自己JavaScript学习笔记排的编号,讲到运算符这一节,我决定把脑子里的东西全部倒出来整理一遍。很多人觉得运算符就那几个符号,看一眼文档就够了,实际上一旦到了真实项目里,隐式转换、短路特性、优先级和结合性会在你毫无防备的时候咬你一口。这篇内容不打算写成枯燥的API手册,而是我想以实际开发视角把JS运算符体系完整拆一遍,适合刚学完基础语法的人,也适合写了几年业务代码但从来没系统整理过运算符的开发者。
1. 先把运算符的地图画清楚
1.1 运算符为什么值得花一整篇去讲
JavaScript里一段代码能不能正常运行,往往取决于你对运算符的理解有多深。我给新人做代码评审的时候,见过最典型的一类bug就是把==和===混着用,或者以为a || b一定返回布尔值。运算符不是简单的符号堆砌,它是JS语言里"值如何被组织、比较、计算"的底层规则。举个例子,const total = price * count + shipping这行代码里就有乘法、加法、赋值三个运算符同时工作,任何一个理解偏差都会导致结果不对,而且排查起来特别费劲。
从语言设计角度来说,运算符和类型系统是紧密绑定的。JS的变量没有固定类型,类型是在运行期由值决定的,所以运算符在执行时会触发各种隐式转换。这正是JS和Java、C这类强类型语言最大的不同点。你在Java里写"a" + 1会直接报错,但在JS里会生成字符串"a1"。这种灵活既是福利也是深渊,理解运算符的本质,就是理解类型转换的规律。
1.2 运算符家族完整一览
先把JS里所有的运算符家族列出来,心里有张地图,后面拆解才有坐标。
| 类别 | 运算符 | 典型场景 |
|---|---|---|
| 算术运算符 | + - * / % ** ++ -- | 数值计算、自增自减、幂运算 |
| 赋值运算符 | = += -= *= /= %= &&= ||= ??= | 变量赋值、状态累积、默认值填充 |
| 比较运算符 | == === != !== > < >= <= | 条件判断、排序比较、严格校验 |
| 逻辑运算符 | && || ! ?? | 短路逻辑、条件分支、空值兜底 |
| 位运算符 | & | ^ ~ << >> >>> | 权限位标记、颜色值计算、性能优化 |
| 一元运算符 | typeof void delete ! + - ~ | 类型判断、忽略返回值、取反 |
| 三元运算符 | ? : | 简单条件分支 |
| 其他 | &&= ||= ??= . ?. , | 解构赋值、对象访问、可选链 |
这里有几个容易被忽略的运算符,比如??(空值合并)和?.(可选链),它们是ES2020之后才引入的新成员。在很多老教程里看不到,但实际项目里它们的出场率已经非常高。
1.3 按优先级分层记忆的思路
运算符优先级不需要死记硬背,我自己的记忆方法是把它分成四层。
最顶层是成员访问和调用,比如a.b、a.b()。第二层是一元运算符,比如!x、-x、typeof x。第三层是二元运算符,这一层内部有细分,但总体上是算术高于比较、比较高于逻辑。第四层是三元和赋值。这样分层之后,你在心里大概有个概念:一元运算符会先执行,赋值运算符几乎总是最后执行。
举个例子:!user && login()。这里!user先执行,得到一个布尔值,然后才参与&&运算。如果你写成user.login && user,一旦user是undefined,user.login会直接抛错,因为成员访问优先级最高,它会在&&判断之前就开始执行。这个细节很关键,它解释了为什么很多空对象判断的代码要写成user && user.login而不是user.login && user。
2. 高频运算符逐个拆解
2.1 算术运算符:加号的二重身份和取余的符号问题
算术运算符里的+是JS里最特殊的运算符,它同时承担数值加法和字符串拼接两个职责。规则是:只要操作数中有一个是字符串,+就会偏向做字符串拼接,另一个操作数也会被转换成字符串。1 + 2 + "3"的结果是"33",因为先算1+2=3,再和字符串"3"拼接。而"1" + 2 + 3的结果是"123",因为第一个操作数就是字符串,从左往右全部变成拼接。这种左右顺序差异,是"史上坑爹面试题"常客。
相比之下,-、*、/、%这些运算符就不会做字符串拼接,它们会尽量把操作数转成数字。所以他写"5" - 2得到的是3,"8" * "2"得到16。这种陷阱藏在"看起来像字符串却参与数学运算"的场景里,比如从DOM获取的值都是字符串,input.value1 + input.value2得到的不是数值之和,而是字符串拼接。
取余运算符%要注意负数的情况。JS的%结果符号跟随被除数,比如-7 % 3的结果是-1,而7 % -3的结果是1。如果你需要的是数学意义上的模运算(结果永远非负),就得自己加一步修正:((a % b) + b) % b。在做循环索引、周期计算时这个细节尤其重要。
幂运算符**是ES2016引入的,替代了以前的Math.pow()。要注意它和*的优先级关系:2 ** 3 * 2结果是16,因为先算2**3=8再乘2。幂运算是右结合的,2 ** 3 ** 2等价于2 ** (3 ** 2),结果是512而不是64,这个比较容易踩坑。
2.2 赋值运算符:一等于之谜和短路赋值
=是赋值,不是数学里的等于。这个从学习第一天就要记住。let x = 1把数字1放到变量x里,x = 2则把x的值改成2。很多初学者会在条件判断里写if (x = 1),这个式子会把x赋值为1,然后判断1是否为真,结果是true。正确的条件判断必须是if (x === 1)。
复合赋值运算符+=、-=等本质上是简写。a += 1等价于a = a + 1,但要注意它隐含了一次取值和一次赋值。如果你操作的是一个表达式,比如arr[index] += 5,它等价于arr[index] = arr[index] + 5,这个过程会先读取原来的值,经过加法运算后写回。理解这个机制,你就知道为什么有时候"两次访问同一个位置,结果却不同"——如果这个位置是一个getter/setter属性,每次访问都可能触发副作用。
ES2021引入的&&=、||=、??=很有意思。a ||= b的意思是:如果a是falsy值,就把b赋值给a,否则a保持不变,等价于a = a || b。a ??= b则表示只有a为null或undefined时才赋值。这三个运算符在做配置合并、默认参数填充时非常好用,比传统的a = a || b更清晰,还能避免"0和空字符串被意外覆盖"的问题。
链式赋值let a = b = 1是JS里一个冷门陷阱。这个表达式先把1赋给b,再把b的值赋给a,所以a和b都是1。但如果b在之前没有声明,这个赋值会在非严格模式下给全局对象创建隐式属性,严格模式下直接抛错。我建议实际项目中不要写链式赋值,一行声明一个变量,清晰度远比少写几个字符重要。
2.3 比较运算符:宽松相等和严格相等是两套逻辑
JS提供了两套相等比较:==和===。===是严格相等,比较值和类型,类型不同直接false。==是宽松相等,它会先做类型转换再比较。这个设计的初衷是方便比较数字和数字字符串,比如5 == "5"返回true。但宽松相等的转换规则极其繁琐,比如null == undefined是true,null == 0是false,[] == ""是true。把一堆诡异规则背下来远不如直接用===来得实在。
引用类型和基本类型的比较还涉及"值比较"与"引用比较"的差异。两个对象字面量{} == {}是false,因为它们都指向不同的内存地址。两个引用变量指向同一个对象,==才会返回true。数组、对象、函数都属于引用类型,比较它们时比较的是"地址"而不是内容。这是JS初学者经常搞混的地方。
>、<、>=、<=这种关系运算符对字符串和数字一视同仁。如果两个操作数都是数字,直接比较数值;如果有一个是字符串,另一个也是字符串,就按字符串的字符编码逐位比较;如果一个字符串和一个数字比较,字符串会被转成数字。经典坑是"10" < "9"。因为两个都是字符串,按字符编码第一个字符"1"的编码小于"9",所以返回true。如果你期望的是数值比较,就需要手动做一次Number("10") < Number("9")。
NaN的处理也很关键。NaN和任何值都不相等,包括它自己。NaN === NaN是false,NaN == NaN也是false。判断一个值是不是NaN要使用Number.isNaN()函数或者!==配合判断。Object.is(NaN, NaN)能返回true,Object.is(0, -0)返回false,这是===做不到的精确度,但日常业务中很少用到这个区别。
2.4 逻辑运算符:短路的真正含义
逻辑与&&和逻辑或||最容易被误解的地方是:它们不总是返回布尔值。&&求值过程是:从左到右,如果左边是falsy,直接返回左边这个值;否则继续求右边,返回右边的值。||则反过来:如果左边是truthy,直接返回左边;否则返回右边。举例:0 && "a"返回0,"hello" && 42返回42,null || "默认值"返回"默认值"。这一点在做默认值设置时非常实用,也解释了为什么a || b可以替代if (a) { use a } else { use b }的逻辑。
短路求值带来的性能优势很明显。func1() && func2()中,如果func1返回false,func2根本不会被调用。这在"前面条件不满足就没必要执行后续操作"的场景中尤其有用,比如isLoggedIn && showPanel()。但也要警惕短路带来的隐患,如果showPanel()本身有副作用,而isLoggedIn为false,副作用就不会发生,有时候你恰好会依赖这个特性,读代码的人却容易忽略。
空值合并运算符??只检测null和undefined,不检测其他falsy值。0 ?? 10返回0,"" ?? "默认"返回空字符串。这跟||的行为完全不一样,0 || 10会返回10。所以当你要处理的业务里,0、空字符串是合法值时,就绝不能写a || "默认值",必须用??。
逻辑非!会先把值转成布尔再取反。!"hello"是false,!0是true,!!value是获取一个值对应布尔表示的常见技巧。注意!的优先级比&&、||高,所以!a && b的意思是把!a的结果和b做与运算。
3. 优先级、结合性与隐藏位运算
3.1 优先级表单:从最优先到最末位的完整脉络
JS运算符的优先级确定了一行表达式里哪些部分先执行。我平时写代码必须凭感觉记住的层级其实就几条。
| 优先级 | 运算符 | 说明 |
|---|---|---|
| 最高 | ()[].?. | 分组、成员访问、调用 |
| 次高 | ++ -- ! ~ typeof | 一元运算 |
| 中高 | ** | 幂运算 |
| 中 | * / % | 乘除取余 |
| 中 | + - | 加减 |
| 中低 | < > <= >= | 关系比较 |
| 低 | == === != !== | 相等比较 |
| 更低 | && | 逻辑与 |
| 更低 | || | 逻辑或 |
| 很低 | ?? | 空值合并 |
| 最低 | ? :=, | 三元、赋值、逗号 |
&&的优先级高于||,所以a || b && c等价于a || (b && c)。为了解决"求生欲极强"的复杂逻辑判断,我强烈建议凡是混合使用多个逻辑运算符的地方都加括号。少一行代码不值得让读代码的人费解三分钟。
3.2 左结合与右结合的实际差异
结合性决定同一优先级运算符连续出现时怎么分组。大多数二元运算符是左结合,比如a - b - c等价于(a - b) - c。有三种例外需要留意:赋值是右结合,a = b = c等价于a = (b = c);幂运算是右结合,2 ** 3 ** 2等价于2 ** (3 ** 2);三元运算符是右结合,a ? b : c ? d : e等价于a ? b : (c ? d : e)。
右结合体现在三元嵌套里的影响特别明显。很多人写多层条件时会写成condition1 ? value1 : condition2 ? value2 : value3,这个写法确实合法,因为条件2只会作为条件1的else分支继续求值。但这种写法一旦嵌套超过两层,可读性就大幅下降。我一般建议超过两层的条件判断直接拆成if/else或者使用switch。
3.3 位运算符的实战价值:不只是炫技
位运算符在JavaScript里被很多人当成"没用过的冷知识",实际在权限系统、颜色处理、状态压缩这些场景里有明确的价值。按位与&、按位或|、按位异或^、按位非~、左移<<、右移>>、无符号右移>>>,它们作用在32位整数上。
最经典的用途是权限位标记。定义一个只读权限const READ = 1,写入权限const WRITE = 2,执行权限const EXEC = 4。let perm = READ | WRITE就可以把两个权限合并到一个数字里。判断某个用户是否有写权限就写if (perm & WRITE),不再需要维护一堆布尔变量。用位运算做状态压缩还有一个好处:组合状态和判断状态的代码都非常快,因为底层就是二进制运算。
另一个频率较高的场景是颜色值转换。RGB三个通道各占8位,一个颜色值可以表示为r << 16 | g << 8 | b。把十六进制颜色值解析回RGB就用(color >> 16) & 0xff拿到红色通道。这种写法在图像处理、UI自动化测试脚本里经常出现。要注意JS位运算会把数值当作32位处理,超过这个范围的结果可能和预期不符,所以大数字慎用位运算。
~有个冷门技巧:indexOf返回-1表示找不到,if (~str.indexOf("abc"))能判断"找到了",因为~(-1)等于0,是falsy;其他非负数的按位非都是非0。不过ES2016之后字符串有了includes方法,这个技巧已经没必要用了。
4. 三元表达式与运算符的真实世界用法
4.1 三元运算符的可读性边界
三元运算符condition ? expr1 : expr2是唯一一个需要三个操作数的运算符,它很适合做"二选一"的赋值。比如const icon = status === "success" ? "check" : "alert"。这个写法比if/else更紧凑,也不容易产生变量泄漏。
但三元运算符有两个常见问题。其一是嵌套过深,a ? b : c ? d : e ? f : g这种链式三元基本没法读,建议超过两层就重构成函数。其二是和返回布尔值的函数混用时,容易搞不清返回值类型,比如a ? true : false完全可以用Boolean(a)或!!a代替,写明意图更好。
正常开发中,我会把三元运算符限定在"赋值"和"组件属性"这类一个表达式就结束的场合。如果分支里涉及多条语句、函数调用、日志输出,一律走if/else。
4.2 空值合并与可选链的组合拳
?.可选链运算符能在访问深层属性时避免一层层的判空。user?.address?.city的意思是,如果user是null或undefined,整个表达式返回undefined,而不是抛TypeError。它和??配合起来特别顺畅:const city = user?.address?.city ?? "未知城市"。这行代码解决了"深层对象 + 缺省默认值"的场景,以前写这类逻辑至少要四五行判空代码。
组合时要注意优先级。??和&&、||一起使用时,JS要求必须用括号来确定关系,直接写a ?? b || c会报语法错误。这是规范故意设计的,为了避免语义歧义。所以别想着省括号,该加就加。user?.name ?? "游客" || showLogin()这种想当然的写法是要被编译器拒绝的。
4.3 运算符在真实业务中的组合场景
举一个不太复杂但很典型的业务例子:购物车结算。满减规则是"满100减20,满200减50"。
function calculateTotal(items, couponCode) { const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0); const discount = subtotal >= 200 ? 50 : subtotal >= 100 ? 20 : 0; const shipping = subtotal >= 99 ? 0 : 10; const finalTotal = subtotal - discount + shipping; return { subtotal, discount, shipping, finalTotal, payTip: finalTotal >= 200 ? "已享满减" : "再凑99元免运费" }; }这里的reduce回调里就用了+和*两个算术运算符;满减判断用了三元嵌套;运费判断用了比较运算符。如果不用运算符,这段逻辑会膨胀两三倍,而且还容易在赋值顺序上出错。运算符是业务表达的浓缩件,这个例子能说明"运算符不只在底层算法里有用,日常业务天天都在用"。
后端接口对接时,运算符用得最多的反而是逻辑组合:if (isAdmin || (isOwner && !isBanned))这样的权限判断。这种组合逻辑用||和&&在一个表达式里写清楚优先级,往往比一堆if嵌套更直观。但前提是你要清楚&&优先于||,并且知道括号能消解歧义。我在团队规范里会要求这种复杂逻辑必须加列注释,注释里写"管理员放行;或作者且未被禁"。
5. 常见坑与排查技巧实录
5.1 类型转换陷阱一览
"2" + 2 === "22",加法遇字符串就变拼接。"2" - 2 === 0,减法则强制转数字。true + 1 === 2,布尔被转成数字。[] + [] === "",空数组转字符串是空字符串。null + 1 === 1,null转成0。undefined + 1结果是NaN。{} + []在某些场景下会是0,因为对象被转成字符串"[object Object]"再拼接;但如果出现在表达式开头,大括号会被当成代码块,解析结果完全不一样。"b" + "a" + + "a" + "a"这个经典的"banana"问题,核心在于一元+会把字符串转数字。
把这些坑列出来不是说"JS糟糕",而是风险控件就在你手里。任何来自用户输入、接口返回、DOM读取的值,在参与运算前先做一次显式类型转换,比如Number(value)、parseInt(value, 10)、String(value),能避掉90%的隐式转换问题。
5.2 优先级与结合性导致的隐蔽bug
优先级类的bug比类型转换更隐蔽,因为代码看起来完全正常,但结果不符合预期。
经典一:!value instanceof Object。这里!value先执行得到布尔值,再判断布尔值是否是Object的实例,结果永远是false。基本没人会故意这么写,但代码重构时很容易引入。
经典二:a && b || c的歧义。它实际等价于(a && b) || c,如果a为true、b为false,c会被执行;但你的本意可能是a && (b || c)。团队协作时这类表达式纯靠上下文推断意图,出了bug极难追责。统一策略:涉及&&、||、??混用的一律加括号。
经典三:setTimeout和运算符的结合。for (var i = 0; i < 5; i++) setTimeout(() => console.log(i), 100),这里的var是函数作用域,循环结束时i已经是5,打印的全是5。这个坑的根源是块作用域和闭包特性,但要根治,把var换成let是最直接的解法,因为let是块级作用域,每次循环都会生成一个新的i。
5.3 常见问题速查表
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
"3" + 3得到"33" | 加号的字符串拼接优先 | 显式转数字:Number("3") + 3 |
"10" < "9"返回true | 字符串按字典序比较 | 转数字后再比较:Number(a) < Number(b) |
if (x = 1)永远为true | 赋值运算符写成比较 | 条件判断用=== |
| `0 | "默认"得到"默认"` | |
user.address.city报错 | user为null | user?.address?.city ?? "未知" |
2 ** 3 ** 2等于512 | 幂运算右结合 | 写括号(2 ** 3) ** 2 |
| 四舍五入不精确 | JS浮点运算存在误差 | 用Math.round或先乘100再除100 |
1和"1"比较结果不一致 | 宽松/严格相等规则不同 | 统一使用=== |
| 取余负数结果不对 | JS按被除数的符号 | ((a % b) + b) % b |
| 位运算大数结果异常 | JS位运算限制32位 | 大数用BigInt或Math代替 |
5.4 排查经验分享
排查运算符相关bug,我一般按这个顺序来:第一步看类型,用console.log(typeof value)确认每个操作数到底是什么类型,这比看运算结果快得多。第二步拆表达式,把复杂的复合表达式拆成单步变量,在控制台逐一验证中间结果。第三步查优先级,把模棱两可的表达式加上括号重写,再对比结果是否一致。第四步用最小化复现,把一段业务代码抽成一个几十行的独立demo,在小环境里还原问题。
实际的排查过程中,"类型打印"比"结果打印"更有价值。有一次同事排查一个商品价格计算bug,打印结果一直是"100100"而不是200,他反复检查计算方法都没发现问题。最后我打印了typeof subtotal,发现是字符串。问题出在前端接口返回的价格字段本身就是字符串,而代码里直接做了加法运算,隐式转换叠加字符串拼接导致结果异常。如果一开始就打印类型,三分钟就能定位。
最后再分享一点个人的习惯
我自己写JS代码的经验是:不要试图用"聪明的写法"来炫技,运算符的目的永远是表达清晰。同一个需求,if (a || b)和if (!!a || !!b)结果基本一样,但后者明显更啰嗦。反过来,a || b这种写法用在赋值场景没问题,用在条件判断里就要慎重,因为返回值不一定是布尔。我给自己定的规矩很简单:赋值用短路和空值合并,条件判断用严格等于和显式转换,复杂逻辑加括号,操作数不明先打印类型。把这些习惯固定下来之后,因为运算符引发的问题数量直接下降了一个量级。js04这篇笔记写完,后面我自己再看也都觉得值得反复翻。