RxJS 常見的應用之一就是將 DOM Event 視為 Event Stream,本文以 RxJS 實現萬年老梗 Counter。
Version
macOS Catalina 10.15.4
WebStorm 2020.1
Vue 2.6.11
RxJS 6.5.5
Browser
初始值為 0
,每按一次 +
就會 +1
。
Subject
<template>
<div>
<button v-stream:click="{ subject: click$, data: 1 }">+</button>
<h1>{{ counter$ }}</h1>
</div>
</template>
<script>
import { startWith, scan, pluck } from 'rxjs/operators'
let subscriptions = function() {
let counter$ = this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
)
return { counter$ }
}
export default {
name: 'app',
domStreams: ['click$'],
subscriptions
}
</script>
第 3 行
<button v-stream:click="{ subject: click$, data: 1 }">+</button>
Vue 要將 DOM event 綁定到 method 要使用 @
或 v-on
directive;若要將 DOM event 綁定到 stream,則要使用 v-stream
directive,之後加上 event 名稱。
事實上 Vue-rx 是將 DOM event 綁定到 RxJS Subject,因此 " "
內為 object,subject
property 接的是 Subject 名稱,一樣使用 RxJS 的命名慣例:在名稱後加上 $
postfix,data
property 接的是要傳給 click$
Subject 的資料。
若 v-stream
只要綁定 Subject,並沒有要傳遞資料,就不必使用 object,可簡寫成:
<button v-stream:click="click$">+</button>
直接在 ""
加上 subject 名稱即可。
24 行
export default {
name: 'app',
domStreams: ['click$'],
subscriptions,
}
Subject 要宣告在 domStreams
property 內,由於會有多個 subject,因此使用的是 array。
另外還要建立 subscriptions()
,RxJS 相關的程式碼要寫在 subscriptions()
內。
11 行
let subscriptions = function() {
let counter$ = this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
)
return { counter$ }
}
由於已經在 domStreams
property 內宣告 click$
,因此可直接使用 this.click$
取得 click$
Subject。
因為 this.click$
是 Subject,因此已經進入 RxJS 領域,可開始使用 RxJS 的 function。
RxJS 如同 Ramda 一樣,首重 function composition,會使用 function pipeline 組合 function 解決問題。
pipe()
12 行
let counter$ = this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
)
由於分成 3 個步驟,因此要以 pipe()
整合 3 個 operator,成為新的 function。
pipe()
將多個 unary function 組合成新 function
pluck()
首先以 pluck()
取得 data
property。
pluck()
從 object 中擷取指定 property
source 為 object,回傳為指定 property 的 value。
startWith()
接下來會陸續得到 stream,我們希望由 0
開始。
startWith()
指定 stream 來之前的初始值
scan()
由於我麼要實現的是 counter,因此每次有新 subject 會累加。
scan()
類似 reduce() 會做累加,但 scan() 會將累加過程傳出,reduce() 只會傳回最終結果
scan()
會將累加的過程不斷的傳出來。
reduce()
只會傳回最終結果。
18 行
return { counter$ }
最終將 counter$
Subject 包成 object 回傳。
RxJS
<template>
<div>
<button v-stream:click="{ subject: click$, data: 1 }">+</button>
<h1>{{ counter }}</h1>
</div>
</template>
<script>
import { startWith, scan, pluck } from 'rxjs/operators'
let mounted = function() {
this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
).subscribe(x => this.counter = x)
}
export default {
name: 'app',
data: () => ({
counter: 0
}),
domStreams: ['click$'],
mounted
}
</script>
若你覺得 Vue-rx 的 subscription 寫法不習慣,也可只使用 Subject 即可。
11 行
let mounted = function() {
this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
).subscribe(x => this.counter = x)
}
在 mounted
hook 直接使用 click$
Subject 的 pipe()
,最後在 subscribe()
寫的 counter
side effect 即可。
Conclusion
- 若以 OOP 方式實作 counter,一定是 click event handler 的 method 對
counter
state 做 side effect 累加;但 RxJS 屬於 FP,我們只看到 click event 成為 event stream,再透過pipe()
組合pluck()
、startWith()
與scan()
3 個 function,整個過程完全沒有 side effect - 學習 RxJS 時,透過 marble diagram 可以一目了然看出 operator 的意義
- RxJS 與 Ramda 有些 function 是重複的,如本文的
pluck()
、scan()
與reduce()
在 Ramda 也有,差異是 Ramda 是用在 synchronous array,而 RxJS 是用在 asynchronous array
Reference
John Lindquist, Access Event from Vue.js Templates as RxJS Streams with domStreams
RxJS, pipe()
RxJS, pluck()
RxJS, startWith()
RxJS, scan()
RxJS, reduce()