写个 Mixin 来自动展示提交状态吧

201 阅读5分钟

我正在参加「掘金·启航计划」

Vue 中处理页面数据有两种交互方式:

  • 骨架屏:加载时提供骨架屏,加载失败展示错误页面和重试按钮,需要维护加载状态数据,适用于注重用户体验的精细页面
  • 消息弹窗:加载过程中展示 loading 遮罩,失败时弹出错误消息提示,不需要维护加载状态数据,适用于后台管理系统等不太看重用户体验的页面,或者提交数据的场景

本文适用于消息弹窗类页面的加载场景和数据提交的场景。

痛点描述

我们考虑一个简单但经典的提交数据的交互:

  • 点击提交按钮时,展示全屏的 loading 遮罩层
  • 提交完成时,展示操作成功的提示信息,并跳转到特定页面
  • 提交失败时,展示操作失败的错误提示

代码如下:

export default {
  methods: {
    async submit() {
      const loading = this.$loading()
      try {
        await this.$http.post('/post/add', this.formData)
        this.$message.success('操作成功')
        this.$router.back()
      } catch {
        this.$message.error('操作失败')
      } finally {
        loading.close()
      }
    },
  },
}

其实提交函数本来是很简单的,但为了展示 loading 遮罩和弹出结果提示,让这个函数变得复杂。

export default {
  methods: {
    async submit() {
      await this.$http.post('/post/add', this.formData)
      this.$router.back()
    },
  },
}

其实我们可以根据提交函数的状态来自动展示各种交互界面,这次我们要实现的目标就是自动展示这些界面。看看这样改进后的代码:

export default {
  mixins: [
    // 加载数据成功时不需要提示
    asyncDialog('getData', { success: false }),
    // 提交数据是展示完整的提示
    asyncDialog('submit'),
  ],
  methods: {
    async getData() {
      this.tableData = await this.$http.get('/post/list')
    },
    async submit() {
      try {
        await this.$http.post('/post/add', this.formData)
        this.$router.back()
        return '操作成功'
      } catch {
        throw '操作失败'
      }
    },
  },
}

可以看到,这种方式不但适用于提交,也适用于数据加载。加载函数和提交函数都变得非常纯净,不需要写一大堆界面交互代码,非常舒适。

Mixin 设计

基本用法

我们需要指定一下 methods 中的哪些方法需要展示交互状态,我们只需要为这些指定的方法添加交互状态。根据我之前在文章《我可能发现了Vue Mixin的正确用法——动态Mixin》中的看法,可以使用函数形式的 mixin 来指定。

export default {
  mixins: [asyncDialog('submit')],
  methods: {
    async submit() {
      try {
        await this.$http.post('/post/add', this.formData)
        this.$router.back()
        return '操作成功'
      } catch {
        throw '操作失败'
      }
  },
}

这会为名为 submit 的方法添加交互,具体包括:

  • 在方法执行过程中展示 loading 遮罩
  • 在方法执行成功时,展示方法返回的字符串
  • 在方法执行失败时,展示方法抛出的字符串

这里解释一下,为什么需要抛出字符串而不是 Error 对象呢?这是为了防止不小心展示了方法中意外抛出的代码错误。只有返回和抛出字符串时,才会展示处理结果。

指定多个方法

也可以用数组指定多个方法名。

export default {
  mixins: [asyncDialog(['save', 'submit'])],
}

不展示特定的交互

指定的方法执行时,默认会展示3个交互:加载中、成功提示和失败提示。我们可以传入第二个参数,来指定不展示其中一些交互。比如加载数据的场景,一般会跳过成功提示。

export default {
  mixins: [asyncDialog('getData', { success: false })],
}

总结

总结一下,我们需要使用函数形式来实现 mixin,函数接收两个参数,分别用于指定需要添加交互的方法名和指定展示的交互类型。

/**
 * @/mixins/async-dialog.mixin.js
 * 维护异步方法的执行状态,为组件中的指定方法添加如下交互状态:
 * - 执行过程中展示 loading 遮罩
 * - 执行成功时展示方法返回的字符串
 * - 执行失败时展示方法抛出的字符串
 * @param {string|string[]} methods 方法名,可指定多个
 * @param {DialogSettings} [dialogs] 指定要展示的交互,默认展示所有交互
 *
 * @typedef DialogSettings
 * @type {object}
 * @prop {boolean} [loading=true] 是否在执行过程中展示 loading 遮罩
 * @prop {boolean} [success=true] 是否在执行成功时展示方法返回的字符串
 * @prop {boolean} [error=true] 是否在执行失败时展示方法抛出的字符串
 */
export function asyncDialog(methods, dialogs) {}

函数返回真正的 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-dialog.mixin.js
export function asyncDialog(methods, dialogs) {
  return {
    data() {
      // 在这里对方法进行重写
      return {}
    }
  }
}

处理参数

我们把 methods 处理成数组形式,并取出 dialogs 中指定的交互。

// @/mixins/async-dialog.mixin.js
export function asyncDialog(methods, dialogs) {
  methods = [].concat(methods)
  const { loading = true, success = true, error = true } = dialogs || {}
}

重写方法

// @/mixins/async-dialog.mixin.js
export function asyncDialog(methods, dialogs) {
  return {
    data() {
      for (const method of methods) {
        if (typeof this[method] === 'function') {
          const fn = this[method]
          // 在这里重写方法
          this[method] = async (...args) => {
            // 打开 loading 遮罩
            const loadingMask = loading && this.$loading()
            try {
              // 调用原始方法
              const result = await fn.call(this, ...args)
              // 成功时提示
              if (success && typeof result === 'string') {
                this.$message.success(result)
              }
              return result
            } catch (err) {
              // 失败时提示
              if (error && typeof err === 'string') {
                this.$message.error(err)
              }
              throw err
            } finally {
              // 关闭 loading 遮罩
              loadingMask?.close()
            }
          }
        }
      }
    }
  }
}

完整代码

最后整合一下完整的代码。

/**
 * @/mixins/async-dialog.mixin.js
 * 维护异步方法的执行状态,为组件中的指定方法添加如下交互状态:
 * - 执行过程中展示 loading 遮罩
 * - 执行成功时展示方法返回的字符串
 * - 执行失败时展示方法抛出的字符串
 * @param {string|string[]} methods 方法名,可指定多个
 * @param {DialogSettings} [dialogs] 指定要展示的交互,默认展示所有交互
 *
 * @typedef DialogSettings
 * @type {object}
 * @prop {boolean} [loading=true] 是否在执行过程中展示 loading 遮罩
 * @prop {boolean} [success=true] 是否在执行成功时展示方法返回的字符串
 * @prop {boolean} [error=true] 是否在执行失败时展示方法抛出的字符串
 *
 * @example
 * export default {
 *   mixins: [
 *     asyncMethodStatus('submit')
 *   ],
 *   methods: {
 *     async submit() {
 *       try {
 *         await this.$http.post('/post/add', this.formData)
 *         this.$router.back()
 *         return '操作成功'
 *       } catch {
 *         throw '操作失败'
 *       }
 *     }
 *   }
 * }
 */
export function asyncDialog(methods, dialogs) {
  methods = [].concat(methods)
  const { loading = true, success = true, error = true } = dialogs || {}
  return {
    data() {
      for (const method of methods) {
        if (typeof this[method] === 'function') {
          const fn = this[method]
          this[method] = async (...args) => {
            const loadingMask = loading && this.$loading()
            try {
              const result = await fn.call(this, ...args)
              if (success && typeof result === 'string') {
                this.$message.success(result)
              }
              return result
            } catch (err) {
              if (error && typeof err === 'string') {
                this.$message.error(err)
              }
              throw err
            } finally {
              loadingMask?.close()
            }
          }
        }
      }
      return {}
    }
  }
}