☰
JavaScript控制流三件套:Switch、三元运算符与循环选型指南
2026/10/31 5:38:25 网站建设 项目流程

第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返回null,Number(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...of
forEach里await没等待回调返回的 Promise 被丢弃改for...of串行,或Promise.all并行
定时器里输出的都是同一个值var声明的循环变量共享改用let
while页面卡死循环体内没更新条件变量改成for,或先更新再continue
遍历数组时元素被跳过边遍历边splice导致索引错位倒序遍历,或改用filter
用for...in遍历数组拿到怪东西遍历的是可枚举属性名,含原型链数组用for...of,对象用for...in加hasOwn

5.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为什么天然解决闭包问题,这些想通了,写代码时的选择就不再靠记忆,而是靠判断。我自己的经验是,把这三块吃透之后,再去看数组方法、异步流程、甚至一些框架源码里的控制流写法,理解速度会明显上一个台阶,因为它们用的都是同一套底层逻辑的不同包装。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询