JavaScript条件语句实战指南:从if/else到短路求值的核心机制
有一次我 review 一段业务代码看到这样一行if (res.code 200) { ... }我当时就问同事“后端这个 code 字段到底返回的是数字还是字符串”同事愣了一下说“好像都有”。问题就出在这。JavaScript 条件语句看似简单但一个就足够让整个分支逻辑跑偏。今天我不想重复教科书里的语法定义而是想从实际项目里摸爬滚打出来的经验出发把if/else、switch、三元运算符、短路求值这些日常用得最多的条件写法彻底讲透同时把类型转换、NaN、对象判空、事件监听、表单校验等高频场景里的坑一起填上。无论你是刚入门前端还是写了几年 JS 想系统梳理一遍这篇都值得花时间看完。1. 条件语句的三块基石if/else、switch、三元运算符该怎么选1.1 if/else 的条件与真假值判定if后面括号里的表达式会被 JavaScript 内部做一次ToBoolean转换。官方维护着一张经典的 falsy 值清单只有下面这些值会被当成假值说明false布尔假0、-0、0n数字零、负零、BigInt 零空字符串null空对象指针undefined未定义NaN非数字除开这些其余几乎所有值都是 truthy。这里我最想强调的是空数组[]和空对象{}在条件判断里是true不是false。很多新手会下意识以为“空”就是假这是错误的。比如下面这段代码const list getList(); if (list) { console.log(list.length); }当list是空数组[]时if (list)为真可以正常输出0。但当list为null时又会进入 else 分支。从业务语义看这显然不能算是“判断列表是否有内容”只能算是“判断 list 是否存在”。类似的场景还经常出现在数字判断上。假如用户积分可能为0而0是一个合法且有业务含义的数值就不能写if (points)因为积分为 0 时会被错误地丢到“没有积分”的分支。正确写法是显式判断是否为null/undefined再对业务值做大于等于的比较。else if也要注意分支顺序。它本质上是嵌套的 else从前往后匹配一旦命中就退出整个链条。如果多个条件存在包含关系比如年龄段判断一定要把范围更小的条件放前面否则结果会错位。1.2 switch/case 的严格比较和直落陷阱switch在匹配 case 的时候用的是严格相等不是宽松相等。这意味着它不会做隐式类型转换const x 1; switch (x) { case 1: console.log(数字); break; case 1: console.log(字符串); break; }这段代码只会输出“字符串”。很多人把switch的匹配机制理解成“像一样”这是一个高频误解。另外case后面不只是固定值也可以是表达式。有人会用switch(true)来做范围判断switch (true) { case n 90: console.log(优); break; case n 80: console.log(良); break; }虽然能跑但我非常不推荐在业务代码里这么写。switch(true)的阅读成本太高看到的人要反应一下才能明白你在用 switch 模拟 if/else。既然要做区间判断老老实实用if/else或者后面的查表法可读性好得多。直落fall-through是另一个经典陷阱。如果某个 case 末尾没有break或return执行流会继续走到下一个 case直到遇到 break 或函数结束。switch (status) { case 0: case 3: console.log(未开始); break; case 1: console.log(进行中); break; default: console.log(其他); }这里故意利用直落让 case 0 和 case 3 共享逻辑这是合理的用法。但如果你只是忘了写 break就有可能出现重复弹窗、重复请求之类的问题。我的经验是如果 switch 是在函数内部优先在每个 case 里写return而不是break返回的意图更明确也不容易漏。1.3 三元运算符的表达式语义与嵌套上限三元运算符是表达式不是语句。这就决定了它不像 if/else 那样“按条件执行一段逻辑”而是“按条件返回一个值”。所以最常见的用法是赋值和返回值const tip isVip ? 尊享用户 : 普通用户;如果你发现自己在用三元运算符做多步骤操作比如里面塞了很多表达式那就该换成 if/else 了。三元表达式的最佳场景是简单的二选一取值。三元运算符的嵌套顺序是从右向左结合的const level a ? 1 : b ? 2 : 3;这行代码实际上等价于a ? 1 : (b ? 2 : 3)。虽然能表达三层条件但可读性非常差。我和团队定的规矩是三元最多嵌套一层超过就用 if/else 或者查表法。代码是写给人读的不是拿来炫技的一个让人需要停下来推演的表达式就是技术债。2. 五个最容易翻车的条件判断细节类型转换、NaN、比较运算符、对象判空2.1 falsy 值清单与隐式转换这一节要把类型意识刻进骨子里。上一章的 falsy 值清单看着不难但实际业务里的边界情况要复杂得多。我列过一张完整的判断结果表供你对照值类型条件判断结果falsebooleanfalse0/-0/0nnumber/bigintfalsestringfalsenullobjectfalseundefinedundefinedfalseNaNnumberfalse[]objecttrue{}objecttrue0stringtruefalsestringtruenew Boolean(false)objecttrue注意最后三行。字符串false是 truthynew Boolean(false)因为是个对象也是 truthy。这两个值经常出现在配置项、接口返回、用户输入里如果条件判断时没有类型意识很容易判断错。我自己的习惯是写条件之前先问一句这个字段值可能是哪些类型比如接口返回的status可能是数字0也可能是字符串0那if (status 0)和if (status 0)的结果完全不同。为了不让这种差异成为隐患接口规范里就应该固定类型代码里再用严格的去判断。2.2 NaN 判断为何不能用 if (x NaN)NaN是 JavaScript 里唯一一个“不等于自身”的值。NaN NaN是 falseNaN NaN也是 false。所以你写if (x NaN) { ... }这段代码是永远进不去的因为它表达的是一个不可能成立的条件。正确的判断方式是用Number.isNaN(x)。注意全局函数isNaN和Number.isNaN不一样isNaN会先把参数转成数字再判断所以isNaN(abc)返回 trueisNaN(undefined)也返回 true。如果你只是想判断一个值是不是NaN本身必须用Number.isNaN。现实里最常见的场景是解析用户输入的数字const num Number(rawValue); if (rawValue.trim() || Number.isNaN(num)) { showError(请输入有效数字); return; }这里一定要先判空再转数字因为Number()的结果是0Number(null)也等于0。如果你先做了Number转换再判断空输入会被当成有效数字 0 给放过去。2.3 何时该用 何时必须用 的隐式转换规则是历史上最出名的一堆坑。我把典型结果列出来表达式结果原因1 1true字符串转数字0 falsetruefalse 转 0 0true空字符串转 0 falsetrue两侧都转 0null undefinedtrue特殊规则null 0falsenull 不转数字[] true数组 toString 得空串[1] 1true数组转字符串再转数字这张表足够说明的行为不只是“不严谨”而是引入了一整套你没有主动参与的转换逻辑。所以我给团队定的准则是默认全部用唯一推荐的例外是判断“空值”if (value null) { // 同时匹配 null 和 undefined }这个写法在很多框架源码里都会看到因为 null能够同时捕捉null和undefined又不会把 0、空字符串、false 误判进去恰好很多场景需要的就是这个语义。ES6 里的Object.is也值得一提。它在的基础上修正了两个特例Object.is(NaN, NaN)为 trueObject.is(-0, 0)为 false。日常开发中用得不多但如果要做精确的数值相等判断可以考虑它。2.4 对象、数组和 null 的判空姿势这一节是运行时错误的重灾区我几乎每天都能看到类似的报错TypeError: Cannot read properties of null (reading length)根源基本都出在没判空或者判空条件写得不到位。对象的判空要分两层。第一层判断对象是否存在第二层判断对象有没有属性if (obj Object.keys(obj).length 0) { ... }注意Object.keys(obj)在obj为 null 或 undefined 时会直接抛错所以前面的obj真值判断是必须的。有时候你还需要区分“自身属性”和“原型链上的属性”。这里有一个经常被问到的基础概念对象访问obj.method时会先找自身属性找不到就去构造函数的prototype对象上找。prototype是函数创建时就存在的对象和“对象是否 new 完”没有关系哪怕构造函数执行到一半只要对象已被创建访问属性时就会沿着原型链查找。正因为如此用if (obj.method)判断方法是否存在得到的不一定是“对象自己拥有这个方法”它可能来自原型链。如果你想确保是自身属性用hasOwnPropertyif (Object.prototype.hasOwnProperty.call(obj, key)) { ... }数组判空不要把希望寄托在if (arr)上空数组也是 truthy。更可靠的是if (Array.isArray(arr) arr.length 0) { ... }字符串判空同样要区分空串和空白串if (str.trim() ) { ... }用现代语法的话可选链?.和空值合并??能把一部分判空条件内化到访问链里const count data?.list?.length ?? 0;这不光省代码更重要的是语义清晰链上任何一环为空结果就是undefined再通过??变成默认值0。但要注意??只处理 null/undefined不处理空字符串和 0这跟下一章要讲的||有本质区别。3. 实战重构从嵌套if到可维护的条件逻辑3.1 早退模式把异常情况先抛出我在做 code review 时最不喜欢看到的代码是“箭头形”多层嵌套if (user) { if (user.status active) { if (user.level 1) { // 真正逻辑 } else { showError(等级不足); } } else { showError(账号未激活); } } else { showError(未登录); }这种三层缩进已经让人很难受了如果后面再加条件很容易把大括号放错位置。而且每一层 else 都对应一个前置条件读代码的人要在脑子里维护一个“当前处于哪个分支”的栈成本极高。早退模式的核心思想是在函数开头把“不满足条件”的路径全部用 return 拦截掉让函数主体只留下满足所有条件的主流程。function getVipPage(user) { if (!user) { showError(未登录); return; } if (user.status ! active) { showError(账号未激活); return; } if (user.level 1) { showError(等级不足); return; } renderVipPage(user); }重构之后缩进层级少了每个异常情况都有了独立出口后面的同事想再加一个“黑名单用户”的判断只需要在最后再加一个 guard 块不需要动主逻辑。这个模式在前端组件里也特别常见先处理 loading、error、empty最后渲染真正的内容。写早退时要注意一点return 之后的逻辑一定不要是通用副作用的延续否则提前退出会跳过不该跳过的代码。3.2 查表法替代多分支对象映射、函数映射与合并对象技巧当条件分支不再是两三个而是一长串 if/else 或者 5 个以上的 switch case 时我的第一反应是换成“查表法”。查表法本质上是用键值结构把“条件匹配”变成“属性查找”代码更短扩展性也更好。常见的坏味道是这样的function handle(type, params) { if (type add) { addNode(params); } else if (type remove) { removeNode(params); } else if (type update) { updateNode(params); } else if (type query) { queryNode(params); } else { console.warn(unknown type, type); } }用对象映射改写const actions { add: (params) addNode(params), remove: (params) removeNode(params), update: (params) updateNode(params), query: (params) queryNode(params), }; function handle(type, params) { const action actions[type] || (() console.warn(unknown type, type)); action(params); }这样做的好处很直接新增一种操作只需要在actions里加一行handle函数本身一行都不用动。当操作种类很多时还能把各个操作拆到不同模块按需组合可维护性会好很多。查表法也不只是处理枚举值。区间判断可以配合数组的find来做const ranges [ { min: 90, level: 优 }, { min: 80, level: 良 }, { min: 70, level: 中 }, ]; const hit ranges.find(item score item.min); const level hit ? hit.level : 待提升;这种写法比一长串 if/else 清爽条件规则也变成数据方便从配置接口下发。这里顺便说一个和条件判断强相关的场景——合并两个对象。比如const defaultConfig { theme: light, pageSize: 10 }; const userConfig { pageSize: 0, theme: dark }; const merged { ...defaultConfig, ...userConfig };...展开运算符会对每个 key 执行覆盖不管值是什么。假如userConfig.pageSize为 0你希望它作为合法值覆盖默认值那没问题但如果你只想在“用户确实配置了该字段”时才覆盖就不能写成if (userConfig.pageSize)因为 0 会被当成 falsy。正确做法是判断值是否为undefined或者用空值合并const pageSize userConfig.pageSize ?? defaultConfig.pageSize;这里??只在null/undefined时才取默认值0 会被保留正好满足业务需求。3.3 、||、?? 的短路求值表达式的条件判断JavaScript 的和||有一个容易忽略的特性它们返回的不一定是布尔值而是表达式两侧其中一个操作数的值。短路规则a b如果 a 为 falsy返回 a不再执行 b如果 a 为 truthy返回 b。a || b如果 a 为 truthy返回 a不再执行 b如果 a 为 falsy返回 b。所以常见的简洁写法是isLogin showPanel();这是一个“有条件地执行”的最小表达式。isLogin为 true 时执行showPanel()为 false 时整个表达式的值就是 false什么也不做。||的经典用法是设置默认值const name inputName || 默认名;当inputName是空字符串、0、false、null、undefined 时都会取默认值。这在很多场景下很方便但也隐藏了一个坑如果inputName为0而你并不想把 0 替换掉这个写法就错了。空值合并运算符??只关心null和undefinedconst count data.count ?? 0;当data.count是0或时会保留原值只有当它为null/undefined时才用 0。两种运算符的差异直接决定了你的条件逻辑是否正确。运算符什么时候取默认值适用场景??左侧为 null/undefined只想处理“未定义/空值”、||、??这些短路求值本质上就是表达式的条件判断。但短路写法和 if/else 混在一起时很容易出现优先级问题这是下一个要聊的坑。3.4 运算符优先级与括号习惯运算符优先级这块我见过太多因为省略括号而翻车的代码。常见的有三类。第一类是位运算和逻辑运算搞混。是按位与是逻辑与。写成if (a b)时执行的是位运算结果是一个数字再被隐式转换为布尔值。数字 1 和 2 进行的结果是 0于是条件为 false但用逻辑与1 2则是 true。这类 bug 一旦混进代码里靠肉眼很难看出来。第二类是链式相等比较。if (a b c)看着像在判断三者相等实际上先执行(a b)得到布尔值再拿这个布尔值和 c 比较。如果 c 是true那意味着只有当 a 全等于 b 时才进入分支如果 c 是false反而 a 不等于 b 时才进入。几乎不可能恰好满足你的本意。第三类是误把赋值写成相等。if (a b)会把 b 赋给 a然后判断赋值的返回值。如果 b 是 0、null、空字符串条件为 false如果 b 是对象条件一定为 true。这个问题相对容易被 ESLint 拦截建议团队规则里开启no-cond-assign。我自己的速记优先级顺序从高到低大概是括号()、一元运算符、乘除、加减、比较、相等、逻辑与、逻辑或、空值合并、三元、赋值。这里特别提醒??不能和||、直接混用而不加括号比如const x a ?? b || c;这在语法上会直接报错。因为规范禁止??与||/在同一表达式里不明确拆分。必须写成const x (a ?? b) || c;我的建议是在复杂表达式中不要依赖优先级一律显式加括号。少一行代码的收益远不如让人一眼读懂的价值大。4. 条件语句在真实业务场景中的落点表单、事件、跨端桥接与报错排查4.1 表单提交中 JS 条件校验与 H5 原生校验的分工HTML5 表单原生校验确实很强大required、pattern、maxlength、min、max都是声明式的。浏览器会在表单提交时自动拦截不合法的输入并弹出气泡提示form input typeemail required placeholder邮箱 button typesubmit提交/button /form但 H5 原生校验解决不了所有问题。它做不了跨字段联动校验比如确认密码必须和密码一致做不了异步校验比如用户名是否已存在也做不了复杂的业务规则比如“勾选企业账号后公司名称必填”。所以真实的业务表单里JavaScript 条件校验一定会存在。在 submit 事件里条件分支的用途非常明确校验失败就preventDefault()阻止提交成功就放行。form.addEventListener(submit, (e) { const pwd document.getElementById(pwd).value; const confirm document.getElementById(confirm).value; if (pwd ! confirm) { e.preventDefault(); showError(两次密码不一致); return; } // 继续提交 });这里有个容易踩的坑form.submit()方法由 JS 直接调用时不会触发submit事件。如果你把校验逻辑全部绑在 submit 事件里然后代码里又调用form.submit()校验会被完全跳过。更稳妥的思路是把校验逻辑抽成独立函数在按钮的 click 事件里先校验再决定调用form.submit()还是直接走 AJAX 提交。另外按钮的type也很关键。如果按钮默认是typesubmit点击后表单会尝试提交。即使 JS 逻辑有问题也不至于让整个页面刷新。所以我的建议是能不加typesubmit的按钮就不加统一用监听器控制提交动作条件判断的主动权留在代码里。4.2 事件监听与 void(0)点击、跳转和防重复绑定的分支设计a hrefjavascript:void(0)是 JavaScript 早期时代留下的经典写法。void是一个一元运算符void(0)会返回undefined。把这个结果放在href里点击链接时表达式结果为 undefined页面不会发生跳转于是它成了“占位链接”的常用手段。在现代前端工程里这种写法已经不多见了。我们更多是直接在 JS 里监听 click然后用e.preventDefault()阻止默认跳转行为link.addEventListener(click, (e) { e.preventDefault(); if (isLoggedIn) { openEditor(); } else { jumpToLogin(); } });事件监听场景下的条件判断最容易翻车的点有三个。第一个是元素不存在。如果document.querySelector(#btn)没找到元素绑定不会报错但后续访问元素属性时就会出问题。更安全的写法是const btn document.querySelector(#btn); btn?.addEventListener(click, handler);第二个是重复绑定。同一个元素被动态渲染或重复初始化时同一处理函数可能被绑定多次点击一次就执行多次逻辑。解决办法是在绑定前加一个判定标记if (!btn._hasBind) { btn._hasBind true; btn.addEventListener(click, handler); }更推荐的是事件代理把监听器挂到父容器上一次性处理所有子元素。这样条件判断的粒度放在“目标元素是谁”上而不是放在“绑定了多少次”上document.querySelector(#list).addEventListener(click, (e) { const target e.target.closest(.item); if (!target) return; // 根据 target 的状态走分支 });事件代理的好处在于内存占用少也天然规避了重复绑定问题。事件冒泡机制会帮你把子元素的点击统一送到父容器处理你要做的只是通过条件判断确认目标。4.3 条件语句运行时报错的完整排查链路判空、可选链与调试技巧JavaScript 运行时报错尤其是类型相关的错误大部分都能追溯到条件判断没写好。出现频率最高的报错大概是TypeError: Cannot read properties of undefined (reading xxx) TypeError: Cannot read properties of null (reading length)这类错误出现的原因是在访问一个可能为null/undefined的对象的属性时没有先用条件语句做判空。完整的排查链路可以这样走。第一步看错误堆栈。它已经告诉了你文件位置和行号。比如报错在第 15 行去看那行代码访问了哪个属性。第二步打印变量状态。在函数入口先把入参打出来function transform(data) { console.log(transform data:, data); const list data.list; // 第 15 行 }这样你能立刻确认是 data 为 null还是 data 存在但没有 list 字段还是 list 不是数组。第三步用条件守卫修正。在实际项目里我更倾向在入口做精确校验function transform(data) { if (!data || !Array.isArray(data.list)) { console.warn(数据格式不正确, data); return []; } return data.list.map(...); }注意这里!data能挡 null/undefined但挡不住空对象{}所以还要判断data.list是否是数组。条件语句的粒度要和后续操作匹配这是我在长期实践中体会最深的一点。第四步如果能用现代语法用可选链和空值合并化简function transform(data) { return data?.list?.filter(item item.active) ?? []; }当 data 为 null、data.list 为 null 时整个表达式返回 undefined再由??变成空数组。可读性比嵌套 if 好很多。调试时还有一个技巧在浏览器 DevTools 或 HBuilderX 里设置条件断点。右键点击断点行号编辑断点条件比如输入data null这样只有 data 确实为 null 时才停下来。遇到难复现的偶发错误这种条件断点能帮你省掉大量手动打印和同步等待的时间。写完条件判断后强烈建议自测这几个边界用例正常数据、数据为 null、字段缺失、空数组、空字符串、数字 0。一个条件语句只有经过这些边界测试才算真正可靠。4.4 跨端桥接OC 与 JS 互调时条件判断的注意点在 WebView 混合开发里JavaScript 经常要和 iOS 的 OCObjective-C代码互相调用。桥接场景下条件判断要管的事情比普通页面更多。首先是调用前的桥可用性判断。JS 调原生方法前必须先确认桥对象存在function callNative(data) { if (window.WebViewJavascriptBridge) { window.WebViewJavascriptBridge.callHandler(share, data, (res) { if (res res.code 0) { showToast(分享成功); } else { showToast(res?.msg || 分享失败); } }); } else { showToast(当前环境不支持原生调用); } }这里的判断至少有三层桥对象是否存在、回调结果是否存在、错误信息是否可读。如果桥不存在又不做降级处理页面会直接抛错用户什么都看不到。其次是返回数据的类型判断。原生端回调可能返回字符串、对象甚至可能是 null。如果拿到的是 JSON 字符串需要先解析再判断bridge.callHandler(getUserInfo, {}, (res) { if (typeof res string) { try { const json JSON.parse(res); if (json json.name) { renderName(json.name); } } catch (e) { console.error(解析失败, e); } } else if (res typeof res object) { renderName(res.name); } });这种“先判断类型再决定下一步处理”的模式在跨端桥接里非常重要。因为原生端和 JS 端对数据类型、字段约定很难做到处处一致条件判断就是最后的防线。不少同事觉得跨端问题很难查其实很多异常都出在边界没有兜住桥对象没判断、返回值为 null 没处理、JSON 解析失败没捕获。把条件写严谨能省掉一大半对接联调的时间。我自己在实际开发里养成了一个习惯写条件判断之前先问三个问题。这个值可能是什么类型0 或空字符串在这个业务里是不是有效值如果某一步突然变成 null/undefined会不会直接抛错这三个问题一旦有了答案条件表达式的写法和边界基本就清晰了。做 code review 这么多年我看到的绝大多数隐蔽 bug都不是因为开发者写不出 if/else而是因为类型边界没想清楚。把基础功打扎实让每个分支都经得起推敲条件语句才不会成为项目里最容易被忽视的定时炸弹。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →