讀古今文學網 > Vue2實踐揭秘 > 第7章 Vuex狀態管理 >

第7章 Vuex狀態管理

在Vue的生態圈內提供了一個叫Vuex的庫,專門用於狀態共享與狀態管理,感覺上它就像Flux的複製品。在最初始接觸Vuex的時候我並不喜歡它,雖然從概念圖譜上來講很容易理解,但對於Vue它卻有著非凡的意義。

開始學習時,即使看著官方提供的中文文檔都會感到一臉的茫然,或者是我過於愚鈍很難一時間理解它的意義所在,所以就將其棄之一角。使用Vue進行項目開發一段時間後,慢慢碰到以下兩種問題:

首先,從前幾章中我們已經非常清楚地知道Vue是一種完全基於組件化的可視化開發框架,它的美在於我們會很自然地用設計原則來規劃組件粒度與關係,用面向對象的思維來思考與編程。然而事物都有兩面性,當我們將視角拉近一點,關注那些流動在組件與組件之間的變量,尤其是那些較為複雜的複合型組件,會發覺父組件與子組件之間、子代組件與其子組件之間的變量維護變得越來越不容易,由於Vue取消了屬性的變量同步功能,父子組件之間的變量傳遞實際上是單向的:當子組件需要對傳入的變量進行修改,同時又希望通知父組件傳入的變量發生了變化時,則不得不使用事件冒泡來進行這種傳遞,最後在父組件中使用一個方法來進行重繪或者執行其他相應的行為,此類同質性操作又不能被封裝為可重用的代碼,這樣會不斷地加大父組件與子組件之間的耦合度,讓它們之間的關係變得錯綜複雜。

其次,vue-router可能是絕大多數的Vue項目都必不可少的工具庫,有了vue-router,我們可以將一個組件視為一個頁面來使用。由於組件只維護自身的狀態(data),組件創建時或者說進入路由時它們被初始化,切換至其他的組件頁時當前頁自然要被銷毀,從而導致data也隨之銷毀。頁面與頁面之間總會產生各種需要的共享變量,如果通過$router.param或者$router.meta來傳遞是遠遠不夠的,很多情況下不得不採用window來保存一些全局的共享變量(有很多的JavaScript框架或者庫都是這樣做的)。一旦這樣就會陷入了新的困局,Vue是不會維護window的這些共享變量的。對於組件來講,這些變量都存在於組件作用域以外,組件並不會「多管閒事」替我們托管。那我們就不得不手工來接管這些變量的賦值與讀取。然而,只要我們知道一些基本的JS編程規範或者風格規範都會明白這麼一條準則:全局變量是毒瘤,是具有極高副作用的。

這樣講可能還不容易理解,還是按照本書的風格以示例說話。舉一個經常遇到的例子:用戶對象的共享。由於登錄邏輯都放在後台,為了減少前後台的請求數量,當頁面加載時我們經常通過服務端頁面直接將一個用戶對像輸出為JSON並保存到window內,讓客戶端代碼無須頻繁地向服務器獲取當前用戶的對象數據。以Rails為例,在用戶登入成功後在Vue的引導頁面中加入下面的代碼:

    <!DOCTYPE html>
    <html>
    <head>
        <title></title>
        <script>
            // 將服務端的@user對像變成瀏覽器端的current_user
            window.$data = { current_user :
        </script>
    </head>
    <body>
        <p>
            <!--用於掛載vue的元素容器-->
        </p>
    </body>
    </html>  

當完成了這一步以後,相當於用windows.$data作為服務端對像到客戶端的一個輸出出口,將變量從服務端「傳遞」到客戶端,接下來就可以在Vue組件內將這個$data變成Vue的data:

    import _ from 'lodash'

    export default {
        data  {
            return _.extend({
                current_user: {
                    is_auth: false
                }
            },
            window.$data)
        },
        // ...
    }  

這種做法是一種很常用也很實用的技巧。因為確實可以省去不少在created鉤子中用$http調用服務端API的初始化動作。當不使用vue-router的時候,或者說window.$data的變量是完全只讀的時候,這種方法是沒有副作用的。

然而,只要將window.$data內的對象綁定到不同的自定義組件內,一旦要對window.$data內的變量進行修改,那麼你的噩夢就開始了——你會發現所有以對像方式綁定的自定義組件,當對像內的某個屬性發生改變時將不會執行自動刷新,所有的計算屬性也同時失效!更詭異的是這種情況並不是絕對出現的,當頁面元素相對簡單的時候一切都顯得很正常,一旦頁面元素增多,對應的交互操作增多時,這種奇怪的現象就會出現。

例如,對於window.$data.current_user.has_new_mail這個字段,我們定義一個這樣的組件:

    <template>
        <p>
          <span v-if="user.has_new_mail">你有新郵件</span>
        </p>
    </template>
    <script>
        export default {
            props: ['user']
        }
    </script>  

並且建立這樣的一個父組件來進行綁定:

    <tempalte>
        <p>
          <email-notifier :user="current_user"></email-notifier>
        </p>
    </tempalte>
    <script>
    import _ from 'lodash'
    import EmailNotifier from './EmailNotifier'

    export default {
        data  {
            return _.extend({
                current_user: {
                    is_auth: false,
                    has_new_mail: false
                }
            },
            window.$data)
        },
        components : {
            EmailNotifier
        }
    

}
    </script>  

此時,如果對current_user進行賦值,email-notifier是不會產生任何變化的,交互式刷新完全失敗!然而這只是將問題最小化至一個組件中,當有很多組件都在共享這個window.$data.current_user對像時,將會非常難以控制它的改變與DOM的刷新。

這種類似的問題在我沒有使用Vuex之前開始不斷地在項目中出現,直至我下決心將狀態變量交由Vuex來管理,這種變量共享問題才得以真正地解決。

7.1 Vuex的基本結構

Vuex是一個專為Vue.js應用程序開發的狀態管理模式。它採用集中式存儲來管理應用所有組件的狀態,並以相應的規則來保證狀態以一種可預測的方式發生變化。Vuex也集成到了Vue的官方調試工具devtools extension中,提供了諸如零配置的time-travel調試、狀態快照導入導出等高級調試功能。

這個狀態自管理應用包含以下幾個部分:

● state——驅動應用的數據源,也就是各種共享變量;

● view——以聲明方式將state映射到視圖,也就是在<template>模塊上引用這些state,讓Vuex自動處理重繪;

● actions——響應在view上用戶輸入導致的狀態變化。

以下是一個表示「單向數據流」理念的示意圖:

但是,當應用遇到多個組件共享狀態時,單向數據流的簡潔性很容易被破壞:

● 多個視圖依賴於同一狀態。

● 來自不同視圖的行為需要變更為同一狀態。

對於問題一,傳參的方法對於多層嵌套的組件將會非常煩瑣,並且對於兄弟組件間的狀態傳遞無能為力。對於問題二,我們經常會採用父子組件直接引用,或者通過事件來變更和同步狀態的多份副本的方式。以上的這些模式非常脆弱,通常會產生無法維護的代碼。

因此,為什麼不把組件的共享狀態抽取出來,以一個全局單例模式管理呢?在這種模式下,組件樹構成了一個巨大的「視圖」,不管在樹的哪個位置,任何組件都能獲取狀態或者觸發行為!

另外,通過定義和隔離狀態管理中的各種概念並強制遵守一定的規則,代碼將會變得更結構化且易維護。

安裝 Vuex
    $ npm i vuex -S  
示例說明

此處我們將使用第3章中的手機書店的示例,當我們使用Vuex對此示例進行改寫時,你會發現所有的視圖模板幾乎沒有發生任何的改變,我們只將代碼內的「狀態」提取到一個可以由this.store訪問的公共狀態對像內,其次我們會將所有的data用計算屬性加以取代,當狀態發生改變時,Vue的響應式系統依然按照原有的工作方式自動跟蹤狀態變化並對視圖進行自動重繪,而當我們需要對狀態進行改變時則是調用store內的Mutation進行的。254

在main.js全局配置文件內引入Vuex並進行基本的配置:

    // main.js
    import Vue from 'vue'
    import App from './App.vue'
    import store from './store'

    new Vue({
      el: '#app',
      store,
      render: h => h(App)
    })  

這個配置語法相信你現在已經很熟悉了,正如前文中的vue-router、vue-resource和vee-validate等庫一樣,在全局Vue實例中引入store後,在每個Vue組件實例內,可以用this.$store來引用這個store對象了。

先建立一個store並保存於~/src/store/index.js文件內,具體內容如下:

    // store/index.js
    import Vue from 'vue'
    import Vuex from 'vuex'

    // 從環境變量判斷當前的運行模式
    const debug = process.env.NODE_ENV !== 'production'

    // 聲明引入此庫文件的Vue實例使用Vuex插件應用狀態管理
    // 這樣可以從main.js文件內減少對vuex庫的依賴
    Vue.use(Vuex)

    // 導出store實例對像
    export default new Vuex.Store({
        strict:debug,                     // 設置運行模式
        plugin: debug ? [createLogger] :  // 調試模式則加入日誌插件
    });  

簡言之,store就是Vuex的全局配置,同時也是Vue實例(this)訪問Vuex獲取狀態共享服務的公共程序調用入口。Store的定義與Vue的定義是有很多相似之處的,以下就是一個完整的Store定義的結構(也就是上述代碼中傳入Vuex.Store(options)方法的options參數):

    export default {
        state: {},
        actions: {}
        

getters : {},
        mutations: {},
        modules: {},
        strict: true,
        plugin 
    }  

● state——Vuex store實例的根狀態對象,用於定義共享的狀態變量,就像Vue實例中的data。

● getters——讀取器,外部程序通過它獲取變量的具體值,或者在取值前做一些計算(可以認為是store的計算屬性)。

● actions——動作,向store發出調用通知,執行本地或者遠端的某一個操作(可以理解為store的methods)。

● mutations——修改器,它只用於修改state中定義的狀態變量。

● modules——模塊,向store注入其他子模塊,可以將其他模塊以命名空間的方式引用。

● strict——用於設置Vuex的運行模式,true為調試模式,false為生產模式。

● plugin——用於向Vuex加入運行期的插件。

由store的定義其實就可以看出一些結構上的端倪,store可以通過modules的註冊形成樹狀的實例結構。我們必須要先建立這樣一個概念,後面的內容就好理解了。modules的具體使用在下文會有詳細的交待。

7.2 data的替代者——State和Getter

我們來為圖書對像建立一個模塊,將其保存於~/src/modules/books.js中。先定義一個state對像作為讀取圖書類數據的容器,簡單地說就是將各個Vue頁面實例內凡是用於保存圖書數據的data屬性一併提取到以下這個文件中,並統一放在state變量內,具體做法如下:

    // ~/src/modules/books.js
    export default {
        state: {
            announcements:,
            promotions:,
            recommended:
      }
    }  

由於Mutation是為了改變state內的狀態而存在的,為了不引起理解上的混淆,在此暫 時先將其放下,後面再對它進行詳細的講解。

我們先從Home.vue文件開始,將原有的代碼重構為使用Vuex的方式。從原則上來講,視圖模板是不需要做出任何改動的。我們首先要做的是將從data獲取數據改由計算屬性去處理。以下是Home.vue原有的data定義:

    export default {
        data  {
            announcements:,
            promotions:,
            recommended:
        },
        computed : {
            promotionCount  {
                return this.promtions.length
            },
            recommendedCount  {
                return this.recommendedCount.length
            }
        }
        // ... 省略
    }  

接入Vuex並改由計算屬性進行處理:

    export default {
        computed: {
            announcements  {
                return this.$store.state.announcements
            },
            promotions  {
                return this.$store.state.promotions
            },
            recommended  {
                return this.$store.state.recommended
            },
            promotionCount  {
                return this.$store.state.promtions.length
            },
            recommendedCount  {
                return this.$store.state.recommendedCount.length
            }
        }
    }  

顯然,如果每個使用到圖書數據的頁面都要這樣改寫定義的話,代碼會變得很囉唆。而且promotionCount和recommendedCount這兩個計算屬性如果在多個地方使用的話,就要複製 。複製 是程序員的大忌,好代碼絕對不能出現任何的複製!凡是可以被複製的代碼就說明其可以被封裝且有重用的需要,因此我們可以採用另一個方法從state中獲取這些變量,這就是Vuex中的讀取器(Getter)。我們將上述代碼統一封裝到圖書模塊內並以Getter的方式將它們重新暴露出來:

    // ~/src/modules/books.js
    export default {
        state: {
            announcements:,
            promotions:,
            recommended:
        },
        getters: {
            announcements: state => state.announcements,
            promotions: state => state.promotions,
            recommended: state => state.recommended,
            totalPromotions: state => state.promotions.length,
            totalRecommended: state => state.recommended.length
        }
    }  

然後Home.vue就可以改寫為:

    export default {
        computed: {
            announcements  {
                return this.$store.getters.announcements
            },
            promotions  {
                return this.$store.getters.promotions
            },
            recommended  {
                return this.$store.getters.recommended
            },
            promotionCount  {
                return this.$store.getters.totalPromotions
            },
            recommendedCount  {
                return this.$store.getters.totalRecommended
            }
        }
    }  

將從狀態值直接讀取數據轉換成從讀取器獲取數據看起來似乎沒有很大的區別,但只要你細想一下Vue實例中data與computed兩者之間的關係,你就能理解了——state相當於store實例的data,而getters就相當於store實例的computed。

進一步用Vuex提供的幫助方法mapGetters簡化上述代碼:

    import {mapGetters} from 'vuex'

    export default {
       computed: {
           ...mapGetters([
                      'announcements',
                      'promotions',
                      'recommended',
                      'promotionCount',
                      'recommendedCount'
                      ])
        }
    }  

mapGetters本質上就是動態方法生成器,作用就是生成上面那些將store.getter方法映射為Vue實例的computed。

上述代碼中採用了ES6 stage-3階段中的對象展開符,如果你學過Python,它的作用就相當於**object,也就是將一個對像進行解體並展開為多個方法。

這只是為了能讓你瞭解Getter存在的意義,才在此例中有意地寫出幾個毫無意義的Getter。如果這些Getter只是單純地返回狀態值的話,我們可以不定義Getter,而使用mapState幫助方法將所有狀態直接映射為計算屬性:

    export default {
        state: {
            announcements:,
            promotions:,
            recommended:
        },
        getters: {
            totalPromotions: state => state.promotions.length,
            totalRecommended: state => state.recommended.length
        }
    }  

Home.vue應簡化為:

    import {mapGetters} from 'vuex'
    

import _ from 'lodash'

    export default {
        computed: {
            ...mapState([
                       'announcements',
                       'promotions',
                       'recommended']),
            ...mapGetters([
                       'promotionCount',
                       'recommendedCount'
                       ])
        }
    }  

7.3 測試Getter

如果你的Getter帶有複雜計算,那麼測試它們是值得的。Getter本質上只是一個普通的JS函數,並不需要做什麼特殊的配置,就當作普通的單元測試來寫就可以了。

以下的Getter用於從狀態變量數組中篩選出指定類別的圖書:

    // getters.js
    export const getters = {
      filteredProducts (state, {filterCategory}) {
        return state.products.filter(product => {
          return product.category === filterCategory
        })
      }
    }  

單元測試:

    // test/unit/store/book-getters.spec.js
    import { getters } from './getters'

    describe('getters',  => {
      it('filteredProducts',  => {
        // 準備仿真的狀態數據
        const state = {
          products: [
            { id: 1, title: '揭開數據真相:從小白到數據分析達人', category: '大數據' },
            { id: 2, title: '淘寶天貓電商運營與數據化選品完全手冊', category: '電商運


營' },
            { id: 3, title: '大數據架構詳解:從數據獲取到深度學習', category: '大數據
' }
          ]
        }

        const filterCategory = '大數據'

        // 直接調用getter
        const result = getters.filteredProducts(state, { filterCategory })

        // 對結果進行深度斷言
        expect(result).to.deep.equal([
          { id: 1, title: '揭開數據真相:從小白到數據分析達人', category: '大數據' },
          { id: 3, title: '大數據架構詳解:從數據獲取到深度學習', category: '大數據' }
        ])
      })
    })  

關於Promise polyfill的問題

加入Vuex後一旦通過Karma啟用單元測試,我們會接收到以下的出錯信息:

    [vuex] vuex requires a Promise polyfill in this browser.  

可以安裝一個Babel的polyfill庫來解決這個問題,先安裝babel-polyfill:

    $ npm i babel-polyfill -D  

然後在Karma.conf.js的file設項內加入以下代碼:

    config.set({
      files: [
          '../../node_modules/babel-polyfill/dist/polyfill.js',
          './index.js'],
      // ... 省略
    })  

7.4 Action——操作的執行者

此時你可能會產生這樣的疑惑,那應該在哪裡使用vue-resource從服務器讀取數據並分別寫到state的各個屬性中呢?首先繼續強調的一點是,一旦引入Vuex後,我們的Vue實例不能直接修改$store.state內的任意內容,要修改狀態就要通過Mutation來修改。在本 例中,在修改這些狀態數據之前,我們需要執行與服務器之間的通信。那麼在使用Mutation之前,我們得先做一些Action來完成這個需要。

用上文的對應法來理解動作(Action)的話,我們可以將它看作$store的methods。接下來就開始定義從服務器中讀取數據的動作(Actions):

    // ~/src/modules/books.js
    import Vue from 'vue'

    export default {
        state: {
            announcements:,
            promotions:,
            recommended:
      },
      getters: {
       totalPromotions: state => state.promotions.length,
       totalRecommended: state => state.recommended.length
      },
      actions: {
       getStarted (context) {
            Vue.http.get('/api/get-start', (res)=>{
               context.commit('startedDataReceived',res.body)
            })
       }
      }
    }  

開始調用vue-resource時,你可能會變得無所適從。在前文中我們都是通過Vue實例的上下文this.$http來獲取vue-resource的對象實例,可現在store實例中的this並不是一個Vue實例,所以我們得重新導入Vue對象的引用,直接從Vue.http裡獲取vue-resource實例:

    Vue.http.get('/api/get-start', (res) => {
        // ...
    })  

Action是不能直接修改state中的狀態的,每個Action定義的第一個參數必然是一個與當前store實例結構相同的context對象,這個對象具有以下屬性:

● state——等同於store.state,若在模塊中則為局部狀態。

● rootState——等同於store.state,只存在於模塊中。

● commit——等同於store.commit,用於提交一個mutation。

● dispatch——等同於store.dispatch,用於調用其他action。

● getters——等同於store.getters,獲取store中的getters。

我們只調用context.commit提交了一個startedDataReceived並傳入AJAX調用返回的JSON對像res.body,這裡是否有點觸發事件的意味?事實正是如此!我們可以將commit方法看作Action的一個執行終點,它只是負責通知Vuex執行完一個動作或者處理完成,這個動作產生的結果就是輸入的參數,交由名為startedDataReceived的Mutation繼續進行下一步的處理。

如果實際面對的業務很複雜,那麼可能一個Action內的操作就不止是像上述代碼中單純地進行遠程方法調用了,有可能在這個Action中讀取其他state對像作為輸入參數,也可能需要執行另外一個Action。簡言之,Action除了像Vue實例中的methods,更像是MVC模式中的Controller,唯一的區別只是Action可以執行任何的異步處理,並且它只對狀態進行讀取而不做出任何修改。

7.5 測試Action

測試Action就有點棘手了,因為它們可能依賴外部API。當測試Action的時候,我們通常需要做某種程度的mocking(模擬)——例如,我們可以把API調用抽像封裝到一個服務類,然後在測試中模擬這個服務。為了簡單模擬依賴,我們可以使用webpack和inject-loader類打包我們的測試文件。

測試異步Action的例子:

    // actions.js
    import shop from '../api/shop'

    export const getAllProducts = ({dispatch}) => {
      dispatch('REQUEST_PRODUCTS')
      shop.getProducts(products => {
        dispatch('RECEIVE_PRODUCTS', products)
      })
    }
    // actions.spec.js

    // use require syntax for inline loaders.
    // with inject-loader, this returns a module factory
    

// that allows us to inject mocked dependencies.
    import { expect } from 'chai'
    const actionsInjector = require('inject!./actions')

    // create the module with our mocks
    const actions = actionsInjector({
      '../api/shop': {
        getProducts (cb) {
          setTimeout( => {
            cb([/* mocked response */])
          }, 100)
        }
      }
    })

    // helper for testing action with expected mutations
    const testAction = (action, args, state, expectedMutations, done) => {
      let count = 0

      // mock commit
      const commit = (type, payload) => {
        const mutation = expectedMutations[count]
        expect(mutation.type).to.equal(type)
        if (payload) {
          expect(mutation.payload).to.deep.equal(payload)
        }
        count++
        if (count >= expectedMutations.length) {
          done
        }
      }

      // call the action with mocked store and arguments
      action({ commit, state }, ...args)

      // check if no mutations should have been dispatched
      if (expectedMutations.length === 0) {
        expect(count).to.equal(0)
        done
      }
    }

    describe('actions',  => {
      it('getAllProducts', done => {
        

testAction(actions.getAllProducts, , {}, [
          { type: 'REQUEST_PRODUCTS' },
          {type: 'RECEIVE_PRODUCTS', payload: { /* mocked response */ }}
        ], done)
      })
    })  

7.6 只用Mutation修改狀態

當我們從遠程數據庫讀取完數據後就要通知store調用一個Mutation來修改狀態,也就是上文中的startedDataReceived,在Action中的寫法有點像在Vue實例內調用$emit觸發事件。確實從理解上來說,Mutation就像是store內的專屬事件,它只能由store本身進行回調,當然也可以通過store.commit方法直接觸發一個Mutation。

加入mutations:

    // ~/src/modules/books.js
    import Vue from 'vue'

    export default {
        state: {
          announcements:,
          promotions:,
          recommended:
        },
        getters: {
            totalPromotions: state => state.promotions.length,
            totalRecommended: state => state.recommended.length
        },
        actions: {
            getStarted (context) {
              Vue.http.get('/api/get-start', (res)=>{
                context.commit('startedDataReceived',res.body)
              })
            }
        },
        mutations: {
            startedDataReceived (state,{started_data}) {
              state.announcements = started_data.announcements
              state.promotions = started_data.promotions
              state.recommended = started_data.recommended
              

}
        }
    }  

因此,在actions的下方我定義了一個名為startedDataReceived的Mutation回調處理方法,使之與getStarted Action內提交的startedDataReceived對應。最後,上述代碼中有一個地方是重複性非常強的,就是startedDataReceived的定義與引用,它們的出現頻率將是mutation定義的兩倍以上,為了避免這種可怕而低級的重複性的出現,我們可以用ES6的常量定義來修改一下上述的代碼,直接將Mutation作為一種事件常量來使用:

    //~/src/store/mutation-types.js
    export const STARTED_DATA_RECEIVED = 'startedDataReceived'  

改用常量定義:

    // ~/src/modules/books.js
    import Vue from 'vue'
    import {STARTED_DATA_RECEIVED} from '../mutation-types'

    export default {
        state: {
         // ...省略
        },
        getters: {
         // ...省略
        },
        actions: {
            getStarted (context) {
                Vue.http.get('/api/get-start', (res)=>{
                    context.commit(STARTED_DATA_RECEIVED,res.body)
                })
            }
        },
        mutations: {
          [STARTED_DATA_RECEIVED] (state,{started_data}) {
            // ...省略
          }
       }
    };  

最後,回到Home.vue中來調用Action,以初始化共享狀態中的數據。Action也是不能直接調用的,它只能通過Vuex的dispatch方法向Vuex發出調用通知,由Vuex來找到這個指定的Action並執行。

    

import {mapGetters} from 'vuex'

    export default {
      computed: {
      ...mapGetters([
                 'announcements',
           'promotions',
           'recommended',
           'promotionCount',
           'recommendedCount'
      ])
      },
      created  {
        this.$store.dispatch('getStarted')
      }
    }  

如前文所提及的,Action相當於Vue的methods。同樣地,Vuex也提供了將Actions映射為methods的幫助方法mapActions,這樣我們就不用直接調用dispatch,而是可以對像化地進行操作了:

    import {mapGetters,mapActions} from 'vuex'
    import _ from 'lodash'

    export default {
        computed: {
        ...mapState([
                           'announcements',
                             'promotions',
                             'recommended'
                  ]),
        ...mapGetters([
                           'promotionCount',
                               'recommendedCount'
                  ])
      },
        methods: {
        ...mapActions(['getStarted'])
      },
        created  {
            this.getStarted
        }
    }  

Vue2中的屬性綁定都是單向的,也就是說,綁定到組件屬性上的變量必須是只讀的、不可改變的(Inmutatable),只有這樣的變量才沒有副作用。

7.7 測試Mutations

Mutations很好測試,因為它們就是僅依賴參數本身的方法。有一個技巧就是,如果用ES2015的模塊化並且把Mutation放到store.js文件裡,除了默認的export,你還可以把這個Mutation以一個命名參數來「export」。

    const state = { ... }

    // 命名參數export mutation
    export const mutations = { ... }

    export default new Vuex.Store({
      state,
      mutations
    });  

使用Mocha + Chai測試一個Mutation的例子:

    // mutations.js
    export const mutations = {
      increment: state => state.count++
    }
    // mutations.spec.js
    import { mutations } from './store'

    // destructure assign mutations
    const { increment } = mutations

    describe('mutations',  => {
      it('INCREMENT',  => {
        // mock state
        const state = { count: 0 }
        // apply mutation
        increment(state)
        // assert result
        expect(state.count).to.equal(1)
      })
    })  

7.8 子狀態和模塊

前文中我們展示了如何在一個$store實例內直接定義購物車的狀態,我們稱其為「單一狀態樹」。此時getter、action和mutation都是圍繞購物車的行為及狀態修改而定義的。從這個示例就可以全面地瞭解操作一個「模型(Model)」所需要的最小的Vuex結構是怎樣的,下面就全面回顧一下store的全部代碼定義:

    //~/src/modules/cart.js
    import Vue from 'vue'
    import * as types from '../mutation-types'
    import * as apis from '../apis'
    export default {
        state {
            all:
        },
        getters: {
           cartItems  {
              return state.all
           }
        },
        actions: {
           addToCart (context,{id}) {
              Vue.http
                .post(apis.ADD_TO_CART,{id:id})
                .then(res => {
                  context.commit(types.ADD_TO_CART,res.body)
                })
           },
           getCartItems (context) {
              Vue.http
                .get(apis.GET_CART_ITEMS)
                .then(res => {
                  context.commit(types.SET_CART_ITEMS,res.body.data)
                })
            },
            clear (context) {
               Vue.http
                 .post(apis.CLEAR_CART)
                 .then(res => {
                   context.commit(type.SET_CART_ITEMS,)
                 })
            }
        

},
        mutations: {
            [types.SET_CART_ITEMS] (state,{items}) {
                state.all = items
            },
            [types.ADD_TO_CART](state,{cartItem}) {
                state.all.push(cartItem)
            }
        }
    }  

真正的項目所需要處理的模型遠遠不止一個,一個商城除了購物車,還需要定義用來存取商品信息的商品(圖書)模型;需要用來記錄商品SKU的庫存模型;需要記錄交易信息的訂單模型,等等。如果向現在的store中加入對圖書商品和訂單的狀態支持,首先就要向state對像加入兩個不同的all狀態,用於獲取所有的圖書與訂單。顯然這樣就會導致命名衝突,那麼就不得不將這三個狀態用以下的方式來命名:

    export default {
        state {
            allCartItems:,
            allBooks:,
            allOrders:
        },
        // ... 省略
    }  

而它們的action和mutation也會面對類似的問題。從語言級別上而言,這是沒有命令空間所導致的大量不得已的含義性重複,隨著項目變得越來越大,這種含義性的重複也將以倍數級別增長,store會變得非常臃腫且難以維護。幸好Vuex提供了「模塊」的定義,通過模塊,我們可以將各種在功能使用上具有獨立性的模形狀態,將各自的模形狀態作為子狀態掛入至store根狀態內,以模板名稱作為命名空間來使用。這樣做的話,操作和變更就被劃分到各自模塊內,由模塊提供的命名空間進行引用,從而使store的結構與代碼組織變得清晰和容易理解。

簡言之,模塊可看作一個獨立處理某個業務對象的store,而store.js文件就作為一個文件入口,是所有業務的「根」,就像main.js文件那樣用作一種全局性的配置,在運行期組合出真正完整的store對象。要達到這樣的效果,就需要對store的工程文件結構進行重整。Vue官方推薦了以下的文件結構的組織方式及使用原則:

(1)應用級的狀態集中在store中。

(2)修改狀態的唯一方式就是通過提交mutation來實現的,它是同步的事務。

(3)異步邏輯應該封裝在action中,並且可以組合action。

只要遵循這些規則,可以任意設計項目結構。如果store文件非常大,直接開始分割action、mutation和getter到多個文件。

對於複雜的應用,我們可能需要使用模塊化。下面是一個項目結構:

    ├── index.html
    ├── App.vue
    ├── main.js
    ├── components
    │    └── ...
    └── store
          ├── index.js               # 配置 store 的配置文件
          ├── actions.js             # 公共根 store 的 actions
          ├── mutations.js           # 公共根 store 的 mutations
          ├── mutation-types.js      # 公共 mutations 名稱定義
          └── modules                # 模塊文件夾
                ├── cart.js          # 模塊定義
                └── products.js      # ...  

用以上這種組織型重新改寫store和各自的模塊,首先入口文件store.js的定義將變成以下的方式:

    import Vue from 'vue'
    import Vuex from 'vuex'
    import createLogger from 'vuex/dist/logger'
    import * as actions from './actions'
    import * as getters from './getters'
    import cart from './modules/cart'
    import products from './modules/products'

    const debug = process.env.NODE_ENV !== 'production'

    Vue.use(Vuex)

    export default new Vuex.Store({
      modules: {
        cart,
        products
      },
      actions: actions,
      getters: getters,
      strict: debug, // 設置運行模式
      plugin: debug ? [createLogger] :  // 調試模式則加入日誌插件
    });  

~/src/store/modules/cart.js就會變為:

    import Vue from 'vue'
    import * as apis from '../apis'
    import * as types from '../mutation-types'

    const state = {
      all: 
    }

    const mutations = {
      [types.SET_CART_ITEMS] (state, cart) {
        state.all = cart.items
      }
    }

    const actions = {
      getItems (context) {
        Vue.http.get(apis.GET_CART_ITEMS)
               .then(res => {
                  context.commit(types.SET_CART_ITEMS,res.body.data)
              })
      }
    }


    export default {
      state,
      mutations,
      actions
    }  

~/src/modules/products.js模板的定義如下:

    import Vue from 'vue'
    import * as apis from '../apis'
    import * as types from '../mutation-types'

    const state = {
      announcement: {},
      top: ,
      promotions: ,
      recommended: 
    }
    

const mutations = {
      [types.SET_BOOK] (state, book) {
        state.book = book
      },

      [types.STARTED_DATA_RECEIVED] (state, {top, announcement, promotions,
recommended}) {
        state.top = top
        state.announcement = announcement
        state.promotions = promotions
        state.recommended = recommended
      }
    }

    const actions = {
      getBook (context, id) {
        Vue.http.get(apis.GET_BOOK,{id}).then(res => {
          context.commit(types.SET_BOOK, res.body)
        })
      },

      getStarted (context) {
        Vue.http.get(apis.GET_STARTED).then(res => {
          context.commit(types.STARTED_DATA_RECEIVED, res.body)
        })
      }
    }

    export default {
      state,
      actions,
      mutations
    }  

當文件結構重新組織和整理後,對cart和product的引用方式也要稍加變化,只需要在對應的state內先加入與模塊同名的對象名稱作為命名空間就可以直接引用子模塊內的對象:

● 引用子狀態——this.$store.state.cart.all。

● 引用子讀取器——this.$store.getters.cart.allItems;

● 引用子動作——this.$store.actions.cart.getAll;

● 引用子變更——this.$store.mutations.cart[types.SET_BOOK]。

那麼子讀取器、子動作和子變更也可以這樣引用嗎?

答案是否定的!事實上,只有子狀態可以通過命名空間引用!我認為這是Vuex設計上的一大遺憾!如果我們用調試器觀察$store變量內的內容,就會發現這一真相。getters、actions和mutations並沒有任何與模塊命名相同的對象命名空間存在,也就是說,如果通過this.$store.getters.cart.allItems引用,你將會得到一個undefined的值!allItems仍然被放置於最頂層的getters變量內,這一點你需要清楚地瞭解,因為這樣就會使Action和Getters不得不使用命名前綴來進行區分。

動態註冊模塊

除了在入口文件store.js中對模塊進行註冊,在某些應用場景中可能需要向現有的store內註冊新的自定義模塊,在這種情況下可以使用registerModule方法,以編程方式動態地向store實例註冊新的模塊:

    store.registerModule('books', {
      // ...
    })  

模塊動態註冊功能可以讓其他Vue插件向已有的store附加新模塊,以此來分割Vuex的狀態管理。例如,vuex-router-sync插件可以集成vue-router與Vuex,管理動態模塊的路由狀態。

也可以使用store.unregisterModule(moduleName)動態地卸載模塊。注意,不能使用此方法卸載靜態模塊(在創建store時聲明的模塊)。

7.9 用服務分離外部操作

當我們開始使用Vuex分離原有大量被摻合在組件當中的各種狀態管理之後,會慢慢地覺得Vuex並不是那麼難以理解,也會體驗到它確實讓Vue回歸了本原:一個製作界面的框架。在進行Vuex編程的過程中,不知道你是否會發現Actions內有大量相同的或者相似的Vue.http的遠程方式調用。它們顯得是如此的類似、不雅,甚至是讓人感到臃腫。

在實際的Vue項目開發過程中,RESTFul API調用可以說是無處不在,在Vue的生態圈內除了前文中推薦使用的vue-resource,還有其他一些功能類似的AJAX包,可以到https://github.com/vuejs/awesome-vue上找到它們。另外這個Repository集中了所有Vue中 各類出色的包,是一個非常值得收藏並參與的資源。

言歸正傳,對於這些像麵條一樣揉搓在我們代碼中的AJAX調用,我們可以引用Angular中的服務概念來進行重構,雖然Angular的服務是通過反轉注入的模式實現的,但模式的過度使用有時並不會減少開發代價,而優秀的思路則不然。因此,我們可以用Vue的方式構建一個服務引用結構來重新將所有的遠程方法調用進行有效的重構。

借用Vuex的文件結構組織方式,建立一個獨立的services文件夾,獨立存放所有的API調用相關的文件:

    ├── index.html
    ├── App.vue
    ├── main.js
    ├── components
    └── services
          ├── index.js        # 配置 service 的入口文件
          ├── apis.js         # API 地址引用
          ├── cart.js         # 購物車相關的RESTful API
          ├── product.js      # 圖書(產品)相關的RESTFul API
          └── ...  

然後構造一個service (index.js)入口對像:

    import _ from 'lodash'
    import Vue from 'vue'
    import VueResource from 'vue-resource'
    import product from './product.js'
    import cart from './cart.js'

    Vue.use(VueResource)

    export default {
      product,
      cart
    }  

product.js的內容如下:

    import Vue from 'vue'
    import * apis from './apis'

    export default {
        get: (id) => Vue.http.get(apis.GET_BOOK,{id})
        getStarted:  => Vue.http.get(apis.GET_START)
    }  

cart.js的代碼如下:

    import Vue from 'vue'
    import * apis from './apis'

    export default {
        getAllItems: (id) => Vue.http.get(apis.GET_CARTITEMS)
    }  

~/src/store/modules/cart.js就會變為:

    import * as types from '../mutation-types'
    import services from '../../services'

    const state = {
        // ... 省略
    }

    const mutations = {
        // ... 省略
    }

    const actions = {
      getItems (context) {
        services.cart.getAllItems.then(res => {
            context.commit(types.SET_CART_ITEMS,res.body.data)
        })
      }
    }

    export default {
      state,
      mutations,
      actions
    }  

這裡採用的是面向對象的一點小技巧,以命名空間的方式有效地重新組織所有API服務,旨在重新合理規劃我們的代碼,以適應不斷膨脹的項目。