拓冰建站拓冰建站
首页 / 资讯中心 / 正文

进阶分支语句:从if-else到卫语句、表驱动,写出易维护的代码

写分支语句大家都会无非是if、else if、else加上一个switch。但你在真实项目里待久了就会发现同样是写分支有人写出来的代码改起来想骂人有人写出来的代码三个月后回头看还能一眼看懂接得上。这个系列我打算从入门到进阶慢慢聊上一篇已经覆盖了最基本的if / else用法和逻辑运算这一篇我们重点聊聊分支在工程里的进阶用法else if链、switch的本质、卫语句压平嵌套、三元表达式和短路运算这些“兵家必争之地”以及我这些年踩过的分支坑。你如果刚刚把if跑通看这篇会有一点收获如果你已经开始维护一段几百行的业务代码那这篇里的很多场景你应该会非常眼熟。1. 从简单 if 到分支结构进阶先理清写分支时脑子里该有哪张“路线图”分支不是语法问题是决策建模问题。我见过太多人写分支就是顺着需求一行行往下堆最后代码变成一长串if藤上结了七八个else if的葫芦每个分支里还拖着两条赋值语句和一次函数调用。这种代码看着也能跑但一旦需求变一点点改起来就非常疼。1.1 if-else if-else 链不只是“多条件”关键是互斥判定多条件判断分成两种一种是条件之间彼此独立满足一个不排除满足另一个另一种是条件互斥命中一个其他就不再判断。if-else if链就是为互斥场景设计的。这里有个初学者特别容易忽略的点else if链是从上往下依次判定的只要有一个条件为真后面的所有分支统统不再执行。这意味着分支的顺序本身就能表达业务优先级。我举个例子比如一个电商系统根据用户等级计算折扣let discount 0; if (user.level vip) { discount 0.8; } else if (user.level member) { discount 0.9; } else { discount 1; }这段代码里一旦user.level是vip后面member和普通用户的分支就不会再看。代码逻辑上没问题但它有一个隐藏的脆弱点如果后来业务加了一个superVip等级你必须在vip之前插入新的分支否则superVip会被vip截胡直接按 8 折算——如果忘了调整顺序线上就会出折扣事故。所以写else if链的第一原则是把命中概率最高、业务优先级最高、计算成本最低的条件放在最前面。这三个指标如果冲突通常优先级最高的是“业务优先级”因为业务错了比性能慢更严重。1.2 switch 与查找表的本质什么时候它比 if 链更合适switch在不少语言里被当成if-else if的语法糖但它俩底层思路有区别。switch更适合处理离散值匹配一个变量可能等于哪些确定的值每个值对应一份逻辑。if链则适合处理区间和复合条件比如score 90、age 60 income 5000这种。switch (command) { case start: startService(); break; case stop: stopService(); break; case restart: restartService(); break; default: logUnknownCommand(command); }为什么switch在值匹配场景里更顺手因为可读性好你一眼能看全这个变量“可能取哪些值、分别干什么”。而if-else if链把条件散落在每一行条件多了之后人脑要逐行比对容易漏。更深一层switch在底层可以编译成跳转表jump table或二分查找对一组离散值做匹配时理论上比一串线性比较更高效。这个优化在 JavaScript 引擎里不一定会体现但在 C/C、Java 这类编译语言里是真实存在的。所以不用迷信性能选switch主要是图结构清晰。我在实际项目里见过一个被改成“对象字面量”的版本更好用这个后面实操部分展开讲。这里先记住结论离散值映射优先用 switch 或查找表范围判断优先用 if 链。2. 几个容易写歪的分支场景嵌套、三元、逻辑组合分支语句单独写都不难难在它们组合在一起的时候。这一节聊三个高频但经常被用歪的场景每一个我都给正反对比。2.1 嵌套分支怎么压扁它避免箭头式代码嵌套分支指if里面套if再套if。随着缩进越来越深代码会向右偏移成一个大箭头if (user) { if (user.isActive) { if (user.plan pro) { if (quota.remaining 0) { doProTask(user); } else { showQuotaExceeded(); } } else { showUpgradePage(); } } else { showInactiveTip(); } } else { showLogin(); }这个写法逻辑没有错但四个缩进层级读起来非常累。你想象一下后来要在这段代码里加一个“试用用户”的判断你需要在第三层和第四层之间再加一层——这就是嵌套的雪崩效应。业内通用的解法是卫语句guard clause先把不满足条件的路径用return直接挡掉留下主干路径是正常逻辑if (!user) { showLogin(); return; } if (!user.isActive) { showInactiveTip(); return; } if (user.plan ! pro) { showUpgradePage(); return; } if (quota.remaining 0) { showQuotaExceeded(); return; } doProTask(user);卫语句的核心思想是正常路径是“主干道”异常/分支情况提前 return 掉。这么写之后每个判断都是一层缩进读代码的人不需要关心前面嵌套了几层看到return就知道“哦这个情况被排除了”。我见过有人反驳说卫语句把所有条件平铺看不出“谁是谁的前置条件”。这个问题可以通过把相关前置条件合并来解决比如!user || !user.isActive可以合成一个早期退出。关键是别把错误处理埋在深处让主干逻辑藏在犄角旮旯。2.2 三元运算符和短路逻辑表达式级分支除了语句级的分支还有一种表达式级的分支一个表达式的求值结果本身由条件决定。最常见的两个工具就是三元运算符?:和逻辑短路、||。三元运算符适合“根据条件返回两个值之一”的场景const statusText isLoggedIn ? 已登录 : 未登录;这行代码等价于四行if/else但它更紧凑且可以作为表达式插入到字符串模板里console.log(用户状态${isLoggedIn ? 已登录 : 未登录});但三元运算符有个大坑不要嵌套三元。cond1 ? a : cond2 ? b : cond3 ? c : d这种写法看着高级实际阅读时人脑很难一眼判断优先级而且极容易漏括号。我看到这种代码第一反应就是全部改回if/else或者改成查找表。记住一条三元运算符最多用一层超过一层就是可读性灾难。逻辑短路是另一种表达式级分支。和||在 JavaScript 里求值时不一定返回布尔值而是返回“决定结果的那个操作数”。利用这个特性可以写出很简洁的默认值逻辑// 如果 options.timeout 是 undefined就用 3000 const timeout options.timeout || 3000;还能写出条件执行isAdmin showAdminPanel();这一行的意思是isAdmin为真时才执行showAdminPanel()为假直接短路不再评估后面的调用。这种写法简洁但它有个边界问题如果options.timeout本身取值为00 || 3000会得到3000因为0是 falsy——所以在合法的 falsy 值场景下短路默认值会写出 bug。更稳妥的默认值写法是用??空值合并运算符它只在null或undefined时才取默认值。这个细节很常见我在代码评审里至少抓到过十几次。2.3 用逻辑运算组合条件别让 if 变成八爪鱼布尔表达式里经常出现一长串和||混合if ((user user.isActive (user.plan pro || user.plan team)) (quota.remaining 0 || user.isExempt)) { // do something }这种“八爪鱼条件”读起来是最痛苦的光是想搞清楚括号里谁跟谁一组就要几秒钟。我的经验是把复合条件抽成命名清晰的函数或变量const isPremiumUser user user.isActive (user.plan pro || user.plan team); const hasQuota quota.remaining 0 || user.isExempt; if (isPremiumUser hasQuota) { // do something }把条件拆开命名之后if里剩下的是一个“读起来像人话”的表达式。这一步看起来只是重构但它对后面排查问题帮助巨大报错时你能单独打印isPremiumUser和hasQuota分别是 true 还是 false不用在八爪鱼表达式里插断点。另外还有一个非常实用的技巧用德摩根律简化否定条件。!(a || b)等价于!a !b!(a b)等价于!a || !b。有时候需求写的是“非会员或未激活用户不能使用”代码里就会冒出if (!(user.isMember || user.isActive)) { showForbidden(); }这种双重否定极难读。可以改成if (!user.isMember !user.isActive) { showForbidden(); }读起来就是“既不是会员又没激活”比“不满足会员或激活”直观得多。我每次在代码评审里看到!(...这种写法都会提醒能不能拆开十有八九能简化。3. 实操环节三个典型分支场景从头到尾写一遍讲了这么多原则下面拿三个真实场景走一遍完整流程。我把思考过程和最终代码都放出来你可以照着敲一遍。3.1 场景一成绩分段输出等级边界值必须看仔细需求很简单根据考试分数输出等级90 分以上为 A80 到 89 为 B70 到 79 为 C60 到 69 为 D60 分以下为 E。很多初学者会写出这样的代码function grade(score) { if (score 90) { return A; } else if (score 80) { return B; } else if (score 70) { return C; } else if (score 60) { return D; } else { return E; } }这段代码功能正确但它的正确依赖一个隐含条件分支从上往下检查后者自动排除了前者。score 80能被执行到说明前一个score 90为假也就是分数一定小于 90所以区间是 [80, 90)。写区间分支时最怕边界值出错。比如把 60写成 60那正好考 60 分的同学就从 D 掉到了 E这是典型的一分之差。我处理边界值时有一个习惯把边界值单独列出来过一遍。上面这个例子我会在脑子里或者直接用测试用例跑一遍 90、89、80、79、70、69、60、59 这几个分数检查输出是否符合预期。另外这段代码没有处理输入非法值比如score 120或score -5照样会输出A或E这在真实系统里可能掩盖上游的数据错误。更稳的写法是在开头加一个合法性校验function grade(score) { if (typeof score ! number || score 0 || score 100) { throw new Error(分数必须在 0 到 100 之间); } // ...后面的分支保持不变 }这就是卫语句的典型应用先把非法输入挡在门外主逻辑才不用操这么多心。3.2 场景二菜单命令分发用对象查找表替换长 switch假设要写一个命令行工具支持start、stop、status、restart四个命令。用 switch 的写法在上面已经给过了。但我在项目里越来越倾向于用对象查找表实现这种映射const commandHandlers { start: () startService(), stop: () stopService(), status: () printStatus(), restart: () restartService(), }; function dispatch(command) { const handler commandHandlers[command]; if (!handler) { logUnknownCommand(command); return; } handler(); }这个写法比 switch 好在三点第一添加新命令不需要动 dispatch 函数体只需要在commandHandlers里加一行符合“开闭原则”的直觉。第二命令和处理的对应关系一目了然是一张纯数据表甚至可以直接从配置文件或后端接口动态构造非常适合插件化架构。第三不容易漏 break。switch 的break漏写会导致 fall-through穿透这是 switch 最经典的 bug 来源而对象查找表天然没有这个问题。它的代价是如果逻辑复杂到每个 case 不是“调用一个函数”这么简单而是需要先判断前置条件再走不同逻辑那对象查找表里的函数会变得臃肿这时候 switch 反而更直观。所以我的选择标准是映射关系简单直白用查找表每个分支逻辑复杂且互相有前置关系用 switch 或 if 链。3.3 场景三复杂业务规则用卫语句和条件函数拆解嵌套最后一个场景综合一点。需求是一个文件上传接口只有满足以下条件才允许上传用户已登录用户是付费会员或当前处于免费试用期文件大小不超过 100MB文件类型在允许列表里。第一版代码很容易写成大嵌套function canUpload(user, file) { if (user) { if (user.isMember || user.isTrial) { if (file.size 100 * 1024 * 1024) { if (allowedTypes.includes(file.type)) { return true; } } } } return false; }这个函数逻辑没错但四个条件糊在一起而且每加一个条件缩进就深一层。按前面讲的卫语句思路重构function canUpload(user, file) { if (!user) { return false; } if (!user.isMember !user.isTrial) { return false; } if (file.size 100 * 1024 * 1024) { return false; } if (!allowedTypes.includes(file.type)) { return false; } return true; }看起来清爽多了。但如果你希望更清楚地表达“这些是明确的拒绝原因”可以再把每个条件拆成独立的函数function isUploadAllowed(user, file) { if (!isLoggedIn(user)) return false; if (!hasUploadPermission(user)) return false; if (!isFileSizeValid(file)) return false; if (!isFileTypeAllowed(file)) return false; return true; }isLoggedIn、hasUploadPermission、isFileSizeValid、isFileTypeAllowed每个函数只回答一个问题是/否逻辑边界非常干净。测试时也能针对每个函数单独写用例。这种模式在业务代码里几乎通用长函数里出现多层 if 嵌套时先问自己能不能拆成多个“单一职责”的布尔函数再加一排骨干式卫语句。我重构过的项目里凡是这么处理过的代码后续加需求时基本只改一个函数不会牵连到其他逻辑。4. 分支语句经典陷阱与排查实录写分支容易栽的坑我这些年基本都踩过一遍。下面挑几个最经典的说每个都尽量带真实的排查思路。4.1 边界值写错还是差之毫厘谬以千里边界值的坑在成绩分段那里已经提过。我再补一个真实事故有一个优惠券系统过期时间是expiredAt判断用户能不能用券代码写的是if (now expiredAt) { // 使用 } else { // 提示已过期 }这个判断完全反了。正确逻辑应该是now expiredAt才能用或者now expiredAt表示已过期。当时代码评审漏了上线后所有券都被拦截报修电话打爆。事后复盘根因是条件写得太“直觉化”没有把判断主语对齐。我现在的习惯是凡是时间、价格、数量、分数这四类边界值敏感的判断一律先把“边界点”写进测试用例。比如过期时间测试用now expiredAt - 1应该能用、now expiredAt边界按业务定义、now expiredAt 1应该不能用三个用例分别验证。4.2 浮点数比较引发的分支“错乱”还有一个我很早就踩过的坑。判断一个商品折扣价是否等于 9.9 元if (price 9.9) { // 显示“特价”标签 }在 JavaScript 里0.1 0.2 0.3是false因为浮点数用二进制表示十进制小数会有精度误差。9.9在内存里也不是严格的 9.9而是一个非常接近 9.9 的小数。所以对浮点数做相等比较分支结果极不稳定。解法有两种一是比较误差范围二是把价格转成分整数计算。// 方式一误差范围 const EPS 1e-6; if (Math.abs(price - 9.9) EPS) { // ... } // 方式二整数分 const priceInCents Math.round(price * 100); if (priceInCents 990) { // ... }我建议涉及金额的场景一律用“分”为单位存储和计算这样所有比较都是整数比较不会有精度问题。这个经验是跟很多金融项目的老代码打过交道后得出的早改早省心。4.3 空值和类型误判分支走向为何总是不符合预期空值和类型问题是前端分支错误的重灾区。比如从接口拿回一个user直接判断user.isAdmin结果user是null程序直接抛异常。或者接口字段不是undefined而是字符串false布尔判断if (config.enabled)得到真因为非空字符串是 truthy。这类问题没有高级解法就是养成两个习惯第一所有来自外部输入的值在使用前先做类型和空值校验第二把校验逻辑放到函数入口处不要用一层层if包住。说白了还是卫语句那套思路。我自己的规则是“外部数据默认不可信入口统一把关内部数据默认可信但要有对账兜底。”还有一种情况是误用 truthy/falsy 判断合法值。比如if (user.score)当score为0时这个条件为假如果业务上0分也是合法分数这个分支就会漏掉。所以当变量可能取0、空字符串等合法值时尽量用显式比较if (user.score ! null user.score ! undefined)。这里同样可以用空值合并运算符??做更简洁的处理。4.4 switch 穿透fall-through漏写 break 的灾难switch 穿透是老生常谈但值得再说一次。看这个例子switch (status) { case pending: showPending(); case done: showDone(); break; case error: showError(); break; }当status是pending时showPending()执行完之后不会停会继续执行showDone()因为第一个 case 后面没有break。ESLint 里有一条规则禁止这种现象但真实项目里还是会有人漏写。我的经验是在 JavaScript/TypeScript 里如果每个 case 都是“执行完就结束”优先用对象查找表替代 switch从根上消灭穿透问题。如果某些场景真的有意识地利用 fall-through 合并逻辑那必须写注释说明否则下一个改代码的人大概率以为这是 bug。4.5 排查实录一个表单校验的分支 Bug最后分享一个实际排查经历。一个表单提交功能用户输入邮箱和手机号二选一。需求是“邮箱或手机号至少填一个”。第一版代码是if (email || phone) { submit(); } else { showError(请填写邮箱或手机号); }看起来没问题但测试发现用户输入了一串空格“ ”作为邮箱竟然也提交成功了。原因就是 是非空字符串truthyemail || phone为真。排查过程很简单打印JSON.stringify(email)发现值是空白字符串。修复方式const isEmailFilled email email.trim().length 0; const isPhoneFilled phone phone.trim().length 0; if (isEmailFilled || isPhoneFilled) { submit(); } else { showError(请填写邮箱或手机号); }这个案例给了一个通用教训“填没填”不只是判断变量是否为真还得判断去掉空格后是否有内容。在写校验型分支时把“空值”的判定逻辑收敛到一个工具函数里比如isEmptyString比每个地方都写一遍trim().length 0要稳妥得多。5. 性能、风格与我的个人原则分支语句写多了我慢慢沉淀出几条自己的原则谈不上标准答案但这些年确实帮我少踩了不少坑。5.1 分支预测到底要不要关心性能与可读性如何平衡很多人在网上聊到性能优化时会提到 CPU 的分支预测if分支在现代处理器上不是简单地“判断真假然后跳转”处理器会提前猜测走哪条路猜对了流水线不中断猜错了要清空流水线重来。于是就有“把大概率发生的情况放在 if 分支里”的优化技巧。这个优化对热循环、对性能极致敏感的场景是有效的。但我在绝大多数业务代码里不会刻意这么做原因有两个。第一业务代码的性能瓶颈几乎不在这种单条if判断上而在数据库查询、网络 IO、子资源加载这些地方。第二为了提高百分之零点几的命中率去调整分支顺序牺牲的是可读性得不偿失。如果真遇到性能敏感的热点函数我的做法是先写可读性最好的版本再通过基准测试benchmark定位热点最后只对明确是热点的函数做针对性调整并且加注释说明为什么顺序是这样。永远不要让“可能的性能优化”绑架日常代码风格。5.2 我在项目中总结的几条分支写法原则这么多年写下来每次代码评审我都会重点看别人怎么写分支也慢慢总结出几条自己的铁律第一分支条件必须是“正向”表达。if (user.isActive)比if (!user.isInactive)好读if (file.isValid())比if (!file.hasError())好读。这看起来是小事但代码里满是双重否定时阅读成本会成倍上升。如果后端返回的字段名是反着的我宁愿在入口处先转换也不要让业务逻辑里到处是!。第二每个分支体只做一件事。如果一个分支里既改了全局状态又更新了 UI还调了接口那这个分支就是“上帝分支”。更好的做法是让分支决定调用哪个函数每个函数只做一件事。分支是“选择器”不是“执行体”。第三分支之间共享的逻辑要提出来。我见过很多if / else两个分支里各有一段几乎相同的代码只是中间一两行不同。合理做法是把不变的部分提到分支外面让每个分支只保留真正不同的逻辑。这样做之后后来者改代码时不用对着两份雷同的代码猜“这里是不是改漏了”。第四控制条件数量上限。一个if条件里出现超过三个/||运算符时就是需要重构的信号。要么把条件拆成命名变量要么把一部分判断单独放到函数里。超过三个连接词人脑几乎不可能一眼判断优先级出错的概率直线上升。5.3 用“表驱动”思路替代冗长分支链最后再分享一个我很喜欢用的技巧表驱动。前面讲菜单命令时提到过对象查找表这个思路可以延伸到很多场景。举个例子根据错误码返回错误提示。用 if 链就是let message; if (code 400) message 请求参数错误; else if (code 401) message 请先登录; else if (code 403) message 没有权限; else if (code 404) message 资源不存在; else if (code 500) message 服务器错误; else message 未知错误;用表驱动就是const errorMessages { 400: 请求参数错误, 401: 请先登录, 403: 没有权限, 404: 资源不存在, 500: 服务器错误, }; const message errorMessages[code] ?? 未知错误;两者功能一致但表驱动版本把“映射关系”和“业务逻辑”分开了映射表本身就是一份可配置数据。后端的错误码增加时只需要在表里加一行不需要改判断逻辑。这就是分支配得上“设计”两个字的样子。根据我个人经验分支语句是代码里最容易被低估的部分。很多人觉得把功能跑通就行分支写得乱一点也能用。但真正维护过老旧项目的人都知道一个几百行的函数里让人崩溃的往往不是算法而是那七八层让人看得头晕的嵌套if。写分支时多想一步“这个条件能不能提前 return”“这个映射能不能用表来表达”“这个判断能不能抽成命名函数”后续维护的人真的会感谢你。最后补一个小习惯遇到复杂的多条件场景先在纸上把条件列成一张真值表把每个组合的期望结果写清楚再开始写代码这会帮你省掉大量调试时间。这个习惯我从写第一行分支代码用到现在每次都有效。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门