01 JAN

编写更好的JavaScript条件式和匹配条件的技巧

 介绍

如果你像我一样乐于见到整洁的代码,那么你会尽可能地减少代码中的条件语句。通常情况下,面向对象编程让我们得以避免条件式,并代之以继承和多态。我认为我们应当尽可能地遵循这些原则。

正如我在另一篇文章 JavaScript 整洁代码的最佳实践里提到的,你写的代码不单单是给机器看的,还是给“未来的自己”以及“其他人”看的。

从另一方面来说,由于各式各样的原因,可能我们的代码最终还是会有条件式。也许是修复 bug 的时间很紧,也许是不使用条件语句会对我们的代码库造成大的改动,等等。本文将会解决这些问题,同时帮助你组织所用的条件语句。

技巧

以下是关于如何构造 if...else 语句以及如何用更少的代码实现更多功能的技巧。阅读愉快!

1. 要事第一。小细节,但很重要

不要使用否定条件式(这可能会让人感到疑惑)。同时,使用条件式简写来表示 boolean 值。这个无须再强调了,尤其是否定条件式,这不符合正常的思维方式。

不好的:

const isEmailNotVerified = (email) => {  // 实现  }  if (!isEmailNotVerified(email)) {  // 做一些事...  }  if (isVerified === true) {  // 做一些事...  }

好的:

const isEmailVerified = (email) => {  // 实现  }  if (isEmailVerified(email)) {  // 做一些事...  }  if (isVerified) {  // 做一些事...  }

现在,理清了上面的事情后,我们就可以开始了。

2. 对于多个条件,使用 Array.includes

假设我们想要在函数中检查汽车模型是 renault 还是 peugeot。那么代码可能是这样的:

const checkCarModel = (model) => {  if(model === 'renault' || model === 'peugeot') {  console.log('model valid');  }  }  checkCarModel('renault'); // 输出 'model valid'

考虑到我们只有两个模型,这么做似乎也还能接受,但如果我们还想要检查另一个或者是几个模型呢?如果我们增加更多 or 语句,那么代码将变得难以维护,且不够整洁。为了让它更加简洁,我们可以像这样重写函数:

const checkCarModel = (model) => {  if(['peugeot', 'renault'].includes(model)) {  console.log('model valid');  }  }  checkCarModel('renault'); // 输出 'model valid'

上面的代码看起来已经很漂亮了。为了更进一步改善它,我们可以创建一个变量来存放汽车模型:

const checkCarModel = (model) => {  const models = ['peugeot', 'renault'];  if(models.includes(model)) {  console.log('model valid');  }  }  checkCarModel('renault'); // 输出 'model valid'

现在,如果我们想要检查更多模型,只需要添加一个新的数组元素即可。此外,如果它很重要的话,我们还可以将 models 变量定义在函数作用域外,并在需要的地方重用。这种方式可以让我们集中管理,并使维护变得轻而易举,因为我们只需在代码中更改一个位置。

3. 匹配所有条件,使用 Array.every 或者 Array.find

在本例中,我们想要检查每个汽车模型是否都是传入函数的那一个。为了以更加命令式的方式实现,我们会这么做:

const cars = [  { model: 'renault', year: 1956 },  { model: 'peugeot', year: 1968 },  { model: 'ford', year: 1977 }  ];  const checkEveryModel = (model) => {  let isValid = true;  for (let car of cars) {  if (!isValid) {  break;  }  isValid = car.model === model;  }  return isValid;  }  console.log(checkEveryModel('renault')); // 输出 false

如果你更喜欢以命令式的风格行事,上面的代码或许还不错。另一方面,如果你不关心其背后发生了什么,那么你可以重写上面的函数并使用 Array.every 或者 Array.find 来达到相同的结果。

const checkEveryModel = (model) => {  return cars.every(car => car.model === model);  }  console.log(checkEveryModel('renault')); // 输出 false

通过使用 Array.find 并做轻微的调整,我们可以达到相同的结果。两者的表现是一致的,因为两个函数都为数组中的每一个元素执行了回调,并且在找到一个 falsy 项时立即返回 false。

const checkEveryModel = (model) => {  return cars.find(car => car.model !== model) === undefined;  }  console.log(checkEveryModel('renault')); // 输出 false

4. 匹配部分条件,使用 Array.some

Array.every 匹配所有条件,这个方法则可以轻松地检查我们的数组是否包含某一个或某几个元素。为此,我们需要提供一个回调并基于条件返回一个布尔值。

我们可以通过编写一个类似的 for...loop 语句来实现相同的结果,就像之前写的一样。但幸运的是,有很酷的 JavaScript 函数可以来帮助我们完成这件事。

const cars = [  { model: 'renault', year: 1956 },  { model: 'peugeot', year: 1968 },  { model: 'ford', year: 1977 }  ];  const checkForAnyModel = (model) => {  return cars.some(car => car.model === model);  }  console.log(checkForAnyModel('renault')); // 输出 true

5. 提前返回而不是使用 if...else 分支

当我还是学生的时候,就有人教过我:一个函数应该只有一个返回语句,并且只从一个地方返回。如果细心处理,这个方法倒也还好。我这么说也就意味着,我们应该意识到它在某些情况下可能会引起条件式嵌套地狱。如果不受控制,多个分支和 if...else 嵌套将会让我们感到很痛苦。

另一方面,如果代码库很大且包含很多行代码,位于深层的一个返回语句可能会带来问题。现在我们都实行关注点分离和 SOLID 原则,因此,代码行过多这种情况挺罕见的。

举例来解释这个问题。假设我们想要显示所给车辆的模型和生产年份:

const checkModel = (car) => {  let result; // 首先,定义一个 result 变量  // 检查是否有车  if(car) {  // 检查是否有车的模型  if (car.model) {  // 检查是否有车的年份  if(car.year) {  result = `Car model: ${car.model}; Manufacturing year: ${car.year};`;  } else {  result = 'No car year';  }  } else {  result = 'No car model'  }  } else {  result = 'No car';  }  return result; // 我们的单独的返回语句  }  console.log(checkModel()); // 输出 'No car'  console.log(checkModel({ year: 1988 })); // 输出 'No car model'  console.log(checkModel({ model: 'ford' })); // 输出 'No car year'  console.log(checkModel({ model: 'ford', year: 1988 })); // 输出 'Car model: ford; Manufacturing year: 1988;'

正如你所看到的,即使本例的问题很简单,上面的代码也实在太长了。可以想象一下,如果我们有更加复杂的逻辑会发生什么事。大量的 if...else 语句。

我们可以重构上面的函数,分解成多个步骤并稍做改善。例如,使用三元操作符,包括 && 条件式等。不过,这里我直接跳到最后,向你展示借助现代 JavaScript 特性和多个返回语句,代码可以有多简洁。

const checkModel = ({model, year} = {}) => {  if(!model && !year) return 'No car';  if(!model) return 'No car model';  if(!year) return 'No car year';  // 这里可以任意操作模型或年份  // 确保它们存在  // 无需更多检查  // doSomething(model);  // doSomethingElse(year);  return `Car model: ${model}; Manufacturing year: ${year};`;  }  console.log(checkModel()); // 输出 'No car'  console.log(checkModel({ year: 1988 })); // 输出 'No car model'  console.log(checkModel({ model: 'ford' })); // 输出 'No car year'  console.log(checkModel({ model: 'ford', year: 1988 })); // 输出 'Car model: ford; Manufacturing year: 1988;'

在重构版本中,我们包含了解构和默认参数。默认参数确保我们在传入 undefined 时有可用于解构的值。注意,如果传入 null ,函数将会抛出错误。这也是之前那个方法的优点所在,因为那个方法在传入 null 的时候会输出 'No car'。

对象解构确保函数只取所需。例如,如果我们在给定车辆对象中包含额外属性,则该属性在我们的函数中是无法获取的。

根据偏好,开发者会选择其中一种方式。实践中,编写的代码通常介于两者之间。很多人觉得 if...else 语句更容易理解,并且有助于他们更为轻松地遵循程序流程。

6. 使用索引或者映射,而不是 switch 语句

假设我们想要基于给定的国家获取汽车模型。

const getCarsByState = (state) => {  switch (state) {  case 'usa':  return ['Ford', 'Dodge'];  case 'france':  return ['Renault', 'Peugeot'];  case 'italy':  return ['Fiat'];  default:  return [];  }  }  console.log(getCarsByState()); // 输出 []  console.log(getCarsByState('usa')); // 输出 ['Ford', 'Dodge']  console.log(getCarsByState('italy')); // 输出 ['Fiat']

上诉代码可以重构,完全去除 switch 语句。

const cars = new Map()  .set('usa', ['Ford', 'Dodge'])  .set('france', ['Renault', 'Peugeot'])  .set('italy', ['Fiat']);  const getCarsByState = (state) => {  return cars.get(state) || [];  }  console.log(getCarsByState()); // 输出 []  console.log(getCarsByState('usa')); //输出 ['Ford', 'Dodge']  console.log(getCarsByState('italy')); // 输出 ['Fiat']

或者,我们还可以为包含可用汽车列表的每个国家创建一个类,并在需要的时候使用。不过这个就是题外话了,本文的主题是关于条件句的。更恰当的修改是使用对象字面量。

const carState = {  usa: ['Ford', 'Dodge'],  france: ['Renault', 'Peugeot'],  italy: ['Fiat']  };  const getCarsByState = (state) => {  return carState[state] || [];  }  console.log(getCarsByState()); // 输出 []  console.log(getCarsByState('usa')); // 输出 ['Ford', 'Dodge']  console.log(getCarsByState('france')); // 输出 ['Renault', 'Peugeot']

7. 使用自判断链接和空合并

到了这一小节,我终于可以说“最后”了。在我看来,这两个功能对于 JavaScript 语言来说是非常有用的。作为一个来自 C# 世界的人,可以说我经常使用它们。

在写这篇文章的时候,这些还没有得到完全的支持。因此,对于以这种方式编写的代码,你需要使用 Babel 进行编译。你可以在自判断链接这里以及在空合并这里查阅。

自判断链接允许我们在没有显式检查中间节点是否存在的时候处理树形结构,空合并可以确保节点不存在时会有一个默认值,配合自判断链接使用会有不错的效果。

让我们用一些例子来支撑上面的结论。一开始,我们还是用以前的老方法:

const car = {  model: 'Fiesta',  manufacturer: {  name: 'Ford',  address: {  street: 'Some Street Name',  number: '5555',  state: 'USA'  }  }  }  // 获取汽车模型  const model = car && car.model || 'default model';  // 获取厂商地址  const street = car && car.manufacturer && car.manufacturer.address && car.manufacturer.address.street || 'default street';  // 请求一个不存在的属性  const phoneNumber = car && car.manufacturer && car.manufacturer.address && car.manufacturer.phoneNumber;  console.log(model) // 输出 'Fiesta'  console.log(street) // 输出 'Some Street Name'  console.log(phoneNumber) // 输出 undefined

因此,如果我们想要知道厂商是否来自 USA 并将结果打印,那么代码是这样的:

const checkCarManufacturerState = () => {  if(car && car.manufacturer && car.manufacturer.address && car.manufacturer.address.state === 'USA') {  console.log('Is from USA');  }  }  checkCarManufacturerState() // 输出 'Is from USA'

我无需再赘述如果对象结构更加复杂的话,代码会多么混乱了。许多库,例如 lodash,有自己的函数作为替代方案。不过这不是我们想要的,我们想要的是在原生 js 中也能做同样的事。我们来看一下新的方法:

// 获取汽车模型  const model = car?.model ?? 'default model';  // 获取厂商地址  const street = car?.manufacturer?.address?.street ?? 'default street';  // 检查汽车厂商是否来自 USA  const checkCarManufacturerState = () => {  if(car?.manufacturer?.address?.state === 'USA') {  console.log('Is from USA');  }  }

这看起来更加漂亮和简洁,对我来说,非常符合逻辑。如果你想知道为什么应该使用 ?? 而不是 || ,只需想一想什么值可以当做 true 或者 false,你将可能有意想不到的输出。

顺便说句题外话。自判断链接同样支持 DOM API,这非常酷,意味着你可以这么做:

const value = document.querySelector('input#user-name')?.value;

接下来我们来探究一下 JS 垃圾回收的机制。

引用

垃圾回收算法主要依赖于引用的概念。

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。

例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最初级的垃圾回收算法。

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。

由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用

如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

来看一个循环引用的例子:

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。 在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。 但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

再来看一个实际的例子:

var div = document.createElement("div"); div.onclick = function() {  console.log("click"); };

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。 此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。 一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。

标记清除算法

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。 简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。 但反之未必成立。

工作流程:

  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。

  2. 从根部出发将能触及到的对象的标记清除。

  3. 那些还存在标记的变量被视为准备删除的变量。

  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。


循环引用不再是问题了

再看之前循环引用的例子:

function f(){  var o = {};  var o2 = {};  o.a = o2; // o 引用 o2  o2.a = o; // o2 引用 o  return "azerty"; } f();

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。 因此,他们将会被垃圾回收器回收。

内存泄漏

什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。 否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。

内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。 这就要求实时查看内存的占用情况。

在 Chrome 浏览器中,我们可以这样查看内存占用情况

  1. 打开开发者工具,选择 Performance 面板

  2. 在顶部勾选 Memory

  3. 点击左上角的 record 按钮

  4. 在页面上进行各种操作,模拟用户的使用情况

  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

来看一张效果图:

JavaScript基础:如何管理JS中的内存?什么是内存泄漏和垃圾回收

我们有两种方式来判定当前是否有内存泄漏:

  1. 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏

  2. 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏

在服务器环境中使用 Node 提供的 process.memoryUsage 方法查看内存情况

console.log(process.memoryUsage()); // { // rss: 27709440, // heapTotal: 5685248, // heapUsed: 3449392, // external: 8772 // }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。

该对象包含四个字段,单位是字节,含义如下:

  • rss(resident set size):所有内存占用,包括指令区和堆栈。

  • heapTotal:"堆"占用的内存,包括用到的和没用到的。

  • heapUsed:用到的堆的部分。

  • external: V8 引擎内部的 C++ 对象占用的内存。

判断内存泄漏,以heapUsed字段为准。

常见的内存泄露案例

意外的全局变量

function foo() { bar1 = 'some text'; // 没有声明变量 实际上是全局变量 => window.bar1 this.bar2 = 'some text' // 全局变量 => window.bar2 } foo();

在这个例子中,意外的创建了两个全局变量 bar1 和 bar2

被遗忘的定时器和回调函数

在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数。 要记得回收这些回调函数。举一个 setInterval的例子:

var serverData = loadData(); setInterval(function() { var renderer = document.getElementById('renderer'); if(renderer) { renderer.innerHTML = JSON.stringify(serverData); } }, 5000); // 每 5 秒调用一次

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。 但如果你没有回收定时器,整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

闭包

在 JS 开发中,我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。 下面这种情况下,闭包也会造成内存泄露:

这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。 同时 unused 是一个引用了 originalThing 的闭包。

这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。 当这段代码被反复执行时,内存会持续增长。

DOM 引用

很多时候, 我们对 Dom