iT邦幫忙

2

Week9 - RxJS到底幫助了我們什麼,用簡單的實戰來說明 - Reactive Programing篇 [前端大作戰系列]

各位好,不知道各位是否有聽過Functional Programming - FP,這是近期很火紅的名詞。

我第一次聽到這個名詞是一個前輩說的:「FP實在太神奇了,可惡,太晚知道FP了,真是千金難買早知道啊」,由於前輩實在很厲害,所以我當初對於FP的感受彷彿白金之星。

「我的code要飛起了嗎?!」,抱著這樣的心態我進一步請教他,於是他貼給了我FP的一大重點 - 「範疇學」,我一點開連結就

...

這是我當下的感受:

神人的世界就是這麼樸實無華且枯燥。

FP的淺見

我個人偏向於實務主義,我常常會想FP到底實際幫助了我們什麼,但許多FP文章都在探討FP的核心,例如純函數、惰性求值、Monad等等,這些都很酷,但換到實戰中,就是不斷不斷的卡關,舉個純函數例子:

有個addOne的函數會對任何數字加一,即輸入1的到的結果必定為2,那如果現在需要有getNumber函數去DB拿數字1,再丟去addOne函數執行,

我們要怎麼確定getNumber函數去DB拿數字1是鐵定拿得到的呢?

答案是不可能的,在程式的世界中,鐵定有不純的地方,我們沒辦法保證所有東西都純,假設DB沒有數字1,getNumber還回傳1給我,那DB根本沒有存在意義,FP所說的純函數,主要是希望使用者更清楚地分開純與不純的界線,不讓整個程式很混亂,更詳細的介紹可以看良葛格的「解開對函數式的誤解

可是知道這件事後,並沒有讓我有得到白金之心的感覺,就是那種「恩!這的確是很正確的思想」,可是前輩所說的很神奇到底是神奇在哪@@?!

直到看到RxJS

就在有次,我遇到了一個以下的情境:

按A按鈕要打一個A API,回傳資料後做alert A
按B按鈕要打一個B API,回傳資料後做alert B
並且在A、B按鈕都按完後,透過A與B API的資料做alert C

我們可以模擬成以下情境:

按A按鈕要取得按A的滑鼠位置,回傳資料後做alert A
按B按鈕要取得按B的滑鼠位置,回傳資料後做alert B
並且在A、B按鈕都按完後,透過A與B的滑鼠資料做alert C

我們先來展示傳統的作法:

線上運行jsfiddle

function alertC () {
  if (Object.keys(mouseDatas.A).length === 0 || Object.keys(mouseDatas.B).length === 0) return
  window.alert(`Alert C. A button x is ${mouseDatas.A.screenX}. B button x is ${mouseDatas.B.screenX}`)
  mouseDatas.A = {} //Reset A
  mouseDatas.B = {} //Reset B
}

const mouseDatas = {
  A: {},
  B: {},
}

document.getElementById('A').addEventListener('click', mouseData => {
  mouseDatas.A = mouseData
  window.alert('Alert A')
  alertC()
})
document.getElementById('B').addEventListener('click', mouseData => {
  mouseDatas.B = mouseData
  window.alert('Alert B')
  alertC()
})

由於A按鈕與B按鈕之間存在著「相依」的關係,所以我們必須生出一個global變數mouseDatas來「分享」彼此的資料

這樣會導致一些問題:

  1. 這是我認為最重要的點,就是第二行的判斷式子
    if (Object.keys(mouseDatas.A).length === 0 || Object.keys(mouseDatas.B).length === 0)
    
    我們需要透過這個判斷來確定alertC()在執行的「時間點」mouseDatas是否我們所期望的狀態,就是mouseDatas的A與B都有值
  2. mouseDatas需要reset A與B,如果錯的「時間點」reset或者就會導致整個流程出錯
  3. 整體流程的意圖有些混亂,在A click的「時間點」會觸發alertC函數嗎?並不一定,因為他受到B click的影響。alert()這函數其實不完全屬於A click或者B click,他是屬於兩者都被點擊的狀況,可讀性上面我們是容易被誤導的
  4. 我們擴大一下情境,如果新增D~Z這些按鈕,並全部按完要alertC函數,那我們全部的按鈕都要添加alert()這段code

大家有發現我一直強調「時間點」嗎?因為我個人認為RxJS最大解決的問題就是

他明確的表達什麼時間點該做什麼

我們再來看看RxJS的作法:

線上運行jsfiddle

const { zip, fromEvent } = rxjs;

document.getElementById('A').addEventListener('click', () => {
    window.alert('Alert A')
})
document.getElementById('B').addEventListener('click', () => {
    window.alert('Alert B')
})

// alertC的邏輯
zip(
    fromEvent(document.getElementById('A'), 'click'),
    fromEvent(document.getElementById('B'), 'click')
)
.subscribe(mouseDatas => {
    window.alert(`Alert C. A button x is ${mouseDatas[0].screenX}. B button x is ${mouseDatas[1].screenX}`)
})

整體程式碼的意思是這樣的,我不用太複雜的一些專有名詞來解釋,而用一些口語的方式:

zip: 可以將兩個event做合併
fromEvent: 取得這按鈕被click的event
subscribe: 上面兩個event被觸發後,會執行的函數,並且也會取得這兩個event的資料,會以array傳入,這邊以mouseDatas命名

與傳統的作法做比較:

  1. 我們不需要判斷時間的式子了,因為zip到subscribe這幾行的意思就是「我要在兩個event都執行完的時間點執行alertC」
  2. 因為沒有global變數,我們不需要reset,subscribe時拿到的資料是很純粹的mouseDatas,他幾乎沒有可能被其他人修改,不用很害怕他是不是原本的資料還是被改過
  3. 整體alertC的邏輯可讀性是很好的,因為他沒有穿插在A click或是B click之中,他就是一個「從zip到subscribe這幾行可以表達的一個邏輯」
  4. 就算我們擴大情境成有D~Z個按鈕,也是單純在zip裡面添加event,不需要去修改各個按鈕的click邏輯

稍微統整,並與FP觀念融合一下

傳統做法上我們透過一個global變數來達到「判斷不同時間點」的能力,但RxJS時直接透過「一行一行來表達什麼時間點該做什麼事」。而這一行一行的作法我們稱為「流」

在對於時間的表達上有根本的不同,另外,傳統做法上我們也可以依靠callback裡面再塞callback來達到「有順序」的執行,這跟時間也是有關係,但RxJS可以靠一個簡單的concat來解決,連結裡面的code大致如下

import { of, concat } from 'rxjs';

const sourceOne = of(1, 2, 3);
const sourceTwo = of(4, 5, 6);

const example = concat(sourceOne, sourceTwo);
const subscribe = example.subscribe(val => console.log(val));

而意思口語上就是

兩者不同時間點執行但我會有順序處理資料(event1, evnet2)
.處理方法subscribe(val => console.log(val))

所以我們才說RxJS提供了新思維,

RxJS提供了流的方法來處理不同時間點的資料,讓我們不用透過改global變數或者callback裡面叫callback這樣不好「表達時間意圖」的方式來處理

所以與FP觀念做融合就是,我這邊特別列出我自己以前的誤解xd:

  1. 純函數: 我們不採用流程外的變數來表達整個流程的目的。
    • 但不太表整個流程沒有副作用,click傳回來的mouseData就是副作用
  2. 聲明式編程很好表達流程: 我們只需透過RxJS的各個函數組合來組合click event,並把流程一行一行描述出來,這個我們稱為聲明式編程,而不用在A click與B click添加alert()邏輯,使得邏輯散亂,這是指令式編程的一種做法。
    • 但不代表聲明式的表達能力好於指令式,比如說以下
    function addOne(number) {
      return number + 1
    }
    
    function getEven(number) {
      return number % 2 === 0
    }
    
    let finalNumber = 0
    
    //聲明式
    finalNumber = [1, 2, 3, 4]
      .map(addOne)
      .filter(getEven)
      .reduce((accumulator, currentValue) => accumulator + currentValue)
    console.log(finalNumber)
    
    //指令式
    finalNumber = 0;
    [1, 2, 3, 4].forEach(number => {
      const addOneNumber = addOne(number)
      if (getEven(addOneNumber)) finalNumber = finalNumber + addOneNumber
    })
    console.log(finalNumber)
    
    以這樣的例子其實我們看不出聲明式有更好表達流程,RxJS主要是透過FP的組合特性來組合各種event,所以自然而然就會變成聲明式的作法,好讀也是因為event變成了流的特性,但我們不能一竿子打翻一條船的說聲明式一定讚
  3. 惰性求值與Monad: 我們這些整個流程最後執行的方式,當然要等到event來在執行,因此惰性求值與Monad就是為了達到此目的,
    • 如果沒有此想法會覺得請函數晚點執行會很不解。

最後分享一下後端也是有使用RxJS情境的

RxJS通常在前端討論,而後端較少,最根本的原因是因為後端通常就是一個requests一個response,我們較少遇到一個requets之中會有不同「時間點」的event,但我們還是有機會遇到的,

比如說: Promise要retry。我相信這是promise愛用者的一個大痛點,用傳統的方法通常採用遞迴,但邏輯流程會很不清楚,RxJS的作法如下:

線上運行runkit

const { from, of } = rxjs;
const { switchMap, retry } = rxjs.operators;

function getSearchResults(url) {
  return new Promise((resolve, reject) => {
    console.log('do again')
    reject("Reject")
  })
}

of("http://foo.com").pipe(
  switchMap(url => from(getSearchResults(url))),
  retry(3)
)
.subscribe({
  next: val => console.log(val),
  error: val => console.log(`Get error ${val}`)
})

對於RxJS,我也是個新手,坦白說我主要以後端為主,所以使用到RxJS的情境沒有前端那麼直觀,在與許多前輩與六角學院的高人討論之後才漸漸稍有RxJS的「流式思維」,不然,我其實在FP的範疇論、Monad、functor裡面打轉,當然這是很重要的,但要怎麼應用我以前一直不太確定。

也希望大家可以分享自己對於RxJS的想法,也歡迎指正我的文章,一起來分享這些新思考方式吧!

最近看JoJo看到完全是中毒狀態

參考資料

FRP與函數式
RxJava 沉思录
希望是最淺顯易懂的 RxJS 教學


尚未有邦友留言

立即登入留言