vue-rx の使いどころについて考えてみた
この記事は Vue.js #2 Advent Calendar 2018 の 12/8 分の記事となります。
よし、セーフだな!
TL;DR
vue-rx を使ってみたかった
使いどころがあるか調べてみた
vue-rx とは
vue-rx とは Vue.js 用に RxJS v6 をまとめたライブラリです。
vue-rx では v-stream
というディレクティブを提供しており、DOM のイベントを Rx Subject にストリームすることができます。
こんな感じで v-stream
をつけてイベントを設定し、
<button v-stream:click="plus$">+</button>
subscriptions
の内で Subject として定義し、加工してやります。
ここの定義の仕方は色々あるので公式の readme を見ていただけたらと思います。
import { Subject } from 'rxjs'
import { map, startWith, scan } from 'rxjs/operators'
new Vue({
subscriptions () {
// declare the receiving Subjects
this.plus$ = new Subject()
// ...then create subscriptions using the Subjects as source stream.
// the source stream emits in the format of `{ event: HTMLEvent, data?: any }`
return {
count: this.plus$.pipe(
map(() => 1),
startWith(0),
scan((total, change) => total + change)
)
}
}
})
使いどころを探るために、次は公式の example コードを読んでみようと思います。
example のコードを読んでみる
example の中にある counter-simple を読んでみます。コード内で ../dist/vue-rx.js
が必要となるので、 rollup をインストール & npm build
で vue-rx.js
を作っておいてください。
簡単なカウンターのアプリになっています。
template 側では各ボタンの click イベントを plus$
と minus$
としてストリームとしています。
<div id="app">
<div>{{ count }}</div>
<button v-stream:click="plus$">+</button>
<button v-stream:click="minus$">-</button>
</div>
script 側では、plus$
を 1 を返すストリーム、minus$
を -1 を返すストリームに map して merge し、 クリックされるたびに total
に足し合わされるストリームになっています。それを count
として画面側に表示している、という流れです。
subscriptions () {
return {
count: merge(
this.plus$.pipe(map(() => 1)),
this.minus$.pipe(map(() => -1))
).pipe(
startWith(0),
scan((total, change) => total + change)
)
}
}
RxJS 単品で扱う場合ですと pipe
のあとに subscribe
メソッドの内部で値の最終的な処理をしますが、 vue-rx では 変数に設定してやることで変数を template 内で直接使ったり computed の中で利用することができます。ここは RxJS と Vue の境界線がキレイに引かれているなー、と感じています。
…ただ、このサンプルだと、「あれ、通常のイベント処理だけでいいんじゃない?」となりますよね。私もそう思います。
ということで、 vue-rx が生きてくるシチュエーションについて考えてみました。
vue-rx が生きてくるシチュエーションについて
RxJS の持つ特長そのままではありますが、イベントと何かが絡み合う場合に効果を発揮するかと思います。
時間とイベントが絡む処理を行いたい場合
公式 example の wiki-search のコードを読んでみます。入力した検索ワードで wiki を検索し検索結果をリスト表示しています。ただ、入力を適宜間引いて ajax 通信しまくることを防いでいます。
subscriptions () {
return {
// this is the example in RxJS's readme.
results: this.$watchAsObservable('search').pipe(
pluck('newValue'),
filter(text => text.length > 2),
debounceTime(500),
distinctUntilChanged(),
switchMap(fetchTerm),
map(formatResult)
)
}
}
$watchAsObservable
は vue の watch 部をストリームとして取り扱うためのメソッドです。値の変更を監視し、 debounceTime(500)
でストリームを間引いています。
通常のイベント処理であれば以下のように書くことができるかと思います。lodash の debounce で間引いています。
methods: {
searchInput() {
if (this.search.length <= 2) {
return
}
_.debounce(() => {
// ajax の処理
}, 500)
}
}
この例ですと人の好みによってどちらを使うか分かれると思いますが、 vue-rx で実装したほうが処理がスッキリしているように見えるかと思います。
イベントとイベントが絡む処理を行いたい場合
RxJS でよく見られる例ですが、ドラッグ処理を vue-rx で実装してみます。ドラッグ処理は mousedown 、mousemove 、mouseup 処理を組み合わせるため、Vue.js のイベントでやろうとすると各イベントハンドラの中であっちいったりこっちいったりと処理が煩雑になりやすいです。
こちら にサンプルコードを上げております。
template 側は以下のように宣言します。mousedown
をトリガーとするのでイベントを定義しておき、ドラッグで変更された座標を反映させるために :style
でスタイルの computed と紐づけておきます。
<div
class="box"
@mousedown="dragStart"
:style="boxStyle"
/>
script 側では、observableMethods
で先ほど宣言したイベントを observable に対応づけさせ、subscription
内で処理を記述しておきます。mousedown 開始後に発生する mousemove のストリームを捉え、[座標, 移動量]
のストリームとして position に渡しています。
ちなみに fromEvent(document, 'mousemove')
や fromEvent(document, 'mouseup')
を merge しているのは、マウスを 思いっきり 移動したときに div の領域外に出てしまい、mousemove などを追いかけられなくなるのを防止するためです。
export default {
name: 'app',
observableMethods: [
'dragStart'
],
subscriptions () {
return {
position: this.dragStart$.pipe(
switchMap((e) => {
const { left, top } = e.target.getBoundingClientRect()
return of([left, top, e.clientX, e.clientY])
}),
switchMap(([left, top, x, y]) => {
return merge(
this.$fromDOMEvent('.box', 'mousemove'),
fromEvent(document, 'mousemove')
).pipe(
switchMap((e) => of([left, top, e.clientX - x, e.clientY - y])),
takeUntil(merge(
this.$fromDOMEvent('.box', 'mouseup'),
fromEvent(document, 'mouseup')
))
)
})
)
}
},
そして、 computed
の内部で position
の値を利用して box のスタイルを生成しています。初回表示時に this.position
が undefined
になるため、this.position || [0, 0, 0, 0]
と設定しています。
computed: {
boxStyle () {
const [left, top, dx, dy] = this.position || [0, 0, 0, 0]
return {
top: `${top + dy}px`,
left: `${left + dx}px`
}
}
}
}
1 つのストリームとして処理することで、可読性を保ったままドラッグ処理を実装することができました。
vue-rx を利用しない方法ですと、以下のような感じで data
や methods
をいったりきたりすることになるかと思います。
疑似コードになりますが、こんな感じになるのではないでしょうか?
data () {
startPos: {} // ドラッグ開始地点の座標
isDragging: false // ドラッグ中かどうかのフラグ
pos: { // オブジェクトの座標情報
top: 0,
left: 0
}
},
methods: {
dragStart (e) {
this.isDragging = true
// e の座標を startPos にいい感じに入れる
},
dragMove (e) {
if (isDragging) {
// startPos と e から 移動距離を算出し、
// pos.top と pos.left にそれぞれ設定
}
},
dragEnd (e) {
this.isDragging = false
}
},
computed: {
// pos の情報を使って座標を算出する computed を作る
}
jQuery 書いてたときにこんなの見たことある!という方もいらっしゃるのではないでしょうか。
このケースではイベント処理するよりも vue-rx を利用する側に軍配が上がるかと思います。
まとめ
- 使いどころはある
- また、イベントと何かが組み合わさる処理に RxJS は向いているので、以下のような棲み分けはアリかも
- 単純なイベント処理であればいつもどおりに書く
- イベントと何か ( 他のイベント、時間、非同期処理 ) が組み合わされる場合は vue-rx
-
subscription
内の変数が RxJS 側と Vue 側の境界線になっているため、Vue 側からは 値を利用して何かする という利用方法になる
別の観点として以下のような棲み分けもアリかも- イベントをトリガーとして何らかの処理をするのであれば通常のイベント処理が向いている
- イベントをトリガーとして何らかの値を導出するという目的であれば vue-rx 向いている
- すべてを vue-rx で書こうとするとツラくなるはずなので用法・用量は正しくお使いください
- イベント処理とストリーム処理がコンポーネント内で混ざり見通しが悪くなる場合があるため、
コーディング時に規約などを作って見通しを良く保つ工夫は必要となるかも
ありがとうございました