
第20天学到 JS 的 Switch 语句、三元运算符和循环说实话这三个东西放在同一天不是巧合。它们在内核上解决的是同一类问题——让程序按条件走不同的路或者把同一段逻辑重复执行若干次。区别只在于表达形式和使用场景的轻重。很多初学者会把这三块当成三份孤立的语法背下来考试能写一到真实代码里就不知道什么时候该用哪个写出来的逻辑能跑但读起来别扭。我写这篇笔记的思路就是不按教材顺序讲语法而是按“你写代码时脑子里的决策顺序”来梳理把 switch 的匹配规则、三元运算符的表达式本质、循环家族的边界条件讲透顺带把那些只有踩过坑才会注意到的细节挑出来。不管你是刚学到控制流的新手还是写了几年 JS 但总觉得某些写法心里没底的同行下面这些内容应该都能直接用上。1. 先把三者的分工掰清楚1.1 它们本质是同一个问题的三种表达条件分支和循环在计算机里属于最基础的控制流JS 只是给了你几种不同颗粒度的工具。if...else是最通用的什么条件都能接switch是if的一个专门化版本专门处理“一个值对上多个候选值”的情况三元运算符是if...else的表达式化形态只能产出值不能容纳多条语句循环则是把“重复”这个动作从代码里抽出来避免你复制粘贴十遍同样的逻辑。理解这个分层很重要因为它决定了选型不是审美问题而是结构问题。我见过不少代码一个 8 分支的switch被硬写成一条长长的if...else if链读的时候要一层层往里钻也见过把复杂的多语句逻辑塞进嵌套三元里一行两百个字符改一个条件要盯屏幕五分钟。这两种都是把工具用错了颗粒度。有个很实用的判断方法如果条件是“某个变量等于某几个固定值之一”优先switch如果条件是区间、复合布尔表达式、存在性判断if更自然如果整个逻辑的产出只是一个值并且分支体只有一行三元运算符最干净。至于循环先问自己“我是在按次数重复还是在按条件重复还是在遍历一个集合”答案基本就把工具定下来了。1.2 选型背后真正的成本是可读性有人会纠结性能switch是不是比if快三元是不是比if快现代 JS 引擎的优化程度已经让这类微差在绝大多数业务场景下没有意义。真正贵的是维护成本。一个分支结构写得清楚后来人改需求时能三秒钟定位到要动的地方写得绕改一次引入一个 bug这个成本远超那点执行时间。所以我的选型原则很简单优先让“结构”自己说话。分支多且平级用switch摊平分支少且条件复杂用if取值场景用三元或直接查表。表驱动是个经常被忽略的选项——如果一个switch每个分支都只是return一个固定值那它其实就是一张映射表用对象字面量或Map反而更短更易扩展。这个思路后面会具体展开。1.3 循环家族的分层逻辑循环这块最容易乱。for、while、do...while是语句级循环管的是“重复执行”forEach、map、filter、reduce是数组方法管的是“对集合做变换”for...in和for...of是遍历语法管的是“把集合里的元素一个个取出来”。这三类不是替代关系而是不同层次的抽象。新手常见的误区是拿forEach当万能循环用结果遇到需要提前退出时抓瞎——forEach里break根本不起作用return也只是跳过当前回调。另一个高频误区是用for...in遍历数组觉得语法短但它拿到的是字符串形式的键还受原型链影响顺序也不保证。这些细节下面会逐个拆。2. Switch 语句匹配规则比语法重要2.1 严格相等匹配与 case 表达式求值switch的语法大家都会写但匹配规则值得单独说。它用的是严格相等比较也就是不做类型转换。这意味着switch(1)不会匹配case 1。这一点在表单场景里特别容易翻车从输入框拿到的值永远是字符串你写case 1:就是永远不命中而且不报错只是默默走到default排查起来很费时间。const raw 2; // 来自输入框是字符串 switch (raw) { case 2: console.log(命中数字 2); break; default: console.log(没命中); // 实际走这里 }处理方式要么在入口处统一转换类型要么case里也用字符串。我倾向在数据进入业务逻辑的那一刻就收敛类型Number(raw)或者用parseInt加基数参数别让类型污染往下游扩散。另一个冷门但真实存在的点case后面可以跟表达式不限于字面量而且这些表达式会按顺序求值直到某个匹配成功为止。function levelOf(score) { switch (true) { case score 90: return 优秀; case score 75: return 良好; case score 60: return 及格; default: return 不及格; } }switch(true)这种写法在区间判断里挺实用等于把一串if...else if换了个壳。但要注意case表达式是按书写顺序求值的匹配到一个就不再往下算所以顺序必须从严格到宽松否则score 60写在前面会吞掉所有 60 分以上的情况。这个坑我见过不止一次。2.2 穿透是特性还是事故取决于你有没有意图case后面不写break会继续执行下一个分支的语句体这叫 fallthrough穿透。教学材料一般把它定性为“错误”但实际上它是语言提供的合并分支的手段。当你需要多个值走同一段逻辑时穿透反而是最简洁的写法。function dayType(day) { switch (day) { case sat: case sun: return 休息日; case mon: case tue: case wed: case thu: case fri: return 工作日; default: return 未知; } }这里前几个case什么都不写直接落到下一行语义很清楚这几个值归为一类。反过来如果你的意图是每个分支独立却漏了break那就是事故。它的危险在于不报错只是逻辑悄悄多跑了一段如果分支里有副作用赋值、push、请求结果就是数据错乱。我的习惯是只要用了穿透就在穿透那一行写一句注释// falls through明确告诉后来人这是故意的。很多静态检查工具也认这个注释。如果团队规模大更省事的做法是干脆禁止穿透需要合并分支时用前面那种多个case并列、只有最后一段语句体的写法安全性最高。提示switch里default的位置放在最后是约定不是语法要求。放在中间时它同样会在没有匹配时执行但会继续往下穿透这个行为很多人没意识到。2.3 switch 里声明变量的坑这是我在真实项目里被咬过的地方。switch的整个代码块共享一个作用域所以你在某个case里用let或const声明变量实际上是把声明提升到了整个switch块顶部只是初始化还在原位。一旦另一个case分支先执行、访问了这个变量就会撞上暂时性死区。switch (type) { case a: let msg A; console.log(msg); break; case b: console.log(msg); // ReferenceError: Cannot access msg before initialization break; }解决方式很干脆每个需要声明变量的case用一对花括号包起来形成独立的块级作用域。switch (type) { case a: { const msg A; console.log(msg); break; } case b: { const msg B; console.log(msg); break; } }这样每个分支各自独立同样的变量名也不用改。这个写法虽然多了两个大括号但在分支逻辑较重的场景下收益明显尤其是每个分支都要算几个中间变量的时候。2.4 什么时候该放弃 switch 改用映射表如果每个case做的事情都是返回一个固定值那这个switch就是一个查找表。用对象或者Map写代码量能砍掉一半而且更容易从配置、接口或者外部数据里动态生成。const STATUS_TEXT { pending: 待处理, doing: 进行中, done: 已完成, }; function statusText(status) { return STATUS_TEXT[status] ?? 未知状态; } // 用 Map 的版本key 可以是任意类型不会把数字键转成字符串 const statusMap new Map([ [1, 待处理], [2, 进行中], [3, 已完成], ]); function statusText2(code) { return statusMap.get(code) ?? 未知状态; }两者有个关键差异值得记住对象字面量的键只能是字符串或 Symbol你写{1: a}实际键是1数字1查得到是因为查属性时会隐式转字符串而Map的键保持原类型1和1是两个不同的键。如果键是数字类型且你不能保证查询时类型一致Map更安全。另外对象字面量有个隐患如果键名恰好是__proto__、constructor这类特殊名字或者你要查的 key 来自用户输入直接用obj[key]可能碰到原型链上的属性。用Object.create(null)创建无原型对象或者用Map能绕开这个问题。这在处理用户可控输入时属于安全层面的考虑不只是代码风格。3. 三元运算符短小好用但别贪3.1 它是表达式不是语句的简写三元运算符条件 ? 值A : 值B最核心的特征是它是表达式有返回值。这一点决定了它的使用边界凡是需要“产出值”的位置它都能出现凡是需要“执行多条语句”的位置它就不合适。所以它和if...else不是互相替代的关系if是语句三元是表达式语法层级不一样。正因为有返回值三元在赋值、函数返回、参数传递、模板字符串插值这些场合特别顺手。const label count 0 ? 共 ${count} 条 : 暂无数据; const level score 60 ? pass : fail; element.classList.toggle(active, isActive ? true : false);上面最后一个例子其实可以直接写isActive因为classList.toggle的第二个参数会自动做布尔转换。写三元反而是多余的。判断标准就是如果条件本身就是布尔值别再套一层三元直接传。3.2 优先级与结合性嵌套之前先想清楚三元运算符的优先级在 JS 里偏低高于赋值和逗号低于大多数算术、比较和逻辑运算符。这带来的常见问题是你敢不敢在条件里写??或者||。// 这个会报语法错误吗 const a x || y ? yes : no; // 实际等价于 const a (x || y) ? yes : no;||优先级高于三元所以上面的写法能正常工作不报错。但??和||、混用时JS 在语法层面直接禁止不带括号的组合必须显式加括号。这类错误的好处是会直接抛语法错误坏处是你可能盯着屏幕半天找不到问题。至于结合性三元是右结合的a ? b : c ? d : e等价于a ? b : (c ? d : e)。利用这个特性可以写多级判断function grade(score) { return score 90 ? 优秀 : score 75 ? 良好 : score 60 ? 及格 : 不及格; }格式化之后可读性尚可这种链式写法在返回固定值的场景里比switch或一长串if都简洁。但超过三级我就建议停手换成switch(true)或者独立的映射函数。原因在于三元链调试起来很痛苦你没法在中间打断点看每个条件的求值情况只能整体跑一遍猜哪一级出了问题。而且加一个分支就得动整块结构diff看起来很脏。3.3 三条硬性红线我在代码审查里对三元有几条基本要求写在这里供参考。第一不要把副作用写进三元的分支。cond ? doA() : doB()这种写法在分支里调用有副作用的函数读代码的人会先花时间判断“这两边是不是都执行了”。它确实是二选一执行但视觉上容易误读。分支带副作用时用if...else更直白。第二不要在分支里写复杂表达式。 分支体一旦超过一个简单变量或字面量长度就会失控。有个粗略的度量整行超过 80 个字符就该考虑换行了超过 120 基本就是结构问题。第三嵌套三元不能超过两层。 两层以上请用函数拆开给每一层起个名字。名字本身就是最好的注释比你自己在脑子里模拟执行路径靠谱得多。// 不推荐三层嵌套 const text role admin ? 管理员 : role editor ? 编辑 : role guest ? 访客 : 未知; // 推荐拆成映射或独立函数 function roleText(role) { const map { admin: 管理员, editor: 编辑, guest: 访客 }; return map[role] ?? 未知; }顺带说一个容易被忽略的场景三元里两个分支的类型不一致时后续运算可能出问题。比如flag ? [] : {}返回值的类型完全不同调用方拿去做.length就会炸。这种情况下用三元看起来简练实际埋了个类型雷。4. 循环体系选对工具比写对语法更要紧4.1 for、while、do...while 的取舍传统for适合循环次数已知或可以算出的场景三个部分——初始化、条件、更新——都写在头部读的人一眼能看清边界。它的三个表达式都可以省略for(;;)是合法的死循环写法等价于while(true)配合内部的break用。while适合循环次数不确定、靠条件收尾的场景比如“一直读直到读到结束标记”“一直重试直到成功或超过次数”。它的一个常见风险是忘了在循环体内更新条件变量直接死循环页面卡死。do...while和while的差别只有一处先执行再判断所以循环体至少执行一次。典型用途是输入校验——你得先问一次用户才有东西可校验。let input; do { input prompt(请输入 1 到 10 的数字); } while (input ! null (Number.isNaN(Number(input)) || Number(input) 1 || Number(input) 10));这里有个小细节值得说do...while的条件里我用input ! null做了短路因为用户点取消时prompt返回nullNumber(null)是0不进判断会陷入死循环。这种“取消操作要单独处理”的逻辑在while版本里反而更别扭因为你没法在第一次询问之前就做判断。还有一个纯语法层面的坑do...while的结尾必须有分号。它在自动分号插入规则里属于特殊情况虽然加不加多数时候能跑但在和下一行以(或[开头的代码相邻时可能合并成一条语句。保守做法是永远写分号。4.2 for...in、for...of 和数组方法的边界这三个经常被混着用其实服务对象完全不同。for...in遍历的是可枚举属性名包括从原型链继承来的。用它遍历数组拿到的是0、1这样的字符串索引顺序还不保证虽然现代引擎对整数键做了保序优化但规范层面不保证。它的正确定位是遍历普通对象的可枚举属性而且用之前最好加一层Object.hasOwn判断避免拿到原型上的方法。const obj { a: 1, b: 2 }; for (const key in obj) { if (Object.hasOwn(obj, key)) { console.log(key, obj[key]); } }for...of遍历的是可迭代对象的值。数组、字符串、Map、Set、arguments、TypedArray都行普通对象不行因为对象不是可迭代的。想遍历对象的键值对用Object.entries()转成数组再for...of。const obj { a: 1, b: 2 }; for (const [key, value] of Object.entries(obj)) { console.log(key, value); }数组方法则是另一层抽象。forEach关注“对每个元素做一件事”map关注“得到长度相同的新数组”filter关注“筛选出一个子集”reduce关注“把整个数组坍缩成一个值”。这四个方法的选择标准不是哪个“高级”而是你的输出形态是什么。输出是新数组就用map是单个值就用reduce只是副作用就forEach别硬拿reduce干map的活儿写出来没人愿意读。forEach有两个硬限制必须清楚不能中断不能直接await。第一个限制的后果是需要提前退出时必须换for或for...of。第二个限制更隐蔽array.forEach(async item { await something })不会等你回调返回的 Promise 被直接丢掉整个forEach瞬间结束。要串行处理异步用for...of配await要并行用Promise.all(array.map(...))。4.3 break、continue 和标签跳转break跳出当前循环continue跳过本次迭代进入下一次。这两个在单层循环里没有理解成本麻烦出在嵌套。嵌套循环里内层的break只跳出内层这个大家都知道。但需要同时跳出两层时很多人会用标志变量加判断写得啰嗦。JS 提供了标签语法就是给循环起个名字然后break 标签名。outer: for (let i 0; i 5; i) { for (let j 0; j 5; j) { if (i * j 6) { break outer; } console.log(i, j); } }标签能跳出的层级是任意的continue 标签名也支持效果是跳到那个外层循环的下一次迭代。这个语法知道的人不多用的人更少因为一旦用了代码结构的复杂度就上去了很多时候把内层逻辑抽成函数、用return提前退出更干净。但在算法题或者需要极简遍历的场景下它确实省事。continue有一个必须警惕的场景在while里如果continue之前没有更新条件变量会直接进入死循环。let i 0; while (i 10) { if (i % 2 0) { continue; // i 永远不会增加死循环 } i; }正确做法是先更新再continue或者干脆改成for把更新表达式放在头部天然规避这个风险。这也是我个人倾向for的一个理由它把“增量”这件事固定在语法结构里不容易忘。4.4 闭包陷阱与 let 的作用域修复这是循环里最经典的一道坎。用var声明循环变量循环体里创建的函数比如定时器回调、事件处理共享同一个变量等回调执行时循环已经结束拿到的都是最终值。for (var i 0; i 3; i) { setTimeout(() console.log(i), 0); // 输出 3 3 3 }用let就正常了因为let在for头部声明时每次迭代都会创建一个新的绑定回调捕获的是各自的那一份。for (let i 0; i 3; i) { setTimeout(() console.log(i), 0); // 输出 0 1 2 }这个行为不是“let修复了闭包”而是for语句配合let时规范定义了逐迭代绑定的语义。理解这一点有助于你判断其他场景换成while配合let就没有这个效果因为let是在循环体外声明的只有一份。如果历史代码必须用var补丁是用 IIFE 把变量“冻结”进去for (var i 0; i 3; i) { (function (n) { setTimeout(() console.log(n), 0); })(i); }现在新代码基本不会碰到这个问题但在读老项目或者面试场景里这个转换过程还是值得过一遍脑子。4.5 遍历时修改集合的后果在循环里删除或添加数组元素是最容易出隐性 bug 的地方。用索引for正序删除时删除一个元素后后面的元素整体前移i继续自增就会跳过一个元素。const list [1, 2, 3, 4]; for (let i 0; i list.length; i) { if (list[i] % 2 0) { list.splice(i, 1); i--; // 必须回退索引 } }补i--能修正但写起来容易忘。更稳的做法是倒序遍历或者干脆用filter生成新数组。如果业务允许不改原数组filter是首选可读性和安全性的收益明显。至于for...of遍历数组时删除元素行为取决于底层迭代器结果不如索引遍历直观我一般不在迭代过程中改动集合结构。真要改先收集要删的元素循环结束后统一处理逻辑最清晰。这个“先标记后处理”的模式在处理大数组时也比边遍历边splice更高效因为避免了大量的元素搬移。5. 常见问题与排查速查5.1 症状、原因、处理对照症状最可能的原因处理方式switch永远走default值的类型和case不匹配字符串 vs 数字入口统一类型转换或用case 1某个分支逻辑莫名多执行一段漏写break造成穿透补break或加// falls through注释明示switch里报“初始化前不能访问”case中let/const声明跨分支共享作用域每个case用{}包裹三元嵌套后结果不对结合性理解错或条件里漏括号显式加括号超过两层拆成函数forEach里break不生效forEach不支持中断换for/for...offorEach里await没等待回调返回的 Promise 被丢弃改for...of串行或Promise.all并行定时器里输出的都是同一个值var声明的循环变量共享改用letwhile页面卡死循环体内没更新条件变量改成for或先更新再continue遍历数组时元素被跳过边遍历边splice导致索引错位倒序遍历或改用filter用for...in遍历数组拿到怪东西遍历的是可枚举属性名含原型链数组用for...of对象用for...in加hasOwn5.2 两个排查现场的实录第一个场景一个状态展示组件需求是按状态码渲染不同文案和颜色。上线后发现部分订单显示“未知状态”但接口返回的status明明在枚举里。排查过程是先在渲染函数入口打一行console.log(typeof status, status)发现是字符串2而枚举里定义的是数字2。原因在上游某次接口调整把数字字段改成了字符串前端没做类型收敛。修复方式是在 API 层统一转换而不是在渲染里加一堆兼容判断。这个案例的教训是类型收敛要在数据入口做不要在消费端补丁否则每多一个消费方就多一份兼容代码。第二个场景一个批量处理逻辑用forEach里包async请求日志显示“处理完成”打印时实际请求还在飞后续汇总数据全是空的。这个问题的排查其实很简单看控制台顺序就知道异步没同步。修法是换成for...of配await做串行或者用Promise.all并发后统一等待。选择哪个取决于接口承受能力和业务是否要求顺序。如果后端有并发限制串行更稳如果追求总耗时Promise.all加一个并发控制分批切片是比较常见的折中。5.3 几条不成文但很省事的心得写分支时先写异常分支再写正常分支用提前return把不符合条件的情况挡在门外主流程就不会被层层缩进包裹。这个习惯能让函数的缩进层级稳定在两层以内读起来舒服很多。循环里用到的边界值尽量提前算出来存成常量别在条件里反复计算。像for (let i 0; i arr.length; i)这种写法虽然引擎优化得很好但如果循环体里对数组做了增删length每次都在变逻辑就飘了。要么提前缓存要么明确自己要的是动态长度还是初始长度。调试循环时在循环体开头打一行带索引的日志比打断点更快。尤其是嵌套循环断点会停下来几十次日志可以直接看输出序列一眼看出是哪个维度的索引不对。最后多用const。在循环里能不改的变量就别让它可变。JS 里没有编译期约束const是你唯一的静态护栏能挡掉相当一部分“不小心改错了变量”的低级错误。虽然它和今天讲的三个主题不直接相关但在写循环和分支时这个习惯的收益会被放大。回头看我第20天这部分内容最值钱的不是语法本身而是每条规则背后的“为什么”。switch为什么用严格相等、三元为什么不能在分支里塞副作用、let为什么天然解决闭包问题这些想通了写代码时的选择就不再靠记忆而是靠判断。我自己的经验是把这三块吃透之后再去看数组方法、异步流程、甚至一些框架源码里的控制流写法理解速度会明显上一个台阶因为它们用的都是同一套底层逻辑的不同包装。