1. 运算符全景图:这东西远比你想象的复杂
我先说个亲身经历。有一回我帮同事排查一个线上BUG,页面上展示的金额跟后台对不上,查了半天发现是金额差值计算时混用了字符串和数字,"100" + 1直接拼成了"1001",而后续判断还用了==去比较,结果出现一堆莫名其妙的分支。这种问题说起来低级,但几乎每个前端组都出现过。归根结底,就是对 JS 运算符的底层机制理解不够透。
JS 里的运算符,表面上就是+ - * / %、== === != !== && || !这一坨符号,规则手册上写得清清楚楚,但实际开发中踩坑的点从来不是"不认识符号",而是隐式类型转换和运算优先级这两个痛点。我经常跟组里的新人说一句话:你把运算符背熟了,只能说明你读过文档;你能预判每一次运算结果,才算真的会写 JS。
这篇文章我会把 JS 运算符的几大分类全部过一遍,每个分类都会配上实操经验、常见坑和性能提醒,尽量用我说人话的方式讲清楚。适合刚入门前端、想夯实基础的同学,也适合写了两年业务代码但一直没系统捋过运算符的老手。
2. 算术运算符:加号是万恶之源
2.1 加减乘除取余取幂的基本操作
算术运算符是+ - * / % **,小学生都认识,但 JS 里的算术运算符有一个特点:它会自动把操作数往数字类型上拖,唯独加号例外。
先看一段实测代码:
console.log(10 + "5"); // "105"(连接字符串) console.log("10" - 5); // 5(减号强制转数字) console.log("10" * "5"); // 50(乘法强制转数字) console.log("10" / 5); // 2 console.log("10" % 3); // 1 console.log("10" ** 2); // 100加号只要有一个操作数是字符串,就会走字符串拼接路线,把另一个操作数转成字符串;减乘除取余取幂则是能转数字就转数字。这就是为什么"100" + 1会得到"1001"而"100" - 1会得到99。
我后来在项目里定了一条规范:所有做数值运算的变量,入口处必须做一次 Number() 包装。不是矫情,是这条规则能救你的命:
function addPrice(a, b) { return Number(a) + Number(b); } // 或者更暴力一点 const formatNum = (v) => (isNaN(Number(v)) ? 0 : Number(v));这样做的好处是,不管后端返回的是字符串"19.9"还是数字19.9,运算结果都稳定。坏处是代码显得啰嗦,但业务稳定比代码美观重要一百倍。
再说说%取余。-7 % 3的结果是-1而不是2,因为 JS 的取余运算会跟随被除数的符号。这个在循环容器下标、时间换算时特别容易出问题。比如你想让一个下标在 0-2 之间循环,用index % 3,如果 index 出现负数,结果就变负数了。稳妥的做法是((index % 3) + 3) % 3。
2.2 自增自减的前后置区别
i++和++i这个梗,每个前端面试题都会出,但实际代码里真写let a = b++ + ++c这种混杂语句的,不是炫技就是想离职。
简单说:
let a = 1; let b = a++; // b = 1, a = 2(先赋值,后自增) let c = 1; let d = ++c; // d = 2, c = 2(先自增,后赋值)实践经验就一条建议:不要把自增自减表达式嵌到其他表达式里。要自增就单独一行,count++;完事了。这样写不是怕你不会,而是怕三个月后的你自己看不懂。
还有个小坑:x++ + ++x这类表达式在 JS 里结果是确定的,只是可读性为零。Code Review 时我看到这种代码,一定会打回去让改成两步写。
2.3 数值精度问题:0.1+0.2 的锅
这是个老生常谈的话题了。0.1 + 0.2 === 0.30000000000000004,原因是 JS 用 IEEE 754 双精度浮点数表示数字,二进制无法精确表示 0.1 和 0.2 的小数部分。
我处理金额精度只有三条铁律:
- 涉及金额、百分比等精度敏感数据,一律用整数分单位计算(比如把元转成分再运算);
- 展示层再统一除以 100 转回元;
- 如果必须用浮点计算结果,用
toFixed(digits)来收敛,但注意toFixed的返回值是字符串,记得Number()包一层。
const fen = (yuan) => Math.round(yuan * 100); const yuan = (fen) => (fen / 100).toFixed(2); let totalFen = fen(19.99) + fen(8.80); console.log(totalFen); // 2879 console.log(yuan(totalFen)); // "28.79"这比任何BigNumber库都省心。当然如果项目里金额计算特别复杂、还有汇率算法,那就直接上decimal.js,别自己造轮子。
3. 比较运算符:全等和宽松相等是个哲学问题
3.1 ==、===、!=、!== 四兄弟
比较运算符这一块,我个人强烈建议:能用全等的绝不宽松等。项目里的 ESLint 规则直接开eqeqeq: error,然后让代码在 CI 阶段就拒绝使用==和!=。
为什么这么严格?因为 JS 的==有一整套复杂的隐式转换规则。比如:
[] == 0 // true(空数组被转成了空字符串,再转数字 0) [] == "" // true(空数组转成空字符串) null == undefined // true(这是规则特例)这些规则虽然规范里写得清清楚楚,但正常人根本记不住,而且记错了就是线上事故。
===就干净多了:先比类型,再比值。类型不同直接 false,不会做任何隐式转换。所以0 === ""是 false,[] === 0也是 false,一切符合直觉。
3.2 比较的类型转换:大小比较同样会隐形转换
除了相等比较,大小比较< > <= >=同样存在类型转换。规则也很微妙:
"2" > "10" // true(都是字符串,按字典序比,因为 "2" > "1") "2" > 10 // false(有一个是数字,字符串转数字,2 > 10 不成立) "02" > "10" // false(字典序,'0' < '1')这里最坑的是字符串和字符串比较时不会转成数字,而是逐位比较字符编码。做排序时如果你直接用原始字符串排序,"10"会排在"9"前面。所以数字字符串排序必须先用map(Number)转掉,或者传给sort时做减法:
["10", "9", "100"].sort((a, b) => a - b); // [ "9", "10", "100" ]另一个日常坑是null和0的大小比较:
null > 0 // false null == 0 // false(没错,宽松等也不等) null >= 0 // true(为什么?因为 null 在大小比较时被转成了 0)这结果看起来像是 JS 的 bug,其实规范就是这么定的:相等比较中 null 不会转成数字,但大小比较中 null 转成 0。逻辑是拧巴的,所以我的实操建议是:凡是可能为 null 的值,先判断再比较,别偷懒让 JS 自己转。
3.3 字符串包含判断的进阶姿势
热搜词里有一条 "js判断字符串是否包含",这是日常高频需求。常规做法是用includes方法:
const str = "hello world"; console.log(str.includes("world")); // true console.log(str.startsWith("he")); // true console.log(str.endsWith("d")); // true这些都是 ES6 引入的字符串 API,比老的indexOf可读性好太多。但要注意两点:
includes区分大小写,如果要忽略大小写,先toLowerCase()两侧再比;includes对空字符串""永远返回 true,这个符合规范但偶尔会误伤业务逻辑。
const includesIgnoreCase = (source, target) => { if (typeof source !== "string" || typeof target !== "string") return false; return source.toLowerCase().includes(target.toLowerCase()); };另外,如果你的匹配模式是正则,直接用RegExp.prototype.test()即可,不要先转数组再 indexOf,纯属绕路。
4. 位运算符:JS 里被低估的工具箱
4.1 位运算符在 JS 中的特殊性
位运算符& | ~ ^ << >> >>>在 JS 里是比较冷门的存在,很多前端写了三年没碰过,但实际场景中它能解决一些很刁钻的问题。
JS 的位运算有个限制:位运算会先把操作数转成 32 位有符号整数,再做运算,结果也是 32 位整数。这意味着你传入一个浮点数或超过 2^31 的数,它会先给你截断取整。
举个例子:
5.9 | 0 // 5(向下取整,注意是对正数而言) -5.9 | 0 // -5(这跟 Math.floor 不一样!Math.floor(-5.9) 是 -6) ~~5.9 // 5 ~~-5.9 // -5很多人用~~做快速取整,但它本质是"截断"而不是"取整",负数时行为跟Math.floor不一样。所以我的建议是:要做向下取整就用 Math.floor,不要炫技用位运算。炫技的代码能跑,但会把团队里其他人绕晕。
4.2 位运算的应用场景
位运算最实际的应用是权限系统。用位掩码表示权限,每个权限占一位,组合、判断都极其高效:
const PERM_READ = 1; // 001 const PERM_WRITE = 2; // 010 const PERM_DELETE = 4; // 100 let userPerm = PERM_READ | PERM_WRITE; // 011,代表只读+写 function canDelete(perm) { return (perm & PERM_DELETE) === PERM_DELETE; } console.log(canDelete(userPerm)); // false userPerm = userPerm | PERM_DELETE; // 追加权限 console.log(canDelete(userPerm)); // true userPerm = userPerm & ~PERM_WRITE; // 去掉写权限这个方案的优势是:多个权限用一个数字就能表示,存储成本低,数据库一个字段搞定,判断时一次位运算即完成。缺点是不直观,调试时要转换成二进制看,所以团队内用之前建议注释写清楚每一位的含义。
另一个高频用法是状态标志位,比如多选组件的值合并。不过一般不推荐业务代码里大量使用位运算,因为可读性损失太大,性能提升又微乎其微。位运算最适合的场景就是底层库、权限中间件这类不需要频繁维护业务逻辑的模块。
4.3 左移右移的日常用法
<<和>>可以用来做乘除 2 的幂快速计算:
1 << 4 // 16(等于 1 * 2^4) 256 >> 4 // 16(等于 256 / 2^4)但这种写法在业务代码里基本不该出现。我见过有人用value << 0做类型截断,效果跟| 0差不多,但不直观。统一用Math.trunc不好吗?代码不是越短越好,而是意图越清晰越好。位运算在业务代码里的出现,必须搭配注释,不然三个月后就是"天书"。
5. 逻辑运算符:短路求值是前端的一切
5.1 &&、||、! 的返回值机制
逻辑运算符&& || !是前端用得最频繁的运算符,没有之一。跟很多其他语言不一样,JS 的&&和||返回的不是布尔值,而是某个操作数的原值。
const a = "hello" && 123; // 123(第一个为真,返回第二个) const b = "" || "default"; // "default"(第一个为假,返回第二个) const c = null && "x"; // null(第一个为假,直接返回第一个)很多新手搞不明白&&和||的默认值写法,其实就是靠这个机制:
const name = user.name || "匿名用户"; const isAdmin = user.role && user.role === "admin";但这里面有个隐患:||做默认值时,只要有假值就会触发默认值逻辑,包括0、""、NaN。比如const num = value || 10;当 value 是 0 时,你会得到 10,这经常是隐蔽 bug 的来源。
如果你希望"只有 null 和 undefined 时才走默认值",请用空值合并运算符??:
const numA = 0 ?? 10; // 0 const numB = null ?? 10; // 10 const numC = undefined ?? 10; // 10这是 ES2020 的标准,现在所有主流浏览器都支持,放心用。前提是团队规范里把??和||的使用场景明确好:??用于"空值回退",||用于"假值回退",别混。
5.2 可选链:连续访问属性不炸
?.可选链运算符也是 ES2020 的产物,它在访问嵌套属性时,如果中间某一层是 null 或 undefined,整个表达式短路返回 undefined,而不是抛 TypeError:
const user = { profile: { address: null } }; console.log(user.profile.address?.city); // undefined,不报错 console.log(user.profile.address.city); // TypeError: Cannot read properties of null配合空值合并简直天衣无缝:
const city = user?.profile?.address?.city ?? "未知城市";这里注意,?.只能短路"左边的链",不能短路后续逻辑。比如user?.profile.address.city里,如果 user 是 undefined,那么整个表达式返回 undefined,但如果 profile 是 undefined,user?.profile已经是 undefined 了,再访问.address也会报错。所以要么每层都加?.,要么确保中间层不可能为 null。我的建议是:嵌套超过三层,每层都加?.,别省,省一次可能就是一次线上故障。
5.3 三目运算符和 if else 的边界
三目运算符condition ? a : b是逻辑运算符的紧凑版。它的缺点是嵌套多了就完全不可读:
const status = a ? (b ? "BOTH" : "A_ONLY") : (c ? "C_ONLY" : "NONE");这种代码能运行,但 review 的人血压直接拉满。我的经验是:三目运算符最多嵌套一层,再多一律改成if或者提前 return 的卫语句。卫语句版本反而更清晰:
let status; if (!a) { status = c ? "C_ONLY" : "NONE"; } else { status = b ? "BOTH" : "A_ONLY"; }逻辑运算符还有一类高级用法是逻辑赋值,ES2021 引入的&&=,||=,??=:
// 传统写法 if (!x) x = "default"; // 等价于 x ||= "default"; // 只对非空值时赋值 y &&= doSomething(); // 如果 y 为真,才执行 doSomething 并把结果赋给 y // 只对 null/undefined 赋值 z ??= "fallback";这些语法现在也完全可用,业务里最常用的是??=,因为它的语义最安全,不误伤 0 和空字符串。其他两个我建议少用,因为语义不够直白。
6. 运算符优先级与结合性:面试杀招和实战防线
6.1 优先级图表:真正该背的是这十几条
网上有完整的运算符优先级表,几十行谁能全记住?我实际开发中只用得上前十几条,整理成了一张常用速查表:
| 优先级 | 运算符 | 说明 |
|---|---|---|
| 1 | () | 括号永远最高 |
| 2 | .[]?. | 属性访问、可选链 |
| 3 | new | 带参数列表的 new |
| 4 | ++ --! ~ typeof | 一元运算符 |
| 5 | ** | 幂运算 |
| 6 | * / % | 乘除取余 |
| 7 | + - | 加减、拼接 |
| 8 | << >> >>> | 移位 |
| 9 | < > <= >= | 大小比较 |
| 10 | == === != !== | 相等比较 |
| 11 | & | 按位与 |
| 12 | ^ | 按位异或 |
| 13 | | | 按位或 |
| 14 | && | 逻辑与 |
| 15 | || | 逻辑或 |
| 16 | ?? | 空值合并 |
| 17 | ?: | 三目 |
| 18 | = += -= ... | 赋值 |
| 19 | , | 逗号 |
关键记忆点其实是:
&&优先级高于||,高于??;- 三目优先级低于逻辑运算,但高于赋值;
??不能和&&或||混用,除非加了括号,否则直接 SyntaxError。原因是 JS 规范怕你写出a ?? b || c这种歧义表达式。
还有个大坑是赋值运算符在链式使用时是从右往左结合:
let a, b; a = b = 5; // b = 5,然后 a = 5这样用没问题,但如果你看到a = b += 5,最好还是拆开写,别挑战大脑缓存。
6.2 实际代码里的优先级陷阱
我最常见到的优先级问题就是字符串拼接和条件判断混在一起:
const result = "总价: " + price * 3 > 100 ? "贵" : "便宜";这里+的优先级高于>,>高于?:,所以实际逻辑是("总价: " + price * 3) > 100,字符串和数字比较,转来转去完全不是你的原意。正确写法必须是:
const result = "总价: " + (price * 3 > 100 ? "贵" : "便宜");我的铁律是:除了简单的加减乘除,所有混合运算都加括号。不是不信任规则表,而是人脑在阅读代码时的解析带宽有限,加括号是给同事和未来的自己降低负担。代码在机器上跑多少次都无所谓,但在人脑里每次解析都是有成本的。
6.3 结合性的实际影响
结合性影响的是表达式里优先级相同的情况。* / %是左结合,**是右结合,赋值和三元是右结合。
2 ** 3 ** 2 // 512,右结合即 2 ** (3 ** 2) 16 / 4 / 2 // 2,左结合即 (16 / 4) / 2生产环境我会刻意避开这种连续幂运算的写法,写全括号的版本。因为哪怕你知道规则,别人不一定知道,而且 Code Review 时纠结这个一点意义都没有——业务代码追求的是直白,不是精巧。
7. 高频实战场景与避坑清单
7.1 判断、取值、多级回退的黄金组合
现代 JS 里,最舒服的取值组合拳是?.+??+ 函数默认参三位一体。
function getDisplayName(user, extra) { const base = user?.profile?.name ?? "未设置昵称"; const suffix = extra?.tag ?? ""; return `${base}${suffix ? " · " + suffix : ""}`; } const user = { profile: { name: "张三" } }; console.log(getDisplayName(user, { tag: "前端" })); // "张三 · 前端" console.log(getDisplayName({}, {})); // "未设置昵称"这套组合拳的好处是:任何一层是 null/undefined 都不会抛错,而且默认值逻辑只对 null/undefined 生效,不会因为空字符串或者 0 就被覆盖掉。这是我目前在团队里大力推行的写法,效果拔群,历史 code review 里那类"空指针"相关的 bug 直线下降。
7.2 运算符导致的数据类型陷阱速查表
我整理了日常遇到频率最高的运算符类型转换陷阱,放在一起方便排查:
| 表达式 | 结果 | 原因 |
|---|---|---|
"10" + 1 | "101" | 加号拼接字符串 |
"10" - 1 | 9 | 减号转数字 |
"10" - "2" | 8 | 转数字相减 |
"2" > "10" | true | 字符串字典序 |
[] == 0 | true | 数组转数字 |
null >= 0 | true | 大小比较中 null 转 0 |
null == undefined | true | 规范特例 |
0 == "0" | true | 字符串转数字 |
"0" == false | true | 两边都转数字 0 |
!!"false" | true | 非空字符串是真值 |
每次遇到奇怪条件分支,先别急着怀疑后端数据,把它丢进控制台跑一遍,对照这个表,通常 5 分钟内就能定位原因。
7.3 运算符和函数组合的高频场景
还有一个高频场景是正则与运算符结合。比如 "js验证url有效性" 这个热搜词,实际实现就是一个正则 +!取反:
const isValidUrl = (url) => { const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$/i; return !!url && pattern.test(url); }; console.log(isValidUrl("https://example.com/path")); // true console.log(isValidUrl("ftp://example.com")); // false注意这里!!url把字符串转成布尔值,避免了空字符串通过正则的意外匹配。这种!!的用法在业务代码里很常见,本质是把任意值布尔化,相当于Boolean(url)。二者等价,看团队风格。我个人偏好写Boolean(url),因为语义直白。但如果整个代码库已经习惯!!,那就保持一致,不要混。
另一个常见需求是 "js获取字典的所有值"。ES2017 提供了Object.values()方法:
const dict = { name: "张三", age: 30, active: true }; console.log(Object.values(dict)); // ["张三", 30, true] console.log(Object.keys(dict)); // ["name", "age", "active"] console.log(Object.entries(dict)); // [["name","张三"], ["age",30], ["active",true]]这三种方法配合数组运算符...(展开运算符,同类为解构赋值)非常好用:
const allValues = [...Object.values(dict)]; const [name, age, active] = Object.values(dict);展开运算符合解构赋值本质上是"运算符"语法糖,用好了配合对象操作效率极高。但注意Object.values返回的数组顺序跟Object.keys一致(字符串键按插入顺序,整数键自动排序在前),如果字典的键是数字字符串,结果顺序可能和你预期不同。
7.4 一段综合案例:三元 + 逻辑运算符重构业务判断
我拿一段真实改造过的代码做示例。原始代码是典型的"判断地狱":
let result = ""; if (user) { if (user.isVip) { if (user.balance > 0) { result = "VIP用户可购买"; } else { result = "VIP余额不足"; } } else { result = "非VIP需升级"; } } else { result = "请先登录"; }用运算符重构之后:
const result = !user ? "请先登录" : !user.isVip ? "非VIP需升级" : user.balance > 0 ? "VIP用户可购买" : "VIP余额不足";这段代码的问题是"三目嵌套"正好三层,已经达到我的容忍上限。如果判断分支再多一点,就绝不建议继续用三目,应改为函数表驱动或者提前 return 的卫语句。所以这个案例的结论其实是:运算符让你能写更紧凑的代码,但代码的终极目标是给同事看。我推荐的策略是"最多三层三目,再多就 switch 或卫语句"。
8. 调试技巧与代码规范建议
8.1 一行代码打印出运算全过程
我调试运算符相关问题的时候,经常直接在控制台把每一步的类型都打出来:
let val = "10"; console.log(val, typeof val, Number(val));因为运算符的坑往往来自类型,而肉眼看到的字符串和数字在页面上长得差不多。多打一个typeof能少花 20 分钟。
还有一种情况是运算结果不符合预期但找不到原因,这时候可以借助eval或Function动态执行表达式,把运算表达式本身打出来:
const expr = `"10" + 5`; console.log(Function(`"use strict"; return (${expr});`)());不过这种手段只用来排查问题,不要用在生产代码里,性能和安全性都无法保证。
8.2 团队 ESLint 配置里的运算符相关规则
我负责的技术组里,ESLint 配置是直接决定代码质量的,跟运算符相关的规则我开了这几条:
eqeqeq: ["error", "always"]—— 禁止使用==和!=;no-ternary: "off"—— 三目可以用但不建议嵌套;no-plusplus: "off"—— 自增自减单独使用时没问题;operator-assignment: ["error", "always"]—— 鼓励a += b这种写法,而不是a = a + b;no-mixed-operators: "error"—— 禁止混用不同优先级且不加括号的运算符,这个规则特别实用;max-depth限制嵌套层级,间接约束运算符连写。
配置上线后,组里代码 review 的争吵明显变少,因为很多低级写法在提交阶段就被拦住了。
8.3 写到最后的一点私货
说实话,JS 运算符这块内容,随便找一份文档都能看到 API 列表,真正值钱的不是"知道有什么运算符",而是在实际场景里能预判每一次运算的结果。入行前三年,我一度以为自己完全掌握了 JS 运算符,后来被"0.1+0.2"和[] == 0轮番教育,才开始老老实实去翻 ECMAScript 规范里的类型转换章节。
我的建议是:刚学的时候可以靠"记忆"理解运算符,工作之后请靠"测试"验证。每当你对某个表达式的输出不确定,就直接在 Node.js 里跑一遍,或者打开浏览器 Console 试一下,比查文档快得多。多踩几次坑,你脑子里那张"类型转换表"就会越来越精确,写代码的直觉也会越来越准。
最后分享一个我个人总结的小技巧:凡是遇到"两个值相等但结果却是 false"这类诡异现象,优先怀疑不是运算符的问题,而是某个值带了隐藏空格,或者接口返回的数字实际是字符串。先用typeof打印类型,再谈运算符。这一步排查法帮我解决过的线上问题,两只手数不过来。