查看原文
其他

【第933期】JavaScript:少一点条件语句

2017-05-12 晒太阳的鱼 前端早读课

前言

今日早读文章由@晒太阳的鱼翻译授权分享。

正文从这开始~

本文是《写出复杂度低的javascript》的第三部分。在之前的文章中,我们表明了缩进(非常粗鲁地)增加了代码复杂性。虽然这不是一个准确全面的指导,至少算是一篇有帮助的指南。接着我们介绍了如何用更高级的抽象来代替循环《无循环的JavaScript》。在本文,我们将注意力转向条件语句。

不幸的是我们无法彻底摆脱条件语句。它意味着彻底重新构建大多数代码库。(尽管技术上是可行的)。但是我们可以改变自身书写条件语句的方式,从而降低代码复杂度。接下来我们介绍处理if语句的两种策略。在那之后,我们将把注意力转向switch语句。

没有else的 if语句

第一种重构条件语句的方法是避免使用else。在JavaScript中,我们可以当做没有Else语句来编写我们的代码。 这件事可能看来很奇怪。实际上在大多数情况下,我们确实不需要else语句。

想象一下,我们正在开发一个给科学家研究发光体的网站。每一个科学家有一个通过AJAX加载的通知菜单。当数据加载完毕,我们有一些用于显示菜单的代码。

function renderMenu(menuData) {
   let menuHTML = '';
   if ((menuData === null) || (!Array.isArray(menuData)) {
       menuHTML = '<div class="menu-error">Most profuse apologies. Our server seems to have failed in it’s duties</div>';
   } else if (menuData.length === 0) {
       menuHTML = '<div class="menu no-notifications">No new notifications</div>';
   } else {
       menuHTML = '<ul class="menu notifications">'
           + menuData.map((item) => `<li><a href="${item.link}">${item.content}</a></li>`).join('')
           + '</ul>';
   }
   return menuHTML;
}

上面这段代码可以正常工作。但是一旦我们确定没有什么通知要呈现,条件的终止点又是哪里?为何不选择直接返回menuHTML这个值?让我们重构并看看代码是什么样子。

function renderMenu(menuData) {
   if ((menuData === null) || (!Array.isArray(menuData)) {
       return '<div class="menu-error">Most profuse apologies. Our server seems to have failed in it’s duties</div>';
   }
   if (menuData.length === 0) {
       return '<div class="menu-no-notifications">No new notifications</div>';
   }

   return '<ul class="menu-notifications">'
       + menuData.map((item) => `<li><a href="${item.link}">${item.content}</a></li>`).join('')
       + '</ul>';
}

如上,我们把代码修改成一旦我们符合一个条件,我们直接返回结果然后退出。对读者来说,如果这个条件是你所关心的,那么没有必要阅读之后的代码了。因为我们知道在If语句后不会再有相关代码,所以不需要进一步阅读与检验了。

这段代码另一个好处就是能够降低主路径(返回一个list)的缩进级别。这使得我们能够更加容易看出该代码预期的通常路径。If语句用来处理主路径上的异常,这样让我们的代码意图显得更加清晰。

这种不使用else的策略是另一个大策略的子集。这个大策略被我称为“早点返回,经常返回“(return early,return often)。一般来说,这个策略能够让代码更清晰并且有时能够减少计算。比如我们在之前文章中提过的find 函数。

function find(predicate, arr) {
   for (let item of arr) {
       if (predicate(item)) {
           return item;
       }
   }
}

在这个find函数内,一旦我们找到想查找的对象就立刻返回该对象并且退出循环。这使得我们的代码更加高效。

“早点返回,经常返回”

移除else是一个好的开始,但仍然让代码有很多缩进。一个稍微更好的策略就是拥抱三元操作符。

不要惧怕三元操作符

三元操作符一直有着让代码更难读的坏名声。在介绍之前,我想声明你永远不应该嵌套使用三元操作符。嵌套使用三元操作符会使代码难以置信地难读【1】

。但是三元操作符比平常的if语句有巨大的优势。为了说明这一点,我们必须深入研究if语句到底做了什么。我们来看一个例子:

let foo;
if (bar === 'some value') {
   foo = baz;
}
else {
   foo = bar;
}

这代码非常简单易读。但是如果我们把块包装在里立刻执行函数表达式(IIFE)里会怎么样呢?

let foo;
if (bar === 'some value') (function() {
   foo = baz;
}())
else (function() {
       foo = qux;
}());

截至目前,我们实际上没有任何改变,两个代码例子做的都是同一样事情。但是请注意,所有的IFFE没有返回值。这意味着它们是不纯的(impure)。这种结果是理所应当的,因为我们仅仅只是原封不动地复制了原来的if语句。但是我们是否可以将这些IIFE重构为纯函数?答案是不能。至少不能做到每一个代码块一个函数。 有一条提议可以改变这种情况。但是现在我们必须接受,除非我们早点返回结果,否则if语句本身就是不纯的事实。为了实现任何有用的逻辑,我们不得不改变一些变量或者在这些分支代码块中造成副作用。除非我们早点返回结果。

但是,假如我们把整个if语句包裹在一个函数内呢?我们能否让这个包装函数变成纯函数?我们来试一下。首先我们把整个if语句包裹在一个IIFE 里:

let foo = null;
(function() {
   if (bar === 'some value') {
       foo = baz;
   }
   else {
       foo = qux;
   }
})();

接着我们改动一下让我们能够从IIFE中返回值。

let foo = (function() {
   if (bar === 'some value') {
       return baz;
   }
   else {
       return qux;
   }
})();

这个改进在于我们不再需要改变任何变量。代码中,我们的IIFE不知道foo变量的存在。但是它通过作用域链在访问bar,baz和qux变量。让我们先处理baz与qux变量。我们把它们变成函数的入参(注意最后一行)。

let foo = (function(returnForTrue, returnForFalse) {
   if (bar === 'some value') {
       return returnForTrue;
   }
   else {
       return returnForFalse;
   }
})(baz, qux);

最终,我们需要处理一下bar。我们可以把它当做变量处理,但是那样做的话会导致只能处理与一些值的比较。假如我们把整个条件当做一个函数入参处理,函数就能变得更加灵活。

let foo = (function(returnForTrue, returnForFalse, condition) {
       if (condition) {
           return returnForTrue;
       }
       else {
           return returnForFalse;
       }
   })(baz, qux, (bar === 'some value'));

现在我们可以把整个函数提取出来(记得避免使用else)。

function conditional(returnForTrue, returnForFalse, condition) {
   if (condition) {
       return returnForTrue;
   }
   return returnForFalse;
}

let foo = conditional(baz, qux, (bar === 'some value')

截至目前,我们做了什么?我们已经抽象设置值的if语句。如果我们想要,我们都能像这样改造所有的if语句,只要这些if语句都是用来设置一个值。作为结果,我们用一个纯函数调用代替了到处都是的if语句。我们将删除一堆缩进并且改进了代码。

但是..我们实际上不需要conditional这个函数。我们已经有了三元操作符来实现同样的功能。

let foo = (bar === 'some value') ? baz : qux;

三元操作符是简洁的,内置于语言中。我们不需要再编写或导入特殊的函数获得所有相同的优势。三元操作符真正唯一的缺点就是你实际上不能对三元操作符使用curry() 和 compose()【2】。所以请给三元操作符一个机会。看看你是否能够用三元操作符来重构你的if语句。至少你能获得如何构建代码的新视角

切换switch

JavaScript有另一种条件结构,如同if语句。Switch语句就是另一种引入缩进的控制结构,并且使用它会带来复杂性。稍后我们将会看看如果编写没有switch语句的代码。但是首先,我想说几句关于它的优点。

Switch语句是我们在JavaScript最接近模式匹配(pattern matching)的【3】。模式匹配是一个好事。计算机科学技术建议我们用模式匹配来代替if语句。因此,我们可以很好地使用switch语句。

Switch语句同时允许定义针对多种情况的单一响应。再次强调,这有点像其他语言中的模式匹配。在某些情况下,这种用法非常方便。所以再说一遍,switch语句并不总是坏的。

在许多情况下我们应该重构switch语句。我们来看看一个例子。回想一下我们之前提到的发光体的论坛例子。假设我们有三种类型的通知。一个科学家可能收到通知当他:

  • 有人引用了他们发表的论文

  • 有人开始加入他们的工作

  • 有人在一篇文章中提到他们

我们有不同的图标与文字格式来显示每一种通知。

let notificationPtrn;
switch (notification.type) {
   case 'citation':
       notificationPtrn = 'You received a citation from {{actingUser}}.';
       break;
   case 'follow':
       notificationPtrn = '{{actingUser}} started following your work';
       break;
   case 'mention':
       notificationPtrn = '{{actingUser}} mentioned you in a post.';
       break;
   default:
       // Well, this should never happen
}

// Do something with notificationPtrn

有一件事情让switch语句变的有点惹人厌,那就是太容易忘记写break了。但是如果我们把switch包裹在一个函数内,我们就能使用之前提过的“早点返回,经常返回”的策略。这代表我们可以摆脱break语句的困扰。

function getnotificationPtrn(n) {
       switch (n.type) {
           case 'citation':
               return 'You received a citation from {{actingUser}}.';
           case 'follow':
               return '{{actingUser}} started following your work';
           case 'mention':
               return '{{actingUser}} mentioned you in a post.';
           default:
               // Well, this should never happen
       }
   }

   let notificationPtrn = getNotificationPtrn(notification);

这样写就好多了。我们现在有一个纯函数来代替修改变量。但是我们可以使用简单JavaScript对象(POJO)来实现同样的结果。

function getNotificationPtrn(n) {
   const textOptions = {
       citation: 'You received a citation from {{actingUser}}.',
       follow:   '{{actingUser}} started following your work',
       mention:  '{{actingUser}} mentioned you in a post.',
   }
   return textOptions[n.type];
}

这个版本能够实现之前的一样的效果。代码变的更加紧凑。但它是否更加简单?

我们所做的事就是用数据代替一种控制结构。这比我们想象中的更有意义。现在我们能够把textOptions作为getNotification的函数入参。例如:

const textOptions = {
   citation: 'You received a citation from {{actingUser}}.',
   follow:   '{{actingUser}} started following your work',
   mention:  '{{actingUser}} mentioned you in a post.',
}

function getNotificationPtrn(txtOptions, n) {
   return txtOptions[n.type];
}

const notificationPtrn = getNotificationPtrn(txtOptions, notification);

起初这段代码看起来不太有趣。但是现在思考一下,textOptions是一个变量。并且该变量不用再被硬编码了。我们可以把它移动到一个JSON配置文件中,或者从服务器获取该变量。现在我们想怎么改textOptions就怎么改。我们可以增加新的选项,或者移除选项。我们也可以把多个地方不同的选项合并到一起。同时这个版本有更少的代码缩进。

你可能注意到了,这个版本我们没有代码处理未知的通知类型。如果用switch语句,我们可以用default实现处理未知的选项。当遭遇未知的类型时,我们可以在default中抛出未知错误,或者返回一段有意义的消息给用户。例如:

function getNotificationPtrn(n) {
   switch (n.type) {
       case 'citation':
           return 'You received a citation from {{actingUser}}.';
       case 'follow':
           return '{{actingUser}} started following your work';
       case 'mention':
           return '{{actingUser}} mentioned you in a post.';
       default:
           throw new Error('You’ve received some sort of notification we don’t know about.';
   }
}

现在我们能够处理未知的通知类型了。但是我们又再次了使用switch语句。我们能否在POJO的方式中处理类似的情况?

一种选项就是使用if 语句:

function getNotificationPtrn(txtOptions, n) {
   if (typeof txtOptions[n.type] === 'undefined') {
       return 'You’ve received some sort of notification we don’t know about.';
   }
   return txtOptions[n.type];
}

但是我们之前说了,我们要尝试去除我们的if语句。所以这种选项并不理想。幸运的是,我们可以利用JavaScript中的弱类型优势结合布尔逻辑来实现我们的目标。在||运算符中,如果第一部分是假值,JavaScript会直接返回第二部分。如果我们传递了未知的通知类型给对象,对象会返回一个undefined结果。在JavaScript中会将undefined作为假值。所以我们可以像这样利用or 表达式:

function getNotificationPtrn(txtOptions, n) {
   return txtOptions[n.type]
       || 'You’ve received some sort of notification we don’t know about.';
}

我们也可以把默认的信息作为参数。

const dflt = 'You’ve received some sort of notification we don’t know about.';

function getNotificationPtrn(defaultTxt, txtOptions, n) {
   return txtOptions[n.type] || defaultTxt;
}

const notificationPtrn = getNotificationPtrn(defaultTxt, txtOptions, notification.type);

看看现在这个版本,是否比switch版本好多了?答案像平常一样,“看情况”。有些人可能会认为这个版本对于初学者难以阅读。这个问题客观存在。为了理解这个版本,你不得不要学习JavaScript如何强制转化变量为布尔类型。但是这个观点问的问题实际上是:“难道这个问题是因为它很复杂,还是因为它不熟悉”。因为不熟悉所以我们要接受更复杂的代码?

但是这段代码难道不是更简单了?让我们看看最新的函数。如果我们把函数取一个更通用的名字(并调整了最后的参数),看看现在是怎么样的?

function optionOrDefault(defaultOption, optionsObject, switchValue) {
       return optionsObject[switchValue] || defaultOption;
   }

我们可以创建像下面一样创建getNotificationPtrn 函数。

const dflt = 'You’ve received some sort of notification we don’t know about.';

   const textOptions = {
       citation: 'You received a citation from {{actingUser}}.',
       follow:   '{{actingUser}} started following your work',
       mention:  '{{actingUser}} mentioned you in a post.',
   }

   function getNotificationPtrn(notification) {
       return optionOrDefault(dflt, textOptions, notification.type);
   }

现在我们有了一个非常清晰的关注点分离(separation of concerns)。文字选项与默认消息现在都是纯数据。它们不在被包含在控制结构中。同时我们有一个便利的函数,optionOrDefault,用于处理类似的情况。数据与选择要显示哪个选项的任务完全分离。

这种模式对于处理返回静态数值的情况非常方便。根据我的经验,可以在60~70%的情况中取代switch语句[4]。但是如果我们想做一些更加有趣的事情?想象一下,如果我们选项中包含了函数而不是字符串?本文已经太长了,所以我们不会再详细讨论这种情况。不过这种情况值得我们思考。

像往常一样,小心地使用你的大脑。optionOrDefault函数可以替代许多switch语句。但不是所有的情况都能这样代替。某些情况下,使用switch更加合理。

总结

重构条件比删除循环需要更多的精力。因为我们以不同的方式使用条件语句,而循环通常配合数组一起使用。但是我们可以应用一些简单的模式来让条件减少交叉。它们包括:“早点返回,经常返回“(return early,return often)、“使用三元操作符”、“用对象代替switch语句”。这些都不是万能银弹,而是打击复杂度的利器。

1. Joel Thom 不同意这个观点。不过我们都主张用三元操作符代替if语句

2. Curry 和compose 都是我们在函数式编程大量使用的工具。如果你没有接触过这些,可以阅读这篇文章。特别是第三部分与第四部分。

3. 也就是说,你眯着眼看,可能觉得switch语句有点想模式匹配。不过只有你使用“早点返回”的策略才会这样。如果你不早点返回,switch语句总是会一团糟。

4. 这里的数据仅仅是一个猜测。我没有真实数据支持这一点。

关于本文

译者:@晒太阳的鱼
译文:https://zhuanlan.zhihu.com/p/26633547
作者:@James Sinclair
原文:http://jrsinclair.com/articles/2017/javascript-but-less-iffy/#fnref:3

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存