发现问题

在前端开发中,有一个很常见的问题:如何在多次请求中,正确地处理响应数据和原始请求保持一致。 说起来有点拗口,我这里直接用一个简洁明了的例子来讲解。假如要写一个可分页的表格页面,它的原始代码如下:

<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只是简单地切换订阅请求,并不会中断上一个请求。