异步计算属性:Vue中更方便的数据加载方式

2,464 阅读6分钟

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

众所周知,Vue 的强大之处在于它的响应式设计。得益于此,Vue 可以很方便地提供诸多好用的工具,例如:计算属性 computed,监听 watch

但是,这套强大的响应式系统,在遇到异步的场景时,就难以发挥出它的优势。例如,从接口加载页面数据时,我们仍然使用事件回调的方式,这其实会存在一些不便之处。

本文介绍一种加载数据的新方式,通过一种叫做异步计算属性的工具,利用 Vue 的响应式特性,尝试解决传统数据加载方式中存在的一些问题。

痛点分析

在页面上异步加载数据的一般做法,是利用生命周期钩子在页面加载时调用 methods 中的异步方法,并将返回结果放入 data 中。

export default {
  data() {
    return {
      tableData: [],
    };
  },
  methods: {
    async getTableData() {
      this.tableData = await this.$http.get("/post/list");
    },
  },
  mounted() {
    this.getTableData();
  },
};

当然,这是个最基础的示例,完善的写法还要考虑很多东西,比如维护加载状态、加载失败的处理等等。不过这些处理代码写起来往往比较麻烦,所以常常会被忽略。

接下来就来分析一下这种传统写法存在哪些不方便的地方。

相关逻辑代码分散

首先,获取数据的相关逻辑代码分散在多个地方。如果页面有多个异步数据需要获取,那么获取每一种数据的相关逻辑都会变得很分离。

export default {
  data() {
    return {
      // 各种表单下拉选项
      yearList: [],
      deptList: [],
      cityList: [],
    }
  },
  methods: {
    // 各种获取数据的方法
    async getYearList() {},
    async getDeptList() {},
    async getCityList() {},
  },
  mounted() {
    // 初始化各种数据
    this.getYearList()
    this.getDeptList()
    this.getCityList()
  },
}

这个示例获取了3种数据,每一种数据的相关代码都分散在多个地方,很不好找。

需要手动获取数据

搜索参数变化时,需要手动重新获取数据,这可能需要你在很多地方监听回调。

<template>
  <!-- 在每一处需要重新获取数据的地方监听事件并手动调用 getTableData -->
  <el-pagination :current-page.sync="pageNum" @current-change="getTableData" />
</template>
<script>
export default {
  data() {
    return {
      tableData: [],
      pageNum: 1
    }
  },
  methods: {
    async getTableData() {
      this.tableData = await this.$http.get("/post/list", {
        pageSize: 20,
        pageNum: this.pageNum
      });
    }
  }
}
</script>

又比如根据路由参数获取数据,需要手动监听路由变化。有时觉得太麻烦可能就不监听了,但不监听又会导致一些情况下路由参数变化却没有更新数据。

<script>
export default {
  data() {
    return {
      post: null
    }
  },
  watch: {
    '$route.query.id'() {
      this.getPost()
    }
  },
  methods: {
    async getPost() {
      this.post = await this.$http.get("/post", {
        id: this.$route.query.id
      })
    }
  }
}
</script>

还有表单级联的场景,比如根据选择的月份,带出当月的活动列表。当级联关系比较多时,代码会变得非常复杂。

<template>
  <el-date-picker
    v-model="formData.month"
    type="month"
    placeholder="选择月份"
    @change="getActivities"
  />
  <el-select v-model="form.activity" placeholder="选择活动">
    <el-option
      v-for="activity in activities"
      :key="activity.id"
      :label="activity.name"
      :value="activity.id"
    />
  </el-select>
</template>
<script>
export default {
  data() {
    return {
      formData: {
        month: null,
        activity: null
      },
      activities: []
    }
  },
  methods: {
    async getActivities() {
      this.activity = null
      if (!this.formData.month) return
      this.activities = await this.$http.get("/activities", {
        month: this.formData.month
      })
    }
  }
}
</script>

状态维护很麻烦

一个完善的异步加载过程,需要维护加载状态,这是比较麻烦的。我们需要维护 loadingerror,这会让代码一下子变得特别多,特别是在有多个数据要加载的情况下。

export default {
  data() {
    return {
      yearList: [],
      yearListLoading: false,
      yearListError: false,
      yearListErrorObj: null,
      deptList: [],
      deptListLoading: false,
      deptListError: false,
      deptListErrorObj: null,
      tableData: [],
      tableDataLoading: false,
      tableDataError: false,
      tableDataErrorObj: null
    }
  }
}

爱偷懒的程序员都不爱写这些代码,所以他们可能会不进行加载状态的显示,在加载失败时也不进行处理,或者只是简单弹出提示,不提供重试按钮。

不进行失败处理可能会导致问题,因为界面上可能显示的是上一次加载成功的旧数据。但失败处理又确实麻烦。

取消加载很麻烦

当频繁改变搜索条件时,正确的做法需要取消之前未完成的加载,以免发生阻塞。但我们往往不会这么做,因为代码写起来很麻烦。来写个示例看看:

export default {
  data() {
    return {
      controller: null,
      tableData: []
    }
  },
  methods: {
    async getTableData() {
      if (this.controller) {
        // 取消之前未完成的请求
        this.controller.abort()
      }
      this.controller = new AbortController()
      const res = await fetch(url, { signal: this.controller.signal })
      this.tableData = await res.json()
    }
  }
}

可以看到,为每个异步方法写取消的代码是非常麻烦的,所以我们一般都不写。但不写的话其实是不太完善的,在服务器响应较慢时,请求可能会发生阻塞。

数据可能被覆盖

最后,传统的加载方式一般还会有一个逻辑错误,就是数据覆盖问题。频繁调用加载方法时,先发起的加载过程可能会后返回结果,所以旧的结果可能会覆盖新的结果。

这个问题当然也是可以处理的,并且可以和取消加载的代码共用。

export default {
  data() {
    return {
      // 取消请求的 controller 作为单次请求唯一标识
      controller: null,
      tableData: []
    }
  },
  methods: {
    async getTableData() {
      if (this.controller) {
        this.controller.abort()
      }
      // 创建请求标识
      const controller = this.controller = new AbortController()
      const res = await fetch(url, { signal: controller.signal })
      const tableData = await res.json()
      // 判断当前标识是否与最新标识一致
      if (controller === this.controller) {
        this.tableData = tableData
      }
    }
  }
}

可以看到,处理这个问题的代码也挺麻烦的,特别是加入状态维护代码的时候,所以我们一般也很少会处理。

异步计算属性

接下来看看如何使用异步计算属性来解决这些问题。

Vue中本身不支持异步计算属性,所以我们需要使用第三方库。这里选择的是 vue-async-computed,我是在 ESLint 规则 vue/no-async-in-computed-properties 的文档中发现它的。

这个库有点古老,但很实用。不过,这个库只支持 Vue2。

基本用法

异步计算属性的用法和计算属性类似,不过不是写在 computed 中,而是写在 asyncComputed 中。并且,它支持异步返回。

export default {
  asyncComputed: {
    async tableData() {
      return await this.$http.get("/post/list");
    },
  },
};

这样,你就可以在模板中直接使用 tableData 了,也可以在 js 中使用 this.tableData。在接口未返回时,tableData 的值会是 null

异步计算属性解决了逻辑代码分散的问题,这在页面有多个初始数据要加载的时候,会显得特别方便。

export default {
  asyncComputed: {
    async deptList() {},
    async yearList() {},
    async tableData() {},
  },
}

相比之下,传统的代码是这样写的:

export default {
  data() {
    return {
      deptList: [],
      yearList: [],
      tableData: [],
    }
  },
  methods: {
    async getDeptList() {},
    async getYearList() {},
    async getTableData() {},
  },
  mounted() {
    this.getDeptList()
    this.getYearList()
    this.getTableData()
  },
}

当然,既然是异步的计算属性,自然会在用到的数据变化时自动刷新。

export default {
  asyncComputed: {
    async getPost() {
      return this.$http.get("/post", {
        id: this.$route.query.id
      })
    }
  },
}

这会在路由参数变化时自动重新调用接口。这个特性使我们在大部分时候可以不再需要手动获取数据。并且,异步计算属性只会返回最后一次加载的结果,所以不存在数据覆盖问题

默认值

你可能想在数据未加载时给个默认值,以免在模板加载时报错,这没有问题。

export default {
  asyncComputed: {
    userDetail: {
      async get() {},
      default: () => ({})
    },
  },
}

props 类似,如果默认值是个对象,需要使用函数返回。

手动刷新

有时,你可能会想要手动重新加载数据,比如在表单提交后重新获取页面数据。这可以通过调用 this.$asyncComputed.属性名.update() 来实现。

export default {
  asyncComputed: {
    async headerData() {},
  },
  methods: {
    submit() {
      // 提交完成后手动刷新数据
      this.$asyncComputed.headerData.update()
    },
  },
}

状态维护

前面提到传统加载方式的状态维护很麻烦,而这个异步计算属性工具提供了自动维护加载状态的功能。我们可以通过 this.$asyncComputed 的以下属性来获取当前状态:

  • state 可能值有 updatingsuccesserror
  • updating 布尔值
  • success 布尔值
  • error 布尔值
  • exception 加载失败时函数抛出的错误对象

高级玩法

状态管理组件

异步计算属性支持自动维护加载状态,这使得 js 中的代码变得非常简洁。但是,在模板中使用这些状态时,却并没有变得更简洁。

<template>
  <div v-if="$asyncComputed.user.success">
    <!-- 显示页面内容 -->
  </div>
  <div v-else-if="$asyncComputed.user.updating">
    <!-- 显示骨架屏 -->
  </div>
  <div v-else-if="$asyncComputed.user.error">
    <!-- 显示失败提示和重试按钮 -->
  </div>
</template>

$asyncComputed 实在太长了,特别是有多个异步数据的时候,状态串联起来会特别长。一个好的解决方法是封装一个异步加载组件,然后把各个状态下需要显示的内容通过插槽传入进去,组件根据当前加载状态来渲染正确的内容。

<template>
  <async-status :async-computed="$asyncComputed" :async-props="['user']">
    <!-- 页面内容 -->
    <div>{{ user.name }}</div>
    <template #updating>
      <!-- 显示骨架屏 -->
    </template>
    <template #error>
      <!-- 显示失败提示和重试按钮 -->
    </template>
  </async-status>
</template>

传入 $asyncComputed 是因为组件需要知道当前加载状态,但如果 <async-status> 的外层没有包其他自定义组件时,可以通过 this.$parent.$asyncComputed 自动获取,这样就可以省略传入 async-computed 属性。

同样,如果不传 async-props 属性,可以通过遍历 $asyncComputed 上的 key 来获取,这样会依赖所有异步计算属性的状态,而不是指定的几个异步计算属性。

当然,每次传入骨架屏和失败提示组件还是太麻烦了,我们可以稍微改动异步加载组件的代码,写入项目默认的骨架屏和失败提示组件。这样默认可以只传页面内容。

<template>
  <async-status>
    <!-- 页面内容 -->
    <div>{{ user.name }}</div>
  </async-status>
</template>

不过,由于插槽的实现细节,这样写可能会报错,因为 user 未加载时默认是 null,所以无法读取 user.name 属性。虽然未加载时没有渲染页面内容,但 Vue2 中传入普通插槽相当于生成 VNode 之后传入 <async-status> 组件,而生成 VNode 时就报错了。解决方法是使用新的插槽语法:

<template>
  <async-status v-slot>
    <div>{{ user.name }}</div>
  </async-status>
</template>

或者这样:

<template>
  <async-status>
    <template #default>
      <div>{{ user.name }}</div>
    </template>
  </async-status>
</template>

这会把插槽内容编译成一个函数,相当于作用域插槽,只在真正渲染时才生成 VNode。你可以在 Vue2 文档的模板编译部分(页面最底部)输入这些模板代码,查看它们的编译结果。

这个组件也可以嵌套使用,比如主体数据依赖头部数据,那么可以把主体部分嵌入到内层。

<template>
  <async-status :async-props="['headerData']">
    <div class="page-header">
      <!-- 头部内容 -->
    </div>
    <async-status
      :async-computed="$asyncComputed"
      :async-props="['tableData']"
    >
      <div class="page-body">
        <!-- 主体内容 -->
      </div>
    </async-status>
  </async-status>
</template>

外层的 <async-status> 省略了 async-computed,因为组件会通过 this.$parent 自动获取。内层的 <async-status> 不能省略,因为它外层有其他自定义组件。

对于加载失败的情况,可以把抛出的错误对象作为插槽变量暴露出来,还可以提供重试的方法,因为重试的方法本质上是调用 $asyncComputed 上的 update() 方法。

<template>
  <async-status>
    <template #error="{ exception, update }">
      <div class="error">
        <div class="errmsg">{{ exception.message }}</div>
        <button @click="update()">重试</button>
      </div>
    </template>
  </async-status>
</template>

好了,有了这样一个组件,就不用单独为每个页面写骨架屏和失败页面,也不用担心大家因为怕麻烦而不展示加载和失败状态了。

更方便的是,我已经封装好了这个组件,只有一个文件,没有其他依赖。组件地址是:github.com/web1706/vue…。不过,组件并没有默认的加载展示和失败处理展示,你可能需要稍微改造它,手动给 update 插槽和 error 插槽传入后备内容。

自动取消加载

其实到此为止,我们已经解决了传统加载时的大部分痛点。现在只剩下一个痛点没解决,就是 取消加载很麻烦

需要取消加载的时机有两个:

  • 依赖的数据改变,触发下一次加载时上一次加载还未完成,需要取消上一次的加载
  • 组件卸载时,将仍在获取数据的加载取消掉

使用异步计算属性之后,我们不取消加载也能逻辑正确,不会出现数据覆盖问题。但我们的加载往往是从接口获取数据,不取消加载的话,可能造成接口阻塞。遗憾的是,这个异步计算属性工具并没有提供取消加载的方式,并且看起来已经不打算再更新了。而一个完善的计算属性库应该要具备取消加载的功能,使用起来像这样:

export default {
  asyncComputed: {
    async userList(onCancel) {
      const CancelToken = axios.CancelToken;
      const source = CancelToken.source();
      onCancel(() => source.cancel());
      return this.$http.get("/user/list", {
        params: {
          keyword: this.keyword,
        },
        cancelToken: source.token,
      });
    },
  },
}

传给 onCancel 的函数会在需要取消加载的时候调用。

所以我们只能手动给异步计算属性添加取消的功能。怎么加呢?我想最好的方法是使用 mixin 来实现。

import asyncComputed from "@/mixins/async-computed.mixin";
export default {
  mixins: [
    asyncComputed({
      async userList(onCancel) {
        // 将取消回调传给onCancel
      },
    }),
  ],
};

你也可以不用 mixin 的方式,这样写起来更好看。但我觉得使用 mixin 的话既不会侵入异步计算属性的源码,也保留了熟悉的使用方式。

不过,有了 onCancel 之后,取消加载还是挺麻烦的。比如取消 axios 请求时,仍然需要手动生成 cancelToken,这会导致大家不愿意在每一处写取消请求的代码。其实我们可以写一个插件,主动生成 axios 请求时使用的 cancelToken,并挂载到 onCancel 上,这样每次可以直接取出来使用,就方便多了。

export default {
  mixins: [
    asyncComputedMixin({
      async userList({ cancelToken }) {
        return this.$http.get("/user/list", {
          params: {
            keyword: this.keyword,
          },
          cancelToken,
        });
      },
    }),
  ],
};

onCancel 仍然可用,但可以从上面取出插件生成的用于快速取消的对象。这些快速取消对象通过插件的方式注入,并且是通过访问器属性(getter)的形式注入的,所以只在取用的时候临时创建。

这个加了取消功能的 mixin 我也实现了,地址是:github.com/web1706/vue…

缺点

  • 需要对响应式原理比较了解,否则容易出现在意料之外重新加载数据的情况
  • 无法使用 Promise 方式加载,不过完全使用响应式思路写代码时应该是不会需要 Promise
  • 无法手动取消加载,虽然需要手动取消加载的场景很少,不过我感觉封装并注入一个手动取消加载的 API 应该也是可行的
  • API 太长,比如为了刷新一个数据,可能需要调用 this.$asyncComputed.tableData.update()
  • vue-devtools 显示不清晰,因为会在组件实例上注入 $asyncComputed 和私有属性 _asyncComputed,这使得通过 Vue devtools 看组件数据时会有点不清晰
  • 不支持分页触底加载场景,分页加载更多的场景仍需要使用传统方式实现