Vue躬行記(9)——Vuex
Vuex是一個專為Vue.js設計的狀態管理庫,適用於多元件共享狀態的場景。Vuex能集中式的儲存和維護所有元件的狀態,並提供相關規則保證狀態的獨立性、正確性和可預測性,這不僅讓除錯變得可追蹤,還讓程式碼變得更結構化且易維護。本文所使用的Vuex,其版本是3.1.1。
一、基本用法
首先需要引入Vue和Vuex兩個庫,如果像下面這樣在Vue之後引入Vuex,那麼Vuex會自動呼叫Vue.use()方法註冊其自身;但如果以模組的方式引用,那麼就得顯式地呼叫Vue.use()。注意,因為Vuex依賴Promise,所以對於那些不支援Promise的瀏覽器,要使用Vuex的話,得引入相關的polyfill庫,例如es6-promise。
<script src="js/vue.js"></script> <script src="js/vuex.js"></script>
然後建立Vuex應用的核心:Store(倉庫)。它是一個容器,儲存著大量的響應式狀態(State),並且這些狀態不能直接修改,需要顯式地將修改請求提交到Mutation(變更)中才能實現更新,因為這樣便於追蹤每個狀態的變化。在下面的示例中,初始化了一個digit狀態,並在mutations選項中添加了兩個可將其修改的方法。
const store = new Vuex.Store({ state: { digit: 0 }, mutations: { add: state => state.digit++, minus: state => state.digit-- } });
接著建立根例項,並將store例項注入,從而讓整個應用都能讀寫其中的狀態,在元件中可通過$store屬性訪問到它,如下所示,以計算屬性的方式讀取digit狀態,並通過呼叫commit()方法來修改該狀態。
var vm = new Vue({ el: "#container", store: store, computed: { digit() { return this.$store.state.digit; } }, methods: { add() { this.$store.commit("add"); }, minus() { this.$store.commit("minus"); } } });
最後將根例項中的方法分別註冊到兩個按鈕的點選事件中,如下所示,每當點選這兩個按鈕時,狀態就會更新,並在頁面中顯示。
<div id="container"> <p>{{digit}}</p> <button @click="add">增加</button> <button @click="minus">減少</button> </div>
二、主要組成
Vuex的主要組成除了上一節提到的Store、State和Mutation之外,還包括Getter和Action,本節會對其中的四個做重點講解,它們之間的關係如圖2所示。
圖2 四者的關係
1)State
State是一個可儲存狀態的物件,在應用的任何位置都能被訪問到,並且作為單一資料來源(Single Source Of Truth)而存在。
當元件需要讀取大量狀態時,一個個的宣告成計算屬性會顯得過於繁瑣而冗餘,於是Vuex提供了一個名為mapState()的輔助函式,用來將狀態自動對映成計算屬性,它的引數既可以是陣列,也可以是物件。
當計算屬性的名稱與狀態名稱相同,並且不需要做額外處理時,可將名稱組成一個字串陣列傳遞給mapState()函式,在元件中可按原名呼叫,如下所示。
var vm = new Vue({ computed: Vuex.mapState([ "digit" ]) });
當計算屬性的名稱與狀態名稱不同,或者計算屬性讀取的是需要處理的狀態時,可將一個物件傳遞給mapState()函式,其鍵就是計算屬性的名稱,而其值既可以是函式,也可以是字串,如下程式碼所示。如果是函式,那麼它的第一個引數是state,即狀態物件;如果是字串,那麼就是從state中指定一個狀態作為計算屬性。
var vm = new Vue({ computed: Vuex.mapState({ digit: state => state.digit, alias: "digit" //相當於state => state.digit }) });
因為mapState()函式返回的是一個物件,所以當元件內已經包含計算屬性時,可以對其應用擴充套件運算子(...)來進行合併,如下所示,這是一種極為簡潔的寫法。
var vm = new Vue({ computed: { name() {}, ...Vuex.mapState([ "digit" ]) } });
2)Getter
Getter是從State中派生出的狀態,當多個元件要對同一個狀態進行相同的處理時,就需要將狀態轉移到Getter中,以免產生重複的冗餘程式碼。
Getter相當於Store的計算屬性,它能接收兩個引數,第一個是state物件,第二個是可選的getters物件,該引數能讓不同的Getter之間相互訪問。Getter的返回值會被快取,並且只有當依賴值發生變化時才會被重新計算。不過當返回值是函式時,其結果就不會被快取,如下所示,其中caculate返回的是個數字,而sum返回的是個函式。
const store = new Vuex.Store({ state: { digit: 0 }, getters: { caculate: state => { return state.digit + 2; }, sum: state => right => { return state.digit + right; } } });
在元件內可通過this.$store.getters訪問到Getter中的資料,如下所示,讀取了上一個示例中的兩個Getter。
var vm = new Vue({ methods: { add() { this.$store.getters.caculate; this.$store.getters.sum(1); } } });
Getter也有一個輔助函式,用來將Getter自動對映為元件的計算屬性,名字叫mapGetters(),其引數也是陣列或物件。但與之前的mapState()不同,當引數是物件時,其值不能是函式,只能是字串,如下所示,為了對比兩種寫法,聲明瞭兩個computed選項。
var vm = new Vue({ computed: Vuex.mapGetters([ "caculate" ]), computed: Vuex.mapGetters({ alias: "caculate" }) });
3)Mutation
更改狀態的唯一途徑是提交Mutation,Vuex中的Mutation類似於事件,也包含一個型別和回撥函式,在函式體中可進行狀態更改的邏輯,並且它能接收兩個引數,第一個是state物件,第二個是可選的附加資料,叫載荷(Payload)。下面這個Mutation的型別是“interval”,接收了兩個引數。
const store = new Vuex.Store({ state: { digit: 0 }, mutations: { interval: (state, payload) => state.digit += payload.number } });
在元件中不能直接呼叫Mutation的回撥函式,得通過this.$store.commit()方法觸發更新,如下所示,採用了兩種提交方式,第一種是傳遞type引數,第二種是傳遞包含type屬性的物件。
var vm = new Vue({ methods: { interval() { this.$store.commit("interval", { number: 2 }); //第一種 this.$store.commit({ type: "interval", number: 2 }); //第二種 } } });
當多人協作時,Mutation的型別適合寫成常量,這樣更容易維護,也能減少衝突。
const INTERVAL = "interval";
Mutation有一個名為mapMutations()的輔助函式,其寫法和mapState()相同,它能將Mutation自動對映為元件的方法,如下所示。
var vm = new Vue({ methods: Vuex.mapMutations(["interval"]) //相當於 methods: { interval(payload) { this.$store.commit(INTERVAL, payload); } } });
注意,為了能追蹤狀態的變更,Mutation只支援同步的更新,如果要非同步,那麼得使用Action。
4)Action
Action類似於Mutation,但不同的是它可以包含非同步操作,並且只能用來通知Mutation,不會直接更新狀態。Action的回撥函式能接收兩個引數,第一個是與Store例項具有相同屬性和方法的context物件(注意,不是Store例項本身),第二個是可選的附加資料,如下所示,呼叫commit()方法提交了一個Mutation。
const store = new Vuex.Store({ actions: { interval(context, payload) { context.commit("interval", payload); } } });
在元件中能通過this.$store.dispatch()方法分發Action,如下所示,與commit()方法一樣,它也有兩種提交方式。
var vm = new Vue({ methods: { interval() { this.$store.dispatch("interval", { number: 2 }); //第一種 this.$store.dispatch({type: "interval", number: 2}); //第二種 } } });
注意,由於dispatch()方法返回的是一個Promise物件,因此它能以一種更優雅的方式來處理非同步操作,如下所示。
var vm = new Vue({ methods: { interval() { this.$store.dispatch("interval", { number: 2 }).then(() => { console.log("success"); }); } } });
Action有一個名為mapActions()的輔助函式,其寫法和mapState()相同,它能將Action自動對映為元件的方法,如下所示。
var vm = new Vue({ methods: Vuex.mapActions([ "interval" ]) });
三、模組
當應用越來越大時,為了避免Store變得過於臃腫,有必要將其拆分到一個個的模組(Module)中。每個模組就是一個物件,包含屬於自己的State、Getter、Mutation和Action,甚至還能巢狀其它模組。
1)區域性狀態
對於模組內部的Getter和Mutation,它們接收的第一個引數是模組的區域性狀態,而Getter的第三個引數rootState和Action的context.rootState屬性可訪問根節點狀態(即全域性狀態),如下所示。
const moduleA = { state: { digit: 0 }, mutations: { add: state => state.digit++ }, getters: { caculate: (state, getter, rootState) => { return state.digit + 2; } }, actions: { interval(context, payload) { context.commit("add", payload); } } };
2)名稱空間
預設情況下,只有在訪問State時需要帶名稱空間,而Getter、Mutation和Action的呼叫方式不變。將之前的moduleA模組註冊到Store例項中,如下所示,modules選項的值是一個子模組物件,其鍵是模組名稱。
const store = new Vuex.Store({ modules: { a: moduleA } });
如果要訪問模組中的digit狀態,那麼可以像下面這樣寫。
store.state.a.digit;
當模組的namespaced屬性為true時,它的Getter、Mutation和Action也會帶名稱空間,在使用時,需要新增名稱空間字首,如下程式碼所示,此舉大大提升了模組的封裝性和複用性。
const moduleA = { namespaced: true }; var vm = new Vue({ el: "#container", store: store, methods: { add() { this.$store.commit("a/add"); }, caculate() { this.$store.getters["a/caculate"]; } } });
如果要在帶名稱空間的模組中提交全域性的Mutation或分發全域性的Action,那麼只要將{root: true}作為第三個引數傳給commit()或dispatch()就可實現,如下所示。
var vm = new Vue({ methods: { add() { this.$store.dispatch("add", null, { root: true }); this.$store.commit("add", null, { root: true }); } } });
如果要在帶名稱空間的模組中註冊全域性的Action,那麼需要將其修改成物件的形式,然後新增root屬性並設為true,再將Action原先的定義轉移到handler()函式中,如下所示。
const moduleA = { actions: { interval: { root: true, handler(context, payload) {} } } };
3)輔助函式
當使用mapState()、mapGetters()、mapMutations()和mapActions()四個輔助函式對帶名稱空間的模組做對映時,需要顯式的包含名稱空間,如下所示。
var vm = new Vue({ computed: Vuex.mapState({ digit: state => state.a.digit }), methods: Vuex.mapMutations({ add: "a/add" }) });
這四個輔助函式的第一個引數都是可選的,用於繫結名稱空間,可簡化對映過程,如下所示。
var vm = new Vue({ computed: Vuex.mapState("a", { digit: state => state.digit }), methods: Vuex.mapMutations("a", { add: "add" }) });
Vuex還提供了另一個輔助函式createNamespacedHelpers(),可建立繫結名稱空間的輔助函式,如下所示。
const { mapState, mapMutations } = Vuex.createNamespacedHelpers("a");
四、動態註冊
在建立Store例項後,可通過registerModule()方法動態註冊模組,如下程式碼所示,呼叫了兩次registerModule()方法,第一次註冊了模組“a”,第二次註冊了巢狀模組“a/b”。
const store = new Vuex.Store(); store.registerModule("a", moduleA); store.registerModule(["a", "b"], moduleAB);
通過store.state.a和store.state.a.b可訪問模組的區域性狀態。如果要解除安裝動態註冊的模組,那麼可以通過unregisterModule()方法實現。
registerModule()方法的第三個引數是可選的配置物件,當preserveState屬性的值為true時(如下所示),在註冊模組時會忽略模組中的狀態,即無法在store中讀取模組中的狀態。
store.registerModule("a", moduleA, { preserveState: true });
五、表單處理
表單預設能直接修改元件的狀態,但是在Vuex中,狀態只能由Mutation觸發更新。為了能更好的追蹤狀態的變化,也為了能更符合Vuex的思維,需要讓表單控制元件與狀態繫結在一起,並通過input或change事件監聽狀態更新的行為,如下所示。
<div id="container"> <input :value="digit" @input="add" /> </div>
然後在Store例項中初始化digit狀態,並新增更新狀態的Mutation,如下所示。
const store = new Vuex.Store({ state: { digit: 0 }, mutations: { add: (state, value) => { state.digit = value; } } });
最後在建立根例項時,將digit狀態對映成它的計算屬性,在事件處理程式add()中呼叫commit()方法,並將控制元件的值作為第二個引數傳入,如下所示。
var vm = new Vue({ el: "#container", store: store, computed: Vuex.mapState(["digit"]), methods: { add(e) { this.$store.commit("add", e.target.value); } } });
還有一個方法也能實現相同的功能,那就是在控制元件上使用v-model指令,但需要與帶setter的計算屬性配合,如下所示(只給出了關鍵部分的程式碼)。
<div id="container"> <input v-model="digit" /> </div> <script> var vm = new Vue({ computed: { digit: { get() { return this.$store.state.digit; }, set(value) { this.$store.commit("add", value); } } } }); </script>
&n