2016 年 9 月

第 31 卷,第 9 期

被动框架 - 利用被动扩展构建启用了 AJAX 的异步网页

作者 Peter Vogel

在之前的文章中,我讨论了如何使用观察者模式来管理长时间运行的任务 (msdn.com/magazine/mt707526)。在那篇文章的末尾,我展示了 Microsoft 被动扩展 (Rx) 如何提供一个简单的机制来管理一系列来自 Windows 应用程序中长时间运行进程的事件。

Rx 仅用于监视一系列来自长时间运行任务的事件,但是并未充分利用此技术。Rx 的魅力在于可用于将任何基于事件的进程与任何其他进程进行异步集成。在本文中,例如,我将使用 Rx 通过在网页上执行 button click 事件来异步调用 Web 服务(button click 是一个事件的有效序列)。要在客户端 Web 环境中使用 Rx,我将会使用适用于 JavaScript 的 Rx (RxJS)。

Rx 可提供一个标准方式来抽象化各种情况并使用类似 LINQ 的 fluent 界面(可从较简单的构建块编排应用程序)对其进行操作。借助 Rx,可同时集成 UI 事件和后端处理,保持其各自独立—你可以利用 Rx 重写 UI 而不必对后端进行相应更改(反之亦然)。

RxJS 还可在 HTML 和代码之间支持完全分离,这可以有效地实现数据绑定而无需特殊 HTML 标记。RxJS 也基于现有客户端技术(如 jQuery)构建而成。Rx 的另一个优势为:所有 Rx 实施看起来都非常类似—本文中的 RxJS 代码与我在之前文章中编写的 Microsoft .NET Framework 代码非常相似。你可以将在一个 Rx 环境中掌握的技能应用于任何 Rx 环境。

RxJS 入门

RxJS 通过将应用程序的某些部分抽象化为两个组来实现其目标。第一组的成员是可观察量:从根本上来说,是可触发事件的任意项。RxJS 提供了一组丰富的运算符来创建可观察量—可观察量包括触发事件时会显示的任意项(例如,Rx 可将数组转换为事件源)。Rx 运算符还可筛选和转换事件的输出。将可观察量视为事件源的处理管道,会是很有价值的。

第二组的成员是观察者,它们接受来自可观察量的结果并为来自可观察量的三个通知提供相应处理操作,其中这三个通知为新事件(具有其关联数据)、错误或事件结尾序列。在 RxJS 中,观察者可以是对象(具有的函数能够处理三个通知中的一个或多个通知)也可以是函数集合(每个函数对应一个通知)。

要将这两个组关联在一起,可观察量应为其管道订阅一个或多个观察者。

可以通过 NuGet(在 NuGet 库中,查找 RxJS-All)将 RxJS 添加到你的项目。出现一个警告,尽管: 首次将 RxJS 添加到配置有 TypeScript 的项目时,NuGet 管理器询问我是否也想要相关 TypeScript 定义文件。单击“是”可添加这些文件并会显示 400“重复定义”错误。我已停止接受此选项。

此外,有许多 Rx 支持库可为 RxJS 提供有用的插件。例如,RxJS-DOM(可通过 NuGet 获得,作为 RxJS 和 HTML 的桥梁)可同时集成事件、客户端 DOM 和 jQuery。在通过 RxJS 创建响应 Web 应用程序时此库必不可少。

正如大多数 Rx 的 JavaScript 插件,RxJS-DOM 将其新功能递交给属于 RxJS 库中心的 Rx 对象。RxJS-DOM 将 DOM 属性添加到具有多个有用功能(包括多个类似于桥接 jQuery 的功能)的 Rx 对象。例如,要使用 RxJS-DOM 进行 AJAX 调用以检索 JSON 对象,你应使用此代码:

return Rx.DOM.getJSON("...url...")

要同时使用 Rx 和 RxJS-DOM,你所需要的只是以下两个脚本标记:

<script src="~/Scripts/rx.all.js"></script>
<script src="~/Scripts/rx.dom.js"></script>

同时使用 rx.all.js 和 rx.dom.js 是一个重量级解决方案,因为它们包括来自两个库的所有功能。幸运的是,Rx 和 RxJS-DOM 在多个较轻的库中对其功能进行了分解,因此你可以仅将具有所需功能的库插入到页面(对于任何运算符,GitHub 上的文档将告诉你运算符属于哪个库)。

集成 RESTful 服务

JavaScript 应用程序中的一个典型情况是,用户单击按钮通过异步调用服务来检索 Web 服务中的结果。构建 RxJS 解决方案的第一步是将按钮上的 click 事件转换为可观察量,RxJS/DOM/jQuery 集成可轻松执行此操作。例如,此代码使用 jQuery 来检索具有 getButton ID 的窗体中元素的引用,然后通过元素的 click 事件来创建可观察量:

var getCust = $('#getButton').get(0);
var getCustObsvble = Rx.DOM.fromEvent(getCust, "click");

fromEvent 函数使你可以在任何元素上通过任何事件创建可观察量。但是,RxJS 包含多个更加“常用”事件(包括 click 事件)的快捷方式。例如,我还可以使用此代码通过按钮的 click 事件来创建可观察量:

var getCustObsvble = Rx.DOM.click(getCust);

我可以为此事件创建更加丰富的处理管道,以简化应用程序中的处理操作。例如,我想要避免处理以下情况:用户快速连续单击两次按钮(例如,对我来说太快,可禁用按钮以防止用户执行此操作)。通过将 debounce 函数添加到管道并指定我只想要看到至少有两秒间隔的单击,便可处理此情况,而无需编写一组超时代码:

var getCustObsvble = Rx.DOM.click(getCust).debounce(2000);

此外,我还可以使用 flatMapLatest 等在事件上执行某些处理,flatMapLatest 使我可以将转换函数插入管道(类似于 LINQ SelectMany)。基本函数—flatMap—可在每个事件上按顺序执行转换。flatMapLatest 函数更进一步并可处理与异步处理相关的典型问题:如果第二次调用转换且异步请求仍挂起,flatMapLatest 会取消先前的异步处理。

借助 flatMapLatest,如果我的一个用户单击了按钮两次(时间间隔多于两秒)且 flatMapLatest 仍在转换先前的事件,flatMapLatest 将取消先前的事件。从而使我无需编写代码来处理取消异步处理。

使用 flatMapLatest 的第一步是创建转换函数。为此,我将先前所述 getJSON 调用置于函数中。因为我的函数与按钮单击相关,所以我不需要来自页面的任何数据(实际上,此函数几乎不是合格的“转换”,因为它会忽略事件的输入)。

此处有一个函数请求 Web API 服务,其在过程中使用一些 jQuery 从页面的元素中检索客户 ID:

function getCustomer()
{
  return Rx.DOM.getJSON("api/customerorders/customerbyid/"
    + $("custId").val());
}

借助 RxJS,无需提供此请求的回叫;调用结果会自动化通过可观察量传递给任何观察者。

要将此转换集成到我的处理链,我只需从我的观察者调用 flatMapLatest,将引用传递给函数:

var getCustObsvble = Rx.DOM.click(getCust).debounce(2000).flatMapLatest(getCustomer);

现在我必须针对可观察量订阅处理函数。我最多可在订阅中分配三个函数:一个函数用于在检索到新事件时处理通知;一个函数用于报告任何错误通知;一个函数用于处理事件序列结束时发送的通知。因为我的 click 事件处理管道专门用于生成事件的序列,所以我不需要提供“序列结束”函数。

用于分配两个必需函数的生成的代码可能如下所示:

var getCustObsvble.subscribe(
  c   => $("#custName").val(c.FirstName),
  err => $("Message").text("Unable to retrieve Customer: "
    + err.description)
);

如果我认为我可能在其它位置找到了此函数集(或只是想简化我的订阅代码),我可以创建观察者对象。观察者对象具有的函数与我先前使用的相同,只是分配给名为 onNext、onError 和 onComplete 的属性。因为我不需要处理 onComplete,所以我的观察者对象将如下所示:

var custObservr = {
  onNext:  c   => $("#custName").val(c.FirstName),
  onError: err => $("#Message").text("Unable to retrieve Customer: "
     + err.description)
};

通过单独的观察者,我的可观察量用来订阅观察者的代码会变得更加简单:

var getCustObsvble.subscribe(custObservr);

将所有这些放在一起,我只需要为页面准备 ready 函数,以检索按钮、附加管道(将按钮转变为非常复杂的可观察量),然后针对管道订阅观察者。因为 RxJS 实施了 fluent 界面,所以我只用两行代码便可执行此操作:一行 jQuery 代码用于检索按钮元素,另一行 RxJS 代码用于构建管道和订阅我的观察者。但是,将 RxJS 管道构造和订阅代码分为两行会使程序员更易于读取 ready 函数。

我的 ready 函数的最终版本将如下所示:

$(function () {
  var getCust = $('#getButton').get(0);
  var getCustObsvble =
    Rx.DOM.click(getCust).debounce(2000).flatMapLatest(getCustomer);
  getCustObsvble.subscribe(custObservr);
});

还有一个额外的好处,此进程集成了 click 事件,Web 服务调用和数据显示异步执行。RxJS 会抽象化所有杂乱的详细信息,使我不再受到相关困扰。

抽象化事件序列

通过抽象化进程(并提供丰富的运算符集),Rx 使许多内容看起来一样。从而使你可以对进程进行重大更改,而不必对代码进行重大结构更改。

例如,因为 RxJS-DOM 处理具有一个事件 (button click) 的可观察量非常类似于生成事件连续序列的可观察量(例如 mousemove),我可以用对 UI 进行重大更改,而不必对我的代码执行太多更改。例如,我不想让用户通过 button click 触发 Web 服务请求,我将在用户键入客户 ID 时尽快检索客户数据。

我首先要更改的内容是要观察其事件的元素。在此情况下,即从页面上的按钮转换为页面上的文本框以包含客户 ID(我还将更改包含此元素的变量的名称):

var getCustId = $('#custId').get(0);

我可以将此文本框的任意数量事件转变为可观察量。例如,如果我在文本框中使用 blur 事件,我将在用户退出文本框时调用 Web 服务。但是,我想要提高响应速度。我改为选择在用户将“enough”字符键入文本框时立即检索客户对象。这意味着转换为会生成一系列事件的 keyup 事件:每一个对应于每个击键。

管道更改将如下所示:

var getCustObsvble = Rx.DOM.keyup(getCustId)

当用户键入字符时,我可以多次结束调用转换函数。实际上,如果用户键入的速度比我检索客户对象的速度更快,我将结束多个堆栈请求。我可以依靠 flatMapLatest 清除这些请求,但有更好的解决方案: 在我的客户订单管理系统中,客户 ID 始终是四个字符长度。因此,除非文本框中正好存在四个字符,否则调用转换函数将变得毫无意义(并且,对于记录,因为我的管道正在获取客户 ID 并返回完整的客户对象,我的转换函数实际上正在执行“转换”)。

要实现此情况,我只需将 Rx filter 函数添加到管道。filter 函数原理类似于 LINQ Where 子句: 必须由包含测试并根据最新事件的相关数据返回布尔值的其他函数(selector 函数)进行传递。只有这些通过测试的事件将被传递给订阅的观察者。filter 函数将自动按顺序递交最新事件对象,因此在我的 selector 函数测试中,我可以使用事件对象的目标属性来检索文本框的当前值,然后检查值的长度。

管道的另一个更改: 我将删除 debounce 函数,因为很难看到它对此新 UI 带来的优势。但是,对 UI 的交互进行重大更改后,创建可观察量和订阅观察者的已修订代码在结构上仍与先前版本相同:

var getCustObsvble = Rx.DOM.keyup(getCustId)
                       .filter(e => e.target.value.length == 4)
                       .flatMapLatest(getCustomer);
getCustObsvble.subscribe(custObservr);

当然,我完全不必对 cust­Observr 做任何更改: 它们不受 UI 更改影响。

我可以对转换函数进行另一个可选更改。我的转换函数始终通过三个参数进行传递—当事件源是按钮时我会忽略它们。传递给 flatMapLatest 转换函数的第一个参数是要观察事件的事件对象。我可以利用事件对象消除会检索客户 ID 的 jQuery 代码,而从事件对象检索文本框的值。此更改使我的代码更加宽松,因为我的转换函数不再与页面上的特定元素相关。

我的新转换函数将如下所示:

function getCustomer(e)
{
  return Rx.DOM.getJSON("api/customerorders/customerbyid/"
    + e.target.value);
}

这是 Rx 抽象的魅力所在: 从 single-­event-producing 元素更改为 sequence-of-events-producing 元素仅需调整标记管道的单行代码(使我有机会增强转换函数)。在所做的所有更改中,最容易产生问题的更改是重命名包含输入元素的变量。强调一下,此更改类似于 LINQ,利用 Rx 成功的秘诀在于熟悉变量运算符。

抽象化 Web 服务调用

Rx 抽象使对 UI 的更改看起来非常相似,良好的抽象还应对后端处理执行相同的操作。例如,假设修订了页面,因此我不仅可以检索客户数据,还可以用检索客户的销售订单,会怎么样? 为使其变得有趣,我将假设没有导航属性,这会使我检索销售订单作为客户对象的一部分,而且我必须进行第二次调用。

在 Rx 中,我首先需要创建第二个转换函数以将客户 ID 转换为客户订单集合:

function getCustomerOrders(e) {
  return Rx.DOM.getJSON("customerorders/ordersbycustomerid/"
    + e.target.value);
}

然后,我需要创建使用此转换函数的第二个可观察量(此代码非常类似于创建客户可观察量的代码):

var getOrdersObsvble = Rx.DOM.keyup(getCustId)
       .filter(e => e.target.value.length == 4)
       .flatMapLatest(getCustomerOrders);

此时,你可能会认为合并和协调这些可观察量会需要大量工作。但是,因为 Rx 使所有可观察量非常相似,因此你可以将来自多个可观察量的输出合并到可由单个(相对简单)观察者处理的单个序列。

例如,如果我有多个可观察量从多个源检索订单,我可以使用 Rx 的 merge 函数将所有订单加入到单个序列中,其中我可以针对此序列订阅单个观察者。例如,以下代码将检索当前订单 (getCurrentOrdersObsvble) 和已发布订单 (getPostedOrdersObsvle) 的可观察量合并到一起;然后将生成的序列传递给名称为 allOrdersObservr 的单个观察者:

Rx.Observable.merge(getCurrentOrdersObsvble, getPostedOrdersObsvble)
             .subscribe(allOrdersObservr);

对我而言,我想要做一些更有趣的事情:将检索客户对象的可观察量与检索此客户所有订单的可观察量合并。幸运的是,RxJS 有一个函数可实现此操作:combineLatest。combineLatest 运算符接受两个可观察量和一个 processing 函数。传递给 combineLatest 的 processing 函数从每个序列传递最新结果并让你指定如何混合结果。对我而言,combineLatest 提供的功能比我需要的要多,因为每个可观察量只有一个结果:客户对象来自一个可观察量,所有客户订单来自另一个。

在以下代码中,我将新的属性(称为 Orders)添加到了第一个可观察量的客户对象,然后将来自第二个可观察量的结果放入此属性。最后,我订阅一个名称为 CustOrdersObsrvr 的观察者来处理新的客户+订单对象:

Rx.Observable.combineLatest(getCustObsvble, getOrdersObsvble,
                           (c, ords) => {c.Orders = ord; return c;})
             .subscribe(CustOrdersObsrvr);

现在,我的 custOrdersObservr 可以处理创建的新对象:

var custOrdersObservr = {
  onNext: co => {
    // ...Code to update the page with customer and orders data...               
  },
  onError: err => $("#Message").text("Unable to retrieve Customer and Orders: "
    + err.description)
};

总结

通过将应用程序的组件抽象化为两类对象(可观察量和观察者)并提供一组丰富的运算符来管理和转换来自可观察量的结果,RxJS 提供了一种极其灵活的方式来创建 Web 应用程序。

当然,使用功能强大的库将复杂进程抽象化为简单代码始终存在一个风险: 应用程序执行意外操作时,调试问题会令人头痛不已,因为你无法查看被抽象消除的各个代码行。当然,你不必再编写所有代码,且与编写代码相比,(可能)抽象层将有更少的缺陷。这是功能和可见性之间常见的利弊权衡。

决定使用 RxJS,可给你带来的真正优势是你可以对应用程序进行重大更改,而不必对代码进行重大更改。


Peter Vogel 是 PH&V Information Services 的系统架构师兼主管。PH&V 提供从 UX 设计到对象建模和数据库设计的全面堆栈咨询服务。你可以通过 peter.vogel@phvis.com 与他取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Stephen Cleary、James McCaffrey 和 Dave Sexton
Stephen Cleary 16 年来一直从事多线程处理和异步编程的工作,自从第一个社区技术预览版出现以来,他就一直在 Microsoft .NET Framework 中使用异步支持。他著有《Concurrency in C# Cookbook》(C# 中的并发指南)(O’Reilly Media,2014)一书。他的主页(包括博客)位于 stephencleary.com

ScriptoJames McCaffrey 供职于华盛顿地区雷蒙德市沃什湾的 Microsoft Research。他参与过多个 Microsoft 产品的工作,包括 Internet Explorer 和 Bing。Scripto可通过 jammc@microsoft.com 与 McCaffrey 取得联系。