ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一文搞懂JavaScript运算符:优先级、类型转换与常见陷阱

一文搞懂JavaScript运算符:优先级、类型转换与常见陷阱 1. 为什么很多前端老手还会在运算符上翻车先说实话JavaScript运算符听起来是入门第一天就该搞定的基础但我在代码评审和实际排错里见过太多“能跑但说不清为什么对”的写法。典型的就是和混着用、后面挂一串表达式、三目运算符嵌套三层、位运算用在不合适的地方。这些问题平时安安静静一旦触发就是那种“看半天不知道哪一步跑偏了”的隐性bug。写代码的人心里都清楚运算符不是背会一张符号表就结束了它背后是一整套运算规则——优先级、结合性、类型转换任何一个环节理解不到位结果就会和你预期的差之千里。这篇文章我不想写成教科书式的目录罗列而是从一个常年在项目里排查这类问题的人的角度把六大运算符家族怎么用、为什么这么设计、实际项目里最容易踩的坑都摊开讲。适合谁看呢一类是刚学完基础语法、想扎扎实实写对表达式的新手另一类是写了几年业务代码、想提升代码质量和代码评审通过率的中级前端。看完之后至少遇到“判断字符串是否包含”“运算符优先级导致结果不对”“负数取余得到负数”这类问题时你能直接判断出原因而不是靠蒙。1.1 运算符不是“记住符号”而是一套语义模型很多教程把运算符列表往读者面前一丢然后说“记住优先级就行”。但真正吃过亏的人会明白运算符背后是ECMAScript规范里的运算语义。比如说号它的行为完全取决于操作数的类型两个数字相加是数学运算一旦出现字符串它就变成了连接操作剩下的操作数还会被隐式转成字符串。这种“看类型下菜碟”的行为就是运算符语义模型的一部分。理解了这一点再看[] {}在控制台输出[object Object]你就不会觉得是玄学而是能顺着“对象转原始值再拼接”的规则推出来。再举一个例子很多人看到![]这种表达式会懵其实它只是逻辑非运算符配合数组对象的布尔转换规则。拆开看[]是真值对象![]就变成false。整个过程没一个是“背”出来的全是按规则推出来的。所以我一直建议学运算符的时候别死背要从“类型 优先级 结合性”三个维度去理解。这也是我在下面每一个运算符家族里都会反复强调的三条线。1.2 这篇内容能帮你解决什么问题把六大运算符家族算术、比较、赋值、逻辑、位、三目的整体逻辑讲透而不是零散地记符号。整理运算符优先级和结合性的核心规则给出一张可以放在手边查的速查表。汇总项目里高频踩坑点比如宽松相等、逻辑短路、负数取余、浮点精度。提供几个能直接抄的实用写法比如判断字符串是否包含、用位运算做权限配置合并。我尽量把每个知识点都落在一个具体场景里而不是停留在定义层面。毕竟运算符这东西只有放到真实代码里才有意义。2. 六大运算符家族画一条线把它们串起来2.1 算术运算符号是最关键的分水岭算术运算符看起来简单 - * / % **这六个符号真正写到代码里坑全在和%上。号的行为由操作数决定。两个都是数字时走标准的数值加法只要有任意一个是字符串它就会尝试把另一个也转成字符串然后做拼接。这在动态类型语言里是很好用的特性但也是隐患来源。比如1 2结果是12而不是3。如果你在写表单逻辑时没注意输入框的值是字符串就会出现这种“明明想算加法结果却变成拼接”的情况。我见过一个搜索筛选功能价格区间传参时忘了Number()转换结果100 200变成100200筛选结果自然一塌糊涂。%取余运算符很多人只看过正整数的例子比如10 % 3等于1。但遇到负数时规则就变成“余数的正负跟随被除数”-10 % 3得到-110 % -3得到1。做分页、轮播图、环形队列这类场景时负数取余的结果经常让人困惑我在4.4节会专门讲修正方法。**是幂运算ES2016才引入的等价于Math.pow但写起来更直观比如2 ** 10就是1024。顺带提一个冷知识**在一部分老版本浏览器里不被支持所以如果要兼容老旧环境还是得用Math.pow。对于算术运算我还有一条强烈建议所有可能涉及浮点加减的代码都要对结果做误差评估。JS采用的是IEEE 754双精度浮点数0.1 0.2的结果并不是0.3而是0.30000000000000004。只要你能接受“用toFixed保留指定位数”或“乘以10的幂换算成整数运算”的方式这类问题基本都能绕开。具体怎么写我放在4.1节里。2.2 比较运算符和要彻底分清比较运算符包含 ! ! 。其中和!在比较时会先做类型转换而和!不会。区别就是这么一句话但影响极其深远。讲个真实场景。我评审代码时经常看到有人写if (res.code 0)这在小程序、接口请求里非常常见。问题是如果后端返回的是字符串0也能通过看起来方便实际上是把类型错误掩盖了。一旦后端某天返回了nullnull undefined在宽松相等规则下会返回true判断逻辑直接就出错了。所以我个人的习惯是95%的场景都用需要用到时一定是明确需要“数字0与字符串0都算相等”这种跨类型比较的场景。、、、这四个关系运算符同样有类型转换规则如果两个操作数都是字符串它们会按字典顺序比较如果其中一个不是字符串则会尝试把它们转成数字再比较。这也就解释了为什么10 9返回true——两个字符串比较时按字符逐个比1小于9。如果你在做排序、筛选时没注意就很容易出现数字大小反直觉的结果。我的建议是凡是语义上要比较数值大小先Number()或parseFloat()转成数字再比较避免字符串字典序的干扰。2.3 赋值运算符从到复合赋值赋值运算符最基础的就是它把右侧的值赋给左侧的变量。很多人会忽略在JavaScript中赋值表达式会返回右侧的值这导致了一个容易踩坑的写法在if条件里写if (x 5)这是赋值而不是比较而且因为它返回5条件恒为真。我曾经在一个维护项目里看到这种代码当时那个位置的业务逻辑一直异常排查半天最后发现就是多打了一个。后来团队规范里就明确禁止在条件表达式里使用裸赋值。复合赋值运算符、-、*、/、%、**本质上是一个“读取原值—计算—再赋值”的语法糖。在绝大多数情况下a 1和a a 1是等价的。但要注意一个容易忽略的点如果a是对象属性那么a 1和a a 1在底层对getter和setter的调用次数会不一样。不过这种场景很少见普通业务代码不用过度担心记住常识就行。ES6的解构赋值也属于赋值运算的一种高级形态。const { a, b } obj和const [x, y] arr这种写法能把对象属性和数组元素一次性取出来在函数参数、React组件props里用得极多。它本身不是新运算符但理解成“按模式匹配后进行赋值”对阅读函数式代码很有帮助。尤其是写[a, b] [b, a]这种交换写法时你就能体会到解构赋值省略临时变量的爽快。2.4 逻辑运算符短路求值才是精髓逻辑运算符包括、||、!后来又补充了空值合并运算符??。这三个二元运算符都有一个共同特点它们并不总是返回true或false而是返回决定表达式结果的那个操作数。这就是“短路求值”。具体来说a b在执行时如果a是假值整个表达式的结果就是a本身b根本不会执行如果a是真值才会接着运算b并返回b。a || b则相反a是真值就直接返回aa是假值才返回b。这个特性在写默认值、配置项时非常好用const name user.name || 匿名。但陷阱也在这里如果user.name是空字符串它也是假值会被替换成匿名。所以后来ES2020引入??运算符它只在左侧是null或undefined时才取右侧空字符串、0、false都不会被替代。从语义上讲??更适合“取默认值”||更适合“取真值”。!是逻辑非它会把操作数先转成布尔再取反。连续的!!就常见于把任意值转成纯粹布尔值的技巧比如!!abc是true!!是false。这种写法在判断值是否存在时很简洁但也要注意别滥用因为可读性确实不如直接写Boolean(value)。另外还常用于“存在才执行”的模式比如callback callback()。这在封装组件、事件回调时很实用能省掉一个if判断。但千万别把当成“必须执行某件事”的开关去写业务逻辑否则当左侧是0或空字符串时右侧一样不会执行结果和预期差很多。2.5 位运算符看起来冷门用对了很香位运算符包括、|、^、~、、、。它们先把操作数转成32位整数再按二进制位运算。日常业务代码中直接用位运算的场景不多但一旦遇到权限系统、多状态标记、性能热点它们能发挥很大作用。举个实际例子在做权限系统时可以用一个整数表示多个权限的叠加。比如读权限为1 0即1写权限为1 1即2执行权限为1 2即4。要判断某用户是否有写权限只需要(permission 2) ! 0要追加一个权限用permission | 2要移除一个权限用permission ~2。这套思路比维护字符串数组更快、也更省空间。我在一个数据处理工具里用这种方式管理多个开关状态字段从三个独立boolean变成一个整数接口存储和传输都轻了一截。和在有些场景里能用来代替乘除2的幂。比如i 1等于i * 2i 1等于Math.floor(i / 2)。虽然JavaScript引擎对*和/的优化已经很好但在实现二分查找、图像处理算法等极致性能的循环里位运算的写法仍能带来肉眼可见的提升。不过要注意位运算只能安全处理32位整数范围内的数值超出范围就会出问题。是无符号右移它会把符号位也一起移掉结果总是非负整数。这个运算符在颜色值处理里很常见比如把一个ARGB整数拆成R、G、B通道就可能用到。也因为结果总是非负它能帮你规避负数右移后得到巨大正数的诡异情况。2.6 三目运算符简洁与可读性的平衡三目运算符条件 ? 表达式1 : 表达式2是JavaScript里唯一的“三目运算”。它的特点是简洁但容易写出天书。我在代码评审中比较反感的写法是三层以上嵌套比如const type a ? b ? c : d : e ? f : g;这种代码任何人看都会头疼包括当时写下它的自己。我的建议是条件分支超过两层就把其中一部分抽成函数或者用switch、Map结构替代。宁可多写几行也不要让读代码的人猜谜。不过如果只用一层三目运算符其实是提升表达力的好工具。比如const message error ? 出错了${error} : 一切正常;它就比等价的if...else短得多而且语义一目了然。还有一点三目运算符的优先级非常低只比赋值运算符和逗号运算符高一点所以在和、等组合时一定要加括号。比如a ? b : c ? d : e实际上会被解析成a ? b : (c ? d : e)如果不加括号很容易产生误解。我通常把三目表达式整体用括号包起来放进模板字符串或拼接表达式里这样既清晰又安全。3. 实操环节优先级、结合性与真正能拿来用的写法3.1 优先级速查表从高到低的排列逻辑运算符优先级决定了表达式里谁先算谁后算。这里给一张精简但覆盖核心的速查表按优先级从高到低排列优先级运算符说明1()括号最优先2.[]()成员访问、下标、函数调用3new带参数列表的实例化4--!~-typeofvoid一元运算符5**幂运算6*/%乘除取余7-加减8位移9ininstanceof关系比较10!!相等比较11按位与12^按位异或1314逻辑与1516??空值合并17?:三目18等赋值19,逗号这张表不用背但需要“感知”。我记得有一次解析一个复杂表达式a b * c d如果没有优先级概念很多人会以为是(a b) * c再和d做逻辑与实际上*比优先比优先所以应该是(a (b * c)) d。这类问题在算法题和真实代码里都会遇到用括号永远最稳。我的经验是项目里写复杂表达式时一律用括号把意图包起来。括号不是给浏览器看的——浏览器解读规则清清楚楚——而是给下一个读代码的同事看的让优先级不再靠推测。代码评审中我只要看到没有括号的混合运算都会建议补上。3.2 结合性从左到右还是从右到左优先级决定了谁先算结合性则决定同级运算符怎么分组。绝大多数运算符是左结合也就是从左往右算比如a - b - c等于(a - b) - c。但有几个例外要特别注意。赋值运算符是右结合。a b c实际上是a (b c)先把c赋给b再把结果赋给a。这其实很符合直觉因为赋值表达式的值是右侧的值。三目运算符也是右结合a ? b : c ? d : e等于a ? b : (c ? d : e)这个在第2.6节提过。还有幂运算符**也是右结合2 ** 3 ** 2等于2 ** (3 ** 2)也就是2 ** 9结果是512而不是(2 ** 3) ** 2的64。这一点很多人会忽略写幂运算连续嵌套时特别容易踩。结合性本身不难但配合优先级就容易出问题。比如!a instanceof b这种写法!的优先级比instanceof高所以它是(!a) instanceof b而不是!(a instanceof b)。如果你想表达后者就必须加括号。我建议在写复杂判断时先想想它们在优先级表上的位置再决定是否需要括号别总依赖编辑器高亮。3.3 判断字符串是否包含四种常见写法这是前端几乎天天遇到的需求。核心写法有这么几种str.includes(目标)ES6引入返回布尔值语义最清晰推荐优先使用。str.indexOf(目标) ! -1老写法返回找到的位置或-1兼容性最好。new RegExp(目标).test(str)适合需要忽略大小写或匹配复杂模式时。str.startsWith(前缀)/str.endsWith(后缀)专门判断开头和结尾。如果顺序写反比如目标.includes(str)大部分场景依然能工作但语义不对而且当str是正则或复杂对象时可能产生类型转换问题。所以判断包含时字符串在前、子串在后这个顺序要写对。这是新手比较容易犯的错。遇到需要忽略大小写的场景最简单的方式是str.toLowerCase().includes(target.toLowerCase())再高级一点用正则new RegExp(target, i).test(str)但如果target来自用户输入直接放进正则里可能产生注入或特殊字符问题需要先做转义。一个常用的转义函数是function escapeRegExp(str) { return str.replace(/[.*?^${}()|[\]\\]/g, \\$); }顺便提醒一句如果你在循环里用带g标志的全局正则做test要注意它的lastIndex会变化导致同样的字符串两次判断结果不同。这也是正则判断里最容易忽略的状态问题。3.4 项目里最好用的几个简写与边界先说默认值。||和??的取舍在第2.4节讲过这里再给一个实战建议如果配置项可能为0、空字符串、false就绝不能用||做默认值。比如分页组件的pageSize配置写成pageSize || 20当用户显式传0时会被错误地改成20这就很闹心。这种情况用??只有在null和undefined时才取默认值。如果你还在维护老代码至少要记住这种场景的坑。再说数字取整。经常有人用Math.floor但也有些位运算技巧能做到。~~3.7是33.7 | 0也是3。问题是负数场景~~(-3.7)是-3向零取整而Math.floor(-3.7)是-4向下取整。所以“快捷取整”要小心负数别把结果方向搞反。我在处理Canvas坐标时常用~~取整但只用于已知非负数值的场景。还有数组长度判断。if (arr.length)比if (arr.length 0)写起来更短但可读性稍差团队规范如果强调表达能力建议写完整比较。另外可选链运算符?.虽然不是经典运算符家族里的成员但它已经非常普及。obj?.a?.b在访问链上遇到null或undefined时直接返回undefined避免了一长串if判断。它和??搭配时有一条重要规则如果?.左侧是null或undefined链上短路后面不再执行访问操作这非常实用。4. 常见问题与排查技巧实录4.1 经典翻车现场0.1 0.2 不等于 0.3这是一个被讲烂了但又必须再讲一次的问题。在JavaScript中0.1 0.2的结果是0.30000000000000004。我第一次遇到时也很懵后来才明白这是IEEE 754双精度浮点数的固有精度限制——计算机用二进制表示十进制小数很多小数无法精确表示只能存一个接近值。解决方式通常有三种用toFixed(n)保留固定小数位注意toFixed返回的是字符串需要转回数字。先放大为整数再运算(0.1 * 10 0.2 * 10) / 10结果精确。引入第三方精度库比如big.js或decimal.js涉及金额计算时强烈推荐。我给你一个简单的金额加法封装function decimalLen(num) { const str String(num); return str.includes(.) ? str.split(.)[1].length : 0; } function add(a, b) { const factor 10 ** Math.max(decimalLen(a), decimalLen(b)); return (a * factor b * factor) / factor; }这个封装的思路就是“先变整数再运算最后还原”能避开绝大多数精度问题。要注意的是a * factor这一步在数字特别大时也可能出现精度波动所以如果涉及超大金额还是直接上精度库更省心。4.2的隐性转换到底在干什么宽松相等在比较不同类型时有一套复杂的规则如果一个是字符串、一个是数字字符串会先转成数字如果一个是布尔值布尔值会先转成数字然后继续比较对象则会调用valueOf或toString转成原始值。最经典的诡异例子是[] ![]结果为true。拆开看![]是false[]是空数组会比较原始值空数组转成空字符串空字符串再转成数字是0false转数字也是0于是成立。这种规则虽然也算自洽但对人脑极不友好。团队规范里几乎都要求用。如果你在代码里看到我的提醒是先停下来问一句这里真的需要跨类型判断吗如果不需要马上改成。还有一个常见场景是接口返回的布尔值有时会是true字符串或1数字。处理这类数据时我建议在解析层就统一转成标准布尔值而不是在业务判断里依赖兜底。这样既避免隐性转换的坑也让数据结构变得清晰可预期。4.3 短路求值带来的两个陷阱和||的短路特性很强大但它会带来两个容易忽略的问题。第一个问题是返回值可能不是布尔值。写const result a b时result可能是a也可能是b。如果a是一个对象、b是一个数字那result的类型会飘忽不定。这在判断条件时没问题但如果后续对result做了加减运算就容易出现对象或undefined参与运算的报错。我在重构一个老项目时就遇到过原本是把user user.score当成数字去累加当user为null时累加就变成了null 10好在前端渲染没炸否则又是一场线上事故。第二个问题是链式写法隐藏逻辑错误。比如if (user user.name user.name.length 0)能跑但读起来费劲。我建议优先用可选链if (user?.name?.length)。??的坑也要单独提它不能和||或直接混用除非加括号因为JS规定??和这两个运算符一起使用时不带括号会报语法错误。比如a ?? b || c直接写是SyntaxError必须写成(a ?? b) || c。这个限制最初让不少人困惑但仔细想想它是在逼迫你把“空值合并”和“布尔逻辑”的语义边界划清楚其实是好事。4.4 取余运算与负数的坑%在JavaScript中不是数学意义上的“模运算”而是“求余运算”结果的正负跟随被除数。举个具体例子-5 % 3在JS里结果是-2但在一些其他语言或数学语境下-5对3取模的结果是1。原因在于JS的实现是“先做除法取整再用被除数减去商乘以除数”。因此如果你在轮播图、环形队列里希望得到非负下标不能直接写index % length否则负索引会返回负数。一个修正写法是function mod(n, m) { return ((n % m) m) % m; }mod(-1, 5)的结果就是4符合环形结构走一圈再退一位的直觉。这个坑在Canvas动画的粒子回环、分页器指针回退时特别容易踩。调试时看到负数下标先别怀疑算法先检查取余是不是没做修正。我之前写一个无限滚动的数据列表上一页索引算错就是在mod函数这里栽的跟头。4.5 看控制台如何快速断案遇到运算符相关的神秘结果最好的调试工具不是反复读文档而是浏览器控制台。直接把表达式拆开打出来看console.log(10 5); // 105 console.log(10 - 5); // 5 console.log([] []); // console.log({} []); // [object Object] console.log(1 true); // true console.log(1 true); // false console.log(10 9); // true每一个结果背后都对应一条运算符规则。看到输出之后再对照优先级表和类型转换规则问题基本就浮出水面。我调试复杂表达式时习惯把每一层拆成中间变量比如const temp1 b * c; const temp2 a temp1; const finalResult temp2 d;这样做的好处是每个值都能单独验证定位准确而且代码评审时也更好解释。5. 我实际用过之后沉淀下来的几条经验写到这里我觉得最值得分享的不是某条语法而是几条贯穿项目全周期的习惯。第一条表达式里的括号永远别嫌多。优先级表我可以看但项目里的代码是给人读的能用括号把运算意图锁死就能少跑好几轮调试。尤其在三目、位运算、逻辑运算混合的表达式里括号是保护同事和自己的第一道防线。第二条不要为了“看起来酷”去用位运算。除非在性能热点或权限系统这种天然场景否则普通业务代码用位运算只会增加理解成本。工具讲究匹配场景不匹配的简洁是不可读。第三条判断类条件尽量写清晰、写完整。if (str.includes(关键词))这种语义明确价值远高于if (str.indexOf(关键词) -1)。好的表达式就像好的注释读一遍就知道代码在干什么。第四条遇到可疑结果先在开发者工具控制台里把表达式拆开验证。JS的交互式环境特别好用[] {}、1 true、10 9直接打出来看比闷头读规范快得多。这也是为什么我坚持认为运算符是JavaScript里最值得回头补课的基础模块之一。很多高级特性——闭包、原型链、异步——其实都建立在“表达式到底怎么求值”这个底层模型上。把这些弄扎实后面学什么框架都不容易慌。
返回列表