likes
comments
collection
share

写个 Mixin 来自动维护 loading 状态吧

作者站长头像
站长
· 阅读数 10

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 设计

基本用法

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 {}
    }
  }
}
转载自:https://juejin.cn/post/7249724085147254845
评论
请登录