感謝社區(qū)中各位的大力支持,譯者再次奉上一點點福利:阿里云產(chǎn)品券滴肿,享受所有官網(wǎng)優(yōu)惠岳悟,并抽取幸運大獎:點擊這里領(lǐng)取
這本書讀到這里,你現(xiàn)在擁有了所有 FP —— 我稱之為 “輕量函數(shù)式編程” —— 基礎(chǔ)的原始概念。在這一章中贵少,我們會將這些概念應(yīng)用于一種不同的環(huán)境呵俏,但不會出現(xiàn)特別的新想法。
至此滔灶,我們做的所有事情幾乎都是同步的普碎,也就是說我們使用立即的輸入調(diào)用函數(shù)并立即得到輸出值。許多工作可以用這種方式完成录平,但對于一個現(xiàn)代 JS 應(yīng)用程序的整體來說根本不夠用麻车。為了真正地對 JS 的現(xiàn)實世界中的 FP 做好準(zhǔn)備,我們需要理解異步 FP斗这。
我們本章的目標(biāo)是將我們對使用 FP 進(jìn)行值的管理的思考动猬,擴展至將這樣的操作分散到一段時間上。
作為狀態(tài)的時間
在你的整個應(yīng)用程序中最復(fù)雜的狀態(tài)就是時間涝影。也就是說枣察,如果狀態(tài)在你堅定的控制之下立即地從一種狀態(tài)轉(zhuǎn)換到另一種,那么狀態(tài)管理就容易多了燃逻。當(dāng)你的應(yīng)用程序的狀態(tài)為了響應(yīng)分散在一段時間上的事件而隱含地變化時序目,它的管理難度就會呈幾何級數(shù)增長。
通過使代碼更可信與更可預(yù)測來使它更易于閱讀 —— 我們在這本書中展示 FP 的方式的每一部分都與此有關(guān)伯襟。當(dāng)你在程序中引入異步的時候猿涨,這些努力將受到很大沖擊。
讓我們說的更明白一點:一些操作不會同步地完成姆怪,就單純這一點來說不是我們關(guān)心的叛赚;發(fā)起異步行為很容易。需要很多額外努力的是稽揭,如何協(xié)調(diào)這些動作的應(yīng)答俺附,這些應(yīng)答中的每一個都會潛在地改變你應(yīng)用程序的狀態(tài)。
那么溪掀,是你作為作者為此努力好呢事镣?還是將這個問題留給你代碼的讀者,讓他們自己去搞清如果 A 在 B 之前完成(或反之)程序?qū)⑹鞘裁礌顟B(tài)揪胃?這是一個夸張的問題璃哟,但從我的觀點來說它有一個十分堅定地答案:為了使這樣復(fù)雜的代碼更具可讀性,作者必須要付出比平常多得多的努力喊递。
遞減時間
異步編程最重要的成果之一随闪,是通過將時間從我們的關(guān)注范圍中抽象出去來簡化狀態(tài)變化管理。
為了展示這一點骚勘,我們首先來看一個存在竟合狀態(tài)(也就是铐伴,時間復(fù)雜性)而必須手動管理的場景:
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;
} );
回調(diào) onCustomer(..)
和 onOrders(..)
處于一種二元竟合狀態(tài)。假定它們同時運行,那么任何一個都有可能首先運行盛杰,而預(yù)測哪一個將會發(fā)生是不可能的挽荡。
如果我們可以將 lookupOrders(..)
嵌入到 onCustomer(..)
內(nèi)部,我們就可以確保 onOrders(..)
在 onCustomer(..)
之后運行。但我們不能這么做,因為我們需要這兩個查詢并發(fā)地發(fā)生琐凭。
那么為了將這種基于時間的狀態(tài)復(fù)雜性規(guī)范化,與一個外部詞法閉包的變量 customer
一起青自,我們在回調(diào)中分別使用了一對 if
語句檢測。當(dāng)每個回調(diào)運行時驱证,它檢查 customer
的狀態(tài)延窜,以此判斷它自己的相對順序;如果對一個回調(diào)來說 customer
沒有設(shè)定抹锄,那么它就是第一個運行的逆瑞,否則是第二個。
這段代碼好用伙单,但從可讀性上看遠(yuǎn)不理想获高。事件復(fù)雜性使這段代碼很難讀懂。
讓我們使用 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;
} );
} );
現(xiàn)在回調(diào) onOrders(..)
位于回調(diào) onCustomer(..)
內(nèi)部吻育,所以它們的相對順序得到了保證念秧。查詢的并發(fā)是通過在指定 then(..)
應(yīng)答處理之前分離地發(fā)起 lookupCustomer(..)
和 lookupOrders(..)
來實現(xiàn)的。
這可能不明顯布疼,不過要不是 promise 的行為被定義的方式摊趾,這個代碼段就會與生俱來地具有竟合狀態(tài)。如果 order
的查詢在 ordersPromise.then(..)
被調(diào)用以提供一個 onOrders(..)
回調(diào)之前完成游两,那么 某些東西 就需要足夠聰明地保持 orders
列表砾层,直到 onOrders(..)
可以被調(diào)用。事實上贱案,當(dāng) record
在 onCustomer(..)
指定要接受它之前出現(xiàn)時梢为,同樣的問題也會出現(xiàn)。
那個 某些東西 就是我們在前一個代碼段中討論過的同種時間復(fù)雜性邏輯轰坊。但我們一點都不用擔(dān)心這種復(fù)雜性,不管是編寫代碼還是 —— 更重要的 —— 閱讀代碼祟印,因為 promise 為我處理好了那種時間規(guī)范化肴沫。
一個 promise 以一種時間無關(guān)的方式表示一個(未來)值。另外蕴忆,從一個 promise 中抽取值就是一個立即值同步賦值(通過 =
)的異步形式颤芬。換句話說,一個 promise 以一種可信(時間無關(guān))的方式,將一個 =
賦值操作分散到一段時間上站蝠。
現(xiàn)在我們將探索如何相似地將本書之前的各種同步 FP 操作分散到一段時間之上汰具。
急切 vs 懶惰
在計算機科學(xué)中急切與懶惰不是贊美與冒犯,而是用來描述一個操作將會立即完成還是隨著時間的推移進(jìn)行菱魔。
我們在這本書中看到的 FP 操作可以被歸類為急切的留荔,因為它們同步(立即)地操作離散的立即值或者值的列表/結(jié)構(gòu)。
回想一下:
var a = [1,2,3]
var b = a.map( v => v * 2 );
b; // [2,4,6]
從 a
到 b
的映射是急切的澜倦,因為它立即在那一時刻操作數(shù)組 a
中的所有值聚蝶,并且生成一個新的數(shù)組 b
。如果稍后你修改了 a
藻治,比如在它的末尾添加一個新的值碘勉,b
的值不會發(fā)生任何變化。
但懶惰的 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(..)
實質(zhì)上在 “監(jiān)聽” 數(shù)組 a
验靡,而且每當(dāng)一個新的值添加到它的末尾時(使用 push(..)
),它都會運行映射函數(shù)并將變形后的值添加到數(shù)組 b
雏节。
注意: mapLazy(..)
的實現(xiàn)沒有展示在這里胜嗓,因為它是一個虛構(gòu)的例子而不是一個真正的操作。要達(dá)成這種 a
與 b
之間的懶惰配對操作矾屯,它們需要比簡單的數(shù)組更智能一些兼蕊。
考慮一下能夠?qū)?a
與 b
配對的好處,無論你什么時候?qū)⒁粋€值放入 a
件蚕,它都會被變形并投射到 b
孙技。這具備與 map(..)
操作相同的聲明式 FP 力量,但是它可以被拉伸至一段時間排作;你不必知道 a
的所有值就可以建立映射牵啦。
響應(yīng)式 FP
為了理解我們?nèi)绾文軌騽?chuàng)建并使用兩組值之間的懶惰映射,我們需要將自己對列表(數(shù)組)的想法進(jìn)行一些抽象妄痪。
讓我們想象一種智能的數(shù)組哈雏,不是那種簡單地持有值而是一種可以懶惰地對一個值進(jìn)行接收和應(yīng)答(也就是 “響應(yīng)”)的數(shù)組∩郎考慮:
var a = new LazyArray();
var b = a.map( function double(v){
return v * 2;
} );
setInterval( function everySecond(){
a.push( Math.random() );
}, 1000 );
至此裳瘪,這個代碼段看起來與一個普通的數(shù)組沒有任何不同。唯一不尋常的東西就是我們習(xí)慣于使 map(..)
急切地運行并立即使用所有從 a
中映射來的值生成 b
罪针。但是那個將隨機值添加到 a
的計時器看起來很奇怪彭羹,因為所有那些值都是在 map(..)
調(diào)用 之后 才出現(xiàn)的。
但是這種虛構(gòu)的 lazyArray
有所不同泪酱;它假設(shè)值可能會在一段時間內(nèi)一次一個地到來派殷。在任何你希望的時候?qū)⒅?push(..)
進(jìn)來还最。b
將會懶惰地映射最終達(dá)到 a
的任何值。
另外毡惜,一旦值得到處理拓轻,我們就不是很需要將它們保持在 a
或 b
中;這種特殊的數(shù)組僅會將值保持必要長的時間经伙。所以這些數(shù)組不一定會隨著時間增加內(nèi)存的用量扶叉,這是懶惰數(shù)據(jù)結(jié)構(gòu)和操作的一個重要性質(zhì)。
一個普通的數(shù)組現(xiàn)在持有所有的值橱乱,而因此是急切的辜梳。一個 “懶惰數(shù)組” 是一個值將會隨著時間推移而到來的數(shù)組。
因為我們不必知道一個新的值什么時候會到達(dá) a
泳叠,所以我們需要的另一個東西是作瞄,能夠監(jiān)聽 b
以便在一個新的值變得可用時它能夠收到通知。我們可以將一個監(jiān)聽器想象成這樣:
b.listen( function onValue(v){
console.log( v );
} );
b
是 響應(yīng)式 的危纫,因為它被設(shè)置為當(dāng)值進(jìn)入 a
時對它們進(jìn)行 響應(yīng)宗挥。FP 操作 map(..)
描述了每個值如何從原來的 a
變形為目標(biāo) b
。每一個離散的映射操作都恰恰是我們對普通同步 FP 的單值操作的建模方式种蝶,但是這里我們將值的來源分散在一段時間上契耿。
注意: 最常用于這些概念的術(shù)語是函數(shù)響應(yīng)式編程(Functional Reactive Programming —— FRP)。我故意避免使用這個詞螃征,因為對于 FP + 響應(yīng)式是否真正的構(gòu)成了 FRP 是存在爭議的搪桂。我們在這里不會完全深入 FRP 的全部含義,所以我將繼續(xù)稱之為響應(yīng)式 FP盯滚。另一種想法是踢械,你可以稱它為事件驅(qū)動的 FP,如果這能讓你明白些的話魄藕。
我們可以認(rèn)為 a
在生產(chǎn)值而 b
在消費它們内列。所以為了可讀性,讓我們重新組織這段代碼背率,將關(guān)注點分離為 生產(chǎn)者 和 消費者 角色:
// 生產(chǎn)者:
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
是生產(chǎn)者话瞧,它實質(zhì)上扮演了一個值的流。我們可以認(rèn)為每一個值到達(dá) a
是一個 事件寝姿。之后 map(..)
操作會觸發(fā) b
上相應(yīng)的事件交排,我們監(jiān)聽 b
來消費新的值。
我們分離 生產(chǎn)者 和 消費者 關(guān)注點的原因是饵筑,這樣做使我們應(yīng)用程序中的不同部分可以分別負(fù)責(zé)于每個關(guān)注點埃篓。這種代碼組織方式可以極大地改善代碼的可讀性與可維護性。
聲明式時間
我們一直對在討論中引入時間十分小心翻翩。具體地講都许,正如 promise 將時間從我們對一個單獨的異步操作的關(guān)注中抽象出去一樣,響應(yīng)式 FP 將時間從一系列的值/操作中抽想象(分離)了出去嫂冻。
從 a
(生產(chǎn)者)的角度講胶征,唯一明顯的時間關(guān)注點是我們的手動 setInterval(..)
循環(huán)。但這只不過是為了演示桨仿。
想象一下睛低,a
實際上可以添附到一些其他的事件源上,比如用戶的鼠標(biāo)點擊和鍵盤擊鍵服傍,從服務(wù)器來的 websocket 消息钱雷,等等。在那樣的場景下吹零,a
自己實際上不必關(guān)心時間罩抗。它只不過是一個與時間無關(guān)的值的導(dǎo)管,不管值什么時候回準(zhǔn)備好灿椅。
從 b
(消費者)的角度來說套蒂,我們不知道或關(guān)心 a
中的值在何時/從何處而來。事實上茫蛹,所有的值都可能已經(jīng)存在了操刀。我們關(guān)心的一切是我們需要這些值,無論它們什么時候準(zhǔn)備好婴洼。同樣骨坑,這也是與時間無關(guān)(也就是懶惰)的 map(..)
變形操作的模型。
a
與 b
之間 時間 的關(guān)系是聲明式的柬采,不是指令式的欢唾。
如此組織跨時間段的操作的價值可能感覺還不是特別高效。讓我們把它與用指令式表達(dá)的相同功能比較一下:
// 生產(chǎn)者:
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(..)
需要自己調(diào)用 this.map(..)
之外匈辱,在這種指令式更強的版本和前面聲明式更強的版本之間有一個重要的不同。在前一個代碼段中杀迹,b
從 a
中拉取亡脸,但是在后一個代碼段中 a
向 b
推送。話句話說树酪,比較 b = a.map(..)
和 b.onValue(v)
浅碾。
在后面的指令式代碼段中,從消費者的角度看续语,值 v
從何而來不是很清楚(可讀性的意義上)垂谢。另外,b.onValue(..)
的指令式硬編碼混入了生產(chǎn)者 a
的邏輯疮茄,這有些違反了關(guān)注點分離原則滥朱。這會使獨立考慮生產(chǎn)者和消費者更困難根暑。
相比之下,在前一個代碼段中徙邻,b = a.map(..)
聲明了 b
的值源自于 a
排嫌,而且將 a
視為我們在那一刻不必關(guān)心的抽象事件流數(shù)據(jù)源。我們 聲明:任何來自于 a
的值在進(jìn)入 b
之前都會經(jīng)過指定的 map(..)
操作缰犁。
不只是映射
為了方便起見淳地,我們通過一對一的 map(..)
展示了這種將 a
與 b
配對的概念。但是許多其他的 FP 操作同樣可以被模型化為跨時段的帅容。
考慮如下代碼:
var b = a.filter( function isOdd(v) {
return v % 2 == 1;
} );
b.listen( function onlyOdds(v){
console.log( "Odd:", v );
} );
這里颇象,一個來自于 a
的值僅會在通過 isOdd(..)
判定時才會進(jìn)入 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(..)
調(diào)用指定 initialValue
并徘,所以在至少兩個值通過 a
之前遣钳,遞減函數(shù) sum(..)
和事件回調(diào) runningTotal(..)
都不會被調(diào)用。
這個代碼段暗示遞減具有某種 記憶饮亏,每當(dāng)一個未來值到達(dá)的時候耍贾,sum(..)
遞減函數(shù)都將帶著前一個 total
以及新的下一個值 v
進(jìn)行調(diào)用。
其他擴展至跨時段的 FP 操作甚至?xí)胍粋€內(nèi)部緩沖路幸,例如 unique(..)
會持續(xù)追蹤每個目前為止遇到的值荐开。
Observables
希望你現(xiàn)在明白了一個響應(yīng)式、事件驅(qū)動简肴、類似數(shù)組 —— 就如我們虛構(gòu)的 LazyArray
那樣 —— 的結(jié)構(gòu)有多么重要晃听。好消息是,這種數(shù)據(jù)結(jié)構(gòu)已經(jīng)存在了砰识,它被稱為 observable能扒。
注意: 只是為了設(shè)定一些期望:接下來的討論只是對 observable 世界的一個簡要介紹。它是一個深刻得多的話題辫狼,受篇幅所限我們無法完整地探索它初斑。但如果你已經(jīng)理解了這本書中的輕量函數(shù)式編程,而且現(xiàn)在又理解了異步時序如何通過 FP 原理建模膨处,那么你繼續(xù)學(xué)習(xí) observable 應(yīng)當(dāng)是非常自然的见秤。
Observable 已經(jīng)由好幾種第三方庫實現(xiàn)了,最著名的就是 RxJS 和 Most真椿。在本書寫作時鹃答,一個將 Observable 直接加入到 JS 中 —— 就像 promise —— 的提案已經(jīng)提上日程。為了展示突硝,我們將在接下來的例子中使用 RxJS 風(fēng)格的 observable测摔。
這是我們先前的響應(yīng)式的例子,使用 observable 來代替 LazyArray
表達(dá)的話:
// 生產(chǎn)者:
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
構(gòu)建為一個 Subject路媚,這樣我們就可以在它上面調(diào)用 next(..)
來將值(事件)推送到它的流中。
如果我們想要讓 Observer 和 Observable 保持分離:
// 生產(chǎn)者:
var a = Rx.Observable.create( function onObserve(observer){
setInterval( function everySecond(){
observer.next( Math.random() );
}, 1000 );
} );
在這個代碼段中 a
是 Observable樊销,不出意料地,分離的 observer 被稱為 observer
脏款;它能夠 “觀察(observe)” 一些事件(比如我們的 setInterval(..)
循環(huán))方法來將事件發(fā)送到 a
的可觀察流中围苫。
除了 map(..)
之外,RxJS 還定義了超過一百種可以在每一個新的值到來時被懶惰調(diào)用的操作符撤师。就像數(shù)組一樣剂府,每個 Observable 上的操作符都返回一個新的 Observable,這意味著它們是可鏈接的剃盾。如果一個操作符函數(shù)的調(diào)用判定一個從輸入 Observable 來的值應(yīng)當(dāng)被傳遞下去腺占,那么它就會在輸出的 Observable 上被觸發(fā);否則就會被丟棄掉痒谴。
一個聲明式 observable 鏈的例子:
var b =
a
.filter( v => v % 2 == 1 ) // 僅允許奇數(shù)only odd numbers
.distinctUntilChanged() // 僅允許接連的變化
.throttle( 100 ) // 放慢一些
.map( v = v * 2 ); // 將它們翻倍
b.subscribe( function onValue(v){
console.log( "Next:", v );
} );
注意: 沒必要將 observable 賦值給 b
然后再與鏈條分開地調(diào)用 b.subscribe(..)
衰伯;這只是為了證實每個操作符都從前一個 observable 返回一個新的 observable。通常积蔚,subscribe(..)
調(diào)用都是鏈條中的最后一個方法意鲸。
總結(jié)
這本書詳細(xì)講解了許多種 FP 操作,它們接收一個值(或者一個立即值的列表)并將它們變形為另一個或一些值尽爆。
對于那些將要跨時段處理的操作怎顾,所有這些基礎(chǔ)的 FP 原理都可以獨立于事件應(yīng)用。正如 promise 模型化了單一未來值漱贱,我們可以將急切的列表模型化為值的懶惰 observable (事件)流槐雾,這些值可能會一次一個地到來。
一個數(shù)組上的 map(..)
對當(dāng)前數(shù)組中的每一個值運行映射函數(shù)幅狮,將所有映射出來的值放入一個結(jié)果數(shù)組募强。一個 observable 上的 map(..)
為每一個值運行映射函數(shù),無論它什么時候到來彪笼,并將所有映射出的值推送到輸出 observable钻注。
換言之,如果對 FP 操作來說一個數(shù)組是一個急切的數(shù)據(jù)結(jié)構(gòu)配猫,那么一個 observable 就是它對應(yīng)的懶惰跨時段版本幅恋。