【译】深层嵌套的Promise链解明

如果你已经写了一段时间的 JavaScript 代码,你大概会听过像是回调地狱或者是噩梦金字塔 这类的说法。在几年前 promises 被添加到 JavaScript 中的时候。我记得我阅读到大量博客文章宣称回调地狱这类问题会得到解决。不幸的是,这看法稍微太乐观了些。随着越来越多的网页的 API 变得以 promise 为基础。我们会证明即使是 promise 也不能阻止我们写出过分嵌套且难以阅读的代码。

这些天我在完全基于 promise 的Service Worker 脚本中看到这件事发生了许多次。特别是在那些应当向我们展示 Service Worker 正确使用方法的博客文章跟教程当中。

下面的代码是一个关于如何使用 Serivce Worker 来实现一个网络优先,带缓存回调的离线支持方案的所谓的”基础”例子。
就是这个代码(或者是他的一个变形版本)可以在网上的多个教程中找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('cache:v1').then((cache) => {
return fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
}).catch(() => {
return cache.match(event.request).then((response) => {
return response || Response.error();
});
});
})
);
});

嗯,我相信这个代码会在这里被称之为“基础”是经过考虑的,因为这里只有几行代码,并且涉及到概念也不算复杂,但是我要质疑的一点是,这个控制流可以说是任何别的什么,但称不上基础。除非你非常熟悉 FetchCacheStorage 这两个新API,又或是你对 promises 所有的细微之处有着坚实的理解。不然这个代码大概会让你神思好几秒来弄清楚这些代码到底做了些什么。

从个人经验来说,我在一个星期以前开始第一次捣鼓 Service Worker :我想要添加一个基础的缓存和离线的分析到这个网站(指的是原作者的blog吧)。但是在我完成我最初的实现之后,我还挺不满意我写的代码的。它并不清晰,也不足以自我说明的的,同时,它看起来相对于我尝试要解决的这个简单问题来说,太过复杂了。

在我花了几个小时去重构代码并认真研究那些新的 API 之后,我得出了一些方案来改善我的代码的可阅读性,因此我想分享出来。这些方案可以归类到一般的软件开发建议,以及最好的实践方案是借力于新的javaScript 语言特性,比如说像是 Async 函数。

注意:我这里不是要指责那些写了 Service Worker 教程的人。我从那些教程中学到了非常多的东西,我认为那些是无法去衡量价值的。而我同样理解在写博客文章的时候,一个简洁的例子的必要性。

这篇文章主要目的是促使那些教程的读者们,去确保他们自己的实现方案有着可读性以及可维护的。从而拒绝那些没有经过全盘理解的,只是简单地从例子模板当中复制粘贴的行为。

这段代码做了什么?

在我开始谈论如何优化这段代码之前,我想要确保每个人都非常明白地理解了这段代码做了什么。

当添加了一个 fetch 来监听一个 Service Worker 事件的时候,你通常会调用 event.respondWith 并传递一个 promise 来处理一个 Response 对象。但是当你传递一个类似于上面的例子中的 promise 链(带有多级嵌套的thencatch 调用),其中那些可能发生的 resolution 中的关键就变得相当难以被发现。

这里用最简洁的文字,给出了一个对于上面的代码例子中的,fetch 事件处理方法内部到底发生了什么,一步步渐进式的解释:

  1. event 对象调用了 respoondWith() 然后传入了一个最终会处理到一个 Response 对象的 promise 链
  2. 这个 promise 链开始于打开 cache:v1 这个缓存调用。
  3. 一旦这个缓存被打开, 它会让一个 fetch() 去向网络请求由 event.request 指定的请求对象。
  4. 如果 fetch() 请求成功:
    a. 它会将这个网络请求的响应复制一份,放到这个缓存中。
    b. 它会返回这个响应作为 promise 的 resolves
  5. 如果 fetch() 请求失败:
    a. 它会尝试从这个缓存中寻找一个匹配的请求。
    b. 如果这个匹配找得到
    i. 它会返回这个缓存响应作为 promise 的 resolves
    
    c. 如果这个匹配找不到
    i. 它会将一个通用的 `Responese.error()` 对象作为 promise 的 resolves
    
    我上面提到的responWith() 会得到一个最终 resolves 到一个 Response 对象的 promise。在上面的逻辑之外, resolution 可能发生在三个不同的地方 4.b, 5.b.i5.c.i

这个代码该如何被优化

接下来的每一章都会介绍一个技巧或者一个原理来帮助你的代码变得更加可读的,并最终让你(或者别的人)在将来干活的时候更加容易。这些技巧会从简单的开始,然后逐渐变得越来越复杂。

给变量一个更有描述性名称

当你在写着 Service Worker 代码的时候,你会发现你在处理相当多的 RequsetResponse 对象,必须说,在你代码中所有出现这些对象的地方,都只用上面的名称去命名这个做法可能会相当诱人的。并且如果每一个 Request 或者 Response 对象只出现在他们独自的不同的作用域当中的话。我们并没有一个技术上的理由,要去改变他们的命名。

然而,在我们这个Service Worker fetch 例子的案例当中,这里存在着不同的逻辑分支,这些的分支可能会导致两个完全不同类型的响应:一个网络的响应或者是一个缓存的响应。

要告诉阅读代码的人哪个条件会导致哪个结果在,最简单的方法是给每一个 Response 对象一个指明其条件的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('cache:v1').then((cache) => {
return fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
}).catch(() => {
return cache.match(event.request).then((cacheResponse) => {
return cacheResponse || Response.error();
});
});
})
);
});

避免代码看起来就像一个错误

如果你看到我们原始例子中的代码的最后五行,这就是你会看到的。请注意,在中间的那一行行末是没有分号的。

1
2
3
4
5
6
// ...
});
});
})
);
});

当我第一次看到这里,我以为这只是一个忘了写分号的失误,但是我错了。

事实上,这个表达式开始于caches.open() 并且这里被分离成自己一行还加上缩进——想必是为了避免在一行里面塞进太多的逻辑。

虽然我是支持那种去分割这段代码为更多可管理的小块的冲动。但如果用这种方法来做,只会使得它看起来像是一个错误(这个分号实在是令人困惑)

如果你有一个非常长/非常复杂的的表达式,让你感觉到需要从视觉上分离它,为什么不实际上去分离它呢:
将这个表达式移到别的地方,并把这个表达式的结果分配给一个变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
self.addEventListener('fetch', (event) => {
const networkOrCacheResponse = caches.open('cache:v1').then((cache) => {
return fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
}).catch(() => {
return cache.match(event.request).then((cacheResponse) => {
return cacheResponse || Response.error();
});
});
});
event.respondWith(networkOrCacheResponse);
});

抽象逻辑单位为单个功能的函数

将一个复杂的表达式的结果赋值给一个变量,再把它传递给别的方法是非常有利于提高可读性的。但是我们还可以做的更好。

就拿上面的示例代码来说,networkOrCacheResponse 对象只能在这个特定的 fetch 方法当中使用。如果你想要在别的地方使用 网络优先带有缓存回调 的方法,你会需要去重写这段逻辑。

为了解决这个问题,我们可以写一个工具函数来接收一个 Reauest对象,并返回一个会选择请求网络或者缓存响应来处理这request的 promise。这个函数看起来可能会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const getNetworkOrCacheResponse = (request) => {
return caches.open('cache:v1').then((cache) => {
return fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
}).catch(() => {
return cache.match(request).then((cacheResponse) => {
return cacheResponse || Response.error();
});
});
});
};
self.addEventListener('fetch', (event) => {
event.respondWith(getNetworkOrCacheResponse(event.request));
});

现在这个方法变得可读多了,但是它依然有些复杂。

你会注意到这里有相当多个层级的 promise 嵌套。当你在某个地方看到非常多的嵌套在一个函数中的时候,这通常意味着这个函数做了太多工作了。换句话说,它的责任太重了。

getNeetworkOrCacheResponse 这个函数包含了两个关注点非常分离的逻辑:

  • 发出一个网络请求
  • 与缓存互动

为了改善这个函数的可阅读性,我们可以抽象出与缓存相关的逻辑,分离到单独一个函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const addToCache = (request, networkResponse) => {
return caches.open('cache:v1')
.then((cache) => cache.put(request, networkResponse.clone()));
}
const getCacheResponse = (request) => {
return caches.open('cache:v1').then((cache) => {
return cache.match(request);
});
}
const getNetworkOrCacheResponse = (request) => {
return fetch(request).then((networkResponse) => {
addToCache(request, networkResponse);
return networkResponse;
}).catch(() => {
return getCacheResponse(request)
.then((cacheResponse) => cacheResponse || Response.error());
});
};
self.addEventListener('fetch', (event) => {
event.respondWith(getNetworkOrCacheResponse(event.request));
});

如果你比较过这个新的逻辑和一开始的方法。你会注意到一个非常重要的不同。在原始的代码中。第一件发生的事情是调用caches.open(),同时它的调用只会发生一次。在新的代码中,在每一次这个工具函数与缓存打交道的时候都会调用caches.open()

第一眼看到它,可能会让人觉得这个抽象最终是做了多于的事。而实际上这是对原始代码的一种优化。这一点在我开始分离代码之后才发现到。

试想一下在 Service Worker 成功请求到一个网络响应的情况。这是最常见的情形,因此我们应当优化它(即尽可能快的得到响应给用户)。在原始的代码中。第一步逻辑是打开一个缓存,然后只在缓存开着的时候才会去请求网络响应。

这绝对不是必要的。当我们真的需要在网络响应的情形中去写一个缓存事件的时候,写入的过程并不需要阻塞给用户的响应。这两件事可以简单的并行化。

如果你好奇为什么将函数写成单责任的函数,是如此重要的事情。这里有两条基础的理由:

  • 这样函数会更加可重用(一个函数所负的责任越多,它就越是一个针对特定使用场景的具体方法)
  • 这样的函数更加容易被测试。(只做各自的一件事打的N个方法,可以被N个测试所测试。相比之下,一个函数做了N件事情通常会需要去测试 N!种可能输出)

使用 Async 函数来去除完全嵌套

或许改善复杂的 promise 代码的最好途径就是使用 async 函数,一个新的 javascript 特性,被用于让异步逻辑读起来更像同步代码。

Async 函数需要通过新的 async 关键字来声明,为了替代立即返回的值,他们会返回一个 最终会 reslove 到这个函数的返回值的 promise 对象。

在一个 async 函数的内部,你通常会找到一个或多个 await 关键字。await 关键字会提前设置置一个 promise (或者是一个解析为 promise的表达式),当解析器解析到这个await关键字,它就会停止执行这个函数,直到这个 promise 被处理掉。一旦这个promise 被处理掉,这个等待已久的表达式就会返回那个promise 处理的结果。

说明得更加清楚,试想想上面一个章节定义的getNetworkOrCacheResponse() 。如果它是一个 async 函数的话,就是他该有的样子:

1
2
3
4
5
6
7
8
9
10
const getNetworkOrCacheResponse = async (request) => {
try {
const networkResponse = await fetch(request);
addToCache(request, networkResponse);
return networkResponse;
} catch (err) {
const cacheResponse = await getCacheResponse(request);
return cacheResponse || Response.error();
}
}

在这个函数的 async版本中,有这样一些需要注意的重要事情。

  • 你可以在 try/catch 作用域中使用 .then()/.catch() 链,并且这里处理错误异常的方法就像在普通的一个try/catch块中那样。
  • 由于await停止了执行并等待一个 promise的返回,那些原本多重层级的嵌套现在可以像是顺序执行那样响应了,最上面哪一行定义了这个表达式(没有用到嵌套)。
  • 由于 一个 async 函数是一个去 resolve 它要返回的值的语法糖,这使得当一个promise 响应发生的时候变得更加清晰。如果是用嵌套的promise 链,它的响应可能会更加模糊些。

这个优化相当之有意义!他们让你的代码更加易于阅读和书写。

立即使用 async 函数

Async 函数已经出现了一段时间了,你可以通过 Babel 去编译 Async 为 ES5代码。然而,为了使用编译过的代码,你也需要引入 babel-plyfill 库(其中的带有 Facebook的环境运行时)。

这对一些项目来说这是可能不太能被接受的,被添加的polyfill的代码大小对于一个50行的 Service Worker 脚本来说是不可接受的高(在我的测试中它添加了将近60k)

幸运的是,当它使用于 Service Worker 脚本的时候,这里还有别的方法。

所有的浏览器都支持 Service Worker 的同时也会支持到 es2015 的特性(特别是他们会支持 generators),你可以避免添加运行时并且只用单个Babel 转化器:async-to-generator 来编译你的代码。

在我这个站点的 Service Worker 脚本中,使用async-to-generator 转化器仅仅只添加额外的258 字节,并且由于我已经使用了 browserify去加载依赖,我可以无脑地使用 async 函数!

都用起来

有了这里提到的那么多的优化方法,比较一下原始的代码和重构后的代码吧

原来的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('cache:v1').then((cache) => {
return fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
}).catch(() => {
return cache.match(event.request).then((response) => {
return response || Response.error();
});
});
})
);
});

重构以后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const addToCache = async (request, networkResponse) => {
const cache = await caches.open('cache:v1');
cache.put(request, networkResponse.clone());
};
const getCacheResponse = async (request) => {
const cache = await caches.open('cache:v1');
const cachedResponse = await cache.match(request);
return cachedResponse;
};
const getNetworkOrCacheResponse = async (request) => {
try {
const networkResponse = await fetch(request);
addToCache(request, networkResponse);
return networkResponse;
} catch (err) {
const cacheResponse = await getCacheResponse(request);
return cacheResponse || Response.error();
}
};
self.addEventListener('fetch', (event) => {
event.respondWith(getNetworkOrCacheResponse(event.request));
});

虽然重构的版本更加长还包含了更多的代码,但是它毫无疑问更易于阅读和理解,这意味着这段代码在将来更新起来更加容易。这也更加模块化,从而更易于被测试和到其他地方复用。

这篇文章介绍了几个概念和方法去帮助你重构复杂的,基于promise的代码为独立可复用的部分。希望这些被介绍到的技术是会有用的。至少,我希望我能促使你去努力让你的代码尽可能地具有可读性和可维护性。