翻译连载 | 第 10 章:异步的函数式(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

时间:2022-12-26 11:35:51

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。经过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,希望可以帮助大家在学习函数式编程的道路上走的更顺畅。比心。

译者团队(排名不分先后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyao

第 10 章:异步的函数式(下)

响应式函数式编程

为了理解如何在2个值之间创建和使用惰性的映射,我们需要去抽象我们对列表(数组)的想法。

让我们来想象一个智能的数组,不只是简单地获得值,还是一个懒惰地接受和响应(也就是“反应”)值的数组。考虑下:

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 里面添加随机的值。

但是这个虚构的 LazyArray 有点不同,它假设了值可以随时的一个一个添加进去。就像随时可以 push(..) 你想要的值一样。可以说 b 就是一个惰性映射 a 最终值的数组。

此外,当 a 或者 b 改变时,我们不需要确切地保存里面的值,这个特殊的数组将会保存它所需的值。所以这些数组不会随着时间而占用更多的内存,这是 惰性数据结构和懒操作的重要特点。事实上,它看起来不像数组,更像是buffer(缓冲区)。

普通的数组是积极的,所以它会立马保存所有它的值。"惰性数组" 的值则会延迟保存。

由于我们不一定要知道 a 什么时候添加了新的值,所以另一个关键就是我们需要有去监听 b 并在有新值的时候通知它的能力。我们可以想象下监听器是这样的:

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

b 是反应性的,因为它被设置为当 a 有值添加时进行反应。函数式编程操作当中的 map(..) 是把数据源 a 里面的所有值转移到目标 b 里。每次映射操作都是我们使用同步函数式编程进行单值建模的过程,但是接下来我们将让这种操作变得可以响应式执行。

注意: 最常用到这些函数式编程的是响应式函数式编程(FRP)。我故意避开这个术语是因为一个有关于 FP + Reactive 是否真的构成 FRP 的辩论。我们不会全面深入了解 FRP 的所有含义,所以我会继续称之为响应式函数式编程。或者,如果你不会感觉那么困惑,也可以称之为事件机制函数式编程。

我们可以认为 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 上面的 listen(..) 事件来消费新的值。

我们分离 生产者消费者 的相关代码,是因为我们的代码应该各司其职。这样的代码组织可以很大程度上提高代码的可读性和维护性。

声明式的时间

我们应该非常谨慎地讨论如何介绍时间状态。具体来说,正如 promise 从单个异步操作中抽离出我们所担心的时间状态,响应式函数式编程从一系列的值/操作中抽离(分割)了时间状态。

a (生产者)的角度来说,唯一与时间相关的就是我们手动调用的 setInterval(..) 循环。但它只是为了示范。

想象下 a 可以被绑定上一些其他的事件源,比如说用户的鼠标点击事件和键盘按键事件,服务端来的 websocket 消息等。在这些情况下,a 没必要关注自己的时间状态。每当值准备好,它就只是一个与值连接的无时态管道。

b (消费者)的角度来说,我们不用知道或者关注 a 里面的值在何时何地来的。事实上,所有的值都已经存在。我们只关注是否无论何时都能取到那些值。或者说,map(..) 的转换操作是一个无时态(惰性)的建模过程。

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

以 operations-over-time 这种方式来组织值可能不是很有效。让我们来对比下相同的功能如何用命令式来表示:

// 生产者:

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 当中去拉取,但是在这个代码中,a 推送给 b。换句话说,把 b = a.map(..) 替换成 b.onValue(v)

在上面的命令式代码中,以消费者的角度来说它并不清楚 v 从哪里来。此外命令式强硬的把代码 b.onValue(..) 夹杂在生产者 a 的逻辑里,这有点违反了关注点分离原则。这将会让分离生产者和消费者变得困难。

相比之下,在之前的代码中,b = a.map(..) 表示了 b 的值来源于 a ,对于如同抽象事件流的数据源 a,我们不需要关心。我们可以 确信 任何来自于 ab 里的值都会通过 map(..) 操作。

映射之外的东西

为了方便,我们已经说明了通过随着时间一次一次的用 map(..) 来绑定 ab 的概念。其实我们许多其他的函数式编程操作也可以做到这种效果。

思考下:

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 的值,无论是 sum(..) 或者 runningTotal(..) 都会等到有 2 个来自 a 的参数时才会被调用。

这段代码暗示了在 reduction 里面有一个 内存空间, 每当有新的值进来的时候,sum(..) 才会带上第一个参数 total 和第二个参数 v被调用。

其他的函数式编程操作会在内部作用域请求一个缓存区,比如说 unique(..) 可以追踪每一个它访问过的值。

Observables

希望现在你可以察觉到响应式,事件式,类数组结构的数据的重要性,就像我们虚构出来的 LazyArray 一样。值得高兴的是,这类的数据结构已经存在的了,它就叫 observable。

注意: 只是做些假设(希望):接下来的讨论只是简要的介绍 observables。这是一个需要我们花时间去探究的深层次话题。但是如果你理解本文中的轻量级函数式编程,并且知道如何通过函数式编程的原理来构建异步的话,那么接着学习 observables 将会变得得心应手。

现在已经有各种各样的 Observables 的库类, 最出名的是 RxJS 和 Most。在写这篇文章的时候,正好有一个直接向 JS 里添加 observables 的建议,就像 promise。为了演示,我们将用 RxJS 风格的 Observables 来完成下面的例子。

这是我们一个较早的响应式的例子,但是用 Observables 来代替 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,它可以去“观察”一些事件(比如我们的setInterval(..)循环),然后我们使用它的 next(..) 方法来发送一些事件到 observable a 的流里。

除了 map(..),RxJS 还定义了超过 100 个可以在有新值添加时才触发的方法。就像数组一样。每个 Observable 的方法都会返回一个新的 Observable,意味着他们是链式的。如果一个方法被调用,则它的返回值应该由输入的 Observable 去返回,然后触发到输出的 Observable里,否则抛弃。

一个链式的声明式 observable 的例子:

var b =
a
.filter( v => v % 2 == 1 ) // 过滤掉偶数
.distinctUntilChanged() // 过滤连续相同的流
.throttle( 100 ) // 函数节流(合并100毫秒内的流)
.map( v = v * 2 ); // 变2倍 b.subscribe( function onValue(v){
console.log( "Next:", v );
} );

注意: 这里的链式写法不是一定要把 observable 赋值给 b 和调用 b.subscribe(..) 分开写,这样做只是为了让每个方法都会得到一个新的返回值。通常,subscribe(..) 方法都会在链式写法的最后被调用。

总结

这本书详细的介绍了各种各样的函数式编程操作,例如:把单个值(或者说是一个即时列表的值)转换到另一个值里。

对于那些有时态的操作,所有基础的函数式编程原理都可以无时态的应用。就像 promise 创建了一个单一的未来值,我们可以创建一个积极的列表的值来代替像惰性的observable(事件)流的值。

数组的 map(..) 方法会用当前数组中的每一个值运行一次映射函数,然后放到返回的数组里。而 observable 数组里则是为每一个值运行一次映射函数,无论这个值何时加入,然后把它返回到 observable 里。

或者说,如果数组对函数式编程操作是一个积极的数据结构,那么 observable 相当于持续惰性的。

** 【上一章】翻译连载 | 第 10 章:异步的函数式(上)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇 **

翻译连载 | 第 10 章:异步的函数式(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

翻译连载 | 第 10 章:异步的函数式(下)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

iKcamp官网:http://www.ikcamp.com

访问官网更快阅读全部免费分享课程:《iKcamp出品|全网最新|微信小程序|基于最新版1.0开发者工具之初中级培训教程分享》。

包含:文章、视频、源代码