[Vue] 跟着 Vue 闯荡前端世界

本文将介绍 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: ''
})

取得读取中 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 这个按钮,等响应后才会重新开启。






效果如下

应用二、产生 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 根组件中即可。





执行效果如下

 

手动启用屏蔽

有时候在执行特殊功能时会比较耗时,因此也会有手动启用 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 官方网站


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

若内容有误或有其他建议请不吝留言给笔者喔 !