分页点击太快数据混乱?精准获取最后加载内容

一个常见的场景:点击页码加载内容,当点击频率过快时,实际返回内容与最后一次点击请求的页码不对应。

造成现象的原因
  1. 异步请求的延迟:网络请求是异步的,意味着请求发送后,JavaScript 不会停下等待服务器响应,而是继续执行后续代码,待服务器响应就绪时再通知 JavaScript 进行处理。快速点击分页按钮会导致多个请求同时发出,但请求的返回顺序可能不一致。
  2. 竞态条件(Race Condition):多个请求的结果到达时,后到的请求结果覆盖了先到的请求结果,造成数据不一致。
  3. 状态更新不及时:前端框架(如 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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值