[vue]使用vue-rx來管理元件間的狀態

  • 5788
  • 0
  • vue
  • 2017-07-08

在vue裡面使用rxjs來管理狀態

前言

越使用vue越讓我覺得很驚奇,因為筆者寫過很多種前端框架,雖然有些可能只是比較入門的部份,但以react算是筆者比較不熟的,也至少有寫到redux和redux thunk了,所以至少都能拿來寫些專案了,不過vue真的是讓我覺得針對架構(簡單的想得很複雜,把複雜的做得很簡單)做得最好的,只能說作者真的很厲害,而這篇想討論的就是vue裡面使用rx了,其實rxjs不是任何一個框架的產物,如果你喜歡的話在jquery使用rxjs也並無不可,但官方就是硬做了一個vuerx來很好的幫你整合rxjs使用,有興趣的人可以參訪此(https://github.com/vuejs/vue-rx),那這篇就來討論一下我們如何在vue裡面使用rxjs了。

導覽

  1. 起手式
  2. 情境模擬
  3. 拆成多個子元件
  4. 狀態管理
  5. 結論

起手式

首先來安裝一下vue-rx和rxjs吧

npm install vue-rx rxjs --save

接著你可以選擇全家桶一起使用,筆者測試全家桶的話,用build打包之後約為280kb,請在main.js裡面加入下面程式碼

import Vue from 'vue'
import Rx from 'rxjs/Rx'
import VueRx from 'vue-rx'

// tada!
Vue.use(VueRx, Rx)

或者你也可以選擇使用部份功能而已,筆者測試這樣的方式大約佔90kb,不過當我們使用到越多的operator的時候,size自然就會再往上增加

import Vue from 'vue'
import VueRx from 'vue-rx'
import { Observable } from 'rxjs/Observable'
import { Subscription } from 'rxjs/Subscription' // Disposable if using RxJS4
import { Subject } from 'rxjs/Subject' // required for domStreams option

// tada!
Vue.use(VueRx, {
  Observable,
  Subscription,
  Subject
})

對我來說我會選擇用全家桶的方式,因為在vs code裡面使用全家桶才不會需要自行import細節的部份,因為rxjs拆得很細,每個operator都要自行引入,那非常的麻煩,而且全部引入可以充分享受vs code的intellisense,豈止一個字舒服了得。

接著來做第一個範例吧,我們做一個計時器在畫面上吧

<template>
  <div class="hello">
    {{count$}}
  </div>
</template>

<script>
import Rx from 'rxjs/Rx'

export default {
  name: 'hello',
  subscriptions: {
    count$: Rx.Observable.interval(1000)
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h1,
h2 {
  font-weight: normal;
}

ul {
  list-style-type: none;
  padding: 0;
}

li {
  display: inline-block;
  margin: 0 10px;
}

a {
  color: #42b983;
}
</style>

因為rxjs強調任何觀察的東西,都是stream所以只要經由rx去建立的observable,全部都能使用operator來轉換並做最後的輸出,所以我們稍稍把count$改一下

count$: Rx.Observable.interval(1000)
      .filter(x => x % 2 === 0)

這是一個非常簡單的範例,不過大家可以想像一下如果今天這個事情要自己寫javascript,需要寫多少程式碼呢,每次的事件推送能透過operator來處理,是非常強大的,還有相對於angular的rxjs,vuerx幫你省了很多事情,首先你不用再自行subscribe了,再者你也不用自己去unsubscribe了,除非你不是寫在subscriptions裡面,有沒有開始覺得vue試圖把很多原本要自己處理的事情,都幫你處理掉了,除了貼心沒有什麼好說的了。

情境模擬

大家想像一下,如果我們有一個深層的元件,那我們從ajax取回資料的時候,需要先傳到子元件,子元件a再傳到子元件b,這樣一層一層的傳下去顯然是非常麻煩的,首先看一下官方示例多元件互動的假設圖

那如果我們透過rxjs又怎麼來處理這種需求呢?又或者當子元件要和子元件溝通的時候又得怎麼處理呢,來假設一個購物車的例子,當按下加入購物車,左上角會加上金額,左下角則顯示購入多少本書,上面則有一個結帳按鈕,按下去會全部歸零

新增一支bookMarketService.js

let state = {
  member: {
    name: 'kinanson',
    totalPrice: 0,
    totalNumber: 0
  },
  books: [{
    id: 1,
    title: 'TensorFlow+Keras 深度學習人工智慧實務應用 ',
    context: `人工智慧時代來臨,必須學習的新技術
輕鬆學會「深度學習」:先學Keras再學TensorFlow

★成長最快領域:深度學習與類神經網路,是人工智慧成長最快的領域,讓電腦更接近人類的思考。
★應用深入生活:手機語音助理、人臉識別、影像辨識、手寫辨識、醫學診斷、自然語言處理。
★實作快速上手:只需Python基礎,依照本書Step by Step學習,就可以輕鬆學會深度學習概念與應用。

TensorFlow功能強大、執行效率高、支援各種平台,然而TensorFlow是低階的深度學習程式庫,學習門檻高。所以本書先介紹Keras,Keras是高階的深度學習程式庫(以TensorFlow作為後端引擎),對初學者學習門檻低,可以很容易地建立深度學習模型,並且進行訓練、預測。等讀者熟悉深度學習模型概念與應用後,再來學習TensorFlow就很輕鬆了。`,
    added: false,
    price: 450
  }, {
    id: 2,
    title: `資料結構--使用Python `,
    context: `資料結構(Data Structures)是資訊學科中的核心課程之一,也是基礎和必修的科目。本書確實闡述資料結構的重要主題,並以圖文並茂的方式表達,最能達到教學與學習事半功倍的效果。

內容共分十三章,分別為第一章演算法分析、第二章陣列、第三章堆疊與佇列、第四章鏈結串列、第五章遞迴、第六章樹狀結構、第七章Heap結構、第八章高度平衡二元搜尋樹、第九章2-3 Tree及2-3-4 Tree、第十章m-way 搜尋樹與B-Tree、第十一章圖形結構、第十二章排序,以及第十三章搜尋。`,
    added: false,
    price: 400
  }, {
    id: 3,
    title: `ffective SQL 中文版 | 寫出良好SQL的61個具體做法 `,
    context: `“與其瞎忙或四處尋找答案,請幫自己一個忙:直接買這本書吧!”
-Dave Stokes,MySQL社群經理,Oracle Corporation`,
    added: false,
    price: 400
  }, {
    id: 4,
    title: `無瑕的程式碼 ── 敏捷完整篇 ── 物件導向原則、設計模式與C#實踐 (Agile principles, patterns, and practices in C#) `,
    context: `這本書是《無瑕的程式碼》系列書的第三冊,也是《名家名著》系列書的第三冊。主題是「敏捷開發」,而重點仍舊是回歸到「如何撰寫出好的程式碼」。

什麼是「敏捷開發(Agile Development)」呢?簡單來說,它是軟體開發的一套方法,特點是只要透過這套方法,就能使你的專案更敏捷。

我們為何非得要讓專案變得敏捷呢?原因無他,就是因為我們有慣老闆、還有慣客戶。也就是說,對於現今的市場環境而言,專案不夠敏捷是不行的。這一點,相信所有的軟體工程師都無法否認吧!`,
    added: false,
    price: 500
  }]
}

const service = {
  get() {
    return state
  }
}

export default service

然後是Hello.vue修改成如下範例,樣式是直接套用bootstrap最新版的

<template>
  <div class="hello" v-if="data.member">
    <nav class="navbar fixed-top navbar-inverse bg-primary">
      <div class="navbar-brand">
        <span class="navbar-toggler-left">結帳金額為:{{data.member.totalPrice}}</span>
        <span>
          <button class="btn btn-primary" @click="confirmPay">確定結帳</button>
        </span>
        <span class="navbar-toggler-right">{{data.member.name}} 歡迎您</span>
      </div>
    </nav>
    <div class="container">
      <div class="row">
        <div class="card col-md-12" v-for="book in data.books">
          <div class="card-header">
            <span class="float-left">書名:{{book.title}}</span>
            <span class="float-right">價格:{{book.price}}</span>
          </div>
          <div class="card-block">
            <div class="float-left">
              <button class="btn btn-info" @click="add(book)">
                <span v-show="!book.added">加入購物車</span>
                <span v-show="book.added">已購買</span>
              </button>
            </div>
            <div class="clearfix"></div>
            <h4 class="card-title">簡介:</h4>
            <p>
              {{book.context}}
            </p>
          </div>
        </div>
      </div>
    </div>
    <div class="fixed-area card">
      <div class="card-block">
        您已購入{{data.member.totalNumber}}本書
      </div>
    </div>
  </div>
</template>

<script>
import bookMarketService from '../bookMarketService'
import Friends from './Friends.vue'
export default {
  name: 'hello',
  components: {
    Friends
  },
  data () {
    return {
      data: {},
      goodTotal: 0
    }
  },
  methods: {
    get () {
      this.data = bookMarketService.get()
    },
    add (book) {
      book.added = !book.added
      this.data.member.totalPrice += book.added ? +book.price : -book.price
      this.data.member.totalNumber += book.added ? +1 : -1
    },
    confirmPay () {
      this.data.books.forEach(item => item.added = false)
      this.data.member.totalPrice = 0
      this.data.member.totalNumber = 0
    }
  },
  mounted () {
    this.get()
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.btn-info {
  color: #fff;
  background-color: #0275D8;
  border-color: #0275D8;
}

.navbar-brand {
  display: inline-block;
  padding-top: .25rem;
  padding-bottom: .25rem;
  margin-right: 1rem;
  font-size: 1.25rem;
  line-height: inherit;
  white-space: nowrap;
  height: 35px;
}

.fixed-area {
  position: fixed;
  top: 650px;
  left: 5px;
}
</style>

拆成多個子元件

我們現在就來把整個頁面拆成多個元件吧,為了模擬複雜的情境,所以我硬是把這個簡單的頁面拆成四個元件

先看一下原本的Hello.vue

<template>
  <div class="hello" v-if="data.member">
    <nav class="navbar fixed-top navbar-inverse bg-primary">
      <div class="navbar-brand">
        <price-count :total-price="data.member.totalPrice"></price-count>
        <span>
          <confirm @on-confirm="confirmPay"></confirm>
        </span>
        <span class="navbar-toggler-right">{{data.member.name}} 歡迎您</span>
      </div>
    </nav>
    <div class="container">
      <div class="row">
        <div class="col-md-12" v-for="book in data.books">
          <detail :book="book" @on-add="add"></detail>
        </div>
      </div>
    </div>
    <div class="fixed-area card">
      <book-count :total-number="data.member.totalNumber"></book-count>
    </div>
  </div>
</template>

<script>
import bookMarketService from '../bookMarketService'
import BookCount from './BookCount.vue'
import Detail from './Detail.vue'
import Confirm from './Confirm.vue'
import PriceCount from './PriceCount.vue'
export default {
  name: 'hello',
  components: {
    BookCount,
    Detail,
    Confirm,
    PriceCount
  },
  data () {
    return {
      data: {},
      goodTotal: 0
    }
  },
  methods: {
    get () {
      this.data = bookMarketService.get()
    },
    add (book) {
      book.added = !book.added
      this.data.member.totalPrice += book.added ? +book.price : -book.price
      this.data.member.totalNumber += book.added ? +1 : -1
    },
    confirmPay () {
      this.data.books.forEach(item => item.added = false)
      this.data.member.totalPrice = 0
      this.data.member.totalNumber = 0
    }
  },
  mounted () {
    this.get()
  }
}
</script>

BookCount.vue

<template>
  <div class="card-block">
    您已購入{{totalNumber}}本書
  </div>
</template>

<script>
export default {
  name: 'bookCount',
  props: ['totalNumber']
}
</script>

Confirm.vue

<template>
  <div>
    <button class="btn btn-primary" @click="$emit('on-confirm')">確定結帳</button>
  </div>
</template>

<script>
export default {
  name: 'confirm'
}
</script>

Detail.vue

<template>
  <div class="card">
    <div class="card-header">
      <span class="float-left">書名:{{book.title}}</span>
      <span class="float-right">價格:{{book.price}}</span>
    </div>
    <div class="card-block">
      <div class="float-left">
        <button class="btn btn-info" @click="$emit('on-add',book)">
          <span v-show="!book.added">加入購物車</span>
          <span v-show="book.added">已購買</span>
        </button>
      </div>
      <div class="clearfix"></div>
      <h4 class="card-title">簡介:</h4>
      <p>
        {{book.context}}
      </p>
    </div>
  </div>
</template>

<script>
export default {
  name: 'detail',
  props: ['book']
}
</script>

PriceCount.vue

<template>
  <div>
    <span class="navbar-toggler-left">結帳金額為:{{totalPrice}}</span>
  </div>
</template>

<script>
export default {
  name: 'priceCount',
  props: ['totalPrice']
}
</script>

狀態管理

這個例子老實說也還不夠複雜到需要狀態管理,但其實寫一個範例要做到非常複雜來說明狀態管理,應該也讓人都讀不懂了,所以我們還是將就一點的用這個範例來測試一下如果用vuerx來管理狀態,應該會是怎麼做的吧,我首先新增一支bookMarketSubject.js,所有邏輯都會集中放在這邊,只要呼叫next就會去修改subject,然後有訂閱的地方就會自動收到通知,在此我們可以想像bookMark就是state,而books$和member$就是getters的感覺。

import Rx from 'rxjs/Rx'
import bookMarketService from './bookMarketService.js'
let bookMark = bookMarketService.get()

const subject = {
  //bookMark$:new Rx.BehaviorSubject(bookMark)可以直接把整個bookMarkt做為一個subject,或把各屬性拆開來
  books$: new Rx.BehaviorSubject(bookMark.books),
  member$: new Rx.BehaviorSubject(bookMark.member),
  addToCar(book) {
    bookMark.books.forEach(item => {
      if (item === book) {
        item.added = !item.added
        book = item
      }
    })

    bookMark.member.totalPrice += book.added ? +book.price : -book.price
    bookMark.member.totalNumber += book.added ? +1 : -1
    this.books$.next(bookMark.books)
    this.member$.next(bookMark.member)
  },
  resetCar() {
    bookMark.member.totalPrice = 0
    bookMark.member.totalNumber = 0
    this.member$.next(bookMark.member)
  }
}

export default subject

再看一下Hello.vue可以發現,幾乎所有的props和event都刪掉了

<template>
  <div class="hello">
    <nav class="navbar fixed-top navbar-inverse bg-primary">
      <div class="navbar-brand">
        <price-count></price-count>
        <span>
          <confirm></confirm>
        </span>
        <span class="navbar-toggler-right">{{member.name}} 歡迎您</span>
      </div>
    </nav>
    <div class="container">
      <div class="row">
        <div class="col-md-12" v-for="book in books">
          <detail :book="book"></detail>
        </div>
      </div>
    </div>
    <div class="fixed-area card">
      <book-count></book-count>
    </div>
  </div>
</template>

<script>
import bookMarketSubject from '../bookMaketSubject'
import BookCount from './BookCount.vue'
import Detail from './Detail.vue'
import Confirm from './Confirm.vue'
import PriceCount from './PriceCount.vue'

export default {
  name: 'hello',
  components: {
    BookCount,
    Detail,
    Confirm,
    PriceCount
  },
  subscriptions: {
    books: bookMarketSubject.books$,
    member: bookMarketSubject.member$
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.btn-info {
  color: #fff;
  background-color: #0275D8;
  border-color: #0275D8;
}

.navbar-brand {
  display: inline-block;
  padding-top: .25rem;
  padding-bottom: .25rem;
  margin-right: 1rem;
  font-size: 1.25rem;
  line-height: inherit;
  white-space: nowrap;
  height: 35px;
}

.fixed-area {
  position: fixed;
  top: 650px;
  left: 5px;
}
</style>

這就是複雜的元件樹想要的,如果我們props一層傳一層,emit也一層一層的發上來,是很累人的,雖然在這個例子還感受不出來,然後再來看bookCount.vue

<template>
  <div class="card-block">
    您已購入{{member.totalNumber}}本書
  </div>
</template>

<script>
import bookMarketSubject from '../bookMaketSubject'
export default {
  name: 'bookCount',
  subscriptions: {
    member: bookMarketSubject.member$
  }
}
</script>

可以發現值是從subscriptions來的了,而不是從props取得的,接著看Confirm.vue

<template>
  <div>
    <button class="btn btn-primary" @click="resetCar">確定結帳</button>
  </div>
</template>

<script>
import bookMaketSubject from '../bookMaketSubject'

export default {
  name: 'confirm',
  methods: {
    resetCar () {
      bookMaketSubject.resetCar()
    }
  }
}
</script>

我們不再是使用emit,而是使用subject的next去改變observable的值,當值改變了,subcription自然會通知component去更新,再來是Detail.vue

<template>
  <div class="card">
    <div class="card-header">
      <span class="float-left">書名:{{book.title}}</span>
      <span class="float-right">價格:{{book.price}}</span>
    </div>
    <div class="card-block">
      <div class="float-left">
        <button class="btn btn-info" @click="add">
          <span v-show="!book.added">加入購物車</span>
          <span v-show="book.added">已購買</span>
        </button>
      </div>
      <div class="clearfix"></div>
      <h4 class="card-title">簡介:</h4>
      <p>
        {{book.context}}
      </p>
    </div>
  </div>
</template>

<script>
import bookMarketSubject from '../bookMaketSubject'
export default {
  name: 'detail',
  props: ['book'],
  methods: {
    add () {
      bookMarketSubject.addToCar(this.book)
    }
  }
}
</script>

最後是PriceCount.vue

<template>
  <div>
    <span class="navbar-toggler-left">結帳金額為:{{member.totalPrice}}</span>
  </div>
</template>

<script>
import bookMarketSubject from '../bookMaketSubject.js'
export default {
  name: 'priceCount',
  subscriptions: {
    member: bookMarketSubject.member$
  }
}
</script>

結論

看起來好像沒有省事多少,那是因為這個範例還太過於簡單,不過vuex其實也是在做相同的事情,而且vuex可能會做得更好,而rxjs其實不只是可以做狀態管理而已,rxjs能做的事情還有多更多,不過如果我們要防止object或array在子元件被修改的話,我們目前也是只能透過vuex來協作,而用了vuex的話state也就不能用v-model來綁定了,青菜蘿菠各有所好,就取決自實做者自己的喜好了,如果對此篇文章有任何想法或指教的話,再請告知筆者。