购物车需要根据金额决定运费,后台要按订单状态显示不同操作,表格程序还得逐行处理成百上千条数据。代码如果只能从第一行机械地走到最后一行,就无法应对这些真实需求。
JavaScript 的控制流解决了这个问题:条件语句负责选择路线,循环负责重复路线,break 和 continue 可以在途中改变节奏,异常机制则在正常路线无法继续时切换到错误处理流程。
本章会从一段顺序执行的代码出发,逐步搭建“决策、重复、跳转、失败处理”四层能力。每个概念都会配上可运行示例、结果分析和随堂题,最后再把它们组合成一个订单处理程序。
文中的控制台示例使用 Node.js v25.2.1 验证。示例只使用标准 JavaScript 语法,在现代浏览器控制台中也能得到相同的核心结果;两个嵌入式交互工具需要在浏览器页面中操作。
完成本章后,你应该能够:
if、else if、else 和条件表达式处理范围判断;switch 匹配离散状态,并解释 break、贯穿与 default;while、do...while、for、for...of 和 for...in;break 和 continue 改变局部流程;throw、try、catch、finally 建立清晰的异常边界;表达式负责产生值。例如 price * 1.1 得到一个数字,age < 18 得到一个布尔值,user.name 读取一个属性值。
语句负责执行动作或组织流程。变量声明、if 分支、for 循环和 throw 都是语句。最简单的程序会从上到下逐条执行,而控制流语句可以让程序选择、重复、跳过或中断其中一部分。

表达式回答“值是什么”,语句回答“接下来执行什么”。
看一段完整的小例子:
const price = 80;
const withTax = price * 1.1;
if (withTax >= 100) {
console.log("需要确认预算");
} else {
console.log("可以直接下单");
}
console.log(`应付金额:${withTax}`);输出:
可以直接下单
应付金额:88这里可以拆出四个层次:
80、price * 1.1、withTax >= 100 都是表达式;const withTax = ... 是变量声明语句,其中包含一个初始化表达式;if...else 是分支语句,它只会选择一个代码块;console.log(...) 是函数调用表达式,被单独写成了一条表达式语句。分号不能用来判断一段代码是不是表达式。表达式后面加上分号,可以成为一条表达式语句;真正要看的是它在当前位置负责“算出值”,还是负责“安排动作”。
if、else if 和 else 处理范围条件if 会先计算圆括号里的表达式,再把结果按真假性理解。结果为 truthy 时执行 if 代码块;结果为 falsy 时跳过它,如果存在 else,就执行 else 代码块。
常见的 falsy 值包括:
false0、-0 和 NaN""null 和 undefined其他大多数值都是 truthy。尤其要注意:字符串 "0"、空数组 [] 和空对象 {} 都是 truthy。
多个互斥范围可以用 else if 串起来。JavaScript 会从上往下检查,找到第一个成立的条件后执行对应代码块,后面的条件不再检查。
for (const age of [2, 10, 30, 70]) {
let ticket;
if (age < 3) {
ticket = "免票";
} else if (age < 12) {
ticket = "儿童票";
} else if (age < 65) {
输出:
2 岁:免票
10 岁:儿童票
30 岁:普通票
70 岁:长者票第二个条件只写 age < 12 就够了。能够走到这里,已经说明第一个 age < 3 不成立,因此当前年龄自然大于或等于 3。利用前面分支已经排除的信息,可以让条件更短、更清楚。
条件顺序非常重要。如果先写 age < 65,那么 2 岁和 10 岁也会立即命中这一分支,后面的儿童规则永远没有机会执行。
下面的条件不会检查爱好是否为 "coding",而是先执行赋值:
let hobby = "dancing";
if ((hobby = "coding")) {
console.log("我也喜欢写代码");
}赋值表达式的结果是 "coding"。非空字符串是 truthy,所以消息会被输出,同时 hobby 也被改掉了。真正的比较应使用严格相等:
if (hobby === "coding") {
console.log("我也喜欢写代码");
}日常判断优先使用 === 和 !==,这样不会在比较时悄悄转换类型。
当任务只是根据一个条件选择两个值时,条件表达式通常比完整分支更紧凑:
const age = 17;
const access = age < 18 ? "暂不开放" : "允许进入";
console.log(access);输出:
暂不开放它的结构是:
条件 ? 条件为真时的值 : 条件为假时的值条件表达式会产生值,因此很适合变量初始化、函数参数或模板字符串。若每条路线需要多步操作,或者出现嵌套条件表达式,就改用 if...else,避免把执行路线压成难读的一行。
switch 匹配一组离散状态if 擅长处理范围、组合条件和不同变量之间的关系。若任务是把同一个值与一组固定选项比较,switch 往往更容易扫读,例如订单状态、菜单命令或权限角色。

范围条件更适合 if,同一值的离散匹配更适合 switch。
const status = "review";
let message;
switch (status) {
case "draft":
message = "草稿";
break;
case "review":
message = "等待审核";
break;
case "published":
message = "已发布";
break;
default:
message
输出:
等待审核读这段代码时,把握四条规则:
switch 先计算括号里的值;case 使用严格匹配的语义,数字 1 不会匹配字符串 "1";break 会离开整个 switch;case 命中时执行 default,它通常放在最后。多个值需要同一结果时,可以有意让空的 case 贯穿到共享代码:
const grade = "B";
let result;
switch (grade) {
case "A":
result = "优秀";
break;
case "B":
case "C":
result = "通过";
break;
case "D":
case "F":
result = "需要补修";
输出:
通过这里 "B" 后面没有语句,代码自然落到 "C" 共用的处理逻辑,这是明确的分支合并。
如果已经执行了一段逻辑却忘记写 break,后续分支也会继续运行:
const activity = "lunch";
switch (activity) {
case "lunch":
console.log("午饭时间");
case "dinner":
console.log("晚饭时间");
break;
}输出:
午饭时间
晚饭时间除非确实需要贯穿,否则每个有实际处理代码的 case 都应明确结束。结束方式不只有 break;在函数中,return 也会直接离开函数,从而结束 switch。
你可以在下面的交互工具中切换条件,观察究竟哪条分支被选中:
while、do...while 还是 for循环用来重复一段代码。无论采用哪种语法,都要先找出四个要素:

三种基础循环的主要差别,在于条件检查和状态更新出现的位置。
while:次数取决于持续变化的状态while 会先检查条件,再决定是否执行循环体。因此条件一开始就是 false 时,循环体一次也不会执行。
let countdown = 3;
while (countdown > 0) {
console.log(countdown);
countdown--;
}输出:
3
2
1每一轮末尾都会让 countdown 减 1,所以条件最终会变成 false。若漏掉更新,循环将一直停在 3。
do...while:无论如何先执行一次do...while 先执行循环体,之后才检查条件。它适合“至少尝试一次”的任务,例如先读取一次数据,再判断是否需要重试。
let attempts = 0;
let token = "";
do {
attempts++;
token = attempts < 3 ? "" : "ready";
console.log(`第 ${attempts} 次:${token || "还没有值"}`);
} while (!token);输出:
第 1 次:还没有值
第 2 次:还没有值
第 3 次:ready即使把初始 token 改成 "ready",循环体仍会先执行一遍,然后才判断是否继续。
for:把计数流程集中在一行当起点、终点和步长都清楚时,for 最紧凑:
let text = "";
for (let i = 0; i < 4; i++) {
text += i;
}
console.log(text);输出:
0123执行顺序是:
let i = 0 只执行一次;i < 4;i++;for 的更新部分不必固定写成 i++。例如 i += 2 可以每次前进两格,i-- 可以倒序遍历。
发现循环停不下来时,先检查三件事:继续条件是否可能变成 false、更新是否每轮都会发生、循环体是否修改了条件依赖的数据。尤其不要一边用 i < array.length 控制循环,一边在每轮向同一数组末尾追加元素。
下面的工具会逐轮展示条件、循环体和更新步骤,适合用来检查边界值:
for、for...of 与 for...in“把一段代码重复五次”和“处理集合里的每一项”都需要循环,但集合遍历还要先决定你需要的是索引、值、属性名,还是属性名与值的组合。
for普通 for 可以直接访问数组索引,因此适合修改指定位置:
const prices = [10, 15, 20];
for (let index = 0; index < prices.length; index++) {
prices[index] *= 2;
}
console.log(prices);输出:
[ 20, 30, 40 ]循环条件应写成 index < prices.length。最后一个有效索引是 length - 1,若写成 <=,就会额外访问一次不存在的位置。
for...offor...of 每轮直接提供一个值,代码更贴近“对每个分数做什么”的自然语言:
const scores = [12, 15, 18];
let total = 0;
for (const score of scores) {
total += score;
}
console.log(`总分:${total}`);输出:
总分:45即使把循环变量声明为 let,给局部变量重新赋值也不会把新值写回原数组;需要修改对应位置时,应使用索引,或构造一个新数组。示例使用 const,因为当前值本身不需要重新绑定。
for...in 取属性名,Object.entries() 同时取键和值for...in 每轮得到一个可枚举的字符串属性名:
const user = {
name: "小夏",
role: "editor",
};
for (const key in user) {
console.log(`${key}: ${user[key]}`);
}输出:
name: 小夏
role: editorfor...in 还可能遍历原型链上的可枚举属性。如果只想处理对象自己的属性,通常把对象转换成数组后再用 for...of 更明确:
for (const [key, value] of Object.entries(user)) {
console.log(key, value);
}常见选择可以归纳为:
不建议用 for...in 遍历数组。它得到的是字符串形式的属性名,还可能把自定义的可枚举属性一起带进来,无法像数组循环那样直接表达“按元素值处理”。
表格、棋盘和座位图都有“行里还有列”的结构。外层循环负责选择一行,内层循环负责处理这一行中的每一列。
下面生成一个 3 行 4 列的乘法表:
const table = [];
for (let row = 1; row <= 3; row++) {
const cells = [];
for (let column = 1; column <= 4; column++) {
cells.push(row * column);
}
table.push(cells);
得到的数据是:
[
[1, 2, 3, 4],
[2, 4, 6, 8],
[3, 6, 9, 12],
]外层循环执行 3 次,每次都会完整运行 4 次内层循环,因此乘法和 push 共执行 3 × 4 = 12 次。一般来说,行数为 r、列数为 c 时,内层工作量约为 r × c。
嵌套不是越多越好。两层循环很适合真实的二维关系;如果已经嵌套了很多层,先检查数据结构是否可以展平,或其中一段逻辑是否可以单独命名。
break 和 continue 控制局部跳转循环通常按固定节奏执行,但数据可能要求提前结束或跳过某一项:
break:立刻离开当前最近的一层循环;continue:跳过当前轮剩余语句,进入下一轮;throw:中断当前正常路线,把异常交给外层处理。
break 离开循环,continue 跳到下一轮,throw 进入异常处理路线。
const numbers = [3, 8, 11, 14, 20];
const accepted = [];
for (const number of numbers) {
if (number < 10) {
continue;
}
if (number > 15) {
break;
}
accepted.
输出:
[ 11, 14 ]执行过程是:
3 和 8 小于 10,continue 让它们跳过收集步骤;11 和 14 被加入数组;20 时执行 break,整个循环结束。continue 不能绕过关键更新在 for 循环里,continue 之后仍会执行更新表达式,再检查下一轮条件。在 while 循环里,它会直接回到条件检查;如果状态更新写在 continue 后面,就可能永远卡在同一个值:
let i = 0;
while (i < 5) {
if (i === 2) {
continue;
}
i++;
}这段代码不要直接运行。i 到 2 后不断触发 continue,再也没有机会执行 i++。更清晰的改法是使用更新位置固定的 for:
for (let i = 0; i < 5; i++) {
if (i === 2) {
continue;
}
console.log(i);
}内层循环里的普通 break 只结束内层,外层仍会继续。确实需要一次离开多层循环时,可以给外层循环加标签:
const groups = [
[3, 7],
[12, 18],
[25, 30],
];
let found;
search:
for (const group of groups) {
for (const value of group) {
if (value > 20) {
输出:
25标签适合少量、明确的多层跳转,但不宜滥用。若跳转路线开始变得难以追踪,把搜索逻辑提取成独立函数通常更容易阅读和测试。
分析循环时,可以逐项记录“当前值、命中的条件、状态变化和结果集合”。这比只看循环头部猜输出更可靠。
try、catch、finally 处理无法继续的情况条件分支处理的是预期中的不同路线,例如会员与非会员、草稿与已发布。异常处理面对的是“当前操作无法按约定继续”,例如数字格式无效、值超出允许范围或依赖操作失败。
四个关键字各有明确职责:
ErrorJavaScript 允许抛出任何值,但实际项目应优先抛出 Error 对象,因为它带有名称、消息和调用栈。常见类型包括:
下面的函数把文本转换成 0 到 100 的分数,不符合约定时就抛出具体异常:
function parseScore(text) {
if (typeof text !== "string" || text.trim() === "") {
throw new TypeError("分数不能为空");
}
const score = Number(text);
if (!Number.isFinite(score)) {
throw new TypeError("分数必须是数字");
throw 一旦执行,函数中位于它后面的正常语句不会再运行。异常会沿调用路径向外寻找最近的 catch;如果始终没有处理者,运行环境会报告未捕获异常。
finallyfor (const input of ["92", "oops", "120"]) {
try {
const score = parseScore(input);
console.log(`${input} -> ${score}`);
} catch (error) {
console.log(`${input} -> ${error.name}: ${error.
输出:
92 -> 92
检查结束
oops -> TypeError: 分数必须是数字
检查结束
120 -> RangeError: 分数范围是 0 到 100
检查结束第一项成功时,catch 不执行,但 finally 仍会执行。后两项抛错后,try 中剩余代码被跳过,控制权转到 catch,随后依然执行 finally。
即使 try 或 catch 中出现 return、break、continue 或再次 throw,finally 通常也会在控制权真正离开前执行。因此它适合关闭资源、恢复状态和记录结束动作。不要在 finally 中写 return,否则可能覆盖原来的返回值或异常,让问题难以发现。
不要用异常代替普通业务分支。“没有勾选会员”“订单还是草稿”“数组中没找到目标”都可能是正常结果,用 if 或 switch 更清楚。catch 也不应只写空代码;无法真正处理的异常应保留上下文或继续向外抛出。
现在把分支、循环、跳转和异常组合起来。目标是处理一批来自外部的数据:已支付订单增加净收入,退款订单减少净收入,取消订单跳过,无效金额或未知状态进入错误路线。
const rawOrders = [
{ id: "A-101", status: "paid", amount: "128" },
{ id: "A-102", status: "cancelled", amount: "50" },
{ id: "A-103", status: "paid", amount: "oops" },
{ id: "A-104", status: "refunded", amount: "80" },
{ id: "A-105", status: "paid", amount: "42" },
];revenue、validCount 和 failedCount 三个计数变量;for...of 逐条处理订单;try 中检查金额:空字符串、非有限数字和负数都应抛出 TypeError;switch 处理 paid、refunded 和 cancelled;continue,未知状态抛出 RangeError;catch 中增加失败数并输出错误消息;finally 中为每条订单输出一次“检查完成”;这里有一个值得观察的细节:取消订单执行 continue 时,finally 仍会先输出“检查完成”,之后才进入下一轮。
3、失败记录为 1、净收入为 90;""、"oops"、"-5" 时都进入 catch;{ id: "A-106", status: "paid", amount: "10" } 后,净收入应变为 100,有效记录应变为 4。进一步挑战:增加一个 pending 状态,让它输出“等待处理”并跳过金额统计;再记录各状态分别出现了多少次。
if...else if...else 从上到下选择第一个成立的分支,范围判断应先写更具体的条件。switch 适合同一个值的离散匹配,使用严格匹配语义;无意的贯穿通常来自漏写 break。do...while 至少执行一次。for...of,需要索引时用普通 for,对象键值对可用 Object.entries()。break 离开最近一层循环,continue 跳过当前轮;嵌套跳转和状态更新都要特别检查。throw 提供上下文,用 catch 处理可恢复问题,用 finally 完成收尾。输出:
A-101: 已计入 128
A-101: 检查完成
A-102: 已取消,跳过
A-102: 检查完成
A-103: A-103 金额无效
A-103: 检查完成
A-104: 已退款 80
A-104: 检查完成
A-105: 已计入 42
A-105: 检查完成
有效记录:3
失败记录:1
净收入:90