我正在参加「掘金·启航计划」
众所周知,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>
状态维护很麻烦
一个完善的异步加载过程,需要维护加载状态,这是比较麻烦的。我们需要维护 loading
和 error
,这会让代码一下子变得特别多,特别是在有多个数据要加载的情况下。
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
可能值有updating
、success
和error
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 看组件数据时会有点不清晰- 不支持分页触底加载场景,分页加载更多的场景仍需要使用传统方式实现