
JavaScript 里有这么一对运算符看着简单实际上坑多得很自增和自减--。我面试前端的时候问一句“a和a有什么区别”十有八九能答上来但把问题升级成“let b a a的结果是多少”绝大多数人当场卡壳。这说明大部分人只是记住了口诀并没有真正理解这两个运算符背后的求值顺序和隐式转换规则。这篇不打算让你背公式而是把自增自减的求值机制、类型转换、优先级、实际场景和工程规范一次讲透。新手可以把它当系统入门写了好几年代码的老手也建议耐心看完后半段你可能会发现自己之前踩过的坑根源就在这里。1. 前置与后置一个运算符两个值1.1 理解“求值结果”和“变量当前值”是两个东西很多人在第一次接触自增自减时只知道“a是先用再加”这句话。这个口诀不算错但它掩盖了一个最关键的坑一个自增表达式运行完之后会产生两个完全不同的数值——一个是表达式本身的值也就是“求值结果”另一个是变量被修改后的值也就是“当前值”。大多数人混淆就是因为把这两个数值当成了同一个东西。我打个比方后置自增a就像你去柜台办业务先取一张旧号牌拿到手里窗口里的排队号才会往上跳一位你手里拿到的号是旧的系统里的号已经变成新的。前置自增a则相反像手机先充值成功再把余额亮给你看。同样是“拿来用”看到的数字新旧完全不同。这句口诀拆开成代码就是// 理解模型后置 a语义上等同于 let tmp a; // 先保存旧值 a a 1; // 再改变量 // 表达式的值 tmp变量的值 a // 理解模型前置 a语义上等同于 a a 1; // 先改变量 // 表达式的值 a变量的值 a请注意这里说的是“语义上等同”不是引擎的真实内部实现。V8 肯定不是这么几个步骤硬算的但你要在脑子里建立这个模型所有自增自减问题都能迎刃而解。1.2 逐步推演后置先取快照前置先改状态直接看例子let a 5; let r1 a; // r1 5a 6 let r2 a; // r2 7a 7第一行a引擎先记录a当前的旧值 5把这个 5 作为表达式的返回值交给r1然后才执行a 6。所以r1是 5a是 6。第二行a引擎先把a从 6 变成 7然后把新值 7 作为表达式的返回值交给r2。所以r2是 7a也是 7。用表格整理一下代码表达式返回给外部的结果变量自增后的值本质动作a56先取快照再自增a77先自增再取新值这个差异单独看不难但一旦放进复杂表达式比如第 3 节的a a你要是不把“求值结果是哪个数、变量现在是多少”分开记必然算错。建议自己动手写几组数据每次都在纸上标两列一列是表达式返回的值一列是变量当前的值多推几轮就有了手感。另外强调一句自增自减只对变量、对象属性、数组元素这些“能赋值的地方”生效比如5这种写法是语法错误因为5不是一个可变的存储位置。这一点到第 3 节讲a时还会展开。2. 隐式类型转换自增自减不是简单加一字符串和数组都在坑里2.1 ToNumber 的转换规则速查表自增自减本质上是一元操作先把操作数强制转换为数字再执行 1 或 -1。所以重点在于你得知道 JavaScript 的“强制转换数字”到底怎么转。引擎内部走的是ToNumber抽象操作字符串、布尔、null、数组、对象各有各的脾气。我整理了一份速查表你可以直接收藏输入例子转换结果自增后的值纯数字334数字字符串101011空字符串01非数字字符串helloNaNNaN布尔 truetrue12布尔 falsefalse01nullnull01undefinedundefinedNaNNaN空数组[]01单元素数字数组[3]34多元素数组[1, 2]NaNNaN普通对象{}NaNNaN为什么数组会有这么多花样因为对象和数组在转数字前会先执行ToPrimitive优先调用valueOf()如果结果不是原始类型再调用toString()。空数组[].valueOf()返回数组本身不是原始类型所以继续走String([])得到空字符串空字符串转数字是 0。[3]走String([3])得到3转数字就是 3。而[1,2]转成字符串是1,2这显然不是一个合法数字于是得到 NaN。2.2 实测各类“非数字”类型执行自增后的结果直接在控制台跑一遍印象会深得多let s1 10; s1; console.log(s1); // 11而且类型已经变成 number let s2 abc; s2; console.log(s2); // NaN let flag true; flag; console.log(flag); // 2 let n null; n; console.log(n); // 1 let u undefined; u; console.log(u); // NaN let arr [5]; arr; console.log(arr); // 6这里有个特别容易忽略的后果自增自减会把变量的类型改掉。10是字符串跑一次s1之后s1变成了数字 11。如果后续代码还用字符串方法比如s1.includes(1)直接报错。这就是所谓的“类型漂移”动态语言的隐性陷阱之一。大多数实际工程里自增自减导致的问题不是“数值不对”而是“类型变了导致后续行为全乱”。还有一个值得注意的细节十六进制字符串也可以转数字。0x10转数值是 16所以自增之后是 17。你可能会在解析颜色值或二进制配置时遇到这种输入心里有个数就行。2.3 NaN 的传染性与排查方向NaN 虽然是“不是一个数字”的意思但在类型上仍然属于 number。一旦某个变量自增后变成 NaN它参与的后续任何算术运算都会继续输出 NaN而且你很难直接看出来是哪一步出的问题。NaN还是 NaNNaN 1也是 NaN它就像病毒一样污染整条计算链。排查时有个高效方向去检查那些“看起来应该是数字、实际上却被隐式转成 NaN”的输入。最常见的是纯字符串、对象、多元素数组。比如后端接口返回的某个字段本来是数字结果空值时给的是或null虽然是 1、null是 1这些反而不容易出大问题真正危险的是字段被塞了个对象或者拼接成了1,2这种形态一个自减下去直接 NaN。顺带提一句自增自减走的是ToNumber和宽松相等里的转换规则并不完全一样别把两者混为一谈。真要判断一个字符串里包含什么用includes之类的明确方法不要依赖隐式转换。3. 优先级和语法边界ab 与 a 为什么让人崩溃3.1 运算符优先级先算谁后算谁自增自减在 JavaScript 运算符优先级表里属于“一元运算符”优先级非常高比乘除、加减都要高。这意味着在一个表达式里和--会优先执行。比如let a 10; let result a * 2 1;很多人以为a整体要等整个表达式算完才生效其实不是。a作为一个后缀表达式求值顺序上会先取a的旧值 10让a变成 11然后拿着旧值 10 去参与10 * 2 1所以result是 21。这里要区分两个层面变量的值是立刻更新的但表达式返回的旧值会留在原地继续参与后续运算。如果不把这个机制想清楚只要表达式中再出现第二个a你就很容易被“现在的 a 到底是多少”绕晕。3.2 连续自增表达式的推演let b a a现在来看开头那道面试题let a 5; let b a a; console.log(b); // 12逐步推演先执行a表达式返回旧值 5变量a从 5 变成 6。再执行a这时a已经是 6先自增变成 7表达式返回新值 7。两个表达式的结果相加5 7 12。所以b是 12而a最终是 7。很多人的第一反应是“右边的 a 难道不也是 5 吗”因为他们潜意识里把两次自增都建立在同一个初始值上忽略了求值是从左到右顺序发生的。这是所有连续自增题的核心考点第一次自增已经把变量改掉了第二次再读取这个变量时拿到的是已经更新过的值。反过来如果你写成前置在前let a 5; let b a a; // a 返回 6a 6 // a 返回 6a 7 // b 6 6 12结果同样是 12但过程完全不同。这说明光记住“结果等于 12”没有意义你得能推出每一步变量状态才算真的懂。3.3 词法解析的“最长匹配”原则ab 到底怎么断句JavaScript 的词法分析器有一个规则能匹配多长就匹配多长maximal munch。所以ab会被解析成a b而不是a b。看代码let a 5; let b 10; let result ab; // 等价于 (a) b console.log(result); // 15 console.log(a); // 6a返回 5a变 6再加上b的 10结果是 15。如果你把同样的代码加个空格比如a b那就是a (b)结果变成5 11 16。字符完全一样只因为空格位置不同结果完全不同。这类写法在真实代码里应该尽量避免。代码规范里通常会要求给运算符两边加空格像a b这样写清楚。遇到模棱两可的表达式别指望阅读的人去翻规范用括号把意图钉死。3.4 a 会报错的真正原因我们常看到有人写a然后一脸困惑为什么控制台报错。报错信息通常是ReferenceError: Invalid left-hand side expression in prefix operation。原因是这样自增自减的操作数必须是一个“可赋值的表达式”规范术语叫LeftHandSideExpression。简单理解操作数得是变量、对象属性、数组元素这种能被赋值的位置。a虽然整体是一个表达式但它的值是数字不是一个可赋值的位置所以不能继续作为的操作数。a会被解析成(a)括号里的a不是 LeftHandSideExpression直接触发解析错误。同样的道理也适用于a----之类写法看着像可以连续减实际上都不合法。记住一个核心边界自增自减的结果不能作为另一个自增自减的操作数。4. 真实项目里的自增自减循环、索引、计数器到底该怎么写4.1 for 循环里 i 和 i 有没有性能差别很多初学者纠结过一个问题for 循环里到底写i还是i早年某些语言里前置自增减因为少一次临时变量的保存理论上更快于是不少 C/C 程序员养成了“一律写 i”的习惯。但在 JavaScript 里这个性能差异在现代引擎下基本可以忽略不计。V8 等引擎会做优化i和i在循环单独使用的场景里生成的机器码几乎一样。所以我的建议是循环里跟着团队规范走别搞个人英雄主义。如果你看代码库里全是i那就统一i如果项目启用了 eslint 的no-plusplus那干脆连i都别用改成i 1。不过语义上的区别依然存在。循环体里如果有别的代码用到 i 的返回值就要小心了。比如const arr [1, 2, 3]; let i 0; while (i arr.length) { console.log(arr[i]); }这里arr[i]会先取i当前的值作为索引再让i自增。如果换成arr[i]第一个访问的索引就会变成 1数组第一个元素直接被跳过了。你写 while 遍历时很容易在这里栽跟头。4.2 用自增当索引数组填充和反向遍历的经典写法自增自减作为索引操作符最大的价值就是“一行做两件事”既访问当前位置又把游标挪到下一个位置。典型场景是填充数组const arr new Array(3); let i 0; arr[i] a; // 等价于 arr[0] a arr[i] b; // 等价于 arr[1] b arr[i] c; // 等价于 arr[2] c反向遍历时i--更是好用到飞起。比如从数组尾部开始处理直到下标越界const arr [a, b, c]; let i arr.length; while (i--) { console.log(arr[i]); // 依次输出 c、b、a }这个while (i--)的套路很经典开始循环前i arr.length也就是 3。进入循环条件判断时i--先返回旧值 3真然后i变成 2所以第一次循环体里访问的是arr[2]。第二次判断时i--返回 2随后i变 1访问arr[1]。第三次返回 1i变 0访问arr[0]。第四次判断时i--返回 0假循环结束。输出顺序正好是 c、b、a。注意这个写法把“访问最后一个元素”和“判断是否还剩元素”合并在一起阅读代码的人需要反应一下。我建议只在你自己能讲清楚、团队能接受的情况下使用。4.3 闭包计数器与前置返回的实用场景自增自减在计数器场景里恰好能体现“返回旧值”和“返回新值”两种语义的差别。比如你要实现一个计数器每次调用inc都返回自增后的最新数量就必须用前置function createCounter(start 0) { let count start; return { inc() { return count; }, dec() { return --count; }, get() { return count; } }; } const counter createCounter(5); counter.inc(); // 6 counter.inc(); // 7 counter.dec(); // 6如果这里误写成count那么第一次调用inc()返回的就是旧值 5计数器的对外表现就错了。类似的还有生成全局自增序号let requestSeq 0; function nextRequestId() { return requestSeq; }这里用requestSeq而不是requestSeq意味着从 1 开始编号避免出现id 0的尴尬。如果你用requestSeq第一次返回 0往往需要额外判断边界。这种“要不要从 0 开始”的偏好就是前置和后置在实际工程中最常见的取舍点。再比如防抖节流里有些实现会用时间戳差值判断而不是自增但如果你要统计“第几次触发”闭包里的count和count就派上用场了。核心就一句话需要立即用新值写前置需要保留旧值再后移写后置。5. 高频面试题与工程规范会写不算懂能说清才是真懂5.1 几个必考表达式逐步推演面试里最常出现的自增自减题无非是几类连续自增减、混合类型、以及把自增减塞进数组索引。我挑三道你可以自己先算一遍再对答案。第一题let a 3; let b a a--; console.log(b); // 7 console.log(a); // 3推演a返回 3a变 4然后a--返回 4a变 3b 3 4 7。最后的a又回到初始的 3看起来像“绕了一圈”容易给人“什么都没发生”的错觉但表达式中间值其实被用掉了。第二题let x 10; x; console.log(x); // 11字符串10被ToNumber转成数字 10自增后是 11而且x的类型变成了 number。这道题考的是隐式转换很多人会想当然觉得字符串拼接结果是101那就错了。第三题let arr [2]; arr; console.log(arr); // 3数组[2]转字符串2再转数字 2自增后是 3。面试官要是再往下问一句“为什么不是 NaN”你就能顺带把ToPrimitive讲一遍印象分直接拉满。5.2 ESLint 为什么建议禁用 no-plusplus很多团队在 ESLint 里开启no-plusplus规则要求禁止使用和--统一改成x 1或x - 1。这条规则激进吗有一点。它的初衷是好的自增自减语义太隐晦尤其在团队协作里阅读代码的人必须额外计算上下文才能确定某个变量的状态极易踩坑。我不建议照搬“一律禁用”的极端做法但强烈建议团队统一边界。可以定两条内部规则自增自减只允许出现在独立语句中比如for循环的更新表达式、单独一行的计数器操作。禁止把、--塞进复合表达式比如return list[i].value、obj[--index]这类写法全部拆开写。我自己在实践中一般这样改造// 不推荐可读性差坑多 const value list[i].data arr[--j]; // 推荐拆开写每一行都直白 const current list[i].data; i 1; const tail arr[j - 1]; j - 1; const value current tail;损失一点打字速度换来的是同事少薅几根头发值得。5.3 我在实际项目中的使用习惯最后聊点个人经验。我写了不少年的 JavaScript见过各种因为自增减翻车的线上问题。印象最深的一次是一个老项目里有人在循环里写了arr[i] data结果另一个同事在循环体末尾又写了一遍i导致索引跳着走数据错位。问题极小排查却花了一下午。从那以后我对自增减的态度就变成了能用顺序语句说清楚的绝不追求一行写法。但我也不是完全不用。处理索引、游标、计数器这些对“新旧值”有明确需求的地方自增减确实简洁高效。我的习惯是单独使用跟随团队风格无所谓i还是i需要立刻拿新值用i或--i需要先拿旧值再推进用i或i--一段逻辑里出现多个自增减宁可拆行多写几行绝不硬塞一个表达式。自增减这个知识点真正难的不是记住规则而是能在复杂的表达式里时刻分清“表达式返回的值”和“变量此时的值”。把这个思维练成本能什么a a、什么ab一眼就能看出答案。基础语法这东西平时看着不起眼关键时刻就是能卡住人。希望你读完这篇以后再碰到自增减心里不虚。