发现问题
在前端开发中,有一个很常见的问题:如何在多次请求中,正确地处理响应数据和原始请求保持一致。 说起来有点拗口,我这里直接用一个简洁明了的例子来讲解。假如要写一个可分页的表格页面,它的原始代码如下:
<script lang="ts" setup>
const rows = ref<{id: number, name: string, age: number}[]>([])
const currentPage = ref(1)
const total = ref(0)
const getList = (page) => {
axios.get(`/api/list?page=${page}`)
.then(res => {
rows.value = res.data.list
total.value = res.data.total
})
}
// 页码改变时,调用 getList
watch(currentPage, (page) => {
// 这里多此一举把 page 传进去,后面自有用处
getList(page)
}, { immediate: true })
</script>
<template>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>age</th>
</tr>
</thead>
<tbody>
<tr v-for="item in rows" :key="item.id">
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.age }}</td>
</tr>
</tbody>
</table>
<!-- 假设这是一个分页组件 -->
<Pagination v-model="currentPage" :total="total" />
</template>
以上代码很好理解,它潜在的问题是:如果当前在第1页,用户点击第2页,又在接口pending的时候点了第3页,好巧不巧,后端在响应第2页的时候卡了几秒才返回,但在响应第3页是秒返回的。在前端体现就是明明在第3页,显示的却是第2页的数据。
两个方案
- 方案1:在请求列表的时候,把翻页按钮disabled掉,不让用户翻页了。
- 方案2:在下一个请求发送时,取消上一个请求。
方案1不够优雅,我们这里考虑如何实现方案2。
实现
废话不多说,直接端上代码,主要用到的是闭包以及axios取消请求:
export const useAbortController = <T extends Array<unknown>, R = any>(fn: (controller?: AbortController, ...args: T) => Promise<AxiosResponse<R>>) => {
// 在闭包里存一个 AbortController
let controller: AbortController | undefined
return (...args: T) => {
// 取消上一个请求(如果有的话)
if (controller) {
controller.abort();
}
// 重新创建一个 AbortController,并传给实际要调的函数
controller = new AbortController()
return fn(controller, ...args)
}
}
有了上面的功能函数,之前的代码可做如下修改:
<script lang="ts" setup>
const rows = ref<{id: number, name: string, age: number}[]>([])
const currentPage = ref(1)
const total = ref(0)
// 用page参数成功演示了如何给闭包内的函数传参
const getList = useAbortController<[page: number]>((controller, page) => {
// 把 controller.signal 传给 axios 后,调用 controller.abort() 就能取消请求了
axios.get(`/api/list?page=${page}`, { signal: controller?.signal })
.then(res => {
rows.value = res.data.list
total.value = res.data.total
})
})
// 页码改变时,调用 getList
watch(currentPage, (page) => {
getList(page)
}, { immediate: true })
</script>
<template>
<!-- 不做改变 -->
</template>
以上可以完美实现响应数据和原始请求保持一致,简单实用。
插曲
以前写Angular的时候,用rxjs的switchMap可以轻松达到该目的
const rows = ref<{id: number, name: string, age: number}[]>([])
const currentPage = ref(1)
const total = ref(0)
const requestSubject = new Subject();
// 订阅 requestSubject,通过 switchMap 实现请求的切换
const subscription = requestSubject.pipe(
switchMap(() => {
// 将 axios 请求包装成 observable
return from(axios.get(`/api/list?page=${currentPage.value}`)).pipe(
catchError((error) => {
return [];
})
);
})
).subscribe(res => {
rows.value = res.data.list
total.value = res.data.total
});
const getList = () => {
// 每次调用 getList 时,都会触发 switchMap 的操作
requestSubject.next();
};
上面用switchMap只是简单地切换订阅请求,并不会中断上一个请求。