點燈坊

失くすものさえない今が強くなるチャンスよ

將 DOM Event 綁定到 Subject

Sam Xiao's Avatar 2020-05-05

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

dom005

初始值為 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 解決問題。

dom000

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

dom001

source 為 object,回傳為指定 property 的 value。

startWith()

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

startWith()
指定 stream 來之前的初始值

dom002

scan()

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

scan()
類似 reduce() 會做累加,但 scan() 會將累加過程傳出,reduce() 只會傳回最終結果

dom003

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

dom004

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()