滚动加载更多内容的通用解决方案

基于IntersectionObserver的滚动加载组件

前言

无限滚动(infinite scroll)在前端有很多实现思路。本文主要针对某些业务场景提出通用的解决方案。

在移动端开发中,经常需要实现滚动加载更多的业务。但是由于ui设计变幻莫测,应用场景也经常变化(如列表滚动在内部,和整个页面滚动),组件库常见的list组件无法很好的覆盖业务场景。listFooter组件更为灵活,能兼容更多的平台,也更容易做样式改造。

功能特性

1. 多状态支持

组件支持四种不同的加载状态:

  • loading: 加载中状态,显示"加载中..."提示
  • loadFinish: 加载完成状态,显示"到底啦"提示
  • loadFail: 加载失败状态,显示"加载失败,点击刷新"并支持点击重试
  • empty: 空数据状态,显示空状态组件

2. 自动触发机制

当组件进入用户视口时,自动触发load事件,通知父组件执行数据加载逻辑。

3. 职责单一

只提供页脚展示和加载更多功能,与列表本身的样式和功能隔离

核心:初始化时,会默认进行一次判断;加载事件仅在组件loading状态触发

技术实现思路

核心思想:IntersectionObserver API

IntersectionObserver是浏览器原生提供的异步API,专门用于监听元素与视口的交集变化,调用方式这里不做描述。我们需要使用的是他的特性,元素进入和离开可视窗时,会触发我们的回调函数。

实际有这个特性的api都可以实现,比如vueUse的useIntersectionObserver,uniapp中的uni.createIntersectionObserver。作者曾经使用的是团队内部的hooks方法,兼容处理了低版本的场景。

关键实现细节

1. 首次触发机制

IntersectionObserver在调用observe()方法时会立即触发一次回调,报告元素的初始交集状态。这确保了即使元素在页面加载时就已经可见,也能正确触发加载逻辑。

主要优点(凑字数)

1. 性能优越

  • 异步执行: IntersectionObserver在独立线程中运行,不阻塞主线程
  • 无需节流: 浏览器内部已优化,无需手动实现节流机制
  • 减少DOM查询 : 避免频繁调用getBoundingClientRect()

2. 代码简洁

  • 核心逻辑仅需几十行代码
  • 无需复杂的事件监听和清理逻辑
  • 状态管理清晰明了

3. 兼容性良好

  • 现代浏览器原生支持
  • 可通过polyfill支持旧版浏览器
  • 渐进增强的设计理念

4. 易于维护

  • 单一职责原则,只负责触发加载
  • 状态驱动的设计,逻辑清晰
  • 完善的生命周期管理

实现代码

代码思路很简单,核心是监听listStatus做实例的挂载和注销

vue 复制代码
<template>
  <view class="list-footer" ref="targetElement">
    <div>
      <div v-if="listStatus === 'loadFail'" class="text loadFail">
        <div @click="refresh">加载失败,点击刷新</div>
      </div>
      <div v-else-if="listStatus === 'loading'" class="text">
        <div class="list-loading">
          <span class="icon-list-loading">加载中...</span>
        </div>
      </div>
      <!-- empty不期望在组件中处理 -->
      <empty v-else-if="listStatus === 'empty'" :show-img="false" />
      <!-- 加载完成状态:提示用户已到底部 -->
      <div v-else-if="listStatus === 'loadFinish'" class="text">
        <div>到底啦</div>
      </div>
    </div>
  </view>
</template>

<script>
// 引入空状态组件
import empty from '@/components/empty';

/**
 * 列表底部状态组件
 * 
 * 功能特性:
 * 1. 支持多种列表状态显示(加载中、加载失败、加载完成、空数据)
 * 2. 基于IntersectionObserver实现可见性检测
 * 3. 当组件进入视口时自动触发加载事件
 * 4. 支持加载失败时的重试功能
 * 
 * 使用场景:
 * - 分页列表的底部状态提示
 * - 无限滚动列表的加载状态管理
 * - 列表数据的错误处理和重试
 * 
 * 事件:
 * @event load - 当组件可见时触发,用于加载更多数据
 * @event refresh - 当点击重试按钮时触发,用于重新加载数据
 */
export default {
  name: 'ListFooter',
  components: {
    empty,
  },
  props: {
    /**
     * 列表状态
     * @type {String}
     * @default 'loading'
     * @example 'loading' | 'loadFinish' | 'loadFail' | 'empty'
     * @description 'loading' 加载中 | 'loadFinish' 加载完成 | 'loadFail' 加载失败 | 'empty' 空数据(此时应不展示组件)
     */
    listStatus: {
      type: String,
      default: 'loading',
      validator(value) {
        return ['loading', 'loadFinish', 'loadFail', 'empty'].includes(value);
      }
    },
  },
  data() {
    return {
      // 组件是否在视口中可见
      isVisible: false,
      // IntersectionObserver实例
      observer: null,
    }
  },
  mounted() {
    // 组件挂载后,如果状态为加载中,则开始观察元素可见性
    if (this.listStatus === 'loading') {
      this.observeElement();
    }
  },
  watch: {
    /**
     * 监听列表状态变化
     * 当状态不为loading时,移除观察器
     * 当状态变为loading时,重新开始观察
     */
    listStatus(val) {
      if (val !== 'loading') {
        this.removeObserver();
      } else {
        this.observeElement();
      }
    },
    /**
     * 监听可见性变化
     * 当组件变为可见时,触发load事件
     */
    isVisible(val) {
      if (val) {
        this.$emit('load');
      }
    },
  },
  beforeDestroy() {
    // 组件销毁前清理观察器
    this.removeObserver();
  },
  methods: {
    /**
     * 刷新/重试方法
     * 当加载失败时点击重试按钮触发
     * 向父组件发送refresh事件
     */
    refresh() {
      this.$emit('refresh');
    },
    
    /**
     * 开始观察元素可见性
     * 使用IntersectionObserver API监听组件是否进入视口
     */
    observeElement() {
      // 先移除之前的观察器,避免重复观察
      this.removeObserver();
      
      // 检查目标元素是否存在
      if (!this.$refs.targetElement) return;
      
      // 创建IntersectionObserver实例
      this.observer = new IntersectionObserver((entries) => {
        const entry = entries[0];
        // 更新可见性状态
        this.isVisible = entry.isIntersecting;
      }, {
        root: null,        // 使用浏览器视口作为根元素
        rootMargin: '0px', // 根元素的外边距
        threshold: 0       // 目标元素0%可见时就触发回调
      });
      
      // 开始观察目标元素
      this.observer.observe(this.$refs.targetElement);
    },
    
    /**
     * 移除观察器
     * 断开IntersectionObserver连接并清空引用
     */
    removeObserver() {
      if (this.observer) {
        this.observer.disconnect();
        this.observer = null;
      }
    },
  },
};
</script>

<style lang="less" scoped>
.list-footer {
  margin-top: 10px;
  margin-bottom: 10px;
  width: 100%;

  .text {
    color: #00000070;
    text-align: center;
    font-size: 14px;
    font-weight: 700;
    line-height: 20px;
    height: 20px;
  }
}

.loadFail {
  color: #156AFF;
}
</style>

使用示例

主要是在@load时加载数据,以及处理listStatus状态。需要重新加载数据时,只要把listStatus置为loading就好。

vue 复制代码
<template>
  <div>
    <!-- 列表内容 -->
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
    </div>
    
    <!-- 滚动加载组件 -->
    <list-footer 
      :list-status="listStatus" 
      @load="loadMore" 
    />
  </div>
</template>

<script>
import ListFooter from '@/components/list-footer.vue'

export default {
  components: { ListFooter },
  data() {
    return {
      list: [],
      listStatus: 'loading', // loading | loadFinish | loadFail | empty
      page: 1
    }
  },
  methods: {
    async loadMore() {
      try {
        this.listStatus = 'loading'
        const data = await this.fetchData(this.page)
        
        if (data.length === 0) {
          this.listStatus = this.list.length === 0 ? 'empty' : 'loadFinish'
        } else {
          this.list.push(...data)
          this.page++
          this.listStatus = 'loading' // 准备下次加载
        }
      } catch (error) {
        this.listStatus = 'loadFail'
      }
    }
  }
}
</script>

总结

回到设计代码的初衷,这个组件很好的处理了两个业务目的。1.与列表组件的ui解耦2.能兼容不同的滚动父级场景。这个组件是几年前写移动端功能的时候设计的,最近开始写uniapp,做了简单的api改造后也能适配。

实际上作者在准备找AI生成文章时,搜了下同类,也有很多标题是IntersectionObserver处理滚动加载的文章。有些是将功能放在列表内。我认为,IntersectionObserver作为滚动处理的方案,实际优势并不体现在他的性能,而是灵活性和通用性。有了这个组件,在不同的列表或者页面加载数据时,就不用考虑设计新的逻辑。IntersectionObserver会在页面维持一个实例,有一定的内存开销。实际在如uniapp这种有专门的scroll-view的场景,不见得是一个性能更好的方案。

现在回头看多年前的组件设计,其实缺少了一个很关键的ready状态。当前组件中,如果用户频繁的上下滚动,可能会重复请求接口。ready状态与loading一样维持Observer实例不销毁,但loading状态不会抛出load事件,这样就能处理重复请求的问题。这里仅提供思路,就不做更正了。

相关推荐
艾小码5 小时前
手把手教你实现一个EventEmitter,彻底告别复杂事件管理!
前端·javascript·node.js
Jedi Hongbin8 小时前
Three.js shader内置矩阵注入
前端·javascript·three.js
掘金安东尼9 小时前
Node.js 如何在 2025 年挤压 I/O 性能
前端·javascript·github
得物技术9 小时前
前端日志回捞系统的性能优化实践|得物技术
前端·javascript·性能优化
ZKshun9 小时前
[ 前端JavaScript的事件流机制 ] - 事件捕获、冒泡及委托原理
javascript
薛定谔的算法9 小时前
JavaScript栈的实现与应用:从基础到实战
前端·javascript·算法
魔云连洲10 小时前
React中的合成事件
前端·javascript·react.js
唐•苏凯11 小时前
ArcGIS Pro 遇到严重的应用程序错误而无法启动
开发语言·javascript·ecmascript
萌萌哒草头将军11 小时前
🚀🚀🚀 Oxc 恶意扩展警告;Rolldown 放弃 CJS 支持;Vite 发布两个漏洞补丁版本;Rslib v0.13 支持 ts-go
前端·javascript·vue.js