輕量函數(shù)式 JavaScript 第十章:函數(shù)式異步

感謝社區(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 進行值的管理的思考,擴展至將這樣的操作分散到一段時間上。

作為狀態(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è)定,那么它就是第一個運行的,否則是第二個。

這段代碼好用,但從可讀性上看遠不理想。事件復(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) recordonCustomer(..) 指定要接受它之前出現(xiàn)時,同樣的問題也會出現(xiàn)。

那個 某些東西 就是我們在前一個代碼段中討論過的同種時間復(fù)雜性邏輯。但我們一點都不用擔(dān)心這種復(fù)雜性,不管是編寫代碼還是 —— 更重要的 —— 閱讀代碼,因為 promise 為我處理好了那種時間規(guī)范化。

一個 promise 以一種時間無關(guān)的方式表示一個(未來)值。另外,從一個 promise 中抽取值就是一個立即值同步賦值(通過 =)的異步形式。換句話說,一個 promise 以一種可信(時間無關(guān))的方式,將一個 = 賦值操作分散到一段時間上。

現(xiàn)在我們將探索如何相似地將本書之前的各種同步 FP 操作分散到一段時間之上。

急切 vs 懶惰

在計算機科學(xué)中急切與懶惰不是贊美與冒犯,而是用來描述一個操作將會立即完成還是隨著時間的推移進行。

我們在這本書中看到的 FP 操作可以被歸類為急切的,因為它們同步(立即)地操作離散的立即值或者值的列表/結(jié)構(gòu)。

回想一下:

var a = [1,2,3]

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

b;          // [2,4,6]

ab 的映射是急切的,因為它立即在那一時刻操作數(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)的例子而不是一個真正的操作。要達成這種 ab 之間的懶惰配對操作,它們需要比簡單的數(shù)組更智能一些。

考慮一下能夠?qū)?ab 配對的好處,無論你什么時候?qū)⒁粋€值放入 a,它都會被變形并投射到 b。這具備與 map(..) 操作相同的聲明式 FP 力量,但是它可以被拉伸至一段時間;你不必知道 a 的所有值就可以建立映射。

響應(yīng)式 FP

為了理解我們?nèi)绾文軌騽?chuàng)建并使用兩組值之間的懶惰映射,我們需要將自己對列表(數(shù)組)的想法進行一些抽象。

讓我們想象一種智能的數(shù)組,不是那種簡單地持有值而是一種可以懶惰地對一個值進行接收和應(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(..) 進來。b 將會懶惰地映射最終達到 a 的任何值。

另外,一旦值得到處理,我們就不是很需要將它們保持在 ab 中;這種特殊的數(shù)組僅會將值保持必要長的時間。所以這些數(shù)組不一定會隨著時間增加內(nèi)存的用量,這是懶惰數(shù)據(jù)結(jié)構(gòu)和操作的一個重要性質(zhì)。

一個普通的數(shù)組現(xiàn)在持有所有的值,而因此是急切的。一個 “懶惰數(shù)組” 是一個值將會隨著時間推移而到來的數(shù)組。

因為我們不必知道一個新的值什么時候會到達 a,所以我們需要的另一個東西是,能夠監(jiān)聽 b 以便在一個新的值變得可用時它能夠收到通知。我們可以將一個監(jiān)聽器想象成這樣:

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

b響應(yīng)式 的,因為它被設(shè)置為當(dāng)值進入 a 時對它們進行 響應(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)為每一個值到達 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(..) 變形操作的模型。

ab 之間 時間 的關(guān)系是聲明式的,不是指令式的。

如此組織跨時間段的操作的價值可能感覺還不是特別高效。讓我們把它與用指令式表達的相同功能比較一下:

// 生產(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(..) 之外,在這種指令式更強的版本和前面聲明式更強的版本之間有一個重要的不同。在前一個代碼段中,ba 中拉取,但是在后一個代碼段中 ab 推送。話句話說,比較 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 的值在進入 b 之前都會經(jīng)過指定的 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(..) 調(diào)用指定 initialValue,所以在至少兩個值通過 a 之前,遞減函數(shù) sum(..) 和事件回調(diào) runningTotal(..) 都不會被調(diào)用。

這個代碼段暗示遞減具有某種 記憶,每當(dāng)一個未來值到達的時候,sum(..) 遞減函數(shù)都將帶著前一個 total 以及新的下一個值 v 進行調(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 表達的話:

// 生產(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é)

這本書詳細講解了許多種 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)的懶惰跨時段版本。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容