
自增和自减--在 JavaScript 里可以说是存在感极强却又最容易让人翻车的两个运算符。很多人刚开始学的时候觉得简单不过就是 i 和 i 嘛一个先加一个后加。可真到面试题、代码评审或者自己写复杂逻辑的时候经常因为一个 的位置不同结果天差地别。今天我就把这个话题展开聊透从底层原理到实际场景再到那些让你怀疑人生的坑全部梳理一遍。如果你是刚入门不久的 JS 学习者这篇文章能帮你把基础打牢如果你写了几年代码但偶尔也会在 i 和 i 上犹豫那这篇文章同样值得一看。1. 先说清楚自增自减到底解决了什么问题1.1 从计数器这个最基本的需求说起写代码的时候计数器几乎是无处不在的。循环要数次数列表要记下标点击事件要统计次数翻页要计算页码……没有自增自减之前你想让一个数字变大一得写i i 1。这句话本身没问题但它有个小麻烦i在表达式里出现了两次。一旦变量名长一点或者运算复杂一点写起来就啰嗦读起来也要多花半秒钟确认这到底是赋值还是判断。自增和自减这两个运算符就是为了解决“在当前值基础上加一或减一”这个高频需求而生的。i等价于i i 1的简化形式i--等价于i i - 1。它们让代码更紧凑也更贴近“把某个东西往上拨一格”的直觉。你想想翻页场景用户点一下“下一页”页码加一写成page比page page 1清爽多了也更不容易写错。不过简化只是表面。真正让自增自减变得复杂、也让很多人在面试题里翻车的是它同时涉及“取值”和“赋值”两个动作而这两个动作的先后顺序直接决定了整个表达式的值。这就是下面要重点展开的部分。1.2 前置和后置两个运算符两种语义JavaScript 里自增自减各有两种写法一共四个运算符i、i、--i、i--。它们在英语里分别叫 pre-increment 和 post-increment中文一般叫前置自增和后置自增自减同理。先说结论这两者的核心差别是后缀形式i先返回变量当前的值再把变量加一。前缀形式i先把变量加一再返回新值。听起来很简单但很多人只记住了“i 先返回值再自增”这句话却没真正理解“返回值”在表达式中意味着什么。我举个例子let a i这里i的返回值是 i 原来的值所以 a 拿到的是旧值而 i 自己变成了新值。反过来let a ia 和 i 都会是同一个新值。这里有个关键点需要强调无论前置还是后置i这个变量本身的最终结果都是一样的都是加了一。差别只在于“整个表达式返回给外面的值”是旧值还是新值。理解到这一层后面看任何复杂表达式都不会慌无非就是按顺序算出每一步的返回值和变量值然后往下推。2. 核心细节返回值、类型转换和求值顺序2.1 表达式的值i 和 i 的本质区别我遇到很多初学者觉得 i 就是“用完再加”i 就是“加完再用”但在代码里真用起来还是会懵。问题的根源在于他们没把“表达式有值”这件事刻在脑子里。在 JavaScript 里任何表达式都有一个值自增自减也不例外。我们来走一遍内部流程。i这个表达式可以拆成三步第一步读取变量 i 当前的值记为 oldValue第二步把变量 i 更新为 oldValue 1第三步整个表达式返回 oldValue。而i则是第一步读取变量 i 当前的值第二步把变量更新为 oldValue 1第三步整个表达式返回的是 newValue。你注意看第三步返回的东西不一样这是全部区别所在。变量 i 的更新动作在两种形式里都会发生而且都在整个表达式结算之前发生区别只是“返回值选的是旧值还是新值”。很多网上解释会说 i 是“先使用再自增”这句话容易误导好像自增发生在使用之后。实际上自增动作本身是立即执行的只是它“提供出去的结果”是旧值。用代码验证一下let i 5; let a i; // a 是 5i 变成 6 let b i; // i 先变成 7所以 b 是 7i 也是 7如果你在浏览器控制台跑这段代码会看到 a 5b 7i 7。理解了返回值这个机制后面所有复杂表达式都可以用“按步骤推演”的方式解开而不是靠死记结果。2.2 隐式类型转换当字符串遇到自增自减JavaScript 是弱类型语言自增自减并不会管操作数是不是数字它会先尝试把操作数转换成数值再加一或减一。这个转换遵循 ToNumber 的规则平时你写加法遇到字符串是拼接但在自增自减这里情况完全不同。直接看例子let s 5; s; // s 变成 6是数字类型 let t hello; t; // t 变成 NaN let b true; b; // b 变成 2 let n null; n--; // n 变成 -1 let u undefined; u; // u 变成 NaN这里有几个容易忽略的点。字符串 5 会被转成数字 5然后自增变成 6注意变量类型也从 string 变成了 number。空字符串 会先转成 0然后自增变成 1。布尔值 true 转成 1自增后是 2。null 转成 0自减后是 -1。undefined 转成 NaNNaN 加一还是 NaN。所以如果你在页面上拿到的值是个字符串形式的数字比如从输入框里取到的 5直接对它做自增是没问题的结果会变成数字 6。但这里藏着一个易错细节如果这个字符串不是合法的数字文本比如 5px 或者 abc那结果就是 NaN而且变量一旦变成 NaN后续任何运算都会继续产生 NaN排查起来很痛苦。另外对象类型也会先转成原始值再转数字。比如let obj { valueOf: function() { return 5; } }; obj; // obj 变成 6正常情况下我们会先调用对象的 valueOf 方法如果结果不是原始值再调用 toString。这个机制和加法运算的转换逻辑类似但容易被人忽略。实际开发中应该很少直接对对象自增但理解它是“先转换再运算”的过程能帮你避免在一些底层封装里踩坑。2.3 连续自增和混合运算的求值陷阱把前置、后置、加法、赋值混在一个表达式里这是 JavaScript 笔试面试最喜欢出的题型也是最容易让经验不足的人当场卡壳的场景。先别急着背答案我教你一个通用的推演方法从左到右依次处理每个运算符遇到自增自减就按第一步读旧值、第二步更新变量、第三步决定返回值这个流程推进把每一步的“中间变量值”和“当前表达式累计值”记下来。看一个经典例子let i 1; let a i i i;按顺序推演第一个i读到旧值 1i 变成 2表达式的第一个数是 1。接着ii 先变成 3表达式的第二个数是 3。最后一个i读到旧值 3i 变成 4表达式的第三个数是 3。最终 a 1 3 3 7i 4。很多人会算成 1 2 3 6就是因为把第二个i理解成了“返回加之前的值”或者忽略了一次变量的更新。推演的时候一定要记住每次自增自减都会立刻改变变量的值后续的自增自减操作是基于最新值进行的。再来看一个有歧义的写法let a ij;这行代码到底是什么意思JavaScript 在进行语法解析时会尽量把操作符往长了匹配所以ij会被解析成i j即(i) j而不是i (j)。这个规则叫做最长有效匹配在了解这个规则之前这种写法就是纯粹的代码打架现场。我给你的建议是在真实项目里永远不要写这种依赖解析器猜心意的表达式。i和i就单独用不要把它和别的运算符强行塞在同一个表达式的复杂位置里。面试题是面试题生产代码是生产代码后者的首要目标是让下一个维护者轻松看懂而不是炫技。3. 实操场景自增自减在真实代码里怎么落地的3.1 for 循环哪个位置用了哪个语义for 循环是自增自减最经典的舞台for (let i 0; i n; i)这个写法几乎人人都会写。但你有没有认真想过为什么这里的更新表达式用的是i而不是i因为在实际开发中循环变量加一之后我们根本不需要把它的返回值用在哪里。不管前置还是后置循环每轮结束的时候i的结果都一样都是加一。因此在这个场景里两者效果完全等价。但有一个微妙的性能差异值得提一下理论上后置自增需要保存旧值、返回旧值如果被用在大型对象上这个保存和返回的步骤会有额外开销。不过现代 JavaScript 引擎对这种基本场景的优化已经很好了写i和i在 for 循环里性能差异微乎其微不用过度纠结。更值得注意的是循环方向。写倒序循环时for (let i n - 1; i 0; i--)这里用i--没问题。但如果你在循环体里既用下标又同时改变下标那就得格外小心了。比如let arr [1, 2, 3, 4, 5]; for (let i 0; i arr.length; i) { console.log(arr[i]); }这个循环每轮执行时 i 自增一次循环体内部又自增一次所以 i 一次跳两格。数组长度为 5 时输出的内容是 arr[0]、arr[2]、arr[4]然后循环结束。如果你本意是遍历整个数组这就错了。这种“循环变量被二次修改”的问题在真实代码里偶尔会出现一旦出现排查起来很费劲因为看似每一行都对但整体行为就是怪异。3.2 数组下标与遍历利用后置自增的返回值后置自增常常被巧用在数组遍历上。你肯定见过这种写法let i 0; while (i arr.length) { console.log(arr[i]); }这里arr[i]的妙处在于先用 i 的当前值作为下标去取元素取完元素之后 i 自动加一。这样一步就完成了“读当前位置 移动指针”两个操作代码非常紧凑。尤其在处理栈、队列这类数据结构时用arr[i]可以少写好几行。同样的套路也常见于多个参数同步移动的场合。比如同时处理两个数组一个数组用arr1[i]另一个用arr2[i]在统计对应位置的数据时会很顺手。但这里要有个度如果逻辑稍微复杂一点比如在同一个表达式里出现两次arr[i]那阅读的人就得停下来想这到底是同一个 i 还是两个不同的 i可读性急剧下降。遇到这种情况不如老老实实拆分两步写先取值再自增一行变两行但脑子清楚得多。另外一个容易犯的错是在 for 循环里“提前消耗”了自增。比如循环体内有一个函数调用函数内部又对同一个变量做了自增操作回来之后循环的i再执行一次等于每轮跳过两个位置。这个问题的本质是变量作用域设计不清晰但很多实际项目里它就是靠“碰运气”debug 出来的。我的建议是循环变量尽量在循环体内保持只读不要在其他地方对这个变量进行额外修改。3.3 计数器、状态标记和对象属性里的常见用法自增自减不止作用于基本类型变量对对象的属性同样有效。比如一个点击计数的功能let counter { count: 0 }; element.onclick function() { counter.count; };这里counter.count等价于先读取counter.count加一再写回。它是合法且直观的。在 React 或者 Vue 这类框架里如果你处理的是局部状态对象直接对属性自增也同样有效但要小心某些框架的数据响应式系统是否监听到了这种“原位修改”。比如 Vue 3 的响应式是基于 Proxy 的state.count会触发 set 操作没问题但如果你用的是旧版 Vue 2 且没有把字段在 data 里声明就可能导致视图不更新。这种框架层面的坑追根溯源还是自增自减“直接改原变量”的特性引发的。自减也有很实用的场景比如倒计时let timeLeft 10; let timer setInterval(() { if (timeLeft 0) { document.title ${timeLeft--} 秒后关闭; } else { clearInterval(timer); // 执行关闭逻辑 } }, 1000);这里有个非常关键的细节${timeLeft--}在模板字符串里使用的是timeLeft--的返回值也就是旧值。所以第一次输出就是 10然后 timeLeft 变成 9接着输出 9、8……一直数到 1然后 timeLeft 变成 0。如果你在这里写的是--timeLeft那第一次输出就是 9倒计时会从 9 开始而且 timeLeft 归零的判断也会提前发生。一个字符的位置不同用户体验就差了整整一秒。3.4 与解构赋值、逗号表达式结合时的表现自增自减还可以和解构赋值、逗号表达式这类“高级语法”联动产生一些看起来很有技巧性的写法。先看逗号表达式let i 0; let result (i, i * 2); // result 是 2i 是 1逗号表达式会从左到右依次执行各个子表达式整个表达式的值是最后一个子表达式的值。在这个例子里i先执行i 变成 1然后i * 2计算得到 2所以 result 是 2。这种方式在某些封装里可以让你把“自增”和“取值”压缩到一行但可读性一般我建议只在确实需要利用这种求值顺序时才用。再看解构赋值比如交换数组元素let arr [1, 2, 3]; let index 0; let [first] [arr[index], arr[index]];这里[arr[index], arr[index]]会把 arr[0] 和 arr[1] 取出来然后 index 从 0 变成 1再用解构把 arr[0] 赋给 first。但说实话这种写法可读性很差完全不推荐在项目里使用。Array 本身就有很多更清晰的方法比如 shift()、splice()没必要为了这点“巧妙”去牺牲代码的可维护性。4. 常见问题与排查技巧4.1 经典易错题为什么 i 和 i 加在同一个表达式里结果完全不同很多人学完这两者的区别后遇到综合表达式还是容易算错。我给你摆一个典型题看看你能不能一遍算对let x 1; let y x x x-- --x;按前面讲的推演流程一步步来第一个x返回 1x 变成 2。第一个xx 先变成 3返回 3。第一个x--返回当前值 3x 变成 2。第一个--xx 先变成 1返回 1。所以 y 1 3 3 1 8x 最终是 1。如果你算出来的不是 8那大概率是在某个节点把“变量当前值”和“表达式返回值”搞混了。记住每一次自增自减执行后变量立刻变化下一个运算符读到的是更新后的值。推演时可以在纸上画四列左边是表达式步骤中间写变量值右边写累计和这样基本不会错。4.2 哪些场景下不要用自增自减有一点值得单独拿出来讲并非所有加一操作都适合用自增自减。比如你希望一次跳两步i 2是对的但你不能写成i i因为这个表达式的结果不是“连续加两次一”那么简单它还会产生中间值参与运算最终 i 虽然加了 2但表达式返回值是第一个 i 的旧值很容易造成逻辑错误。更直白一点一行代码里多次对同一个变量自增自减是代码风格极差的表现评审时大概率会被打回。另外自增自减只能作用于“可以被赋值的引用”比如变量和对象属性不能直接作用于字面量5会直接报错。函数调用的返回值也不能直接自增比如getCount()是错的因为函数返回值是临时值不是可赋值的引用。正确做法是先赋值给变量再自增。ESLint 里有个no-plusplus规则有些大型团队会禁用自增自减要求统一写成i 1或i - 1。这样做的主要目的是规避“前置后置”带来的认知负担毕竟不是每个写代码的人都能在半夜三点的时候一眼看穿while (i 10)和while (i 10)的区别。如果你的团队接受了这个规则那就老老实实用加法赋值这不是能力问题是项目规范问题。4.3 与其他编程语言的对比这些坑是 JS 独有的吗很多有 C 或 Java 背景的人学 JavaScript 的自增自减会觉得无缝衔接但实际上 JavaScript 有两个典型的差异点。第一个差异是隐式类型转换。在 C 语言里你几乎不会对字符串做自增编译器会直接报错。但在 JavaScript 里5后变量会变成数字 6这种“弱类型”带来的灵活性和危险性并存。你写的时候觉得很顺等 debug 的时候才发现变量类型已经被偷偷改变了。第二个差异是 BigInt 和普通数字混用的问题。在 JavaScript 中BigInt 类型也可以自增let n 1n; n; // n 变成 2n没问题但你写n 1就会报错因为 BigInt 和 Number 不能直接混用。所以如果你在代码里用到了 BigInt想加一用n或者n 1n都是对的唯独n 1会出事。这个细节非常冷门但我在实际项目里真见过有人踩坑排查了很久才发现是 typeof 变成了 bigint 导致的问题。4.4 高效调试技巧如何在复杂表达式里快速定位问题遇到一个包含自增自减的表达式算错时我推荐一个傻瓜式且极其有效的做法把表达式拆开用临时变量记录每一步的结果。比如let i 1; let step1 i; // step1 1, i 2 let step2 i; // step2 3, i 3 let result step1 step2;这样一旦 result 不对你立刻能看出来是 step1 还是 step2 出了问题再对照“返回旧值还是新值”这个原则去复核。这是我在代码评审和带新人的时候反复强调的调试方式。如果你在处理的是字符串转数字后的自增还可以在控制台打印变量类型let s 5; s; console.log(s, typeof s); // 6 number遇到类型相关的诡异问题先typeof一下很多时候答案就藏在类型转换里。自增自减看起来只是加减一底层却牵扯到取值、赋值、类型转换这三件事任何一件没理清楚都会在代码里留下一个难缠的 bug。最后再分享一个我个人的经验凡是看到一行表达式里出现两个及以上自增自减运算符我就直接把这段代码判定为“需要重写”。不是所有工程师都能在高压环境下准确推演这种表达式的值代码本来就是写给后来人看的不是用来证明作者运算能力的。用显式的、一步步的写法替代花哨的压缩写法会让代码的寿命长很多。