一个常见的场景:点击页码加载内容,当点击频率过快时,实际返回内容与最后一次点击请求的页码不对应。
造成现象的原因
- 异步请求的延迟:网络请求是异步的,意味着请求发送后,JavaScript 不会停下等待服务器响应,而是继续执行后续代码,待服务器响应就绪时再通知 JavaScript 进行处理。快速点击分页按钮会导致多个请求同时发出,但请求的返回顺序可能不一致。
- 竞态条件(Race Condition):多个请求的结果到达时,后到的请求结果覆盖了先到的请求结果,造成数据不一致。
- 状态更新不及时:前端框架(如 Vue)的响应式系统在状态更新时可能会有延迟,导致页面显示的内容与当前页码不一致。
核心解决思想
保证获取到的是最后一次请求的结果,那么防抖可以解决吗?
1. 🚨 防抖 Debounce
防抖主要用于限制某个操作在短时间内被多次触发的频率。核心思想是:在事件触发后,延迟一段时间再执行操作,如果在这段时间内再次触发事件,则重新计时。这种方式可以有效地减少频繁触发的事件,避免不必要的操作。
防抖能否解决快速点击分页的问题?
不一定
防抖(debounce)适用于减少请求频率,但不能确保最终页码的数据一定被正确显示,因为:
- 它仅仅限制了请求频率,不能保证最后一次点击的页码对应的数据最终渲染。
- 请求是异步的,后面发起的请求不一定比前面的请求后返回,可能造成数据覆盖错误。
eg:用户点击了页码 1、2、3,防抖可能只会发送页码 3 的请求,但如果请求 3 的响应比请求 2 的响应先到达,用户仍然可能看到与他们的点击不一致的数据。
举个 🌰,但不是必现场景。
<template>
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="debouncedHandlePageChange"
/>
<ul>
<li v-for="item in listData" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { debounce } from 'lodash-es'
import { ElMessage } from 'element-plus'
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
const listData = ref([])
// 模拟 API 请求
const fetchData = async (page: number) => {
return new Promise((resolve) => {
setTimeout(() => {
const start = (page - 1) * pageSize.value
const end = Math.min(start + pageSize.value, total.value)
resolve(
Array.from({ length: end - start }, (_, i) => ({
id: start + i + 1,
name: `选项 ${start + i + 1}`
}))
)
}, 1000) // 1秒延迟
})
}
// 防抖的分页切换
const handlePageChange = async (page: number) => {
currentPage.value = page
ElMessage.info(`切换到第 ${page} 页`)
listData.value = await fetchData(page)
}
// 使用 lodash 防抖
const debouncedHandlePageChange = debounce(handlePageChange, 500)
</script>
⚠ 失败情况
如果用户 快速点击多次分页(比如 1 → 2 → 3 → 4),最终可能 只会请求第 4 页的数据,但 currentPage 仍然是 2 或 3,导致页面显示与数据不同步。
debounce 只会执行最后一次点击的回调,但 currentPage 可能已经被错误更新,导致 currentPage 与 listData 不匹配。由于请求是 异步 的,可能导致 debounce 逻辑被提前打断。
2. 🛠 唯一请求 ID
🔍 解决原理:在每次分页请求前,生成一个唯一 requestId。在数据返回时,只保留最后一个请求的数据,丢弃所有过时的请求。
优点:逻辑简单,兼容所有浏览器。不会丢弃正确的数据,保证 UI 始终显示最新页面的数据。适用于任何异步数据请求场景。
缺点:无法阻止多余的请求,可能会浪费一些网络资源(不过不影响数据正确性)。
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
const listData = ref([])
let latestRequestId = 0 // 记录最新的请求 ID
const fetchData = async (page: number, requestId: number) => {
return new Promise((resolve) => {
setTimeout(() => {
if (requestId !== latestRequestId) return // 旧请求丢弃
const start = (page - 1) * pageSize.value
const end = Math.min(start + pageSize.value, total.value)
resolve(
Array.from({ length: end - start }, (_, i) => ({
id: start + i + 1,
name: `选项 ${start + i + 1}`
}))
)
}, 1000)
})
}
const handlePageChange = async (page: number) => {
latestRequestId++ // 生成唯一请求 ID
const currentRequestId = latestRequestId
currentPage.value = page
ElMessage.info(`切换到第 ${page} 页`)
const result = await fetchData(page, currentRequestId)
if (currentRequestId === latestRequestId) {
listData.value = result as any
}
}
3. 取消未完成请求(推荐)
方案思路:在发起新请求前,通过 axios.CancelToken 或 AbortController 取消之前的请求,确保只有最后一次请求有效。
优点:彻底防止数据错乱,保证 UI 和数据一致。减少不必要的网络请求,提高性能。
缺点:需要 支持 AbortController(不支持 IE)。
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
const listData = ref([])
let abortController: AbortController | null = null
const fetchData = async (page: number) => {
if (abortController) {
abortController.abort() // 取消前一个请求
}
abortController = new AbortController()
return new Promise((resolve, reject) => {
setTimeout(() => {
if (abortController?.signal.aborted) {
reject(new Error('请求被取消'))
return
}
const start = (page - 1) * pageSize.value
const end = Math.min(start + pageSize.value, total.value)
resolve(
Array.from({ length: end - start }, (_, i) => ({
id: start + i + 1,
name: `选项 ${start + i + 1}`
}))
)
}, 1000)
})
}
const handlePageChange = async (page: number) => {
currentPage.value = page
ElMessage.info(`切换到第 ${page} 页`)
try {
const result = await fetchData(page)
listData.value = result as any
} catch (error) {
console.log('请求被取消,不更新数据')
}
}
4. 使用队列管理
方案思路:使用一个队列存储所有的请求,确保只有最后一个请求的结果会更新 UI。旧请求仍然会完成,但它们的结果会被丢弃。
优点:适用于所有环境(不依赖 AbortController)。不影响前端性能,只会渲染最新请求的数据。
缺点:旧请求仍然会占用服务器资源,不能减少请求数量。
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
const listData = ref([])
let requestQueue: number[] = [] // 存储请求队列
const fetchData = async (page: number) => {
return new Promise((resolve) => {
setTimeout(() => {
const start = (page - 1) * pageSize.value
const end = Math.min(start + pageSize.value, total.value)
resolve(
Array.from({ length: end - start }, (_, i) => ({
id: start + i + 1,
name: `Item ${start + i + 1}`
}))
)
}, 1000)
})
}
const handlePageChange = async (page: number) => {
const requestId = Date.now() // 生成唯一请求 ID
requestQueue.push(requestId)
currentPage.value = page
ElMessage.info(`切换到第 ${page} 页`)
const result = await fetchData(page)
// 只处理最新请求
if (requestQueue[requestQueue.length - 1] === requestId) {
listData.value = result as any
requestQueue = [] // 清空队列
}
}
5. 利用 Vue 组件内置的 key 重新渲染
思路:通过 改变 key 值 强制 Vue 重新渲染组件,确保 UI 不会显示旧数据。
优点:避免数据状态残留,保证切换分页时 UI 立即更新。适用于一些 Vue 组件无法正确更新的情况。
缺点:每次分页都会重新渲染组件,可能影响性能。
<template>
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
<div :key="currentPage">
<ul>
<li v-for="item in listData" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
const listData = ref([])
const fetchData = async (page: number) => {
return new Promise((resolve) => {
setTimeout(() => {
const start = (page - 1) * pageSize.value
const end = Math.min(start + pageSize.value, total.value)
resolve(
Array.from({ length: end - start }, (_, i) => ({
id: start + i + 1,
name: `选项 ${start + i + 1}`
}))
)
}, 1000)
})
}
const handlePageChange = async (page: number) => {
currentPage.value = page
listData.value = await fetchData(page)
}
</script>