JavaScript 如何对大量的 if-else 判断进行优化
在讲优化方案前先来了解常见的if-else结构类型。所谓的【面条代码】,常见于对复杂业务流程的处理中。内容长结构乱嵌套深主流的编程语言均有函数或方法来组织代码。对于面条代码,不妨认为它就是满足这几个特征的函数吧。if...if型、型。if...if。
当业务比较复杂,判断条件比较多,项目进度比较赶时,特别容易使用过多if-else。其弊端挺多的,如代码可读性差、代码混乱、复杂度高、影响开发效率、维护成本高等。
因此在日常编程中,有必要采取一些措施,对一些if-else进行优化。
目录
什么是面条代码
在讲优化方案前先来了解常见的if-else结构类型。所谓的【面条代码】,常见于对复杂业务流程的处理中。它一般会满足这么几个特点:
- 内容长
- 结构乱
- 嵌套深
主流的编程语言均有函数或方法来组织代码。对于面条代码,不妨认为它就是满足这几个特征的函数吧。根据语言语义的区别,可以将它区分为两种基本类型:if...if
型、else if...else if
型。
if...if
型
这种类型的代码结构形如:
function demo (a, b, c) {
if (f(a, b, c)) {
if (g(a, b, c)) {
// ...
}
// ...
if (h(a, b, c)) {
// ...
}
}
if (j(a, b, c)) {
// ...
}
if (k(a, b, c)) {
// ...
}
}
if-if-before:
它通过从上到下嵌套的 if
,让单个函数内的控制流不停增长。不要以为控制流增长时,复杂度只会线性增加。我们知道函数处理的是数据,而每个 if
内一般都会有对数据的处理逻辑。那么,即便在不存在嵌套的情形下,如果有 3 段这样的 if
,那么根据每个 if
是否执行,数据状态就有 2 ^ 3 = 8 种。如果有 6 段,那么状态就有 2 ^ 6 = 64 种。从而在项目规模扩大时,函数的调试难度会指数级上升。
else if...else if
型
这个类型的代码控制流,同样是非常常见的。形如:
function demo (a, b, c) {
if (f(a, b, c)) {
if (g(a, b, c)) {
// ...
}
// ...
else if (h(a, b, c)) {
// ...
}
// ...
} else if (j(a, b, c)) {
// ...
} else if (k(a, b, c)) {
// ...
}
}
else-if-before:
else if
最终只会走入其中的某一个分支,因此并不会出现上面组合爆炸的情形。但是,在深度嵌套时,复杂度同样不低。假设嵌套 3 层,每层存在 3 个 else if
,那么这时就会出现 3 ^ 3 = 27 个出口。如果每种出口对应一种处理数据的方式,那么一个函数内封装这么多逻辑,也显然是违背单一职责原则的。并且,上述两种类型可以无缝组合,进一步增加复杂度,降低可读性。
但为什么在这个有了各种先进的框架和类库的时代,还是经常会出现这样的代码呢?个人的观点是,复用的模块确实能够让我们少写【模板代码】,但业务本身无论再怎么封装,也是需要开发者去编写逻辑的。而即便是简单的 if else
,也能让控制流的复杂度指数级上升。
重构策略
基本情形
对看起来复杂度增长最快的 if...if
型面条代码,通过基本的函数即可将其拆分。下图中每个绿框代表拆分出的一个新函数:
if-if-after:
不论控制流再复杂,函数体内代码的执行顺序也都是从上而下的。因此,我们完全有能力在不改变控制流逻辑的前提下,将一个单体的大函数,自上而下拆逐步分为多个小函数,而后逐个调用之。
这种做法中所谓的不改变控制流逻辑,意味着改动并不需要更改业务逻辑的执行方式,只是简单地【把代码移出去,然后用函数包一层】而已。
但是通过这种方式,我们能够把一个有 64 种状态的大函数,拆分为 6 个只返回 2 种不同状态的小函数,以及一个逐个调用它们的 main 函数。这样一来,每个函数复杂度的增长速度,就从指数级降低到了线性级。这样一来,我们就解决了 if...if
类型面条代码了。
查找表
对于 else if...else if
类型的面条代码,一种最简单的重构策略是使用所谓的查找表。它通过键值对的形式(key-value)来封装每个 else if
中的逻辑。(具体例子代码见下文)
const rules = {
x: function (a, b, c) { /* ... */ },
y: function (a, b, c) { /* ... */ },
z: function (a, b, c) { /* ... */ }
}
function demo (a, b, c) {
const action = determineAction(a, b, c)
return rules[action](a, b, c)
}
每个 else if
中的逻辑都被改写为一个独立的函数,这时我们就能够将流程按照如下所示的方式拆分了:
else-if-lookup:
对于先天支持反射的脚本语言来说,这也算是较为 trivial 的技巧了。但对于更复杂的 else if
条件,这种方式会重新把控制流的复杂度集中到处理【该走哪个分支】问题的 determineAction
中。
责任链模式
在上文中,查找表是用键值对实现的,对于每个分支都是 else if (x === 'foo')
这样简单判断的情形时,'foo'
就可以作为重构后集合的键了。但如果每个 else if
分支都包含了复杂的条件判断,且其对执行的先后顺序有所要求,那么我们可以用职责链模式来更好地重构这样的逻辑。
对 else if
而言,注意到每个分支其实是从上到下依次判断,最后仅走入其中一个的。这就意味着,我们可以通过存储【判定规则】的数组,来实现这种行为。如果规则匹配,那么就执行这条规则对应的分支。我们把这样的数组称为【责任链】
else-if-chain:
可以通过一个职责链数组来定义与 else if
完全等效的规则,具体代码例子见下文。
if/else语句优化
排非策略
逻辑非(logic NOT),是逻辑运算中的一种,就是指本来值的反值。
示例一:
// 优化前
// 判断是否为空
if(value === null || value === NaN || value === 0 || value === ''|| value === undefined)
{
……
}
// 优化后
if(!value) {
……
}
示例二:
// 优化前
// 判断是否数组是否含有符合某条件的元素
const name = arr.find(item => item.status === 'error')?.name
if(name !== undefined && name !== '') {
……
}
// 优化后
if(!!arr.find(item => item.status === 'error')?.name) {
……
}
示例三:提前return,去除不必要的else
// 优化前
if (user && password) {
// 逻辑处理
} else {
throw('用户名和密码不能为空!')
}
// 优化后
if (!user || !password) return throw('用户名和密码不能为空!') // 逻辑处理
! 对比 !!
!可将变量转换成boolean类型,null
、undefined
和空字符串取反都为true
,其余都为false
。
!null=true
!undefined=true
!''=true
!100=false
!'abc'=false
!! 常常用来做类型判断。
undefined
和null
为false
,只能表示不是undefined
或null
,并不能判断是否有元素和内容。- 任意数组,对象,函数(函数是特殊的对象)都转化为
true
,即使是空数组,空对象。 - 空字符串为
false
,非空字符串为true
。 - 数值正负0,不确定值(NaN)为
false
,其它为true
,无穷大也是true
。
字符串'0'和数值0可以相互转换,但它们转换为不同的布尔值,即0可转换为'0','0'可转换为true
,但0却转换为false
,可见Javascript中类型转换不具有传递性。
let a; // null、undefined、''、0
if (a !== null && typeof(a) !== "undefined" &&
a !== undefined && a !== '' && a !== 0){
//a为truthy时才执行,或者说a为真值时才执行
}
完全等价于:
let a;
if(!!a){
//a为truthy时才执行,或者说a为真值时才执行
}
!与 !!对比:
// 示例一:
var temp = null
alert(temp) // null
alert(!temp) // true
alert(!!temp) // false
// 示例二:
var temp
alert(temp) // undefined
alert(!temp) // true
alert(!!temp) // false
// 示例三:
var temp = ''
alert(temp) // 空
alert(!temp) // true
alert(!!temp) // false
// 示例四:
var temp = 1
alert(temp) // 1
alert(!temp) // false
alert(!!temp) // true
// 示例五:
var temp = 0
alert(temp) // 0
alert(!temp) // true
alert(!!temp) // false
// 示例六:
var temp = 'abc'
alert(temp) // abc
alert(!temp) // false
alert(!!temp) // true
// 示例七:
var temp = [1, 2]
alert(temp) // 1,2
alert(!temp) // false
alert(!!temp) // true
// 示例八:
var temp = { color: "#A60000", "font-weight": "bold" }
alert(temp) // [object: Object]
alert(!temp) // false
alert(!!temp) // true
三元运算符 c ? t : f
三元运算符: condition ? exprIfTrue : exprIfFalse; 如果条件为真值,则执行冒号(:)前的表达式;若条件为假值,则执行最后的表达式。
示例一:
// 优化前
let allow = null
if(age >= 18){
allow = '通过';
} else {
allow = '拒绝';
}
// 优化后
let allow = age >= 18 ? '通过' : '拒绝'
示例二:
// 优化前
if (flag) {
success();
} else {
fail();
}
//优化后
flag ? success() : fail();
三元运算符相比if/else来说,只需一行语句,代码简练精炼。
使用短路运算符 && 、|| 、
&&
为取假运算,从左到右依次判断,如果遇到一个假值,就返回假值,以后不再执行,否则返回最后一个真值;||
为取真运算,从左到右依次判断,如果遇到一个真值,就返回真值,以后不再执行,否则返回最后一个假值。
// 优化前
if (flag) {
this.handleFn() // handleFn是普通函数
}
// 优化后
flag && this.handleFn()
这种写法就比较清晰,简洁,好读。
合并条件表达式
另外如果遇到有很多的if语句,但是执行的功能函数却是一致的情况,可以用”逻辑与“(&&)或者”逻辑或“(||)来把他们合并成一个表达式。如果这些彼此独立的条件判断可以被视为同一次检查的场景时,一次检查的意图明显在可读性上优于多次的条件检查。例如:
// 优化前
if (!(staffInfo.patientName && staffInfo.phone)) {
// doSomething
}
...
if (!(staffInfo.phone && staffInfo.idCardNum)) {
// doSomething
}
// 优化后
if(!(staffInfo.patientName && staffInfo.phone) || !(staffInfo.phone && staffInfo.idCardNum)){
// doSomething
}
|| 对比 ??
||
和??
都是指定默认值
读取对象属性的时候,如果某个属性的值是null
或undefined
,有时候需要为它们指定默认值。常见做法是通过||
运算符指定默认值。
const headerText = response.settings.headerText || 'Hello, world!';
const animationDuration = response.settings.animationDuration || 300;
const showSplashScreen = response.settings.showSplashScreen || true;
上面的三行代码都通过||
运算符指定默认值,但是这样写是错的。开发者的原意是,只要属性的值为null
或undefined
,默认值就会生效,但是属性的值如果为空字符串或false
或0,默认值也会生效。
为了避免这种情况,ES2020 引入了一个新的 Null 判断运算符??
。它的行为类似||
,但是只有运算符左侧的值为null
或undefined
时,才会返回右侧的值。
而||
是运算符左侧的值为null
、undefined
、0
、''
或NaN
时,都会返回右侧的值。
单个if多条件优化
// 优化前
function test(type) {
if (type === 'jpg' || type === 'png' || type === 'gif' || type === 'svg') {
console.log("该文件为图片");
}
}
// 优化后
function test(type) {
const imgArr = ['jpg', 'png', 'gif', 'svg']
if (imgArr.includes(type)) {
console.log("该文件为图片")
}
}
多个else if分支优化
多个else if通常是一个糟糕的选择,它导致设计复杂,代码可读性差,并且可能导致重构困难。
// 示例一:优化前
if (this.type === 'A') {
this.handleA();
} else if (this.type === 'B') {
this.handleB();
} else if (this.type === 'C') {
this.handleC();
} else if (this.type === 'D') {
this.handleD();
} else {
this.handleE();
}
// 示例二:优化前
function getTranslation(type) {
if (type === 4) {
return "forbidden_area";
} else if (type === 6) {
return "elevator_area";
} else if (type === 7) {
return "dangerous_area";
} else if (type === 10) {
return "restricted_area";
}
return "other_area";
}
// 示例三:优化前
let result;
if (type === 'add'){
result = a + b;
} else if(type === 'subtract'){
result = a - b;
} else if(type === 'multiply'){
result = a * b;
} else if(type === 'divide'){
result = a / b;
} else {
console.log('Calculation is not recognized');
}
不同条件分支的代码具有很高的耦合度,先前的条件判断将影响后续的代码流,并且此类代码在后续开发中难以维护。我们可以通过switch、key-value和Map来优化代码。
switch
我们可以通过使用 switch 语句优化,如下所示:
// 示例一:
// switch 优化后
switch(val){
case 'A':
handleA()
break
case 'B':
handleB()
break
case 'C':
handleC()
break
case 'D':
handleD()
break
}
// 示例二:
// switch 优化后
function getTranslation(type) {
switch (type) {
case 4:
return "forbidden_area";
case 6:
return "elevator_area";
case 7:
return "dangerous_area";
case 10:
return "restricted_area";
default:
return "other_area";
}
}
// 示例三:
// switch 优化后
let result;
switch (type) {
case 'add':
result = a + b;
break;
case 'subtract':
result = a - b;
break;
case 'multiply':
result = a * b;
break;
case 'divide':
result = a / b;
break;
default:
console.log('Calculation is not recognized');
}
但这仍然没有什么可读性。switch 语句也容易出错。
在这种情况下,我们只是返回一个值,但是当你具有更复杂的功能时,很容易错过 break 语句并引入错误。
key-value
虽然switch语句在逻辑上确实比else if语句简单,但是代码本身也有点多。 其实可以对象枚举,将条件与特定操作相关联的键值。
// 示例一:
// key-value优化后
let enums = {
'A': handleA,
'B': handleB,
'C': handleC,
'D': handleD,
'E': handleE
}
function action(val){
let handleType = enums[val]
handleType()
}
这种方式消除了所有条件语句,并使用键值对象存储条件和操作之间的关系。当我们需要根据条件执行代码时,我们不再需要使用else if或switch语句来处理相应的动作,我们只需从中提取相应的函数handleType并执行它即可。
// 示例二:
// key-value优化后
function getTranslation(type) {
const types = {
4: 'forbidden_area',
6: 'elevator_area',
7: 'dangerous_area',
10: 'restricted_area'
}
return types[type] ?? 'other_area'
}
有时你可能需要在你的条件中执行一些更复杂的逻辑。为此,你可以将函数作为值传递给对象键并执行响应:
// 示例三:处理更复杂的逻辑
function calculate(action, num1, num2) {
const actions = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => a / b,
};
return actions[action]?.(num1, num2) ?? "Calculation is not recognised";
}
?.
有不懂的话,可以先看下面,我们正在选择我们想要做的计算并执行响应,传递两个数字。你可以使用可选链接(最后一行代码中的?.
)来仅执行已定义的响应。否则,将使用默认的返回字符串。
如果函数里的逻辑足够复杂也可以把函数提取出来:
// 把函数提取出来
function add(num1, num2) {
return num1 + num2
}
function subtract(num1, num2) {
return num1 - num2
}
function multiply(num1, num2) {
return num1 * num2
}
function divide(num1, num2) {
return num1 / num2
}
function calculate(action, num1, num2) {
const actions = {
add,
subtract,
multiply,
divide
}
return actions[action]?.(num1, num2) ?? 'Calculation is not recognised'
}
定义相关函数拆分逻辑,简化代码举例:
// 定义相关函数拆分逻辑,简化代码
// 优化前
function itemDropped(item, location) {
if (!item) {
return false;
} else if (outOfBounds(location) {
var error = outOfBounds;
server.notify(item, error);
items.resetAll();
return false;
} else {
animateCanvas();
server.notify(item, location);
return true;
}
}
// 优化后
// 定义dropOut和dropIn, 拆分逻辑并提高代码可读性
function itemDropped(item, location) {
const dropOut = function () {
server.notify(item, outOfBounds);
items.resetAll();
return false;
};
const dropIn = function () {
animateCanvas();
server.notify(item, location);
return true;
};
return !!item && (outOfBounds(location) ? dropOut() : dropIn());
}
细心的朋友会发现,在这个例子中,同时使用了前文提及的优化方案。这说明在编码时可以根据实际情况混合使用多种解决方案。
?.的说明
编程实务中,如果读取对象内部的某个属性,往往需要判断一下,属性的上层对象是否存在。比如,读取 message.body.user.firstName
这个属性,安全的写法是写成下面这样:
// 错误的写法
const firstName = message.body.user.firstName || 'default';
// 正确的写法
const firstName = (message
&& message.body
&& message.body.user
&& message.body.user.firstName) || 'default';
上面例子中,firstName
属性在对象的第四层,所以需要判断四次,每一层是否有值。
这样的层层判断非常麻烦,因此 ES2020 引入了“链判断运算符”(optional chaining operator)?.
,简化上面的写法。
// 简化后的写法
const firstName = message?.body?.user?.firstName || 'default'
上面代码使用了?.
运算符,直接在链式调用的时候判断,左侧的对象是否为null
或undefined
。如果是的,就不再往下运算,而是返回undefined
。
下面是判断对象方法是否存在,如果存在就立即执行的例子。
iterator.return?.()
上面代码中,iterator.return
如果有定义,就会调用该方法,否则 iterator.return
直接返回undefined
,不再执行?.
后面的部分。
下面是?.
运算符常见形式,以及不使用该运算符时的等价形式。
a?.b
// 等同于
a == null ? undefined : a.b
a?.[x]
// 等同于
a == null ? undefined : a[x]
a?.b()
// 等同于
a == null ? undefined : a.b()
a?.()
// 等同于
a == null ? undefined : a()
上面代码中,特别注意后两种形式,a?.b()
和a?.()
。
如果a?.b()
里面的a.b
有值,但不是函数,不可调用,那么a?.b()
是会报错的。
a?.()
也是如此,如果a
不是null
或undefined
,但也不是函数,那么a?.()
会报错。
Map
实际上我们还可以通过Map来进一步的优化我们的代码。
对比Object的话,Map具有许多优点:
- 对象的键只能是字符串或符号,而Map的键可以是任何类型的值。它可以是一个对象、数组或者更多类型,更加灵活。
- 我们可以使用Map size属性轻松获取Map的键/值对的数量,而对象的键/值对的数量只能手动确定。
- 具有极快的查找速度。
上面的例子一可以优化如下:
// 示例一:
let enums = new Map([
['A', handleA],
['B', handleB],
['C', handleC],
['D', handleD],
['E', handleE]
])
function action(val){
let handleType = enums.get(val)
handleType()
}
如果我们遇到多层复杂条件,Map语句优势就更明显了:
let statusMap = new Map([
[
{ role: "打工人", status: "1" },
() => { /*一些操作*/},
],
[
{ role: "打工人", status: "2" },
() => { /*一些操作*/},
],
[
{ role: "老板娘", status: "1" },
() => { /*一些操作*/},
],
])
let getStatus = function (role, status) {
statusMap.forEach((value, key) => {
// JSON.stringify()可用于深比较/深拷贝
if (JSON.stringify(key) === JSON.stringify({ role, status })) {
value()
}
})
}
getStatus("打工人", "1"); // 一些操作
再复杂一点:
// 优化前
if (mode == 'kline') {
if (this.type === 'A') {
this.handleA()
} else if (this.type === 'B') {
this.handleB()
} else if (this.type === 'C') {
this.handleC()
} else if (this.type === 'D') {
this.handleD()
}
} else if ((mode = 'depth')) {
if (this.type === 'A') {
this.handleA()
} else if (this.type === 'B') {
this.handleB()
} else if (this.type === 'C') {
this.handleC()
} else if (this.type === 'D') {
this.handleD()
}
}
对于上述如此复杂的场景,是否可以通过Map来进行优化? 其实只需要将不同的判断语句连接成一个字符串,以便可以将条件和操作以键值格式关联在一起。
// 优化后
let enums = new Map([
['kline_A', handleKlineA],
['kline_B', handleKlineB],
['kline_C', handleKlineC],
['kline_D', handleKlineD],
['kline_E', handleKlineE],
['depth_A', handleDepthA],
['depth_B', handleDepthB],
['depth_C', handleDepthC],
])
function action(mode, type){
let key = `${mode}_${type}`
let handleType = enums.get(key)
handleType()
}
责任链模式
责任链模式:将整个处理的逻辑改写成一条责任传递链,请求在这条链上传递,直到有一个对象处理这个请求。
例如 JS 中的事件冒泡。简单来说,事件冒泡就是在一个对象上绑定事件,如果定义了事件的处理程序,就会调用处理程序。相反没有定义的话,这个事件会向对象的父级传播,直到事件被执行,最后到达最外层,document对象上。
这意味着,在这种模式下,总会有程序处理该事件。
// 优化前
function demo (a, b, c) {
if (f(a, b, c)) {
if (g(a, b, c)) {
// ...
}
// ...
else if (h(a, b, c)) {
// ...
}
// ...
} else if (j(a, b, c)) {
// ...
} else if (k(a, b, c)) {
// ...
}
}
// 优化后
// 可以通过一个职责链数组来定义与 else if 完全等效的规则
// rules 中的每一项都具有 match 与 action 属性。这时我们可以将原有函数的 else if 改写对职责链数组的遍历:
const rules = [
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
},
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
},
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
}
// ...
]
// 每个职责一旦匹配,原函数就会直接返回。
// 这也完全符合 else if 的语义。
// 通过这种方式,我们就实现了对单体复杂 else if 逻辑的拆分了。
function demo (a, b, c) {
for (let i = 0; i < rules.length; i++) {
if (rules[i].match(a, b, c)) {
return rules[i].action(a, b, c)
}
}
}
策略模式+工厂方法
此法比较复杂,感兴趣的可以去学习一下。
引用文章:
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)