ARTICLE DETAIL

资讯详情

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

JavaScript 自增自减运算符完全指南:从表达式语义到踩坑排查

JavaScript 自增自减运算符完全指南:从表达式语义到踩坑排查 先给结论let x 5; let y x;这行代码跑完y是 5x是 6。这不是什么高深的东西但你要问一句“为什么y拿到的是 5 而不是 6”能当场说清楚的人其实没那么多。我见过太多人把和--背成“先加再用、先用再加”这种口诀一旦碰到x x或者a a这种组合表达式照样翻车。原因很简单自增自减这个运算符在 JavaScript 里不仅会改变量的值它本身作为一个表达式还会产出一个值。这两件事的先后顺序决定了所有看似诡异的输出结果。这篇文章我会把js 自增和自减从表达式语义、隐式类型转换、求值顺序、实战场景到踩坑排查全部过一遍。内容不算深但每一节都是我实际写代码、看别人代码时反复踩到过的地方。适合刚学 JavaScript 的朋友打基础也适合准备面试或者写代码写腻了想回头补补基本功的同行。1. 先搞清楚一件事 到底是“改值”还是“表达式”1.1 从一道被问烂的面试题开场let x 5; let y x;这种题目在笔试题里出现的频率高到让人麻木。但很多人只是背住了答案“y等于5x等于6”你换一种问法——把x换成x——他又得重新背一遍。说白了问题出在大家把x当成了一条“命令”而不是一个“表达式”。在 JavaScript 里和--是少数同时具备“返回值”和“副作用”的运算符。它们做两件事副作用把变量本身加 1或减 1这一步直接修改内存里的值必然发生。表达式值整个x或x作为一个表达式会向外部交出某个数值。后置版本x交出去的是自增前的旧值前置版本x交出去的是自增后的新值。两条路最终都会把变量加 1差别只是“向外拿出哪个时刻的数”。打个比方你在记账账本上是 5 块钱。x相当于你先跟别人说“账上现在 5 块”然后默默改成 6x则是先把账改成 6再告诉别人“账上现在 6 块”。账本是同一本动作都是加 1但说出去的时机不一样听到的数字自然不同。1.2 核心差异i 取原值i 取新值用三行代码可以把所有情况摸干净let a 10; let b a; // b 10, 同时 a 变成 11 let c a; // a 先变成 12, 然后 c 12 console.log(a, b, c); // 12 10 12每次执行完之后a都被改了一次区别只在“表达式返回给外部的是哪一刻的值”。这句话如果你能用自己的话讲清楚自增自减的 80% 的坑你就算迈过去了。还有一个高频混淆点当i单独作为一行语句出现时和i完全等价——因为没人去接它的表达式值副作用执行完毕就够了。所以别再直接问“应该用 i 还是 i”要先问自己“这个表达式的返回结果我要不要用”。我见过不少人在循环里纠结i还是i在 for 循环的第三个表达式里两者根本没差别后面我会专门细说。1.3 一个容易忽视的细节自增自减会改变变量的类型这个细节面试很少直接考但实际业务里阴人得很。看这段let s 5; s; console.log(s); // 6 console.log(typeof s); // number和--在执行前会先把操作数做一次 ToNumber 隐式转换。数字字符串5被转成数字 5加 1 后变成数字 6原来的 string 类型直接被覆盖成 number。这一步和二元运算符的行为完全不同5 1是字符串拼接结果是51但5是数值运算结果是数字 6。记住这个对比将来遇到奇怪输出时排查方向会清晰很多。2. 类型转换和隐式行为JS 里的 比你想得野2.1 字符串参与 的两种走向既然自增自减会触 ToNumber 转换那不同类型的操作数参与进来结果走向就有规律可循。先看字符串let s1 5; s1; console.log(s1); // 6 let s2 5.5; s2; console.log(s2); // 6.5 let s3 abc; s3; console.log(s3); // NaN let s4 ; s4; console.log(s4); // 1空字符串和纯空白字符串在 ToNumber 规则里会被当作 0所以 结果是 1。非数字字符串转不出来得到NaN并且NaN再加 1 还是NaN。这一点很关键一旦你发现自增之后变量变成NaN优先怀疑是不是某个字符串或者undefined被卷进来了。2.2 true、false、null、undefined 参与 的结果老规矩直接上表初始值执行一次v后的值typeof 变化true2boolean → numberfalse1boolean → numbernull1object → numberundefinedNaNundefined → numberNaNNaNnumber 不变1nBigIntTypeError抛异常前四个继续走 ToNumber 转换路线true转 1false转 0null转 0undefined转不了所以是NaN。BigInt 是个特殊存在对 BigInt 会直接抛 TypeError因为规范不允许 BigInt 与 number 混用。工作中如果你真的遇到v变成NaN不要盯着代码看半天先打印一下变量类型十有八九是undefined。2.3 浮点数、NaN 与精度问题自增自减对浮点数也照做不误。0.1结果看着像 1.1但你要知道 1.1 在 IEEE 754 的二进制表示里并不精确只是控制台显示的时候帮你做了舍入。如果在做金额、进度条百分比这类精度敏感的计算不要指望和--能给你精确的小数运算。经典例子就是0.1 0.2 ! 0.3自增本质上就是“加 1”同样逃不掉浮点表示的限制。另外NaN这个东西一旦产生它的特点是“粘人”NaN还是NaN你加多少次都醒不过来。所以排查的时候看见NaN要回头找它第一次产生的位置而不是在当前这行纠结。2.4 对象参与 时会触发 valueOf这可能是隐藏的雷这一点很多人没意识到对对象执行自增自减JavaScript 会先做 ToPrimitive 转换——实际就是优先调用对象的valueOf()拿不到原始值时再尝试toString()。看个例子const obj { valueOf() { return 5; } }; obj; console.log(obj); // 6 console.log(typeof obj); // numberobj从一个对象变成了数字 6类型都变了。在实际项目中你不会刻意写这种代码但你可能在浏览别人源码时看到类似模式比如对某个配置对象做计数意外触发了它的valueOf。这个行为再次印证这一节的核心观点和--不是“给变量加上 1”这么简单它们在背后完成了一整套类型转换流程。理解这个流程比记住“字符串加 1 是拼接”这种零散结论重要得多。3. 组合表达式的求值顺序这是翻车重灾区3.1 看看 x x 到底发生了什么如果说自增自减有什么经典名场面x x绝对排第一let x 1; x x; console.log(x); // 1很多人第一次看到输出 1 都愣住以为逻辑上应该“先把 x 加 1再赋值给 x”所以应该是 2。实际执行顺序是这样的先计算赋值符号右边的表达式x。x作为后缀表达式取出旧值 1同时把 x 改成 2。整个x的结果是 1。把 1 赋值给 x于是 x 被覆盖回 1。整个过程里 x 确实变成过 2但随后又被赋值语句用旧值 1 覆盖。所以最终是 1。这个例子极其适合用来检验自己对“表达式值”和“副作用”是否真的分清了。3.2 连续自增与混合自增的求值明白了x x再看稍微复杂一点的组合。JavaScript 的运算规则是子表达式按从左到右的顺序依次求值每求一个值变量同步更新下一个子表达式能立刻看到更新后的结果let a 1; let b a a; // 第一次 a 返回 1, a 变成 2; 第二次 a 返回 2, a 变成 3; b 3 console.log(a, b); // 3 3 let c a a; // 此时 a 是 3; a 先把 a 变 4, 返回 4; a 返回 4, a 变成 5; c 8 console.log(a, c); // 5 8把过程拆开打印会更直观let a 1; console.log(a a); // 3在这个表达式里运算符优先级决定了先算两个a而两个a从左到右依次执行。第一个从 a 取出 1 并把 a 改成 2第二个再从 a 取出 2 并把 a 改成 3最后 1 2 得 3。3.3 优先级与括号何时该拆开写关于运算符优先级自增自减的优先级非常高高于、高于赋值。所以i j会被解释成(i) j。但这里真正值得警惕的优先级案例是i i这类——你确实能靠优先级算出结果但代码给人读的难度会直线上升。我现在的态度很明确只要一个表达式里有多个自增自减并且它们操作同一个变量就直接拆行。比如let b a a这种没有任何实际业务场景需要你写成一行。拆开是两行代码维护成本低一截let temp a; // 取旧值 temp a; // 再加下一次旧值类似地x x永远不要出现在真实代码里。它唯一的贡献就是作为面试题检验基本功。代码是写给同事读的不是写给编译器看的。4. 实战场景从 for 循环到业务计数的正确姿势4.1 for 循环里用 i 还是 ifor (let i 0; i n; i)和for (let i 0; i n; i)在这行代码里效果完全一致因为第三个表达式独立存在没有人消费它返回的值。有些从 C 语言时代过来的老程序员习惯写i理由是老式编译器里i会多生成一个临时变量拷贝。这个理由在现代 JavaScript 引擎里基本不成立V8、JavaScriptCore 早就把这种简单模式优化掉了。所以你在 JavaScript 里选哪个纯粹是风格问题团队规范怎么定就怎么写。真正危险的是在 for 循环体里手动改索引。比如下面这行for (let i 0; i 5; i) { console.log(i); // 循环体会再自增一次 }这段代码的输出不是 0 1 2 3 4而是 0 2 4然后循环体的i和 for 第三步的i叠加导致每次跳两格。经典跳步 bug。我的经验是循环索引只允许出现在 for 的三个表达式里循环体内一律别动它。4.2 数组遍历、游标和计数器的典型写法arr[i]在 C 语言里是很优雅的写法JavaScript 里也能用let arr [10, 20, 30]; let i 0; while (i arr.length) { console.log(arr[i]); }这个小例子输出 10 20 30。但我给新手的建议是除非你确定团队风格接受这种紧凑写法否则老老实实分成两行while (i arr.length) { const item arr[i]; i; console.log(item); }业务里的计数器一般更简单点击统计count、出队索引queueIndex、分页游标offset pageSize。这些场景把作为独立语句使用既安全又自然。真正要小心的是在函数参数里传i或者arr[i]这种“依赖求值顺序”的写法一旦参数顺序有变化行为就可能跟着变。4.3 框架代码里的自增为什么很多项目禁用 打开项目的 ESLint 配置你大概率见过no-plusplus这条规则。Airbnb 的 JavaScript 规范也明确禁止使用和--建议用i 1替代。原因不是这个运算符有 bug而是它能写出极其不易读的表达式就和前面列的那些组合例子一样。写成i i 1或i 1至少不会有人拿错旧值新值。这条规则在 for 循环里通常会被豁免因为i太常见了。如果你所在团队启用了相关规则注意看 for 循环是否允许例外。我在几个团队里都见过这种设置普通语句禁for 循环放行。这是一种务实取舍——把最危险的复杂表达式堵死保留最普遍、最不易出错的用法。4.4 给明确建议到底要不要学 我的态度很直接必须学必须懂但尽量少用。自增自减属于 JavaScript 的基础语法阅读老旧代码、面试、调试别人写的脚本都会遇到。你可以主动选择不写它但看不懂它是不合格的。尤其是x x、i i这类组合虽然工作中几乎不会有人主动写但你得有当场拆解的能力。把这理解成“你可以不开手动挡但不能连什么是离合器都不知道”。5. 常见问题与排查技巧速查5.1 自测八道典型的自增自减题我自己整理了一份小测试覆盖前面提到的常见坑。你可以先不看答案在编辑器里跑一遍或者直接在浏览器控制台验证// 1 let a 1; console.log(a a); // ? // 2 let b 1; console.log(b b); // ? // 3 let s 5; s; console.log(typeof s, s); // ? // 4 let n null; n--; console.log(n); // ? // 5 let x 1; x x; console.log(x); // ? // 6 let i 3; console.log(i i); // ? // 7 let arr [1, 2, 3]; let j 0; console.log(arr[j] arr[j]); // ? // 8 let obj { valueOf: () 2 }; obj; console.log(obj); // ?答案和分析如下题号输出解释13第一个a返回 1a 变 2再加 a 的当前值 2得 324b先让 b 变 2 再返回 2加 b 的当前值 2得 43number/6字符串被转成数字并覆盖类型结果不是64-1null转 0自减后是 -151右边先取旧值 1 并自增到 2赋值又把 1 写回 x68i返回 3i 变 4i先把 i 变 5 再返回 53 5 873两个j分别返回 1 和 2j 最后变成 283先触发valueOf()拿到 2对象转数值后自增成 3做错不要紧关键是做错之后能不能说出“表达式值”和“副作用”这两个词。能说出来说明你抓到了自增自减的命门。5.2 排查与调试的实用技巧看到NaN先怀疑类型。这是最常见的一条。v输出NaN不要盯着这行看往上找这个变量最后一次被赋值的类型是不是字符串或undefined。我用浏览器的 DevTools 排查这种问题时一般直接给关键位置加一行console.log(typeof v, v)比断点调试快得多。复杂表达式优先拆解。如果一段代码里有a b这种组合别急着心算。在控制台里手动执行let a 1; let left a; console.log(left, a); let right a; console.log(right, a);一步步算完再组合误差率会低很多。DevTools 的 Watch 面板里也可以同时监视a的值和a的返回值能直观看到顺序关系。5.3 代码评审时的避坑建议给团队做编码规范时我一般提三条建议第一自增自减速力放在独立语句中使用。比如计数器count、循环变量i这是安全边界超出这个边界就考虑改写。第二实在需要先取值再递增显式写const temp value; value 1;别用value混在复杂表达式里。第三开启 ESLint 的no-plusplus或者至少用no-unused-expressions让工具帮你在代码评审前拦住可疑写法。有个经验数据我在 Code Review 时遇到的“我算不对的表达式”80% 都跟自增自减混在数组下标、函数参数里有关。这些代码几乎都可以无痛改写——不改变行为只是把求值过程拆成显式步骤。可读性上去了bug 自然也就少了。我自己写 JavaScript 也有几年了对和--的态度经历了三个阶段第一阶段是刚学时觉得这就是个偷懒语法能用就多用第二阶段是踩了x x这种坑之后深恶痛绝看到就皱眉第三阶段是现在心态平和了——它就是个普通运算符有明确的行为规则关键是你有没有把它放在一个让这些规则自然显影的语境里。最后再分享一个我实际写代码时默认遵守的小规则和--永远独立成行不参与任何复合表达式如果必须消费“自增前的值”我会改成const temp value; value 1;哪怕多写一行也不心疼。这套笨办法过去几年帮我躲掉了几乎所有自增相关的隐性 bug。你如果也被这类问题折磨过不妨试试。
返回列表