Vue3 + TypeScript + Better-Scroll 万能上拉下拉组件

大家好,我是鱼樱!!!

关注公众号【鱼樱AI实验室】持续每天分享更多前端和AI辅助前端编码新知识~~喜欢的就一起学反正开源至上,无所谓被诋毁被喷被质疑文章没有价值~

一个城市淘汰的自由职业-农村前端程序员(虽然不靠代码挣钱,写文章就是为爱发电),兼职远程上班目前!!!热心坚持分享~~~

下面是一个基于 Vue3、TypeScript 和 better-scroll 封装的万能上拉下拉组件,使用 <script setup> 语法糖实现。

组件代码 (PullRefresh.vue)

html 复制代码
<template>
  <div class="pull-refresh-container" ref="wrapperRef">
    <div class="pull-refresh-content">
      <!-- 下拉刷新提示区域 -->
      <div class="pull-down-tip" v-if="props.enablePullDown">
        <template v-if="pullDownStatus === 'pulling'">
          <slot name="pulling">
            <div class="tip-text">下拉刷新</div>
          </slot>
        </template>
        <template v-else-if="pullDownStatus === 'loosing'">
          <slot name="loosing">
            <div class="tip-text">释放刷新</div>
          </slot>
        </template>
        <template v-else-if="pullDownStatus === 'loading'">
          <slot name="loading">
            <div class="tip-text">
              <span class="loading-icon"></span>
              刷新中...
            </div>
          </slot>
        </template>
      </div>

      <!-- 主内容区域 -->
      <slot></slot>

      <!-- 上拉加载提示区域 -->
      <div class="pull-up-tip" v-if="props.enablePullUp">
        <template v-if="!isFinished">
          <slot name="loadingMore" v-if="pullUpStatus === 'loading'">
            <div class="tip-text">
              <span class="loading-icon"></span>
              加载中...
            </div>
          </slot>
        </template>
        <template v-else>
          <slot name="finished">
            <div class="tip-text">没有更多数据了</div>
          </slot>
        </template>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import BScroll from '@better-scroll/core'
import Pulldown from '@better-scroll/pull-down'
import Pullup from '@better-scroll/pull-up'

// 注册插件
BScroll.use(Pulldown)
BScroll.use(Pullup)

// 下拉状态类型
type PullDownStatus = 'idle' | 'pulling' | 'loosing' | 'loading'
// 上拉状态类型
type PullUpStatus = 'idle' | 'loading'

// 组件属性
interface Props {
  /**
   * 是否开启下拉刷新功能
   * @default true
   */
  enablePullDown?: boolean
  
  /**
   * 是否开启上拉加载功能
   * @default true
   */
  enablePullUp?: boolean
  
  /**
   * 下拉刷新阈值(触发刷新的下拉距离)
   * @default 60
   */
  pullDownRefreshThreshold?: number
  
  /**
   * 回弹动画时长(ms)
   * @default 300
   */
  bounceTime?: number
  
  /**
   * 下拉刷新停留时间(ms),-1 表示不自动回弹
   * @default 600
   */
  pullDownStayTime?: number
  
  /**
   * 上拉加载阈值
   * @default 0
   */
  pullUpLoadThreshold?: number
  
  /**
   * 加载数据是否完成
   * @default false
   */
  finished?: boolean
  
  /**
   * 是否立即触发下拉刷新
   * @default false
   */
  immediateRefresh?: boolean
  
  /**
   * Better-Scroll 配置项
   */
  bsOptions?: Record<string, any>
}

// 事件
interface Emits {
  /**
   * 下拉刷新时触发
   */
  (e: 'refresh'): void
  
  /**
   * 上拉加载更多时触发
   */
  (e: 'loadMore'): void
}

// 默认值处理
const props = withDefaults(defineProps<Props>(), {
  enablePullDown: true,
  enablePullUp: true,
  pullDownRefreshThreshold: 60,
  bounceTime: 300,
  pullDownStayTime: 600,
  pullUpLoadThreshold: 0,
  finished: false,
  immediateRefresh: false,
  bsOptions: () => ({})
})

const emit = defineEmits<Emits>()

// 组件内部状态
const wrapperRef = ref<HTMLElement | null>(null)
const bs = ref<BScroll | null>(null)
const pullDownStatus = ref<PullDownStatus>('idle')
const pullUpStatus = ref<PullUpStatus>('idle')
const isFinished = ref(props.finished)

// 监听 finished 属性
watch(() => props.finished, (newVal) => {
  isFinished.value = newVal
})

// 初始化 BetterScroll
onMounted(() => {
  if (!wrapperRef.value) return
  
  // 合并基础配置和用户配置
  const options = {
    click: true,
    probeType: 3,
    scrollY: true,
    ...props.bsOptions
  }

  // 添加下拉刷新配置
  if (props.enablePullDown) {
    Object.assign(options, {
      pullDownRefresh: {
        threshold: props.pullDownRefreshThreshold,
        stop: props.pullDownRefreshThreshold,
      }
    })
  }

  // 添加上拉加载配置
  if (props.enablePullUp) {
    Object.assign(options, {
      pullUpLoad: {
        threshold: props.pullUpLoadThreshold
      }
    })
  }

  // 创建 BetterScroll 实例
  bs.value = new BScroll(wrapperRef.value, options)

  // 下拉刷新相关事件处理
  if (props.enablePullDown) {
    bs.value.on('pullingDown', handlePullDown)
    bs.value.on('scroll', handleScroll)
  }

  // 上拉加载相关事件处理
  if (props.enablePullUp) {
    bs.value.on('pullingUp', handlePullUp)
  }

  // 是否立即触发刷新
  if (props.immediateRefresh) {
    setTimeout(() => {
      refresh()
    }, 10)
  }
})

// 销毁实例
onBeforeUnmount(() => {
  bs.value?.destroy()
})

// 下拉过程中的状态处理
const handleScroll = (pos: { x: number, y: number }) => {
  if (!props.enablePullDown || pullDownStatus.value === 'loading') return
  
  if (pos.y > 0 && pos.y < props.pullDownRefreshThreshold) {
    pullDownStatus.value = 'pulling'
  } else if (pos.y >= props.pullDownRefreshThreshold) {
    pullDownStatus.value = 'loosing'
  } else {
    pullDownStatus.value = 'idle'
  }
}

// 处理下拉刷新
const handlePullDown = async () => {
  if (pullDownStatus.value === 'loading') return
  
  pullDownStatus.value = 'loading'
  emit('refresh')
  
  // 延迟结束刷新状态
  if (props.pullDownStayTime > 0) {
    await new Promise(resolve => setTimeout(resolve, props.pullDownStayTime))
    finishRefresh()
  }
}

// 处理上拉加载
const handlePullUp = async () => {
  if (isFinished.value || pullUpStatus.value === 'loading') {
    bs.value?.finishPullUp()
    return
  }
  
  pullUpStatus.value = 'loading'
  emit('loadMore')
}

// 完成下拉刷新
const finishRefresh = () => {
  pullDownStatus.value = 'idle'
  bs.value?.finishPullDown()
  bs.value?.refresh()
}

// 完成上拉加载
const finishLoadMore = () => {
  pullUpStatus.value = 'idle'
  bs.value?.finishPullUp()
  bs.value?.refresh()
}

// 手动触发下拉刷新
const refresh = () => {
  if (!bs.value || pullDownStatus.value === 'loading') return
  
  pullDownStatus.value = 'loading'
  bs.value.scrollTo(0, props.pullDownRefreshThreshold + 1, props.bounceTime)
  handlePullDown()
}

// 暴露给父组件的方法
defineExpose({
  /**
   * 完成下拉刷新
   */
  finishRefresh,
  
  /**
   * 完成上拉加载
   */
  finishLoadMore,
  
  /**
   * 手动触发下拉刷新
   */
  refresh,
  
  /**
   * 获取BScroll实例
   */
  getBScroll: () => bs.value
})
</script>

<style scoped>
.pull-refresh-container {
  height: 100%;
  width: 100%;
  overflow: hidden;
  position: relative;
}

.pull-refresh-content {
  position: relative;
}

.pull-down-tip,
.pull-up-tip {
  height: 50px;
  line-height: 50px;
  text-align: center;
  color: #969799;
}

.tip-text {
  font-size: 14px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.loading-icon {
  display: inline-block;
  width: 16px;
  height: 16px;
  margin-right: 5px;
  border: 2px solid #ccc;
  border-top-color: #666;
  border-radius: 50%;
  animation: loading-rotate 0.8s linear infinite;
}

@keyframes loading-rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>

使用示例

html 复制代码
<template>
  <div class="list-container">
    <h1>下拉刷新/上拉加载示例</h1>
    
    <PullRefresh
      ref="pullRefreshRef"
      @refresh="onRefresh"
      @loadMore="onLoadMore"
      :finished="finished"
      :enablePullDown="true"
      :enablePullUp="true"
    >
      <div class="list">
        <div v-for="(item, index) in list" :key="index" class="list-item">
          {{ item }}
        </div>
      </div>
      
      <!-- 自定义下拉提示 -->
      <template #pulling>
        <div>👇 下拉刷新</div>
      </template>
      
      <template #loosing>
        <div>👆 释放立即刷新</div>
      </template>
      
      <template #loading>
        <div>🔄 刷新中...</div>
      </template>
      
      <!-- 自定义上拉提示 -->
      <template #loadingMore>
        <div>🔄 加载更多中...</div>
      </template>
      
      <template #finished>
        <div>🎉 已加载全部数据</div>
      </template>
    </PullRefresh>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import PullRefresh from './components/PullRefresh.vue'

const pullRefreshRef = ref<InstanceType<typeof PullRefresh> | null>(null)
const list = ref<string[]>([])
const finished = ref(false)
const page = ref(1)
const pageSize = 10

// 模拟获取数据
const fetchData = (p: number): Promise<string[]> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 模拟数据总量为 50 条
      if (p > 5) {
        resolve([])
      } else {
        const result = Array.from({ length: pageSize }, (_, i) => 
          `Item ${(p - 1) * pageSize + i + 1}`
        )
        resolve(result)
      }
    }, 1500) // 模拟网络请求延迟
  })
}

// 初始化数据
const initData = async () => {
  page.value = 1
  const data = await fetchData(page.value)
  list.value = data
  finished.value = data.length < pageSize
}

// 刷新事件处理
const onRefresh = async () => {
  try {
    await initData()
  } finally {
    // 不管成功失败都要结束刷新状态
    pullRefreshRef.value?.finishRefresh()
  }
}

// 加载更多事件处理
const onLoadMore = async () => {
  try {
    page.value++
    const data = await fetchData(page.value)
    
    if (data.length > 0) {
      list.value.push(...data)
    }
    
    // 判断是否已加载全部数据
    finished.value = data.length < pageSize
  } finally {
    // 不管成功失败都要结束加载状态
    pullRefreshRef.value?.finishLoadMore()
  }
}

// 初始化
initData()
</script>

<style scoped>
.list-container {
  height: 100vh;
  width: 100%;
}

.list {
  padding: 10px;
}

.list-item {
  margin-bottom: 10px;
  height: 60px;
  line-height: 60px;
  text-align: center;
  background-color: #f5f5f5;
  border-radius: 8px;
}
</style>

核心功能说明

这个上拉下拉组件具有以下主要特性

  1. 高度可配置:通过props提供多种配置选项,包括是否启用上拉/下拉、阈值设置等
  2. 完全类型化:使用TypeScript提供完整的类型定义,提高代码健壮性
  3. 自定义插槽:支持自定义各种状态下的提示内容
  4. 事件驱动 :提供refreshloadMore事件处理上拉下拉操作
  5. 手动控制API :暴露finishRefreshfinishLoadMore等方法供外部调用

使用注意事项

  1. 安装依赖:需要安装以下依赖:

    bash 复制代码
    npm install @better-scroll/core @better-scroll/pull-down @better-scroll/pull-up
  2. 容器高度:使用组件的父容器必须设置固定高度,否则滚动将无法正常工作

  3. 结束状态 :在数据加载完成后,必须调用finishRefreshfinishLoadMore方法来结束加载状态

  4. 性能优化:对于大列表,建议配合虚拟列表插件使用,以提升渲染性能

这个组件通过合理的抽象和接口设计,可以适用于大多数列表加载场景,满足各种自定义需求。

相关推荐
乐闻x6 分钟前
React 如何实现组件懒加载以及懒加载的底层机制
前端·react.js·性能优化·前端框架
小鱼冻干8 分钟前
http模块
前端·node.js
悬炫8 分钟前
闭包、作用域与作用域链:概念与应用
前端·javascript
jiaHang9 分钟前
小程序中通过IntersectionObserver实现曝光统计
前端·微信小程序
打野赵怀真31 分钟前
前端资源发布路径怎么实现非覆盖式发布(平滑升级)?
前端·javascript
顾林海40 分钟前
Flutter Dart 流程控制语句详解
android·前端·flutter
tech_zjf41 分钟前
装饰器:给你的代码穿上品如的衣服
前端·typescript·代码规范
xiejianxin52043 分钟前
如何封装axios和取消重复请求
前端·javascript
parade岁月43 分钟前
从学习ts的三斜线指令到项目中声明类型的最佳实践
前端·javascript
狼性书生1 小时前
electron + vue3 + vite 渲染进程与渲染进程之间的消息端口通信
前端·javascript·electron