轻量函数式 JavaScript:十、函数式异步

简介:

这本书读到这里,你现在拥有了所有 FP —— 我称之为 “轻量函数式编程” —— 基础的原始概念。在这一章中,我们会将这些概念应用于一种不同的环境,但不会出现特别的新想法。

至此,我们做的所有事情几乎都是同步的,也就是说我们使用立即的输入调用函数并立即得到输出值。许多工作可以用这种方式完成,但对于一个现代 JS 应用程序的整体来说根本不够用。为了真正地对 JS 的现实世界中的 FP 做好准备,我们需要理解异步 FP。

我们本章的目标是将我们对使用 FP 进行值的管理的思考,扩展至将这样的操作分散到一段时间上。

作为状态的时间

在你的整个应用程序中最复杂的状态就是时间。也就是说,如果状态在你坚定的控制之下立即地从一种状态转换到另一种,那么状态管理就容易多了。当你的应用程序的状态为了响应分散在一段时间上的事件而隐含地变化时,它的管理难度就会呈几何级数增长。

通过使代码更可信与更可预测来使它更易于阅读 —— 我们在这本书中展示 FP 的方式的每一部分都与此有关。当你在程序中引入异步的时候,这些努力将受到很大冲击。

让我们说的更明白一点:一些操作不会同步地完成,就单纯这一点来说不是我们关心的;发起异步行为很容易。需要很多额外努力的是,如何协调这些动作的应答,这些应答中的每一个都会潜在地改变你应用程序的状态。

那么,是你作为作者为此努力好呢?还是将这个问题留给你代码的读者,让他们自己去搞清如果 A 在 B 之前完成(或反之)程序将是什么状态?这是一个夸张的问题,但从我的观点来说它有一个十分坚定地答案:为了使这样复杂的代码更具可读性,作者必须要付出比平常多得多的努力。

递减时间

异步编程最重要的成果之一,是通过将时间从我们的关注范围中抽象出去来简化状态变化管理。

为了展示这一点,我们首先来看一个存在竟合状态(也就是,时间复杂性)而必须手动管理的场景:

var customerId = 42;
var customer;

lookupCustomer( customerId, function onCustomer(customerRecord){
    var orders = customer ? customer.orders : null;
    customer = customerRecord;
    if (orders) {
        customer.orders = orders;
    }
} );

lookupOrders( customerId, function onOrders(customerOrders){
    if (!customer) {
        customer = {};
    }
    customer.orders = customerOrders;
} );

回调 onCustomer(..)onOrders(..) 处于一种二元竟合状态。假定它们同时运行,那么任何一个都有可能首先运行,而预测哪一个将会发生是不可能的。

如果我们可以将 lookupOrders(..) 嵌入到 onCustomer(..) 内部,我们就可以确保 onOrders(..)onCustomer(..) 之后运行。但我们不能这么做,因为我们需要这两个查询并发地发生。

那么为了将这种基于时间的状态复杂性规范化,与一个外部词法闭包的变量 customer 一起,我们在回调中分别使用了一对 if 语句检测。当每个回调运行时,它检查 customer 的状态,以此判断它自己的相对顺序;如果对一个回调来说 customer 没有设定,那么它就是第一个运行的,否则是第二个。

这段代码好用,但从可读性上看远不理想。事件复杂性使这段代码很难读懂。

让我们使用 JS promise 来把时间抽离出去:

var customerId = 42;

var customerPromise = lookupCustomer( customerId );
var ordersPromise = lookupOrders( customerId );

customerPromise.then( function onCustomer(customer){
    ordersPromise.then( function onOrders(orders){
        customer.orders = orders;
    } );
} );

现在回调 onOrders(..) 位于回调 onCustomer(..) 内部,所以它们的相对顺序得到了保证。查询的并发是通过在指定 then(..) 应答处理之前分离地发起 lookupCustomer(..)lookupOrders(..) 来实现的。

这可能不明显,不过要不是 promise 的行为被定义的方式,这个代码段就会与生俱来地具有竟合状态。如果 order 的查询在 ordersPromise.then(..) 被调用以提供一个 onOrders(..) 回调之前完成,那么 某些东西 就需要足够聪明地保持 orders 列表,直到 onOrders(..) 可以被调用。事实上,当 recordonCustomer(..) 指定要接受它之前出现时,同样的问题也会出现。

那个 某些东西 就是我们在前一个代码段中讨论过的同种时间复杂性逻辑。但我们一点都不用担心这种复杂性,不管是编写代码还是 —— 更重要的 —— 阅读代码,因为 promise 为我处理好了那种时间规范化。

一个 promise 以一种时间无关的方式表示一个(未来)值。另外,从一个 promise 中抽取值就是一个立即值同步赋值(通过 =)的异步形式。换句话说,一个 promise 以一种可信(时间无关)的方式,将一个 = 赋值操作分散到一段时间上。

现在我们将探索如何相似地将本书之前的各种同步 FP 操作分散到一段时间之上。

急切 vs 懒惰

在计算机科学中急切与懒惰不是赞美与冒犯,而是用来描述一个操作将会立即完成还是随着时间的推移进行。

我们在这本书中看到的 FP 操作可以被归类为急切的,因为它们同步(立即)地操作离散的立即值或者值的列表/结构。

回想一下:

var a = [1,2,3]

var b = a.map( v => v * 2 );

b;            // [2,4,6]

ab 的映射是急切的,因为它立即在那一时刻操作数组 a 中的所有值,并且生成一个新的数组 b。如果稍后你修改了 a,比如在它的末尾添加一个新的值,b 的值不会发生任何变化。

但懒惰的 FP 操作看起来是什么样子呢?考虑一下像这样的东西:

var a = [];

var b = mapLazy( a, v => v * 2 );

a.push( 1 );

a[0];        // 1
b[0];        // 2

a.push( 2 );

a[1];        // 2
b[1];        // 4

我们在这里想象的 mapLazy(..) 实质上在 “监听” 数组 a,而且每当一个新的值添加到它的末尾时(使用 push(..)),它都会运行映射函数并将变形后的值添加到数组 b

注意: mapLazy(..) 的实现没有展示在这里,因为它是一个虚构的例子而不是一个真正的操作。要达成这种 ab 之间的懒惰配对操作,它们需要比简单的数组更智能一些。

考虑一下能够将 ab 配对的好处,无论你什么时候将一个值放入 a,它都会被变形并投射到 b。这具备与 map(..) 操作相同的声明式 FP 力量,但是它可以被拉伸至一段时间;你不必知道 a 的所有值就可以建立映射。

响应式 FP

为了理解我们如何能够创建并使用两组值之间的懒惰映射,我们需要将自己对列表(数组)的想法进行一些抽象。

让我们想象一种智能的数组,不是那种简单地持有值而是一种可以懒惰地对一个值进行接收和应答(也就是 “响应”)的数组。考虑:

var a = new LazyArray();

var b = a.map( function double(v){
    return v * 2;
} );

setInterval( function everySecond(){
    a.push( Math.random() );
}, 1000 );

至此,这个代码段看起来与一个普通的数组没有任何不同。唯一不寻常的东西就是我们习惯于使 map(..) 急切地运行并立即使用所有从 a 中映射来的值生成 b。但是那个将随机值添加到 a 的计时器看起来很奇怪,因为所有那些值都是在 map(..) 调用 之后 才出现的。

但是这种虚构的 lazyArray 有所不同;它假设值可能会在一段时间内一次一个地到来。在任何你希望的时候将值 push(..) 进来。b 将会懒惰地映射最终达到 a 的任何值。

另外,一旦值得到处理,我们就不是很需要将它们保持在 ab 中;这种特殊的数组仅会将值保持必要长的时间。所以这些数组不一定会随着时间增加内存的用量,这是懒惰数据结构和操作的一个重要性质。

一个普通的数组现在持有所有的值,而因此是急切的。一个 “懒惰数组” 是一个值将会随着时间推移而到来的数组。

因为我们不必知道一个新的值什么时候会到达 a,所以我们需要的另一个东西是,能够监听 b 以便在一个新的值变得可用时它能够收到通知。我们可以将一个监听器想象成这样:

b.listen( function onValue(v){
    console.log( v );
} );

b响应式 的,因为它被设置为当值进入 a 时对它们进行 响应。FP 操作 map(..) 描述了每个值如何从原来的 a 变形为目标 b。每一个离散的映射操作都恰恰是我们对普通同步 FP 的单值操作的建模方式,但是这里我们将值的来源分散在一段时间上。

注意: 最常用于这些概念的术语是函数响应式编程(Functional Reactive Programming —— FRP)。我故意避免使用这个词,因为对于 FP + 响应式是否真正的构成了 FRP 是存在争议的。我们在这里不会完全深入 FRP 的全部含义,所以我将继续称之为响应式 FP。另一种想法是,你可以称它为事件驱动的 FP,如果这能让你明白些的话。

我们可以认为 a 在生产值而 b 在消费它们。所以为了可读性,让我们重新组织这段代码,将关注点分离为 生产者消费者 角色:

// 生产者:

var a = new LazyArray();

setInterval( function everySecond(){
    a.push( Math.random() );
}, 1000 );


// **************************
// 消费者:

var b = a.map( function double(v){
    return v * 2;
} );

b.listen( function onValue(v){
    console.log( v );
} );

a 是生产者,它实质上扮演了一个值的流。我们可以认为每一个值到达 a 是一个 事件。之后 map(..) 操作会触发 b 上相应的事件,我们监听 b 来消费新的值。

我们分离 生产者消费者 关注点的原因是,这样做使我们应用程序中的不同部分可以分别负责于每个关注点。这种代码组织方式可以极大地改善代码的可读性与可维护性。

声明式时间

我们一直对在讨论中引入时间十分小心。具体地讲,正如 promise 将时间从我们对一个单独的异步操作的关注中抽象出去一样,响应式 FP 将时间从一系列的值/操作中抽想象(分离)了出去。

a (生产者)的角度讲,唯一明显的时间关注点是我们的手动 setInterval(..) 循环。但这只不过是为了演示。

想象一下,a 实际上可以添附到一些其他的事件源上,比如用户的鼠标点击和键盘击键,从服务器来的 websocket 消息,等等。在那样的场景下,a 自己实际上不必关心时间。它只不过是一个与时间无关的值的导管,不管值什么时候回准备好。

b (消费者)的角度来说,我们不知道或关心 a 中的值在何时/从何处而来。事实上,所有的值都可能已经存在了。我们关心的一切是我们需要这些值,无论它们什么时候准备好。同样,这也是与时间无关(也就是懒惰)的 map(..) 变形操作的模型。

ab 之间 时间 的关系是声明式的,不是指令式的。

如此组织跨时间段的操作的价值可能感觉还不是特别高效。让我们把它与用指令式表达的相同功能比较一下:

// 生产者:

var a = {
    onValue(v){
        b.onValue( v );
    }
};

setInterval( function everySecond(){
    a.onValue( Math.random() );
}, 1000 );


// **************************
// 消费者:

var b = {
    map(v){
        return v * 2;
    },
    onValue(v){
        v = this.map( v );
        console.log( v );
    }
};

这可能看起来很微妙,但是除了 b.onValue(..) 需要自己调用 this.map(..) 之外,在这种指令式更强的版本和前面声明式更强的版本之间有一个重要的不同。在前一个代码段中,ba 中拉取,但是在后一个代码段中 ab 推送。话句话说,比较 b = a.map(..)b.onValue(v)

在后面的指令式代码段中,从消费者的角度看,值 v 从何而来不是很清楚(可读性的意义上)。另外,b.onValue(..) 的指令式硬编码混入了生产者 a 的逻辑,这有些违反了关注点分离原则。这会使独立考虑生产者和消费者更困难。

相比之下,在前一个代码段中,b = a.map(..) 声明了 b 的值源自于 a,而且将 a 视为我们在那一刻不必关心的抽象事件流数据源。我们 声明:任何来自于 a 的值在进入 b 之前都会经过指定的 map(..) 操作。

不只是映射

为了方便起见,我们通过一对一的 map(..) 展示了这种将 ab 配对的概念。但是许多其他的 FP 操作同样可以被模型化为跨时段的。

考虑如下代码:

var b = a.filter( function isOdd(v) {
    return v % 2 == 1;
} );

b.listen( function onlyOdds(v){
    console.log( "Odd:", v );
} );

这里,一个来自于 a 的值仅会在通过 isOdd(..) 判定时才会进入 b

甚至 reduce(..) 都可以模型化为跨时段的:

var b = a.reduce( function sum(total,v){
    return total + v;
} );

b.listen( function runningTotal(v){
    console.log( "New current total:", v );
} );

因为我们没有给 reduce(..) 调用指定 initialValue,所以在至少两个值通过 a 之前,递减函数 sum(..) 和事件回调 runningTotal(..) 都不会被调用。

这个代码段暗示递减具有某种 记忆,每当一个未来值到达的时候,sum(..) 递减函数都将带着前一个 total 以及新的下一个值 v 进行调用。

其他扩展至跨时段的 FP 操作甚至会引入一个内部缓冲,例如 unique(..) 会持续追踪每个目前为止遇到的值。

Observables

希望你现在明白了一个响应式、事件驱动、类似数组 —— 就如我们虚构的 LazyArray 那样 —— 的结构有多么重要。好消息是,这种数据结构已经存在了,它被称为 observable。

注意: 只是为了设定一些期望:接下来的讨论只是对 observable 世界的一个简要介绍。它是一个深刻得多的话题,受篇幅所限我们无法完整地探索它。但如果你已经理解了这本书中的轻量函数式编程,而且现在又理解了异步时序如何通过 FP 原理建模,那么你继续学习 observable 应当是非常自然的。

Observable 已经由好几种第三方库实现了,最著名的就是 RxJS 和 Most。在本书写作时,一个将 Observable 直接加入到 JS 中 —— 就像 promise —— 的提案已经提上日程。为了展示,我们将在接下来的例子中使用 RxJS 风格的 observable。

这是我们先前的响应式的例子,使用 observable 来代替 LazyArray 表达的话:

// 生产者:

var a = new Rx.Subject();

setInterval( function everySecond(){
    a.next( Math.random() );
}, 1000 );


// **************************
// 消费者:

var b = a.map( function double(v){
    return v * 2;
} );

b.subscribe( function onValue(v){
    console.log( v );
} );

在 RxJS 的世界中,一个 Observer 订阅一个 Observable。如果你组合一个 Observer 和一个 Observable 的功能,你就得到一个 Subject。为了使我们的代码段简单一些,我们将 a 构建为一个 Subject,这样我们就可以在它上面调用 next(..) 来将值(事件)推送到它的流中。

如果我们想要让 Observer 和 Observable 保持分离:

// 生产者:

var a = Rx.Observable.create( function onObserve(observer){
    setInterval( function everySecond(){
        observer.next( Math.random() );
    }, 1000 );
} );

在这个代码段中 a 是 Observable,不出意料地,分离的 observer 被称为 observer;它能够 “观察(observe)” 一些事件(比如我们的 setInterval(..) 循环)方法来将事件发送到 a 的可观察流中。

除了 map(..) 之外,RxJS 还定义了超过一百种可以在每一个新的值到来时被懒惰调用的操作符。就像数组一样,每个 Observable 上的操作符都返回一个新的 Observable,这意味着它们是可链接的。如果一个操作符函数的调用判定一个从输入 Observable 来的值应当被传递下去,那么它就会在输出的 Observable 上被触发;否则就会被丢弃掉。

一个声明式 observable 链的例子:

var b =
    a
    .filter( v => v % 2 == 1 )        // 仅允许奇数only odd numbers
    .distinctUntilChanged()            // 仅允许接连的变化
    .throttle( 100 )                // 放慢一些
    .map( v = v * 2 );                // 将它们翻倍

b.subscribe( function onValue(v){
    console.log( "Next:", v );
} );

注意: 没必要将 observable 赋值给 b 然后再与链条分开地调用 b.subscribe(..);这只是为了证实每个操作符都从前一个 observable 返回一个新的 observable。通常,subscribe(..) 调用都是链条中的最后一个方法。

总结

这本书详细讲解了许多种 FP 操作,它们接收一个值(或者一个立即值的列表)并将它们变形为另一个或一些值。

对于那些将要跨时段处理的操作,所有这些基础的 FP 原理都可以独立于事件应用。正如 promise 模型化了单一未来值,我们可以将急切的列表模型化为值的懒惰 observable (事件)流,这些值可能会一次一个地到来。

一个数组上的 map(..) 对当前数组中的每一个值运行映射函数,将所有映射出来的值放入一个结果数组。一个 observable 上的 map(..) 为每一个值运行映射函数,无论它什么时候到来,并将所有映射出的值推送到输出 observable。

换言之,如果对 FP 操作来说一个数组是一个急切的数据结构,那么一个 observable 就是它对应的懒惰跨时段版本。

目录
相关文章
|
1月前
|
前端开发 JavaScript
如何处理 JavaScript 中的异步操作和 Promise?
如何处理 JavaScript 中的异步操作和 Promise?
14 1
|
1月前
|
前端开发 JavaScript 数据处理
在JavaScript中,什么是异步函数执行的例子
在JavaScript中,什么是异步函数执行的例子
10 0
|
1月前
|
前端开发 JavaScript
JavaScript的异步操作
JavaScript的异步操作
|
7月前
|
前端开发 JavaScript 容器
如何使用Promise在JavaScript中处理异步操作
JavaScript是一门单线程的编程语言,但是在实际开发中,我们经常需要处理一些耗时的异步操作,例如网络请求、文件读写等。为了更好地管理和处理这些异步操作,JavaScript引入了Promise。
82 0
|
7天前
|
Web App开发 缓存 JavaScript
|
2月前
|
前端开发 JavaScript
前端JavaScript中异步的终极解决方案:async/await
在深入讨论 async/await 之前,我们需要了解一下 JavaScript 的单线程和非阻塞的特性。JavaScript 是单线程的,也就是说在任何给定的时间点,只能执行一个操作。然而,对于需要大量时间的操作(例如从服务器获取数据),如果没有适当的管理机制,这种单线程特性可能会导致应用程序的阻塞。为了解决这个问题,JavaScript 引入了回调函数和后来的 Promise,用来管理这些异步操作。
|
2月前
|
前端开发 JavaScript
关于 JavaScript 的异步操作
关于 JavaScript 的异步操作
23 1
|
3月前
|
JavaScript
JS中同步和异步的区别
JS中同步和异步的区别
24 0
|
3月前
|
监控 前端开发 JavaScript
【面试题】聊聊 js 异步解决方案
【面试题】聊聊 js 异步解决方案
|
8月前
|
JavaScript 前端开发
JavaScript同步、异步及事件循环
JavaScript同步、异步及事件循环
76 1