写个 Mixin 来自动维护 loading 状态吧
Vue 中处理页面数据有两种交互方式:
- 骨架屏:加载时提供骨架屏,加载失败展示错误页面和重试按钮,需要维护加载状态数据,适用于注重用户体验的精细页面
 - 消息弹窗:加载过程中展示 loading 遮罩,失败时弹出错误消息提示,不需要维护加载状态数据,适用于后台管理系统等不太看重用户体验的页面,或者提交数据的场景
 
本文适用于骨架屏类的页面数据加载场景。
痛点描述
我们日常加载页面数据时,可能需要维护 loading 状态,就像这样:
<template>
<el-table v-loading="loading" :data="tableData"></el-table>
</template>
<script>
export default {
data() {
return {
tableData: [],
loading: false,
    }
  },
methods: {
async getTableData() {
this.loading = true
try {
this.tableData = await this.$http.get("/user/list");
      } finally {
this.loading = false;
      }
    },
  },
}
</script>
其实加载函数本来可以只有一行代码,但为了维护 loading 状态,让我们的加载函数变得复杂。如果还要维护成功和失败状态的话,加载函数还会变得更加复杂。
export default {
data() {
return {
tableData: [],
loading: false,
success: false,
error: false,
errmsg: "",
    }
  },
methods: {
async getTableData() {
this.loading = true;
this.success = false;
this.error = false;
try {
this.user = await this.$http.get("/user/list");
this.success = true;
      } catch (err) {
this.error = true;
this.errmsg = err.message;
      } finally {
this.loading = false;
      }
    },
  },
}
如果页面有多个数据要加载,比如表单页面中有多个下拉列表数据,那么这些状态属性会变得特别多,代码量会激增。
export default {
data() {
return {
yearList: [],
yearListLoading: false,
yearListLoaded: false,
yearListError: false,
yearListErrmsg: "",
deptList: [],
deptListLoading: false,
deptListLoaded: false,
deptListError: false,
deptListErrmsg: "",
tableData: [],
tableDataLoading: false,
tableDataLoaded: false,
tableDataError: false,
tableDataErrmsg: ""
    }
  }
}
其实我们可以根据加载函数的状态来自动维护这些状态数据,这次我们要实现的目标就是自动维护这些状态数据,并将它们放到对应函数的属性上。看看这样改进后的代码:
<template>
<div v-if="tableData.success">
<!-- 显示页面内容 -->
</div>
<div v-else-if="tableData.loading">
<!-- 显示骨架屏 -->
</div>
<div v-else-if="tableData.error">
<!-- 显示失败提示 -->
</div>
</template>
<script>
export default {
data() {
return {
tableData: []
    }
  },
methods: {
async getTableData() {
this.tableData = await this.$http.get("/user/list");
    },
  }
}
</script>
加载函数变得非常纯净,data 中也不需要定义一大堆状态数据,非常舒适。
Mixin 设计
基本用法
我们需要指定一下 methods 中的哪些方法是用来加载数据的,我们只需要对这些加载数据的方法添加状态属性。根据我之前在文章《我可能发现了Vue Mixin的正确用法——动态Mixin》中的看法,可以使用函数形式的 mixin 来指定。
export default {
mixins: [asyncStatus('getTableData')],
methods: {
async getTableData() {},
  },
}
指定多个方法
也可以用数组指定多个方法名。
export default {
mixins: [asyncStatus([
'getDeptList',
'getYearList',
'getTableData'
  ])],
}
自动扫描所有方法
如果不传参数,则通过遍历的方式,给所有组件实例方法加上状态属性。
export default {
mixins: [asyncStatus()]
}
全局注入
虽然给所有的组件实例方法加上状态属性是没必要的,但也不影响。而且这有个好处,就是可以注册全局 mixin。
Vue.mixin(asyncStatus())
默认注入的属性
我们默认注入的状态字段有4个:
loading是否正在加载中success是否加载成功error是否加载失败exception加载失败时抛出的错误对象
指定注入的属性名
当然,为了避免命名冲突,可以传入第二个参数,来指定添加的状态属性名。
export default {
mixins: [asyncStatus('getTableData', {
// 注入的加载状态属性名是 isLoading
loading: 'isLoading',
// 注入的错误状态属性名是 hasError
error: 'hasError',
// 错误对象的属性名是 errorObj
exception: 'errorObj',
// 不注入 success 属性
success: false,
  })]
}
随意传入参数
由于第一个参数和第二个参数的形式没有重叠,所以省略第一个参数也是可行的。
export default {
mixins: [asyncStatus({
loading: 'isLoading',
error: 'hasError',
exception: 'errorObj',
success: false,
  })]
}
总结
总结一下,我们需要使用函数形式来实现 mixin,函数接收两个参数,并且两个参数都是可选的。
/**
 * @/mixins/async-status.mixin.js
 * 维护异步方法的执行状态,为组件中的指定方法添加如下属性:
 * - loading {boolean} 是否正在执行
 * - success {boolean} 是否执行成功
 * - error {boolean} 是否执行失败
 * - exception 方法执行失败时抛出的异常
 * @param {string|string[]} [methods] 方法名,可指定多个
 * @param {Alias} [alias] 为注入的属性指定属性名,或将某个属性设置成false跳过注入
 *
 * @typedef Alias
 * @type {object}
 * @prop {boolean|string} [loading=true]
 * @prop {boolean|string} [success=true]
 * @prop {boolean|string} [error=true]
 * @prop {boolean|string} [exception=true]
 */
export function asyncStatus(methods, alias) {}
函数返回真正的 mixin 对象,为组件中的异步方法维护并注入状态属性。
Mixin 实现
注入属性的时机
实现这个 mixin 是有一定难度的,首先要找准注入属性的时机。我们希望尽可能早往方法上注入属性,至少在执行 render 函数之前,以便在加载状态变化时可以重现渲染,但又需要在组件方法初始化之后。
所以,你需要熟悉 Vue 的组件渲染流程。在 Vue2 的源码中有这样一段组件初始化代码:
Vue.prototype._init = function (options?: Object) {
// ...
initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm, "beforeCreate");
initInjections(vm); // resolve injections before data/props
initState(vm);
initProvide(vm); // resolve provide after data/props
callHook(vm, "created");
// ...
};
而其中的 initState 方法的源码如下:
export function initState (vm: Component) {
  vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
  } else {
observe(vm._data = {}, true /* asRootData */)
  }
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
  }
}
所以总结一下 Vue 组件的初始化流程:
- 执行 
beforeCreate - 挂载 
props - 挂载 
methods - 执行并挂载 
data - 挂载 
computed - 监听 
watch - 执行 
created 
我们必须在 methods 初始化之后开始注入属性,否则方法还没挂载到组件实例上。可以选择的是 data 或者 created。为了尽早注入,我们应该选择在 data 中注入。
// @/mixins/async-status.mixin.js
export function asyncStatus(methods, alias) {
return {
data() {
// 在这里为方法注入状态属性
return {}
    }
  }
}
处理参数
由于参数的形式比较自由,我们需要处理并统一一下参数形式。我们把 methods 处理成数组形式,并取出 alias 中指定注入的状态属性名。
// @/mixins/async-status.mixin.js
export function asyncStatus(methods, alias = {}) {
// 只传入 alias 的情况
if (typeof methods === 'object' && !Array.isArray(methods)) {
    alias = methods
  }
// 将 methods 规范化成数组形式
if (typeof methods === 'string') {
    methods = [methods]
  }
if (!Array.isArray(methods)) {
// TODO: 这里应该换成遍历出的所有方法名
    methods = []
  }
// 获取注入的状态属性名
const getKey = (name) =>
typeof alias[name] === 'string' || alias[name] === false
      ? alias[name]
      : name
const loadingKey = getKey('loading')
const successKey = getKey('success')
const errordKey = getKey('error')
const exceptionKey = getKey('exception')
}
遍历组件方法
没有传入 methods 的时候,需要遍历组件上定义的所有方法。办法是遍历 this.$options.methods 上的所有属性名,这样遍历出的结果会包含从 mixins 中引入的方法。
// @/mixins/async-status.mixin.js
export function asyncStatus(methods, alias = {}) {
return {
data() {
if (!Array.isArray(methods)) {
// 遍历出的所有方法名,注意这段代码需要在 data 中执行
        methods = Object.keys(this.$options.methods)
      }
return {}
    }
  }
}
维护加载状态
需要注意的是,只有响应式对象上的属性才会被监听,也就是说,只有响应式对象上的属性值变化才能引起组件的重新渲染。所以我们必须创建一个响应式对象,把加载状态维护进去。这可以通过 Vue.observable() 这个API来创建。
// @/mixins/async-status.mixin.js
export function asyncStatus(methods, alias = {}) {
return {
data() {
for (const method of methods) {
if (typeof this[method] === 'function') {
// 存储状态值
const status = Vue.observable({})
          loadingKey && Vue.set(status, loadingKey, false)
          successKey && Vue.set(status, successKey, false)
          errorKey && Vue.set(status, errorKey, false)
          exceptionKey && Vue.set(status, exceptionKey, false)
// 设置状态值
const setStatus = (key, value) => key && (status[key] = value)
        }
      }
return {}
    }
  }
}
我们把加载状态维护到 status 中。
// @/mixins/async-status.mixin.js
export function asyncStatus(methods, alias = {}) {
return {
data() {
for (const method of methods) {
if (typeof this[method] === 'function') {
const fn = this[method]
// 用于识别是否最后一次调用
let loadId = 0
// 替换原始方法
this[method] = (...args) => {
// 生成本次调用方法的标识
const currentId = ++loadId
setStatus(loadingKey, true)
setStatus(successKey, false)
setStatus(errorKey, false)
setStatus(exceptionKey, null)
try {
// 这里调用原始方法,this 为组件实例
const result = fn.call(this, ...args)
// 兼容同步和异步方法
if (result instanceof Promise) {
return result
                  .then((res) => {
// 最后一次加载完成时才更新状态
if (loadId === currentId) {
setStatus(loadingKey, false)
setStatus(successKey, true)
                    }
return res
                  })
                  .catch((err) => {
// 最后一次加载完成时才更新状态
if (loadId === currentId) {
setStatus(loadingKey, false)
setStatus(errorKey, true)
setStatus(exceptionKey, err)
                    }
throw err
                  })
              }
setStatus(loadingKey, false)
setStatus(successKey, true)
return result
            } catch (err) {
setStatus(loadingKey, false)
setStatus(errorKey, true)
setStatus(exceptionKey, err)
throw err
            }
          }
        }
      }
return {}
    }
  }
}
注入状态属性
其实需要注入的属性都在 status 中,可以把它们作为访问器属性添加到对应的方法上。
// @/mixins/async-status.mixin.js
export function asyncStatus(methods, alias = {}) {
return {
data() {
for (const method of methods) {
if (typeof this[method] === 'function') {
// 存储状态值
const status = Vue.observable({})
// 替换原始方法
this[method] = (...args) => {}
// 注入状态值到方法中
Object.keys(status).forEach((key) => {
Object.defineProperty(this[method], key, {
get() {
return status[key]
              }
            })
          })
Object.setPrototypeOf(this[method], fn)
        }
      }
return {}
    }
  }
}
完整代码
最后整合一下完整的代码。
import Vue from 'vue'
/**
 * @/mixins/async-status.mixin.js
 * 维护异步方法的执行状态,为组件中的指定方法添加如下属性:
 * - loading {boolean} 是否正在执行
 * - success {boolean} 是否执行成功
 * - error {boolean} 是否执行失败
 * - exception 方法执行失败时抛出的异常
 * @param {string|string[]|Alias} [methods] 方法名,可指定多个
 * @param {Alias} [alias] 为注入的属性指定属性名,或将某个属性设置成false跳过注入
 *
 * @typedef Alias
 * @type {object}
 * @prop {boolean|string} [loading=true] 加载状态的属性名
 * @prop {boolean|string} [success=true] 加载成功状态的属性名
 * @prop {boolean|string} [error=true] 加载失败状态的属性名
 * @prop {boolean|string} [exception=true] 加载失败时存储错误对象的属性名
 *
 * @example
 * <template>
 *  <el-table v-loading="getTableData.loading" />
 * </template>
 * <script>
 *  export default {
 *    mixins: [
 *      asyncMethodStatus('goFetchData')
 *    ],
 *    methods: {
 *      async getTableData() {
 *        this.tableData = await this.$http.get('/user/list');
 *      }
 *    }
 *  }
 * </script>
 */
export default function asyncMethodStatus(methods, alias = {}) {
// 规范化参数
if (typeof methods === 'object' && !Array.isArray(methods)) {
    alias = methods
  }
if (typeof methods === 'string') {
    methods = [methods]
  }
const getKey = (name) =>
typeof alias[name] === 'string' || alias[name] === false
      ? alias[name]
      : name
const loadingKey = getKey('loading')
const successKey = getKey('success')
const errorKey = getKey('error')
const exceptionKey = getKey('exception')
return {
data() {
if (!Array.isArray(methods)) {
// 默认为所有方法注入属性
        methods = Object.keys(this.$options.methods)
      }
for (const method of methods) {
if (typeof this[method] === 'function') {
const fn = this[method]
let loadId = 0
const status = Vue.observable({})
          loadingKey && Vue.set(status, loadingKey, false)
          successKey && Vue.set(status, successKey, false)
          errorKey && Vue.set(status, errorKey, false)
          exceptionKey && Vue.set(status, exceptionKey, false)
const setStatus = (key, value) => key && (status[key] = value)
this[method] = (...args) => {
const currentId = ++loadId
setStatus(loadingKey, true)
setStatus(successKey, false)
setStatus(errorKey, false)
setStatus(exceptionKey, null)
try {
const result = fn.call(this, ...args)
if (result instanceof Promise) {
return result
                  .then((res) => {
if (loadId === currentId) {
setStatus(loadingKey, false)
setStatus(successKey, true)
                    }
return res
                  })
                  .catch((err) => {
if (loadId === currentId) {
setStatus(loadingKey, false)
setStatus(errorKey, true)
setStatus(exceptionKey, err)
                    }
throw err
                  })
              }
setStatus(loadingKey, false)
setStatus(successKey, true)
return result
            } catch (err) {
setStatus(loadingKey, false)
setStatus(errorKey, true)
setStatus(exceptionKey, err)
throw err
            }
          }
Object.keys(status).forEach((key) => {
Object.defineProperty(this[method], key, {
get() {
return status[key]
              }
            })
          })
Object.setPrototypeOf(this[method], fn)
        }
      }
return {}
    }
  }
}
来源:juejin.cn/post/7249724085147254845