[Vue] 跟著 Vue 闖蕩前端世界 - 17 使用 Vuex 掌握全站 web api 讀取中狀態

本文將介紹 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
  }
}
Mutation 必須是同步方法,如果需要非同步操作請至 Action 中進行。
需遵守 Vue 的響應規則,因此在物件上添加新屬性時,必須使用 Vue.set(obj, 'newProp', 123) 或 state.obj = { ...state.obj, newProp: 123 } 方式進行,否則畫面上綁定的狀態是無法同步響應變化。

 

於組件中 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
嚴格模式 (strict mode) 表示狀態變更若不是由 mutation 函數執行時,將會拋出錯誤,以保證所有狀態變更都能夠被 dev tool 追蹤回朔;切記僅能在開發測試環境使用,避免效能的損耗。

 

接著加入 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
}
於組件中取得 Getter 狀態方式的兩種方式如下:
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
  }
}
其中 isEnable 為 payload ,可在 commit 提交 mutation 時傳入。

 

Action

定義 loading mask 切換時機的 Action 邏輯要點如下:

  • ACTION 計數器 +1 :  dispatch「啟動遮罩」Action
  • ACTION 計數器 -1 :  當 counter 為 0 時 dispatch「關閉遮罩」Action
  • ACTION 啟動遮罩 :
    • 紀錄啟動時間 (用以補足遮罩顯示最小週期,避免畫面閃動)
    • 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 效果。

 

 

參考資訊


Vuex 官方網站


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !