本文將介紹 Vuex 的使用方式 ( 模組化 Store ),並且利用 Vuex 掌握全站讀取中的 web api 數量狀態,自動依據該狀態來自動呈現 loading 互動效果。
前言
網站中多少都會有全域共用的狀態需要保存,若在狀態改變時無需有立即畫面響應變化的情況下,多數會使用 cookie / session / local storage 來存放,但有些情境需要在狀態改變時主動響應來產生畫面互動效果,此時可以透過 Vuex 來管理這些響應式的狀態資料。本文透過實務範例來說明如何使用 Vuex 來管理「讀取中 Web API 數量」狀態,並透過這個狀態來產生一些畫面效果應用。以下介紹。
vuex: 3.0.1
定義 Module 檔案
在使用 vuex 之前必須先定義出各 module 檔案,可以依照存放的狀態類型做分類,讓我們將「狀態」及「狀態操作邏輯」都封裝於此。以下以 app
模組說明 module 中須放置的項目有哪些。
State
定義全域「狀態名稱」及「初始值」,於此定義 loadingCounter 作為全站讀取中 API 的計數器。
const state = {
loadingCounter: 0
}
1. 在自定義的 computed 屬性中回傳 this.$store.state.app.loadingCounter
2. 使用 ...mapState('app', ['loadingCounter ']) 直接加到 computed 屬性中
Mutation
允許直接更改 Vuex 的 store 中狀態的唯一方法,透過提交 (commit) 特定 mutation 來修改狀態資料,非常類似事件的概念,需要定義事件類型 (type) 和回調函數 (handler),而回調函數就是執行狀態更改的地方。
事件類型
可以考慮於獨立的 /store/mutationTypes.js
檔案中定義 mutation type 常數,這樣可以使 linter 之類的工具發揮作用,並且讓共同開發的夥伴對整個 app 包含的 mutation 一目瞭然;我們於此定義兩個 mutation type 作為對計數器進行增減數量的事件型態。
/* store/mutationTypes.js */
// 使用常量替代 Mutation 事件類型
// 命名規則: [module]_[mutation name]
/* app */
export const APP_INCREASE_LOADING_COUNTER = 'APP_INCREASE_LOADING_COUNTER'
export const APP_DECREASE_LOADING_COUNTER = 'APP_DECREASE_LOADING_COUNTER'
回調函數
使用 ES2015 風格的計算屬性命名功能來使用上述 mutation type 常數作為函數名,可在函式中取得 state
及 commit 時傳入的 payload
資料,以下分別實作對 state.loadingCounter 計數器狀態之增減行為。
import * as types from '../mutationTypes'
const mutations = {
[types.APP_INCREASE_LOADING_COUNTER] (state) {
state.loadingCounter += 1
},
[types.APP_DECREASE_LOADING_COUNTER] (state) {
state.loadingCounter -= 1
}
}
於組件中 commit 提交 Mutation 的兩種方式如下:
import * as types from '../../store/mutationTypes'
export default {
name: 'component'
methods: {
increaseCounter: function () {
// 直接 commit 提交 mutation
// 如果 mutation 中有 payload 可以直接傳入 commit 第二個參數中
this.$store.commit(`app/${types.APP_INCREASE_LOADING_COUNTER}`)
}
}
}
import { mapMutations } from 'vuex'
import * as types from '../../store/mutationTypes'
export default {
name: 'component'
methods: {
// 使用 mapMutations 將 mutation 加入 methods 中
// 可直接呼叫 increaseCounter() 方法提交 mutation
// 如果 mutation 中有 payload 可以直接傳入 increaseCounter() 方法中
...mapMutations('app', {increaseCounter: types.APP_ADD_LOADING_COUNTER})
}
}
Actions
可依據動作行為來命名較貼切 action 名稱,在 action 中都是透過提交 mutation 來變更狀態,而不是直接變更狀態,並且可以包含非同步的操作 (如分發其他非同步 action )。在 action 中可接受的傳入參數依序如下:
-
context : 與 store 實例具有相同方法和屬性 { dispatch, commit, state }
-
payload : 分發 action 時傳入的任意資料可由此獲得
在此定義 increaseLoadingCounter
及 decreaseLoadingCounter
兩個 action 來提交 mutation 去變更計數器狀態,在比較複雜的應用會有更多狀態的改變及非同步行為邏輯混雜其中。
let enableLoadingMaskTime = Date.now()
const actions = {
increaseLoadingCounter ({ dispatch, commit, state }) {
commit(types.APP_INCREASE_LOADING_COUNTER)
},
decreaseLoadingCounter ({ dispatch, commit, state }) {
commit(types.APP_DECREASE_LOADING_COUNTER)
}
}
於組件中 dispatch 分發 Action 的兩種方式如下:
export default {
name: 'component'
methods: {
increaseCounter: function () {
// 直接 dispatch 分發 action
// 如果 action 中有 payload 可以直接傳入 dispatch 第二個參數中
this.$store.dispatch(`app/increaseLoadingCounter `)
}
}
}
import { mapActions } from 'vuex'
export default {
name: 'component'
methods: {
// 使用 mapActions 將 action 直接加入 methods 中
// 可直接呼叫 increaseCounter() 方法 dispatch 分發 action
// 如果 action 中有 payload 可以直接傳入 increaseCounter() 方法中
...mapActions('app', ['increaseCounter'])
// 或是有衝突時可以指定特定方法名稱 addOne 來 dispatch 分發 action
...mapActions('app', { addOne: 'increaseCounter' })
}
}
包裝成 module 物件格式
完整 module 如下,會以 vuex 規定的物件格式 export 出去。
import * as types from '../mutationTypes'
const state = {
loadingCounter: 0
}
const actions = {
increaseLoadingCounter ({ dispatch, commit, state }) {
commit(types.APP_ADD_LOADING_COUNTER)
},
decreaseLoadingCounter ({ dispatch, commit, state }) {
commit(types.APP_REMOVE_LOADING_COUNTER)
}
}
const mutations = {
[types.APP_INCREASE_LOADING_COUNTER] (state) {
state.loadingCounter += 1
},
[types.APP_DECREASE_LOADING_COUNTER] (state) {
state.loadingCounter -= 1
}
}
export default {
namespaced: true,
state,
actions,
mutations
}
namespaced: true
的方式使其成為帶命名空間的模塊;當模塊被註冊後,它的所有 getter、action 及 mutation 都會自動根據模塊註冊的路徑調整命名。
最後透過 modules/index.js
動態 export 資料夾下所有 module
/* Dynamic Exporter:
* Dynamically export all json files (except self) in current folder
*/
const req = require.context('.', false, /\.js$/)
req.keys().forEach((key) => {
const name = key.replace(/^\.\/(.*)\.js/, '$1')
if (name !== 'index') {
module.exports[name] = req(key).default
}
})
建立 store 實體及加入 Vue 使用
將上述各 module 放入 store 中來產生全站使用的 store 實體。
/* store/index.js */
import Vue from 'vue'
import Vuex from 'vuex'
import modules from './modules'
Vue.use(Vuex)
const isDebug = process.env.NODE_ENV !== 'production'
var store = new Vuex.Store({
modules,
strict: isDebug
})
export default store
接著加入 store 實體到 Vue 中使用就大功告成了。
/* main.js */
import Vue from 'vue'
import router from './router'
import i18n from './setup/setupLocale'
import store from './store'
import App from './App'
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
i18n,
store,
components: { App },
template: '<App/>'
})
取得讀取中 API 數量
在統計讀取中 API 數量時,為留一條後路作為後續應用中的例外情境控制,因此定義不進入統計範圍的 API 清單,相關邏輯會封在 apiService 內集中處理。
/* services/apiService.js */
// 不需進行 request counting 的 api
const notCountRequest = [
'/api/boo',
'/api/qoo'
]
// 目前 api 是否需要進行 request counting 處理
const isCountingRequest = url => {
return notCountRequest.findIndex(r => url.includes(r)) === -1
}
export default {
isCountingRequest
}
在使用 axios 作為 http client 來呼叫 web api 的情況下,可以透過 request / response 的 interceptor 以 AOP 方式進行實作,當流進 request 時分發 increaseLoadingCounter
action 在計數器上 +1 ,流出 response 時就分發 decreaseLoadingCounter
action 在計數器上 -1,這樣透過計數器就可以得知目前正在讀取中的 api 筆數;另外先前有訂定不加入計數的黑名單,因此會使用 isCountingRequest 判斷 url 是否需作統計。
import axios from 'axios'
import store from '../store'
import apiService from 'services/apiService'
// 全局設定 Request 攔截器 (interceptor)
axios.interceptors.request.use(async function (config) {
if (apiService.isCountingRequest(config.url)) {
// 分發 increaseLoadingCounter action 在計數器上 +1
store.dispatch('app/increaseLoadingCounter')
}
return config
}, function (error) {
return Promise.reject(error)
})
// 全局設定 Response 攔截器 (interceptor)
axios.interceptors.response.use(function (response) {
if (apiService.isCountingRequest(response.config.url)) {
// 分發 decreaseLoadingCounter action 在計數器上 -1
store.dispatch('app/decreaseLoadingCounter')
}
return response
}, function (error) {
if (apiService.isCountingRequest(error.config.url)) {
// 分發 decreaseLoadingCounter action 在計數器上 -1
store.dispatch('app/decreaseLoadingCounter')
}
return Promise.reject(error)
})
應用發想
至此我們已經可以獲得全站讀取中 API 的數量狀態,因此可以拿這個狀態來做以下應用:
- 產生 loading 時失效的按鈕 ( submit 資料後 disable 按鍵 )
- 產生 loading 時遮罩效果 ( submit 資料後 mask 畫面 )
應用一、產生 loading 時失效的按鈕
此應用比較單純,只是去切換特定元素的 disabled 屬性,所以就只要在 module/app.js
中建立 isLoading 的 Getter 來表示讀取中旗標,而 isLoading 會像計算屬性 (Computed Property) 將返回值根據它的依賴被緩存起來,且只有當它的依賴值發生了改變才會被重新計算。
const getters = {
isLoading: state => state.loadingCounter > 0
}
1. 在自定義的 computed 屬性中回傳 this.$store.getters.app.isLoading
2. 使用 ...mapGetters ('app', ['isLoading']) 直接加到 computed 屬性中
記得 export module 的時候也要將 getters 一併輸出喔!
接著建立讀取中就 disable 的按鈕組件,透過 mapGetters
取得 vuex 中 isLoading 值,將值綁到 button 的 disabled 屬性上,當按鈕按下去呼叫 web api 時會自動 disable 這個按鈕,等回應後才會重新開啟。
<!--HTML-->
<template>
<button :disabled="isLoading" @click.prevent="click">
<slot></slot>
</button>
</template>
<!--JavaScript-->
<script>
import { mapGetters } from 'vuex'
import debounce from 'lodash/debounce'
export default {
name: 'LoadingDisableButton',
props: {
click: {
type: Function,
required: true
}
},
computed: {
// 使用對象展開運算符將 isLoading getter 加入 computed 對象中
...mapGetters('app', ['isLoading'])
},
methods: {
click: debounce(function () {
// 避免連點造成重複觸發
this.$emit('click')
}, 1000, { 'leading': true, 'trailing': false })
}
}
</script>
效果如下
應用二、產生 loading 時遮罩效果
在等待 API 回應的期間,多會利用各種形式來告知處理等待中的狀況,有些會使用 spinner 在畫面的左上角轉動,或者是整個遮罩蓋上後在畫面中間顯示讀取中的動態樣式;以下筆者將利用 store 中「讀取中 API 數量」的狀態資料,延伸實作一個自動化啟動 / 關閉的 loading mask 效果。
State
新增 isEnableLoadingMask 狀態來控制 loading mask 啟用與否。
const state = {
// ... 略 ...
isEnableLoadingMask: false
}
Mutation
新增 mutation type 常數 APP_SET_IS_ENABLE_LOADING_MASK ,並以此名稱加入新的 mutation 去切換 isEnableLoadingMask 狀態。
/* store/mutationTypes.js */
// ... 略 ...
export const APP_SET_IS_ENABLE_LOADING_MASK = 'APP_SET_IS_ENABLE_LOADING_MASK'
import * as types from '../mutationTypes'
const mutations = {
// ... 略 ...
[types.APP_SET_IS_ENABLE_LOADING_MASK] (state, isEnable) {
state.isEnableLoadingMask = isEnable
}
}
Action
定義 loading mask 切換時機的 Action 邏輯要點如下:
ACTION 計數器 +1
: dispatch「啟動遮罩」ActionACTION 計數器 -1
: 當 counter 為 0 時 dispatch「關閉遮罩」ActionACTION 啟動遮罩
:- 紀錄啟動時間 (用以補足遮罩顯示最小週期,避免畫面閃動)
- commit 顯示遮罩旗標 mutation 為 true
ACTION 關閉遮罩
:- 比較啟動時間,補足遮罩顯示最小週期 (避免時間太短,畫面閃動)
- commit 顯示遮罩旗標 mutation 為 false
let enableLoadingMaskTime = Date.now()
const actions = {
// 讀取中 api 數量加 1
increaseLoadingCounter ({ dispatch, commit, state }) {
commit(types.APP_INCREASE_LOADING_COUNTER)
// 目前仍有 api 在讀取中時,啟動遮罩
if (state.loadingCounter > 0 && !state.isEnableLoadingMask) {
dispatch('enableLoadingMask')
}
},
// 讀取中 api 數量減 1
decreaseLoadingCounter ({ dispatch, commit, state }) {
commit(types.APP_DECREASE_LOADING_COUNTER)
// 目前沒有 api 在讀取中時,關閉遮罩
if (state.loadingCounter <= 0 && state.isEnableLoadingMask) {
dispatch('disableLoadingMask')
}
},
// 啟動遮罩
enableLoadingMask ({ commit, state }) {
enableLoadingMaskTime = Date.now()
commit(types.APP_SET_IS_ENABLE_LOADING_MASK, true)
},
// 關閉遮罩
disableLoadingMask ({ commit, state }) {
// 避免切換速度過快而造成畫面閃動,所以定義最小顯示時間
let minMaskShowPeriod = 300 /* ms */
let pastMilliseconds = parseInt(Date.now() - enableLoadingMaskTime)
let isShorterThanMinMaskShowPeriod = minMaskShowPeriod > pastMilliseconds
let remainMillisenconds = minMaskShowPeriod - pastMilliseconds
// 若低於最小顯示時間,將使用 setTimout 補足顯示時間後關閉
setTimeout(() => {
// 真正要關閉時要確認目前是否還有 Request 執行中(避免延遲過程中又發出 request 被馬上關閉)
if (state.loadingCounter <= 0 && state.isEnableLoadingMask) {
commit(types.APP_SET_IS_ENABLE_LOADING_MASK, false)
}
}, isShorterThanMinMaskShowPeriod ? remainMillisenconds : 0)
}
}
由於遮罩會是全站機制,因此直接定義在 app.vue 根組件中即可。
<!--HTML-->
<template>
<div id="app">
<!-- 讀取中遮罩 -->
<loading-mask v-if="isEnableLoadingMask" />
<!-- ... 略 ... -->
</div>
</template>
<!--JavaScript-->
<script>
import { mapState } from 'vuex'
export default {
name: 'App',
computed: {
...mapState('app', ['isEnableLoadingMask'])
},
components: {
LoadingMask
}
// ... 略 ...
}
</script>
執行效果如下
手動啟用遮罩
有時候在執行特殊功能時會比較耗時,因此也會有手動啟用 loading mask 的需求,這時可以建立一個共用的方法,將特定程式碼區塊包裹起來,在進入時開啟遮罩,結束後關閉遮罩;由於使用的機制都是透過計數器來進行,差別只在這是手動加減,因此不會影響原本機制,可以兼容使用。
將 loadingMaskBlock 方法定義在 vue global mixin 中,方便所有頁面組件使用。
import Vue from 'vue'
Vue.mixin({
methods: {
// 讀取中遮罩區塊 (透過 counter 加減來調整遮罩)
loadingMaskBlock: async function (action) {
try {
this.$store.dispatch('app/increaseLoadingCounter')
await action()
} finally {
this.$store.dispatch('app/decreaseLoadingCounter')
}
}
}
})
此時若有耗時的運算可以使用 loadingMaskBlock 包裹,在執行時即可自動產出 loading mask 效果。
參考資訊
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !