DOM Event 也能使用 RxJS

RxJS 常見的應用之一就是綁定到 DOM Event,將 Event 視為 Stream,萬年老梗 Counter 若使用 RxJS 將如何實現呢 ?

Version


Vue 2.5.21
Vue-rx 6.1.0
RxJS 6.3.3

Rx Subject


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
<div>
<button v-stream:click="{ subject: click$, data: 2 }">+</button>
<h1>{{ random$ }}</h1>
</div>
</template>

<script>
import { map, startWith, scan, pluck } from 'rxjs/operators';

const subscriptions = function() {
const random$ = this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
);

return {
random$
};
};

export default {
name: 'HelloWorld',
domStreams: ['click$'],
subscriptions,
};
</script>

第 3 行

1
<button v-stream:click="{ subject: click$, data: 2 }">+</button>

Vue 要將 DOM event 綁定到 method 要使用 @v-on directive;若要將 DOM event 綁定到 stream,則要使用 v-stream directive,之後加上 event 名稱。

事實上 Vue-rx 是將 DOM event 綁定到 Rx Subject,因此 "" 內為 object,subject property 接的是 Subject 名稱,一樣使用 RxJS 的命名慣例:在名稱前加上 $ postfix,data property 接的是要傳給 click$ subject 的資料。

v-stream 只要綁定 Rx Subject,並沒有要傳遞資料,就不必使用 object,可簡寫成:

1
<button v-stream:click="click$">+</button>

直接在 "" 加上 Subject 名稱即可。

24 行

1
2
3
4
5
export default {
name: 'HelloWorld',
domStreams: ['click$'],
subscriptions,
};

Rx Subject 要宣告在 domStreams property 內,由於會有多個 Rx Subject,因此使用的是 array。

另外還要建立 subscriptions(),RxJS 相關的程式碼要寫在 subscriptions() 內。

11 行

1
2
3
4
5
6
7
8
9
10
11
const subscriptions = function() {
const random$ = this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
);

return {
random$
};
};

由於已經在 domStreams property 內宣告 click$,因此可直接使用 this.click$ 取得 click$ subject。

因為 this.click$ 是 Rx Subject,因此已經進入 RxJS 領域,可開始使用 RxJS 的 operator。

RxJS 如同 Ramda 一樣,首重 Function Composition,會使用 Pipeline 組合 operator 方式思考問題。

dom000

Pipe()


12 行

1
2
3
4
5
const random$ = this.click$.pipe(
pluck('data'),
startWith(0),
scan((a, x) => a + x),
);

由於分成 3 個步驟,因此要以 pipe() 整合 3 個 operator,成為新的 function。

pipe()
pipe(...fns: Array<UnaryFunction<any, any>>): UnaryFunction<any, any>
將多個 unary function 組合成新的 function

Pluck()

首先以 pluck() operator 取得 data property。

pluck()
pluck<T, R>(...properties: string[]) : OperatorFunction<T, R>
從 object 中擷取指定 property,並回傳新的 function

dom001

  • source 為 object,target 為指定 property 的 value

StartWith()


接下來會陸續得到 stream,我們希望由 0 開始。

startWith()
startWith<T, D>(...array: Array<T | SchedulerLike>): OperatorFunction<T, T | D>
已指定值作為 stream 的起始值,並傳回新的 function

dom002

  • 指定 stream 來之前的初始值

Scan()


由於我麼要實現的是 counter,因此每次有新的 subject 會累加。

scan()
scan<T, R>(accumulator: (acc: R, value: T, index: number) => R, seed?: T | R): OperatorFunction<T, R>
類似 reduce() 會做累加,但 scan() 會將累加過程傳出,reduce() 只會傳回最終結果

dom003

  • scan() 會將累加的過程不斷的傳出來

dom004

  • reduce() 只會傳回最終結果

18 行

1
2
3
return {
random$
};

最終將 random$ subject 包成 object 回傳。

dom005

  • 初始值為 0,每按一次 + 就會 +2

Conclusion


  • 若以 OOP 方式實作 counter,一定是 click event handler 的 method 對 counter state 做 side effect 累加;但 RxJS 屬於 FP,我們只看到 click event 成為 stream,再透過 pipe() 組合 pluck()startWith()scan() 3 個 function,整個過程完全沒有 side effect
  • 學習 RxJS 時,透過 Marble Diagram 可以一目了然看出 operator 的意義

Sample Code


完整範例可以在我的 GitHub 上找到

Reference


Vue, Vue-rx
Egghead.io, Access Event from Vue.js Templates as RxJS Streams with domStreams
RxJS, pipe()
RxJS, pluck()
RxJS Marbles, pluck()
RxJS, startWith()
RxJS Marbles, startWith()
RxJS, scan()
RxJS Marbles, scan()
RxJS, reduce()
RxJS Marbles, reduce()