我在后台系统改一处权限判断时,被新同事反问得很不舒服:“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为 false,undefined == 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 和 false
4.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为 true,userPermission & true相当于userPermission & 1。如果用户权限恰好有最低位权限,这个判断就错误通过。
正确写法必须加括号:
if ((userPermission & READ) === READ) { // ... }不要在这类表达式上省括号。权限判断关系到系统安全,宁可多写一点,也不要留下优先级隐患。
6. 特殊运算符:?. ?? typeof instanceof in void
6.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); // "undefined"instanceof检查对象是否在某个构造函数的原型链上:
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 里常见href="javascript: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,你能不能快速判断是隐式转换、优先级、结合性、短路返回类型还是浮点精度的问题。
我现在带人做代码评审时,最常说的话是:“不是让你不用运算符,是让你在关键表达式上别省括号、别省转换、别省中间变量。”
有个小习惯分享给各位:每写完一行复杂表达式,问自己一个问题,旁边同事不查文档,能不能看懂这行在做什么?如果答案是不能,那就拆一行,加个变量名,或者加一对括号。运算符可以帮你写出很短的代码,但合格的工程师往往更关心代码能被下一个接手的人顺利读懂。