element 的 notification 消息组件解析
这是承继上一篇——不知道哪一篇提到我在公司内部写一套 ui 控件的下一篇。因为最近也需要实现一个类似 element 的 notification 消息组件。
因此便直接研究参考了 element 的实现源码,相对简单,于是写一下记下来。
作为一个前端开发者我总是寻找一些可以使得反馈路径尽可能短的工具,而我相信 linting 会是其中一个。当然,有了 IDE 与文本编辑器的集成你一定用过它们。另一方面,还有那些云端代码质量工具,比如像是 CodeClimate 等一些工具,又或者是你自定义的 CI 工具,因而你大概也有用到。不过在这两者之间有一个小小的夹缝。
想象以下情形:你向代码仓库提交了一个 PR, 里面包含了一些类似检查启用与一些快速提交的代码。几分钟之后,在你已经进入另一个任务状态中的时候,你突然收到一份邮件说 CI 步骤失败了,而且很有可能是因为忘记了分号!切换任务,修复它,提交,等待 CI 通过…… “我希望我可以在提交改动到代码仓库 以前 lint 这份代码 ” - 我无数次这样想到。
但很可惜的是,即使是在有 IDE 集成 linting 的地方,也没有能阻止我和我的同事在不同的项目中提交了带有 linting 错误的代码。在多数时候这不都是一个大问题,然而在极少数的一些情况下,这会令我在这些提交后,反过来花费数小时去寻找 bug 。明明只要我们不允许推送这些 💩 代码到代码仓库中,这些浪费时间的行为就可以被轻易的阻止!
我反对“初级开发者”这个头衔,因为它让我感到相当的困扰。虽然这看起来像是胡言乱语,而且也没有什么意义。但是一个来自推特的发言让我相信这仍有写出来的价值。
非常反感我们把那些拥有 2年以上开发经验 (一个理论物理学士学位或者十年经验的艺术家)称之为’初级’
— Yehuda Katz (@wycats) December 7, 2015
如果你已经写了一段时间的 JavaScript 代码,你大概会听过像是回调地狱或者是噩梦金字塔 这类的说法。在几年前 promises 被添加到 JavaScript 中的时候。我记得我阅读到大量博客文章宣称回调地狱这类问题会得到解决。不幸的是,这看法稍微太乐观了些。随着越来越多的网页的 API 变得以 promise 为基础。我们会证明即使是 promise 也不能阻止我们写出过分嵌套且难以阅读的代码。
这些天我在完全基于 promise 的Service Worker 脚本中看到这件事发生了许多次。特别是在那些应当向我们展示 Service Worker 正确使用方法的博客文章跟教程当中。
下面的代码是一个关于如何使用 Serivce Worker 来实现一个网络优先,带缓存回调的离线支持方案的所谓的”基础”例子。
就是这个代码(或者是他的一个变形版本)可以在网上的多个教程中找到。
|
|
嗯,我相信这个代码会在这里被称之为“基础”是经过考虑的,因为这里只有几行代码,并且涉及到概念也不算复杂,但是我要质疑的一点是,这个控制流可以说是任何别的什么,但称不上基础。除非你非常熟悉 Fetch
和 CacheStorage
这两个新API,又或是你对 promises 所有的细微之处有着坚实的理解。不然这个代码大概会让你神思好几秒来弄清楚这些代码到底做了些什么。
从个人经验来说,我在一个星期以前开始第一次捣鼓 Service Worker :我想要添加一个基础的缓存和离线的分析到这个网站(指的是原作者的blog吧)。但是在我完成我最初的实现之后,我还挺不满意我写的代码的。它并不清晰,也不足以自我说明的的,同时,它看起来相对于我尝试要解决的这个简单问题来说,太过复杂了。
在我花了几个小时去重构代码并认真研究那些新的 API 之后,我得出了一些方案来改善我的代码的可阅读性,因此我想分享出来。这些方案可以归类到一般的软件开发建议,以及最好的实践方案是借力于新的javaScript 语言特性,比如说像是 Async 函数。
注意:我这里不是要指责那些写了 Service Worker 教程的人。我从那些教程中学到了非常多的东西,我认为那些是无法去衡量价值的。而我同样理解在写博客文章的时候,一个简洁的例子的必要性。
这篇文章主要目的是促使那些教程的读者们,去确保他们自己的实现方案有着可读性以及可维护的。从而拒绝那些没有经过全盘理解的,只是简单地从例子模板当中复制粘贴的行为。
在我开始谈论如何优化这段代码之前,我想要确保每个人都非常明白地理解了这段代码做了什么。
当添加了一个 fetch
来监听一个 Service Worker 事件的时候,你通常会调用 event.respondWith
并传递一个 promise 来处理一个 Response
对象。但是当你传递一个类似于上面的例子中的 promise 链(带有多级嵌套的then
与catch
调用),其中那些可能发生的 resolution 中的关键就变得相当难以被发现。
这里用最简洁的文字,给出了一个对于上面的代码例子中的,fetch
事件处理方法内部到底发生了什么,一步步渐进式的解释:
event
对象调用了 respoondWith()
然后传入了一个最终会处理到一个 Response
对象的 promise 链cache:v1
这个缓存调用。fetch()
去向网络请求由 event.request
指定的请求对象。fetch()
请求成功:fetch()
请求失败:i. 它会返回这个缓存响应作为 promise 的 resolves
c. 如果这个匹配找不到i. 它会将一个通用的 `Responese.error()` 对象作为 promise 的 resolves
我上面提到的responWith()
会得到一个最终 resolves 到一个 Response
对象的 promise。在上面的逻辑之外, resolution 可能发生在三个不同的地方 4.b
, 5.b.i
和 5.c.i
。接下来的每一章都会介绍一个技巧或者一个原理来帮助你的代码变得更加可读的,并最终让你(或者别的人)在将来干活的时候更加容易。这些技巧会从简单的开始,然后逐渐变得越来越复杂。
当你在写着 Service Worker 代码的时候,你会发现你在处理相当多的 Requset
和 Response
对象,必须说,在你代码中所有出现这些对象的地方,都只用上面的名称去命名这个做法可能会相当诱人的。并且如果每一个 Request
或者 Response
对象只出现在他们独自的不同的作用域当中的话。我们并没有一个技术上的理由,要去改变他们的命名。
然而,在我们这个Service Worker fetch
例子的案例当中,这里存在着不同的逻辑分支,这些的分支可能会导致两个完全不同类型的响应:一个网络的响应或者是一个缓存的响应。
要告诉阅读代码的人哪个条件会导致哪个结果在,最简单的方法是给每一个 Response
对象一个指明其条件的名字:
|
|
如果你看到我们原始例子中的代码的最后五行,这就是你会看到的。请注意,在中间的那一行行末是没有分号的。
当我第一次看到这里,我以为这只是一个忘了写分号的失误,但是我错了。
事实上,这个表达式开始于caches.open()
并且这里被分离成自己一行还加上缩进——想必是为了避免在一行里面塞进太多的逻辑。
虽然我是支持那种去分割这段代码为更多可管理的小块的冲动。但如果用这种方法来做,只会使得它看起来像是一个错误(这个分号实在是令人困惑)
如果你有一个非常长/非常复杂的的表达式,让你感觉到需要从视觉上分离它,为什么不实际上去分离它呢:
将这个表达式移到别的地方,并把这个表达式的结果分配给一个变量。
|
|
将一个复杂的表达式的结果赋值给一个变量,再把它传递给别的方法是非常有利于提高可读性的。但是我们还可以做的更好。
就拿上面的示例代码来说,networkOrCacheResponse
对象只能在这个特定的 fetch
方法当中使用。如果你想要在别的地方使用 网络优先带有缓存回调 的方法,你会需要去重写这段逻辑。
为了解决这个问题,我们可以写一个工具函数来接收一个 Reauest
对象,并返回一个会选择请求网络或者缓存响应来处理这request的 promise。这个函数看起来可能会是这样的:
|
|
现在这个方法变得可读多了,但是它依然有些复杂。
你会注意到这里有相当多个层级的 promise 嵌套。当你在某个地方看到非常多的嵌套在一个函数中的时候,这通常意味着这个函数做了太多工作了。换句话说,它的责任太重了。
getNeetworkOrCacheResponse
这个函数包含了两个关注点非常分离的逻辑:
为了改善这个函数的可阅读性,我们可以抽象出与缓存相关的逻辑,分离到单独一个函数中:
|
|
如果你比较过这个新的逻辑和一开始的方法。你会注意到一个非常重要的不同。在原始的代码中。第一件发生的事情是调用caches.open()
,同时它的调用只会发生一次。在新的代码中,在每一次这个工具函数与缓存打交道的时候都会调用caches.open()
。
第一眼看到它,可能会让人觉得这个抽象最终是做了多于的事。而实际上这是对原始代码的一种优化。这一点在我开始分离代码之后才发现到。
试想一下在 Service Worker 成功请求到一个网络响应的情况。这是最常见的情形,因此我们应当优化它(即尽可能快的得到响应给用户)。在原始的代码中。第一步逻辑是打开一个缓存,然后只在缓存开着的时候才会去请求网络响应。
这绝对不是必要的。当我们真的需要在网络响应的情形中去写一个缓存事件的时候,写入的过程并不需要阻塞给用户的响应。这两件事可以简单的并行化。
如果你好奇为什么将函数写成单责任的函数,是如此重要的事情。这里有两条基础的理由:
或许改善复杂的 promise 代码的最好途径就是使用 async 函数,一个新的 javascript 特性,被用于让异步逻辑读起来更像同步代码。
Async 函数需要通过新的 async
关键字来声明,为了替代立即返回的值,他们会返回一个 最终会 reslove 到这个函数的返回值的 promise 对象。
在一个 async 函数的内部,你通常会找到一个或多个 await
关键字。await
关键字会提前设置置一个 promise (或者是一个解析为 promise的表达式),当解析器解析到这个await
关键字,它就会停止执行这个函数,直到这个 promise 被处理掉。一旦这个promise 被处理掉,这个等待已久的表达式就会返回那个promise 处理的结果。
说明得更加清楚,试想想上面一个章节定义的getNetworkOrCacheResponse()
。如果它是一个 async 函数的话,就是他该有的样子:
在这个函数的 async
版本中,有这样一些需要注意的重要事情。
try/catch
作用域中使用 .then()/.catch()
链,并且这里处理错误异常的方法就像在普通的一个try/catch
块中那样。await
停止了执行并等待一个 promise的返回,那些原本多重层级的嵌套现在可以像是顺序执行那样响应了,最上面哪一行定义了这个表达式(没有用到嵌套)。async
函数是一个去 resolve 它要返回的值的语法糖,这使得当一个promise 响应发生的时候变得更加清晰。如果是用嵌套的promise 链,它的响应可能会更加模糊些。这个优化相当之有意义!他们让你的代码更加易于阅读和书写。
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 函数!
有了这里提到的那么多的优化方法,比较一下原始的代码和重构后的代码吧
原来的代码:
重构以后:
虽然重构的版本更加长还包含了更多的代码,但是它毫无疑问更易于阅读和理解,这意味着这段代码在将来更新起来更加容易。这也更加模块化,从而更易于被测试和到其他地方复用。
这篇文章介绍了几个概念和方法去帮助你重构复杂的,基于promise的代码为独立可复用的部分。希望这些被介绍到的技术是会有用的。至少,我希望我能促使你去努力让你的代码尽可能地具有可读性和可维护性。