我在后台系统改一处权限判断时被新同事反问得很不舒服“JavaScript运算符不是入门第一课吗还有什么好讲的”我把线上事故清单打开给他看库存数字被隐式转换加成了字符串、默认值用逻辑或结果把 0 和空字符串直接覆盖、位运算缺括号导致用户权限位被误判……他看完之后默默回去把“JS基础”那一栏重新刷了一遍。这篇文章不是临时起意而是我在 HoRain 云技术社区做公开分享的整理底稿主题就挂在标题下面JavaScript运算符全解析从入门到精通。我想把它写成这样一份东西新手能拿它当查漏补缺的索引已经写了几年代码的人也能在好几段里找到自己当年踩坑的影子。可以先给一个不太严谨但很形象的定义变量和值像面粉和水运算符是揉面的手法。同样的原料有人揉出能拉丝的面团有人揉出一盆死面。在 JavaScript 里运算符是语法最基础、也最容易“以为会了”的一部分很多线上 bug 的根源写到最后都是一行运算符表达式。1. 从认知地图开始JavaScript运算符到底有哪些1.1 运算符的本质不是符号而是运算协议很多教程会把运算符讲成“一种符号”这个说法会把初学者带偏。更准确的理解是运算符是一套规则协议它决定了两件事一是怎么取出操作数的值二是按什么规则产出新值必要时还要做类型转换、触发副作用。比如“”号它在 JavaScript 里不是单纯的数学加号。两个数字操作数的时候做加法只要有一个操作数是字符串就倾向于做字符串拼接。这条规则从一个层面解释了为什么1 2会得到12而不是3。如果只是把“”当成“加法符号”第一次碰到这种输出就会懵。从另一个层面看运算符也是表达式求值顺序的锚点。为什么(a b) * c和a b * c的结果不同因为运算符优先级决定了哪些先算。为什么a b c能一次给多个变量赋值因为赋值运算符是右结合。这些都不是靠死记硬背能长期记住的而是要在写代码、看报错、review 别人代码的过程里反复验证。我在带团队的时候常说一句话如果你能在一个报错信息里迅速判断“这是隐式类型转换导致的还是优先级导致的还是短路求值导致的”你对 JavaScript 运算符的理解就已经超过一半的人了。1.2 一张速查表看懂运算符家族JavaScript 的运算符比很多人以为的要多得多。下面这张表不追求覆盖 ECMA-262 里每一个冷门条目而是把日常开发里真正会频繁遇到的类别先串起来每个类别后面都附一个真实落点。类别常见运算符最常见的应用场景算术运算符 - * / % ** --数值计算、累加器、取余分页、幂运算赋值运算符 - * / % ** ^ |变量更新、状态叠加比较运算符 ! ! 条件判断、排序、边界比较逻辑运算符 || !短路取值、条件渲染、判空位运算符 | ^ ~ 权限位、状态标志、二进制处理三元运算符? :简单二选一赋值空值合并与可选链?? ?.安全读取深层属性、默认值类型相关运算符typeof instanceof in delete void类型判断、属性检测、清理属性逗号运算符,for 循环、精简表达式下标与调用[] ()属性访问、函数调用这里要特别说一句运算符“入门”容易是因为大多数开发者日常只用算术、比较、逻辑、赋值四类“精通”难是因为隐式转换、优先级、结合性、返回值类型这些隐藏规则经常联合起来制造诡异问题。2. 算术运算符与赋值运算符从日常计算到隐式转换陷阱2.1 “”号的双重身份加法还是拼接我在面试里经常让候选人现场写出下面几个表达式的结果能全对的并不多console.log(1 2); // 12 console.log(1 2); // 12 console.log(1 2 3); // 33 console.log(1 2 3); // 123 console.log(5 - 2); // 3 console.log(5 * 2); // 10前四个看着绕其实规律很简单只要遇到字符串就把另一个操作数转成字符串做拼接。但注意执行顺序是从左到右所以1 2 3先把 1 和 2 相加得到 3再与3拼接成33。而1 2 3里第一个操作数就是字符串后面全部跟着拼接得到123。后两个才是真正值得警惕的-、*、/、%这些运算符没有字符串语义所以 JavaScript 会尝试把字符串转换成数字。5 - 2就成了数字减法结果是3。这种隐式转换在业务代码里非常危险尤其是从表单、接口数据里拿到“看起来像数字”的字符串时。我在实际项目中遇到最多的场景是金额计算。接口返回的是字符串19.90前端直接19.90 0.1结果不是19.9而是19.900.1。筛了好半天最后发现是加号拼接。经验需要数值相加时先显式转换再相加。不要依赖隐式转换。2.2 一元正号与数值转换的其他姿势除了Number()还有一个很容易被忽略的转换工具一元正号。它放在操作数前面会强制把操作数转成数字。const price 19.90; const total price 0.1; console.log(total); // 19.999999999999996 浮点精度问题后面再说一元负号-也一样但它还会改变符号。写代码时看到str这种写法不要觉得奇怪它是非常常见的数字转换技巧。不过我建议在团队里统一风格如果是核心逻辑写Number(str)可比str好读得多。2.3 自增自减、取余、指数运算里的边界情况自增和自减--是历史悠久的运算符规则不复杂前置版本先改变变量值再返回新值后置版本先返回旧值再改变变量值。let i 1; console.log(i); // 1 console.log(i); // 2 let j 1; console.log(j); // 2 console.log(j); // 2这个差异在 for 循环里影响不大但在同一个表达式里多次修改同一个变量时就成了灾难。比如let k 1; const result k k;各浏览器的结果一致但人脑读起来非常费劲。我在代码评审里遇到这类写法只有一个处理原则拆开别秀。取余运算%也藏着一个分歧点。JavaScript 里-5 % 2的结果是-1而数学上余数更接近1。很多从 Python 或数学场景转过来的开发者会在这里栽跟头。如果需要“总是非负”的余数可以自己加一个校正const mod (n, m) ((n % m) m) % m; console.log(mod(-5, 2)); // 1指数运算符**是 ES2016 加入的优先级很高而且是右结合console.log(2 ** 3 ** 2); // 512因为先算 3 ** 2 9再算 2 ** 9更麻烦的是一元负号和指数运算符放一起时直接写-2 ** 2是语法错误必须写成(-2) ** 2。这个细节看似冷门但在配置计算、图形代码里偶尔会出现。2.4 浮点精度0.1 加 0.2 不等于 0.3讲算术运算符不可能绕过浮点精度。0.1 0.2在 JavaScript 里得到0.30000000000000004原因是二进制浮点数无法精确表示某些十进制小数。实际工程里处理方式一般有三种金额用整数最小单位计算比如“分”而不是“元”显示层用toFixed(2)保留两位小数需要高精度计算时使用专门库。我之前在某项目中做过一套优惠计算最初直接用浮点累加用户下单金额偶尔多一分钱或少一分钱。后来把所有金额统一转成“分”整数再计算问题彻底消失。2.5 赋值运算符的链式与解构赋值赋值运算符本身也有返回值那就是右侧表达式的结果。所以可以写a b 5含义是把5赋给b再把b的值赋给a。结合右结合规则这个表达式是从右往左执行的。ES6 之后我更推荐解构赋值它比链式赋值可读性好得多const [first, second] [1, 2]; const { name, age } user;解构本质上也是“从右侧对象或数组里取出值赋给左侧变量”并不是新的运算符但它和赋值运算符配合非常紧密。需要注意的是如果右侧是null或undefined会直接报错所以解构前最好做好判空。ES2021 又引入了逻辑赋值运算符 || ??它们把逻辑运算符和赋值结合起来a || default; // 等价于 a || (a default) b other; // b 为真时才把 other 赋给 b c ?? fallback; // c 为 null/undefined 时才赋默认值这种写法简洁但在团队里如果不统一阅读成本会上升。我一般只在初始化场景里用。3. 比较运算符 和 到底差在哪里3.1 宽松等于与严格相等的本质差异这是 JavaScript 里最容易产生争议的内容之一。会做隐式类型转换要求类型和值都相等。先看几个非常容易让人产生误解的例子console.log(null undefined); // true console.log(null undefined); // false console.log(null 0); // false console.log( 0); // true console.log( false); // true console.log([] false); // true console.log([] 0); // true console.log([1] 1); // truenull 0是 false 这行特别反直觉。从运行机制看有一个特殊的规则null和undefined彼此相等而且它们不会跟其他任何值做隐式转换比较。所以null 0为 falseundefined false也为 false。我的编码规范很简单默认全部使用和!。唯一允许用 null的场景是同时判断null和undefined的时候if (value null) { // 同时覆盖 value null value undefined }这个技巧非常实用而且避免了typeof value undefined || value null这种冗余表达。3.2 关系比较和字符串排序的隐藏规则关系运算符、、、同样有类型转换问题。两个数字比较没问题数字和字符串比较时字符串会转成数字两个字符串比较时则会逐字符按 Unicode 编码比较console.log(2 10); // true因为 2 的编码比 1 大 console.log(2 10); // false因为 10 被转成了数字 10 console.log(b a); // true正因为字符串比较是逐字符的才导致一个著名现象Array.prototype.sort()默认会把所有元素转成字符串再排序。const arr [10, 9, 100, 2]; arr.sort(); console.log(arr); // [10, 100, 2, 9]想要真正按数字排序必须传比较函数arr.sort((a, b) a - b); console.log(arr); // [2, 9, 10, 100]对中文排序不要直接用localeCompare之外的方式。默认字符串排序是按 Unicode 编码来的中文的编码顺序和拼音顺序并不一致直接用 sort 排中文等于随机排。3.3 比较运算符与数组/对象的隐式转换当操作数里有对象或数组时比较运算符会先尝试把对象转成原始值。这个转换过程一般会调用对象的valueOf()如果返回的不是原始值再尝试toString()所以[] 0为 true因为空数组先转成空字符串再转成数字0。这个机制相当底层但了解它能帮你调试很多“怪异”结果。我处理此类问题的原则是不要写依赖隐式转换的比较表达式。遇到数组和对象比较先明确取的是什么值再比较比如arr.length 0、obj.id targetId。4. 逻辑运算符与短路求值不只是 true 和 false4.1 逻辑运算符返回的不一定是布尔值初学者最容易误以为和||一定返回布尔值。实际上它们在执行短路求值之后返回的是某个操作数本身的值。a || b的规则是如果a是真值返回a否则返回b。a b的规则是如果a是假值返回a否则返回b。所以console.log(0 || 默认值); // 默认值 console.log(有值 || 默认值); // 有值 console.log(1 2); // 2 console.log(0 2); // 0理解了这一点就能看懂很多框架源码里的写法const name user.name || 游客; const isLogin user user.token;user user.token在user为null或undefined时直接返回user避免继续读取user.token报错在user存在时返回user.token。这正是短路求值的价值通过一个表达式同时完成判空和取值。4.2 短路求值在业务代码里的三种常见场景第一个场景是条件渲染。React 里很常见{isLogin Dashboard /}isLogin为 false 时false就直接作为表达式结果不会渲染出任何内容。第二个场景是累加或初始化。比如统计购物车总价const totalPrice cart.items.reduce((sum, item) sum item.price, 0);这里不多说逻辑运算符但它依赖每次迭代把数值赋给累加器。第三个场景是给参数设置默认值。在 ES6 之前常见写法是function fetchData(url, options) { options options || {}; }这种方式有一个坑如果调用方传入0、、false这类假值也会被替换成默认值。如果业务上确实允许或0作为有效参数就不能用||要用 ES2020 的空值合并运算符??。这个在后面的特殊运算符章节单独展开。4.3 双重否定是一种类型转换技巧逻辑非!会把操作数转成布尔值再取反。写两个!就得到“把任意值转成布尔值”的效果console.log(!!hello); // true console.log(!!0); // false console.log(!!undefined); // false console.log(!![]); // true!!不是独立运算符只是两次逻辑非。它比Boolean(value)短也比Boolean(value)更容易混在一长串表达式里。我平时更推荐Boolean(value)因为意图更明确。不过阅读老代码、压缩代码时还是会经常看到!!至少要能读懂。这里还要注意逻辑非的优先级。很多新手会以为!user.age 18是“用户的年龄不小于 18”但实际运算顺序是(!user.age) 18先取反得到一个布尔值再和数字 18 比较。布尔值false和数字比较时会转成0结果就是0 18永远是 false。经典坑位想表达“年龄不小于 18”应该写成!(user.age 18)或者user.age 18。4.4 逻辑运算符的优先级比你想的低不少人都知道优先级高于||所以true || false false会先算false false结果是true。但真正到业务表达式里经常因为混合使用导致误判。我最推荐的做法是混合和||时一定要加括号。不是因为优先级背不下来而是因为括号能让未来读代码的人不产生误解。写代码不只是写给解释器更是写给人看。5. 位运算符实战从权限标志位到二进制技巧5.1 位运算先把数字变成 32 位二进制位运算符包括、|、^、~、、、它们操作的是数字的二进制表示。JavaScript 的位运算会把操作数先转成 32 位有符号整数再按位计算最后返回一个 32 位有符号整数。这意味着位运算不适合直接处理很大的数字超过 2^31 - 1 的数值参与位运算时可能被截断。这个限制平时很少遇到但权限系统里如果权限值设计得很大就需要注意。最常见的权限判断写法是“位标志”模式。假设一个系统有三类操作权限读、写、删分别定义为const READ 1; // 二进制 001 const WRITE 2; // 二进制 010 const DELETE 4; // 二进制 100用户权限存成一个数字比如5二进制是101代表有读和删的权限。判断有没有写权限时const userPermission 5; console.log((userPermission WRITE) WRITE); // false因为 101 010 0给用户加权限const newPermission userPermission | DELETE; // 101 | 100 111去掉某个权限const removeRead userPermission ~READ; // 101 ~001 100这种方式的优点是只用一个数字就能表达组合状态数据库存储、缓存传输都非常方便。我在后台管理系统的角色权限模块里用过这套方案几十种权限叠加起来代码依然很清晰。5.2 几个高频但容易被误用的位运算小技巧第一个是快速判断奇偶const isEven (n) (n 1) 0;第二个是用~~截断小数位console.log(~~12.99); // 12 console.log(~~-12.99); // -12~~本质上不是数学取整而是“按位取反再取反”先把小数部分丢掉。它比Math.trunc()短但可读性差。我建议在业务里写Math.trunc()。第三个是判断一个数是不是 2 的幂const isPowerOfTwo (n) n 0 (n (n - 1)) 0;第四个是交换两个变量这是很多算法课程里的经典题目let a 3; let b 5; a ^ b; b ^ a; a ^ b;这个技巧看起来很酷但普通业务代码里直接用解构[a, b] [b, a]更清晰。位运算交换只在极度追求底层的场景里才有意义。5.3 位运算的优先级坑和可读性取舍位运算符的优先级比比较运算符低比逻辑运算符高但也不是最高。这里有一个非常经典的坑也是我在代码评审里反复抓的问题。有人想判断一个权限位是否包含某个标志位写出了if (userPermission READ READ) { // ... }这里的错误在于的优先级高于所以实际执行的是if (userPermission (READ READ)) { // ... }READ READ为 trueuserPermission true相当于userPermission 1。如果用户权限恰好有最低位权限这个判断就错误通过。正确写法必须加括号if ((userPermission READ) READ) { // ... }不要在这类表达式上省括号。权限判断关系到系统安全宁可多写一点也不要留下优先级隐患。6. 特殊运算符?. ?? typeof instanceof in void6.1 空值合并运算符 ?? 与 || 的区别空值合并运算符??是 ES2020 引入的它只对null和undefined做“兜底”不会像||那样把0、、false也当成需要兜底的值。看这段对比let value 0; console.log(value || 默认值); // 默认值 console.log(value ?? 默认值); // 0业务里最常见的坑是配置项允许0结果用||给默认值后0永远没法生效。比如优惠金额为0表示不打折但||会把0当成无值导致错误地套用默认折扣。还有一个语法细节??不能和||或直接混合使用而不加括号否则直接报语法错误。a ?? b || c; // SyntaxError (a ?? b) || c; // 正确 a ?? (b || c); // 正确我遇到很多次新手看文档写options.timeout ?? 3000 || fallback要么报错要么运行行为和预期不一致。我的习惯是只要出现??就把相关表达式整体用括号包起来。6.2 可选链运算符 ?. 的三种形态可选链?.也是 ES2020 引入的作用是在访问属性、调用函数、读取数组下标之前自动判空。它只有三种形态obj?.prop // 属性访问 obj?.[expr] // 下标访问 obj?.(args) // 函数调用举一个真实场景。接口返回结构可能是const user { address: { city: 深圳 } }; console.log(user?.address?.city); // 深圳如果user是null写user.address会抛 TypeError但写成user?.address会安全返回undefined。可选链也不会对“空字符串”“0”做兜底它只关心null和undefined。这一点和??是同一套哲学。它们经常搭配使用const city user?.address?.city ?? 未知;这套组合基本取代了以前user user.address user.address.city || 未知的写法代码干净很多。6.3 typeof、instanceof、in 和 delete 的边界情况typeof用来判断值的类型但它有一堆历史遗留问题最大的就是console.log(typeof null); // object这是 JavaScript 诞生初期的设计缺陷直到今天也不能用typeof null null判断 null。判断 null 直接用value null最靠谱。typeof对未声明的变量不会抛错而是返回undefined这是它和直接访问变量的最大区别console.log(typeof notExist); // undefinedinstanceof检查对象是否在某个构造函数的原型链上console.log([] instanceof Array); // true但跨 realm比如 iframe、不同窗口对象的情况下数组的Array构造函数可能不同导致instanceof Array为 false。判断数组我建议直接用Array.isArray()。in运算符用来检查某个属性是否存在于对象中包括继承来的属性console.log(toString in {}); // true如果想只检查自身属性用Object.prototype.hasOwnProperty.call(obj, key)或者Object.hasOwn(obj, key)。delete运算符用来删除对象属性它返回布尔值表示是否删除成功但对局部变量使用基本上无效甚至会带来困惑。我以往只在清空对象属性映射时用它。6.4 void、逗号运算符和下标运算符void运算符现在用得不多它接受一个表达式并总是返回undefined。在前端领域老式 HTML 里常见hrefjavascript:void(0)目的是让点击链接不产生页面跳转。现代工程里这个写法已经比较少见了但了解它还是有必要的因为void 0曾经是安全的“取 undefined”的方式。逗号运算符,会从左到右执行多个表达式并返回最后一个表达式的值。最常见的场景是 for 循环for (let i 0, j 10; i j; i, j--) { console.log(i, j); }也可以用来精简代码const result (fn1(), fn2(), 42);但业务代码里我强烈不建议这样写可读性太差。下标运算符[]本质是属性访问表达式。arr[0]、obj[key]里的key会先被计算再作为属性名去读取。正因为如此它能读取动态属性名。前端处理 map 结构时经常用const typeName [info, warning, error][type] || info;7. 运算符优先级与结合性bug 重灾区7.1 一张足够用的优先级简表优先级是运算符最容易被忽略、也最容易产生连环 bug 的地方。我不会要求任何人背下《ECMAScript 规范》里几十行优先级但下面这张精简表最好能熟记它覆盖了 95% 的开发场景。优先级说明运算符高成员访问、函数调用、创建实例.[]()new高一元运算符!~-typeofvoiddelete--中高指数运算**中高乘除取余*/%中加减-中移位中关系比较ininstanceof中相等比较!!中位与、异或、或^|低逻辑与、或、空值合并||??低三元? :低赋值-等低逗号,这张表至少要记住几个关键顺序一元运算符的优先级高于乘除相等比较的优先级高于位运算逻辑运算符低于关系运算符赋值运算符基本垫底。正因为的优先级高于才会出现第 5 章里那个权限判断的坑。同理typeof a string能正常运算是因为typeof先执行再与字符串比较。7.2 结合性赋值、三元、指数从右往左优先级解决的是“谁先算”结合性解决的是“同一个优先级的连写时怎么算”。绝大多数运算符从左到右但有三类比较特殊赋值运算符从右到左所以a b c能连续赋值。 三元运算符也是右结合所以a ? b : c ? d : e等价于a ? b : (c ? d : e)。但嵌套三元可读性非常差我建议只要出现第二层三元就改成 if 分支。 指数运算符也是右结合所以2 ** 3 ** 2先算右上角。结合性问题最麻烦的地方是“看起来合法但理解错”。我在代码评审里遇到过一个很典型的例子const result condition1 ? value1 : condition2 ? value2 : value3;这个表达式确实能跑但第一眼读的时候很容易理解为(condition1 ? value1 : condition2) ? value2 : value3。为了不让同事误读我后来统一要求嵌套三元一律拆成纯 if或者加括号。7.3 什么情况下必须加括号从代码可维护性出发我总结了几条硬性规则只要混合了位运算符和比较运算符就加括号。比如权限判断写成(permission READ) READ。 只要混合了??和||/就加括号。这是语法强制要求。 只要表达式同时出现加减和移位我会加括号。移位优先级比加减低是不少人记反的地方。 只要三元表达式里有复杂条件就先把条件存成命名变量三元表达式本身只保留最简形式。括号本身不是坏味道。真正的坏味道是“一会儿依赖优先级一会儿依赖结合性”让读代码的人被迫回忆运算符表。8. 常见问题与避坑清单8.1 高频坑速查表下面这些案例全部来自真实代码场景我按“问题现象-根因-正确姿势”整理成表。常见问题产生原因正确姿势金额相加得到字符串拼接接口返回字符串触发了拼接先用Number()转成数字再相加0.1 0.2不等于0.3二进制浮点数精度限制用整数分、toFixed或精度库权限判断永远为 true优先级高于(perm READ) READ数组 sort 没有按数字排默认转字符串比较arr.sort((a, b) a - b)默认值把合法0覆盖使用了 读嵌套属性报错中间层为 null使用?.NaN判断失败NaN NaN为 false用Number.isNaN(value)空字符串被判成无值用了 NaN值得单独说一下。NaN是唯一和自身不相等的值所以value NaN永远是 false。判断NaN要用Number.isNaN(value)或者写value ! value这种 hack。实际业务里最常见的NaN来源是parseFloat解析失败、除法分母为 0、后端字段缺失直接做减法。排查这类问题的时候第一步就是检查是不是产生了NaN。8.2 一个真实的重构案例权限位判断修复我印象里最深的一次 bug 排查就是在后台系统权限模块里。当时的代码长这样if (userPermission roleFlag roleFlag) { // 执行某些高风险操作 }这个代码从逻辑意图上看是想判断userPermission是否包含roleFlag对应的位。但是由于优先级问题实际执行的是if (userPermission (roleFlag roleFlag))roleFlag roleFlag是 true表达式变成userPermission 1。也就是说只要用户拥有最低位权限所有需要更高权限的操作都会放行。某次安全审查发现问题后我们排查了半小时最后定位在一行“看起来很合理”的表达式上。修复很简单if ((userPermission roleFlag) roleFlag) { // 执行某些高风险操作 }从那以后我禁止团队里出现任何“位运算条件不加括号”的写法。并且把类似问题写进了代码评审检查单。8.3 从入门到精通我理解为“定位根因的速度”写到最后说说我对“精通”这两个字的理解。JavaScript 运算符不是考试知识点它真正的衡量标准是项目里出了诡异的数值 bug你能不能快速判断是隐式转换、优先级、结合性、短路返回类型还是浮点精度的问题。我现在带人做代码评审时最常说的话是“不是让你不用运算符是让你在关键表达式上别省括号、别省转换、别省中间变量。”有个小习惯分享给各位每写完一行复杂表达式问自己一个问题旁边同事不查文档能不能看懂这行在做什么如果答案是不能那就拆一行加个变量名或者加一对括号。运算符可以帮你写出很短的代码但合格的工程师往往更关心代码能被下一个接手的人顺利读懂。