Vue 3 高度可复用、通用的滚动加载组件

✅ 通过 props 传入:

  • 异步数据加载函数(loadMoreFn
  • 每页数量(pageSize,可选,默认 10)
  • 容器高度(height,可选)

✅ 内部自动处理:

  • “空空如也”、“加载中....”、“到底了”
  • 初始数据不足一屏时自动加载更多
  • 自动清理 Observer 和事件监听

✅ 使用 <script setup> + TypeScript 风格(但不强制 TS,纯 JS 也可运行)


📦 通用组件:InfiniteScroll.vue

<template>
  <div
    ref="containerRef"
    class="infinite-scroll-container"
    :style="{ height: `${height}px` }"
  >
    <!-- 插槽:用于自定义 item 渲染 -->
    <div v-if="list.length > 0" class="infinite-list">
      <slot v-for="(item, index) in list" :item="item" :index="index" :key="index" />
    </div>

    <!-- 空状态 -->
    <div v-else class="infinite-empty">
      <slot name="empty">空空如也</slot>
    </div>

    <!-- 加载中 -->
    <div v-if="loading" class="infinite-loading">
      <slot name="loading">加载中....</slot>
    </div>

    <!-- 到底了 -->
    <div v-else-if="!hasMore && list.length > 0" class="infinite-no-more">
      <slot name="no-more">到底了</slot>
    </div>

    <!-- 哨兵元素 -->
    <div ref="sentinelRef" class="infinite-sentinel"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'

const props = defineProps({
  // 必传:加载下一页的函数,接收 { page, pageSize },返回 Promise<{ data: any[], hasMore: boolean }>
  loadMoreFn: {
    type: Function,
    required: true
  },
  // 可选:每页数量,默认 10
  pageSize: {
    type: Number,
    default: 10
  },
  // 可选:容器高度,默认 400px
  height: {
    type: [Number, String],
    default: 400
  }
})

// 内部状态
const list = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)

// DOM refs
const containerRef = ref(null)
const sentinelRef = ref(null)

let observer = null

// 加载下一页
const loadMore = async () => {
  if (loading.value || !hasMore.value) return

  loading.value = true
  try {
    const result = await props.loadMoreFn({ page: page.value, pageSize: props.pageSize })
    const { data = [], hasMore: more = false } = result

    list.value.push(...data)
    hasMore.value = more
    page.value++
  } catch (err) {
    console.error('InfiniteScroll: 加载失败', err)
    // 可在此处 emit error 事件(如需)
  } finally {
    loading.value = false
  }
}

// 判断是否撑满容器
const isContainerFull = () => {
  const el = containerRef.value
  return el.scrollHeight > el.clientHeight
}

// 自动补全(直到撑满或无更多)
const autoLoadIfNotFull = async () => {
  await nextTick()
  while (hasMore.value && !loading.value && !isContainerFull()) {
    await loadMore()
    await nextTick()
  }
}

// 初始化
onMounted(async () => {
  await loadMore()
  autoLoadIfNotFull()

  // 设置 IntersectionObserver
  observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting && hasMore.value && !loading.value) {
        loadMore()
      }
    },
    {
      root: containerRef.value,
      threshold: 0.1
    }
  )
  if (sentinelRef.value) {
    observer.observe(sentinelRef.value)
  }

  // 绑定 scroll 事件用于动态补全
  containerRef.value?.addEventListener('scroll', handleScroll)
})

const handleScroll = () => {
  if (!isContainerFull() && hasMore.value && !loading.value) {
    autoLoadIfNotFull()
  }
}

onUnmounted(() => {
  if (observer) {
    observer.disconnect()
  }
  containerRef.value?.removeEventListener('scroll', handleScroll)
})

// 暴露内部状态(可选,供父组件控制)
defineExpose({
  list,
  reload: async () => {
    list.value = []
    hasMore.value = true
    page.value = 1
    await loadMore()
    autoLoadIfNotFull()
  },
  loadMore
})
</script>

<style scoped>
.infinite-scroll-container {
  overflow-y: auto;
  border: 1px solid #ddd;
  padding: 10px;
  box-sizing: border-box;
}

.infinite-list {
  margin-bottom: 10px;
}

.infinite-empty,
.infinite-loading,
.infinite-no-more {
  text-align: center;
  padding: 12px;
  color: #888;
}

.infinite-sentinel {
  height: 1px;
}
</style>

🧪 使用示例

<template>
  <InfiniteScroll
    :load-more-fn="fetchData"
    :page-size="8"
    :height="500"
  >
    <!-- 默认插槽:渲染每个 item -->
    <template #default="{ item }">
      <div class="custom-item">
        {{ item.title }}
      </div>
    </template>

    <!-- 可选:自定义空状态 -->
    <template #empty>
      <div>暂无数据 😢</div>
    </template>

    <!-- 可选:自定义加载中 -->
    <template #loading>
      <div>正在拼命加载...</div>
    </template>

    <!-- 可选:自定义“到底了” -->
    <template #no-more>
      <div>没有更多啦 ✨</div>
    </template>
  </InfiniteScroll>
</template>

<script setup>
// 模拟 API
const fetchData = ({ page, pageSize }) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const total = 30
      const items = []
      for (let i = 0; i < pageSize; i++) {
        const idx = (page - 1) * pageSize + i
        if (idx >= total) break
        items.push({ id: idx + 1, title: `文章 ${idx + 1}` })
      }
      resolve({
        data: items,
        hasMore: (page * pageSize) < total
      })
    }, 600)
  })
}
</script>

<style scoped>
.custom-item {
  padding: 12px;
  margin: 6px 0;
  background: #f0f8ff;
  border-radius: 4px;
}
</style>

📌 关键优势

特性 说明
完全解耦 数据加载逻辑由父组件提供,组件只负责 UI 和滚动逻辑
插槽灵活 支持自定义 item、empty、loading、no-more 的渲染
自动补全 初始数据不足一屏?自动加载直到撑满或无更多
类型安全友好 若使用 TypeScript,可轻松为 loadMoreFn 添加类型
可重置/重载 通过 expose 提供 reload() 方法

如果你使用 TypeScript,还可以为 loadMoreFn 添加类型约束,例如:

interface LoadMoreResult<T> {
  data: T[]
  hasMore: boolean
}

type LoadMoreFunction<T> = (params: { page: number; pageSize: number }) => Promise<LoadMoreResult<T>>

需要我提供 TS 版本吗?或者集成到你的具体业务场景?欢迎继续告诉我!

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容