1. 什么是无限滚动?我们为什么需要它?

当你刷着社交媒体、逛着电商网站时,一定见过这样的场景:页面滚动到底部时新内容自动加载,永远翻不到尽头。这就是无限滚动(Infinite Scroll)的典型应用,它改变了传统的分页交互模式,让用户可以持续沉浸在内容流中。

在Vue3项目中实现这一功能,最关键的就在于"如何优雅地感知列表底部是否进入可视区域"。传统的实现方式可能需要计算滚动位置、元素高度等信息,但当遇到动态内容、响应式布局时,这将成为代码维护者的噩梦。而VueUse的useIntersectionObserver为我们提供了一种更优雅的解决方案。

2. IntersectionObserver的前世今生

2.1 原生API之痛

过去我们需要通过监听scroll事件,手动计算元素位置:

window.addEventListener('scroll', () => {
  const { scrollTop, clientHeight, scrollHeight } = document.documentElement;
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    loadMore();
  }
});

这种方案存在三大痛点:

  1. 频繁触发事件导致性能问题
  2. 计算逻辑可能不准确(特别是存在动态内容时)
  3. 代码耦合度高难以复用

2.2 救世主登场

IntersectionObserver API的出现完美解决了这些问题:

  • 异步监听元素可见性变化
  • 支持阈值(threshold)配置
  • 自动处理边缘情况检测
  • 浏览器原生支持(兼容性良好)

而VueUse的useIntersectionObserver在这之上又做了三个关键优化:

  1. 自动unwatch元素解除绑定
  2. 响应式参数配置
  3. 与Vue生命周期完美集成

3. 手把手搭建无限滚动组件

3.1 项目初始化

先通过vite创建Vue3项目:

npm create vite@latest infinite-scroll-demo -- --template vue-ts

安装必要依赖:

npm install @vueuse/core axios

3.2 核心组件实现

创建src/components/InfiniteList.vue

<script setup lang="ts">
import { ref } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';
import axios from 'axios';

// 分页配置
const pageSize = 20;
const currentPage = ref(1);
const isLoading = ref(false);
const hasMore = ref(true);

// 数据源
const items = ref<{ id: number; content: string }[]>([]);

// 加载更多数据
const loadMore = async () => {
  if (isLoading.value || !hasMore.value) return;
  
  isLoading.value = true;
  try {
    // 模拟API调用
    const { data } = await axios.get('/api/items', {
      params: { page: currentPage.value, size: pageSize }
    });
    
    items.value.push(...data);
    hasMore.value = data.length >= pageSize;
    currentPage.value++;
  } finally {
    isLoading.value = false;
  }
};

// 滚动检测器元素引用
const scrollTrigger = ref<HTMLElement | null>(null);

// 设置观察器
useIntersectionObserver(
  scrollTrigger,
  ([{ isIntersecting }]) => {
    if (isIntersecting) {
      loadMore();
    }
  },
  {
    // 提前200px触发加载
    rootMargin: '0px 0px 200px 0px',
    // 当元素出现至少10%时触发
    threshold: 0.1
  }
);
</script>

<template>
  <div class="list-container">
    <div v-for="item in items" :key="item.id" class="list-item">
      {{ item.content }}
    </div>
    
    <!-- 滚动触发器 -->
    <div ref="scrollTrigger" class="scroll-trigger">
      <!-- 加载状态反馈 -->
      <div v-if="isLoading" class="loading-indicator">
        正在加载更多...
      </div>
      <div v-else-if="!hasMore" class="no-more-data">
        没有更多数据啦~
      </div>
    </div>
  </div>
</template>

<style scoped>
.list-container {
  max-width: 800px;
  margin: 0 auto;
}

.list-item {
  padding: 20px;
  margin: 10px 0;
  background: #f5f5f5;
  border-radius: 8px;
}

.scroll-trigger {
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #666;
}

.loading-indicator::after {
  content: '...';
  display: inline-block;
  animation: dot-flash 1.5s infinite;
}

@keyframes dot-flash {
  0%, 100% { content: '.'; }
  33% { content: '..'; }
  66% { content: '...'; }
}
</style>

这个示例实现了:

  1. 滚动自动加载
  2. 加载状态反馈
  3. 数据结束判断
  4. 节流控制(通过isLoading状态)
  5. 失败重试机制(通过try-finally)

4. 性能优化实践

4.1 使用Suspense处理异步

<template>
  <Suspense>
    <template #default>
      <InfiniteList />
    </template>
    <template #fallback>
      <div class="loading">初始加载中...</div>
    </template>
  </Suspense>
</template>

4.2 滚动恢复策略

在路由跳转时保存滚动位置:

// 保存位置
const saveScroll = () => {
  sessionStorage.setItem('listScroll', window.scrollY.toString());
};

// 恢复位置
onMounted(() => {
  const saved = sessionStorage.getItem('listScroll');
  if (saved) {
    window.scrollTo(0, Number(saved));
  }
});

5. 你必须知道的注意事项

5.1 SSR的兼容性

如果使用服务端渲染:

const isBrowser = typeof window !== 'undefined';
const scrollTrigger = ref<HTMLElement | null>(null);

onMounted(() => {
  if (isBrowser) {
    useIntersectionObserver(/* ... */);
  }
});

5.2 移动端适配

// 添加touch事件处理
const handleTouchMove = () => {
  // 移动端的弹性滚动处理
};

onMounted(() => {
  window.addEventListener('touchmove', handleTouchMove);
});

onUnmounted(() => {
  window.removeEventListener('touchmove', handleTouchMove);
});

6. 技术方案对比

6.1 传统分页 vs 无限滚动

维度 分页导航 无限滚动
用户认知成本 需要学习操作 直觉自然
数据定位 精确 相对模糊
SEO友好性 更好 需要特殊处理
性能表现 内存占用低 需要维护大列表

6.2 useIntersectionObserver的优势

  1. 自动解除绑定:组件销毁时自动停止监听
  2. 智能阈值触发:精确控制触发时机
  3. 响应式配置:动态调整rootMargin

7. 最佳应用场景

7.1 推荐使用

  • 社交媒体动态流
  • 电商商品瀑布流
  • 实时聊天记录
  • 长列表内容展示

7.2 谨慎使用

  • 需要精准定位的场景(如商品筛选)
  • SEO敏感的内容
  • 大量复杂DOM的列表

8. 总结与展望

通过本文的实践,我们实现了:

  • 基于VueUse的高效无限滚动
  • 完善的加载状态管理
  • 移动端友好体验
  • 性能优化策略

未来可以尝试:

  1. 集成虚拟滚动优化性能
  2. 添加骨架屏提升体验
  3. 基于Web Worker处理复杂计算