RxJS 實現摩斯密碼(內附腦圖)
參加 2018 ngChina 開發者大會,特別喜歡 Michael Hladky 奧地利帥哥的 RxJS 分享,現在拿出來好好學習工作坊的內容(工作坊Demo地址),結合這個示例,做了一個改進版本,實現更簡潔,邏輯更直觀。
一、摩斯密碼是什麼?
瞭解者可跳過次章節
摩斯密碼(Morse),是一種時通時斷的訊號程式碼,這種訊號程式碼通過不同的排列組合來表達不同的英文字母、數字和標點符號等。
地球人都知道的 SOS 求救訊號,就是 Morse,三短(S) 三長(O) 三短(S)。
訊號對應表如下:

二、業務邏輯分析
分析關鍵步驟,很巧,和把大象裝進冰箱裡同樣都只需要三步耶:
第一步,識別點訊號,短為 “滴” 長為“嗒”。
第二步,根據 “長間隔” 來切片分組。
第三步,分組資料根據對應錶轉化出最終結果。
三、擼程式碼,優化後版本(完整線上示例)
開始前要做好熱身活動:
Morse 的最小單元,"." 代表嘀,"-" 代表嗒,點選事件用 Down 代表 mousedown,Up 代表 mouseup。
200ms 間隔用來區別嘀嗒,1s 間隔用來區分一個 Morse 單元組的結束。
// 點訊號 = Down - Up = 間隔 < 200ms ?"." : "-"; // Down <200ms Up >1s = "." = E // Down <200ms Up <1s Down >200ms Up >1s = ".", "-" = A // 直接使用 fromEvent 操作符,來生成點選操作的流,然後用 map 操作符轉化成時間戳, // takeUntil 用來控制流的結束,避免重複訂閱。 const clickBegin$ = fromEvent(this.sendButtonElementRef.nativeElement, 'mousedown') .pipe( takeUntil(this.onDestroy$), map(n => Date.now()) ) const clickEnd$ = fromEvent(this.sendButtonElementRef.nativeElement, 'mouseup') .pipe( takeUntil(this.onDestroy$), map(n => Date.now()) )
第一步,識別點訊號為 “滴” “嗒”
前面程式碼已經拿到點選事件的流,並且用 "map" 操作符,把資料轉化為當前的時間戳。
下面開始計算 Down & Up 之間的間隔時間,思考,合併兩個流的的操作符有哪些呢?
-
forkJoin、concat ?
需要兩個流 complate 狀態後才返回資料,不適應資料持續輸出的場景。
-
merge ?
Down & Up 的時間戳不會同時獲得,還需要處理儲存的問題,不完全適應場景。
-
combineLatest ?
滿足資料持續輸出,滿足同時獲得,哎喲,還不錯。
但是這個操作符的特點是,會快取上一次的值,所以第二次 Down 也會獲得到資料,Up - Down 也就會為負值,取絕對值後可以用來判斷是否 >1s,來區分一個 Morse 單元組的結束。
-
zip ?
哎呀哈,這個更合適呢,盤它!
單詞選的很到位,這個操作符功能可以理解為像拉鍊一樣,確保獲得資料每一次都是一個純淨的 Down & Up。
但是需要注意 zip 會自動快取資料,例如 zip(A$, B$),A$收到的資料一直比B$多太多,有記憶體溢位風險,就像拉錯位的拉鍊,很藍瘦。
// zip的實現 zip(clickBegin$, clickEnd$) .pipe( // 計算 Down - Up 間隔時間 map(this.toTimeDiff), // 根據間隔時間,轉化為嘀嗒替代字元 "." "-" map(this.msToMorseCharacter) ) .subscribe(result => { // 傳送到主訊號流 morseSignal$.next(result); });
第二步,根據 “長間隔” 來切片分組
分組的操作符有哪些?
-
partition?
根據函式拆成兩個流。
-
groupBy?
根據函式拆成 n 個流。
-
window?
根據流拆成 n 個流。以上各位都打擾了,我還要自己處理資料快取,再見。
-
buffer?
哇,初戀般的感覺,用流控制來做切片資料成陣列,拿到陣列只需要 join 一下就好,就可以去去匹配對應表了,好棒!
“長間隔”的切片流,怎麼獲得呢?拿出法寶 debounceTime(1000) ,當點選的 Down Up 週期完成後,間隔 1s 就認為是一個Morse 單元組的結束。
然後又遇到了問題,怎麼判斷一個點選週期呢?不用單純用 Up ,因為下一個 Down Up 週期可能會超出 1s,就會導致切片時機錯誤。所以模擬了點選持續的流 clickKeeping$,用 switchMap 替換為新的流且不影響原來的流,timer 產生一個小於 1s 間隔的持續流訊號,用 takeUntil 在 Up 事件流 clickEnd$ 後把整個流結束。
// 點選持續狀態流 const clickKeeping$ = clickBegin$ .pipe( // 替換為新的流,不影響原來的流 switchMap(() => { // 定時在持續傳送資料,維持點選中狀態 return timer(0, morseTimeRanges.lessThenlongBreak).pipe( // 直到 Up 後結束點選狀態 takeUntil(clickEnd$) ); }) ) // “長間隔”的切片流 const morseBreak$ = clickKeeping$.pipe( debounceTime(morseTimeRanges.longBreak) ); // 獲得 Morse 單元組 morseSignal$ .pipe( // 切片分組主訊號流 buffer(morseBreak$) // 轉化為,例如 ['.', '.', '.'] )
第三步,分組資料根據對應錶轉化出最終結果
join('') Morse 單元組去匹配對應表,很簡單不用說。
錯誤發生在 switchMap 中,分支流報錯,但是主流不會收到影響,然後用 catchError 捕捉錯誤。
// Morse 單元組去匹配對應表 private translateSymbolToLetter = morseArray => { const morseCharacters = morseArray.join(''); const find = morseTranslations.find(n => n.symbol === morseCharacters) // 這裡 find 可能為 undefined 導致報錯,但是錯誤會被 catchError 捕捉 return find.char; } // 轉化+錯誤處理,最終完成 morseSignal$ .pipe( buffer(morseBreak$), switchMap(n => { return of(n).pipe( // 只為了 Demo 演示中的展示用 tap(n => this.lastMorseGroupCharacters = n.join(' ')), // 轉化成對應表中字元 map(this.translateSymbolToLetter), // 捕捉錯誤 catchError(n => { return of(morseCharacters.errorString); }) ) }) ) .subscribe(result => { // 輸出最終轉化結果 this.morseLog.push(result); console.log('結果:', result) });
四、解讀 Michael Hladky 大神的示例
整體上,把 “嘀嗒” “短間隔” “長間隔” 都轉化成替代符,過濾無用的替代符,然後 filter “長間隔” 替代符的流,來做 buffer 切片資料。其他還有因為使用 combineLatest 操作符導致的不同。
// 識別 “嘀” “嗒” const morseCharFromEvents$ = observableCombineLatest(this.startEvents$, this.stopEvents$) .pipe( // 計算 mousedown mouseup 時間間隔 map(this.toTimeDiff), // 轉化成識別符號 map(this.msToMorseChar), // 過濾 Morse 單元組中的 “短間隔“ 識別符號 filter(this.isCharNoShortBreak as any) ); // 主訊號流 this.morseChar$ = observableMerge(morseCharFromEvents$, this.injectMorseChar$) // 識別 “長間隔“ 識別符號,來作為切片流 const longBreaks$ = this.morseChar$ .pipe(filter(this.isCharLongBreak as any)); // 切片成 Morse 單元組 this.morseSymbol$ = this.morseChar$ .pipe( buffer(longBreaks$), map(this.charArrayToSymbol), filter(n => (n !== '') as any) ) // 錯誤處理 + 識別符號對應錶轉化 this.morseLetter$ = this.morseSymbol$ .pipe( switchMap(n => observableOf(n).pipe(this.saveTranslate('ERROR'))) ); // Up 後補4個 “長間隔“ 識別符號,用來做 Morse 單元組的結束 const breakEmitter$ = observableTimer(this.msLongBreak, this.msLongBreak) .pipe( mapTo(this.mC.longBreak), take(4) ); this.stopEventsSubject .pipe( switchMapTo( breakEmitter$.pipe(takeUntil(this.startEventsSubject)) ) ) .subscribe(n => this.injectMorseChar(n));
總結
下圖是讀完《深入淺出RxJS》後的學習筆記,標註了一些操作符的快速記憶特點,方便使用的適合查閱。
