前一段時間,我寫了兩篇文章,一篇是對目前前端主流檢視框架的思考:#37,一篇是深入使用RxJS控制複雜業務邏輯的:#38,在這兩篇中,我分別提到:
- 期望在複雜業務邏輯方面使用RxJS,更好地進行抽象,但是檢視上使用輕量MVVM以達到快速開發的目的。
- 目前VueJS中,如果要結合RxJS,可能需要手動訂閱和取消訂閱,寫起來還是沒有CycleJS方便。
最近,VueJS社群升級了vue-rx這個庫,實現了比較方便地把VueJS和RxJS結合的能力。
我們來詳細瞭解一下。
在檢視上繫結Observable
VueJS本身不是基於RxJS這一套理念構建的,如果不借助任何輔助的東西,可能我們會需要幹這麼一些事情:
- 手動訂閱某些Observable,在observer裡面,把資料設定到Vue的data上
- 在檢視銷燬的時候,手動取消訂閱
在業務開發中,我們最常用的是繫結簡單的Observable,在vue-rx中,這個需求被很輕鬆地滿足了。
與早期版本不同,vue-rx 2.0在Vue例項上新增了一個subscriptions屬性,裡面放置各種待繫結的Observable,用的時候類似data。
比如,我們可以這麼用它:
rx-simple.vue
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
<template> <div> <h4>Single Value</h4> <div>{{single$}}</div> <h4>Array</h4> <ul> <li v-for="item of arr0$">{{item}}</li> </ul> <ul> <li v-for="item of arr1$">{{item}}</li> </ul> <h4>Interval</h4> <div>{{interval$}}</div> <h4>High-order</h4> <div>{{high$}}</div> </div> </template> <script> import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/of' import 'rxjs/add/observable/from' import 'rxjs/add/operator/toArray' import 'rxjs/add/observable/interval' import 'rxjs/add/observable/range' import 'rxjs/add/operator/map' import 'rxjs/add/operator/mergeAll' const single$ = Observable.of(Math.PI) const arr0$ = Observable.of([1, 1, 2, 3, 5, 8, 13]) const arr1$ = Observable.from([1, 1, 2, 3, 5, 8, 13]).toArray() const interval$ = Observable.interval(1000) const high$ = Observable.range(1, 5) .map(item => Observable.interval(item * 1000)) .mergeAll() export default { name: 'rx-simple', subscriptions: { single$, arr0$, arr1$, interval$, high$ } } </script> |
這個demo裡面,演示了四種不同的Rx資料形態。其中,single$
和interval$
雖然建立方式不同,但實際上用的時候是一樣的,因為,對它們的訂閱,都是取其最後一個值,這兩者的區別只是,一個不變了,一個持續變,但介面展示的始終是最後那個值。
關於陣列,初學者需要稍微注意一下,從同樣的陣列,分別通過Observable.of和Observable.from出來的形態是大為不同的:
- of建立的這個,裡面只有一個值,這個值是個陣列,所以,訂閱它,會得到一個陣列
- from建立的這個,裡面有若干個值,每個值是由陣列中的元素建立的,訂閱它,會一次性得到多個值,但展示的時候只會有最後一個,因為前面的都被覆蓋掉了
那麼,這個high$
代表什麼呢?
- range操作,建立了一個流,裡面有多個簡單數字
- map操作,把這個流升級為二階,流裡面每個元素又是一個流
- mergeAll操作,把其中的每個流合併,降階為一階流,流裡面每個元素是個簡單數字
如果說不mergeAll,直接訂閱map出來的那個二階流,結果是不對的,vue-rx只支援一階訂閱繫結,不支援把高階流直接繫結,如果有業務需要,應當自行降階,通過各種flat、concat、merge操作,變成一階流再進行繫結。
將Vue $watcher轉換為Observable
上面我們述及的,都是從Observable的資料到Vue的ReactiveSetter和Getter中,這條路徑的操作已經很簡便了,我們只需把Observable放在vue例項的subscriptions裡面,就能直接繫結到檢視。
但是,反過來還有一條線,我們可能會需要根據某個資料的變化,讓這個資料進入一個資料流,然後進行後續運算。
例如:有一個num屬性,掛在data上,還有一個資料num1,表達:始終比num大1
這麼一件事。
當然,我們是可以直接利用computed property去做這件事的,為了使得我們這個例子更有說服力,給它這個加一計算新增一個延時3秒,強行變成非同步:始終在num屬性確定之後,等3秒,把自己變成比num大1的數字
。
這樣,computed property就寫不出來了,我們可能就要手動去$watch
這個num,然後在回撥方法中,去延時加一,然後回來賦值給num1。
在vur-rx中,提供了一個從$watch
建立Observable的方法,叫做$watchAsObservable
,我們來看看怎麼用:
rx-watcher.vue
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 29 30 31 32 33 34 35 |
<template> <div> <h4>Watch</h4> <div> <button v-on:click="num++">add</button> source: {{num}} -> result: {{num$}} </div> </div> </template> <script> import 'rxjs/add/operator/pluck' import 'rxjs/add/operator/startWith' import 'rxjs/add/operator/delay' export default { name: 'rx-watch', data() { return { num: 1 } }, subscriptions() { return { num$: this.$watchAsObservable('num') .pluck('newValue') .startWith(this.num) .map(a => a + 1) .delay(3000) } } } </script> |
這個例子裡面的num$經過這麼幾步:
this.$watchAsObservable('num')
,把num屬性的變動,對映到一個資料流上- 這個資料流的結果是一個物件,裡面有newValue和oldValue屬性,我們通常情況下,要的都是newValue,所以用pluck把它挑出來
- 注意,這個檢測的只是後續變動,對於已經存在的值,是
$watch
不到的,所以,用startWith,把當前值放進去 - 然後是常規的rx運算了
那麼,這件事的原理是什麼呢?
我們知道,Vue例項中,data上的屬性都會存在ReactiveSetter,所以它被賦值的時候,就會觸發這個setter,所以,$watchAsObservable
的內部只需根據資料變動,生成一個Observable就可以了。
$watchAsObservable
的方法簽名如下:
1 |
$watchAsObservable(expOrFn, [options]) |
這個options,跟vue的$watch
方法的options一樣。
有時候,我們會有這樣的情況:在元件例項化的時候,資料流由於缺少某些條件,可能還沒法建立。
比如說,某個元件,依賴於路由上面的某個引數,這時候,可能你不知道怎麼去初始化繫結。
其實,產生這樣的想法,本身就錯了,因為沒有用Rx的理念去思考問題。想一下下面這句話:
資料流的定義,與初始條件是否具備無關。
初始條件其實也只是整個資料流管道中的一節,如果初始不確定的話,我們只要給它留一個資料入口就好了,後續的流轉定義可以全部寫得出來。
1 2 3 4 |
const taskId$ = new Subject() const task$ = taskId$ .distinctUntilChanged() .switchMap(id => this.getInitialData(id)) |
然後,在路由變更等事件裡,往這個taskId$
裡面next當前的id就可以了。通過這種方式,我們就可以把task$
直接繫結到介面上。
或者,taskId$
也可以通過在路由上面的watch轉化而成,只是不能直接用$watchAsObservable
,可以考慮改進一下這種情況。
這樣可以實現元件canReuse的情況下,改動路由引數,觸發當前頁面的資料重新整理
,實現檢視的更輕量級的重新整理。
將DOM事件轉化為Observable
使用RxJS可以直接把DOM事件轉化為Observable,vue-rx也提供了一個類似的方法來做這個事,不過我沒理解這兩個東西有什麼差異?具體參見官方示例吧。
構建優化
關注vue-rx的readme,可以發現,目前推薦使用繫結的方式是這樣:
1 2 3 4 5 6 |
import Vue from 'vue' import Rx from 'rxjs/Rx' import VueRx from 'vue-rx' // tada! Vue.use(VueRx, Rx) |
但這樣會有一個問題,import的是rxjs/Rx,我們看到,這個檔案裡把所有可以被掛接到Rx物件上的東西都import進來了,這會導致構建的時候沒法tree-shaking,用不到的那些操作符也被構建進來了,一個簡單的demo,可能構建結果也有200多k,這還是太大了。
我們檢視一下vue-rx的原始碼,發現傳入的這個Rx是怎麼使用的呢?
1 2 3 4 5 6 7 8 9 10 11 |
var obs$ = Rx.Observable.create(function (observer) { ... // Returns function which disconnects the $watch expression var disposable if (Rx.Subscription) { // Rx5 disposable = new Rx.Subscription(unwatch) } else { // Rx4 disposable = Rx.Disposable.create(unwatch) } |
這裡,其實只是要使用Observable和Subscription這兩個東西,所以我們可以改成這樣:
1 2 3 4 5 6 7 |
import Vue from 'vue' import { Observable } from 'rxjs/Observable' import { Subscription } from 'rxjs/Subscription' import VueRx from 'vue-rx' // tada! Vue.use(VueRx, { Observable, Subscription }) |
再試試,構建大小隻有不到100k了,而且是可以正常執行的。如果用的是Rx 4,需要傳入的就是Disposable而不是Subscription。
另外,如果我們使用了$watchAsObservable
,還會需要引入另外一個東西:
1 |
import 'rxjs/add/operator/publish' |
這是因為在$watchAsObservable
裡面,為了共享Observable,把它pubish之後refCount了,所以要引入,用不到這個方法的話,可以不引。
如果使用了$fromDOMEvent
,還需要引入這個:
1 |
import 'rxjs/add/observable/empty' |
因為$fromDOMEvent
裡面的這段:
1 2 3 |
if (typeof window === 'undefined') { return Rx.Observable.empty() } |
小結
有了這個庫之後,我們就可以比較優雅地結合VueJS和RxJS了。之前,兩者之間結合的麻煩點主要在於:
在RxJS體系中,資料的進、出這兩頭是有些繁瑣的。
所以,CycleJS採用了比較極端的做法,把DOM體系也包括進去了,這樣,編寫程式碼的時候,資料就沒有進出的成本,但這麼做,其實是犧牲了一些檢視層的編寫效率。
而Angular2中,用的是async這個pipe來解決這問題,這也是一種比較方便的辦法,在繫結Observable這一點上,跟有了vue-rx之後的Vue是差不多簡便的。
React體系裡面也有對RxJS的適配,而且還有跟Redux,Mobx對接的適配,感興趣的可以自行關注。
從個人角度出發,vue-rx這次的升級很好地滿足了我對複雜應用開發的需求了。
本文示例程式碼參見:這裡