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. 性能优化:对于大列表,建议配合虚拟列表插件使用,以提升渲染性能

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

相关推荐
Qrun13 分钟前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp14 分钟前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.1 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl3 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫5 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友5 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理7 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻7 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
mapbar_front8 小时前
在职场生存中如何做个不好惹的人
前端
牧杉-惊蛰8 小时前
纯flex布局来写瀑布流
前端·javascript·css