關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。
譯者團隊(排名不分先後):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿蔔、vavd317、vivaxy、萌萌、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(..)
的轉換操作是一個無時態(惰性)的建模過程。
時間 與 a
和 b
之間的關係是宣告式的,不是命令式的。
以 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(..)
之外。在之前的程式碼中, b
從 a
當中去拉取,但是在這個程式碼中,a
推送給 b
。換句話說,把 b = a.map(..)
替換成 b.onValue(v)
。
在上面的命令式程式碼中,以消費者的角度來說它並不清楚 v
從哪裡來。此外命令式強硬的把程式碼 b.onValue(..)
夾雜在生產者 a
的邏輯裡,這有點違反了關注點分離原則。這將會讓分離生產者和消費者變得困難。
相比之下,在之前的程式碼中,b = a.map(..)
表示了 b
的值來源於 a
,對於如同抽象事件流的資料來源 a
,我們不需要關心。我們可以 確信 任何來自於 a
到 b
裡的值都會通過 map(..)
操作。
對映之外的東西
為了方便,我們已經說明了通過隨著時間一次一次的用 map(..)
來繫結 a
和 b
的概念。其實我們許多其他的函數語言程式設計操作也可以做到這種效果。
思考下:
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》姊妹篇 **
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。
iKcamp官網:www.ikcamp.com 訪問官網更快閱讀全部免費分享課程:《iKcamp出品|全網最新|微信小程式|基於最新版1.0開發者工具之初中級培訓教程分享》。 包含:文章、視訊、原始碼
2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!