当前位置: 首页 > news >正文

Vue3下拉刷新组件实战:从零封装到全局注册(附完整代码)

Vue3下拉刷新组件实战:从零封装到全局注册(附完整代码)

移动端开发中,下拉刷新是提升用户体验的关键功能之一。在Vue3生态中,我们可以利用Composition API和新的响应式系统,构建一个高性能、可复用的下拉刷新组件。本文将带你从零开始,逐步实现一个完整的下拉刷新解决方案,最终将其封装为全局组件,方便在项目中任意位置调用。

1. 基础实现:触摸事件与动画效果

下拉刷新的核心逻辑是监听触摸事件,根据手指移动距离控制元素位移。我们先实现最基础的版本:

<template> <div class="wrap" ref="freshContainer" @touchstart="handleStart" @touchmove="handleMove" @touchend="handleEnd"> <div class="hint-text" v-if="distance > 0"> 下拉刷新 </div> <slot></slot> </div> </template> <script setup> import { ref } from 'vue' const startY = ref(0) const distance = ref(0) const freshContainer = ref(null) const handleStart = (e) => { startY.value = e.touches[0].pageY } const handleMove = (e) => { const currentY = e.touches[0].pageY distance.value = Math.floor(currentY - startY.value) freshContainer.value.style.transform = `translateY(${distance.value}px)` } const handleEnd = () => { distance.value = 0 freshContainer.value.style.transform = 'translateY(0)' } </script> <style scoped> .wrap { position: relative; } .hint-text { text-align: center; color: #999; padding: 10px 0; } </style>

这个基础版本实现了:

  • 触摸开始记录初始位置
  • 移动时计算位移并应用transform
  • 释放时复位元素位置

2. 体验优化:限制位移与加载状态

基础版本存在几个问题:没有位移限制、缺少加载状态反馈。我们来优化这些体验细节:

<template> <div :class="['wrap', { 'animate': isAnimating }]" ref="freshContainer" @touchstart="handleStart" @touchmove="handleMove" @touchend="handleEnd"> <div class="hint-text" v-if="distance > 0 && !isLoading"> {{ distance >= threshold ? '释放刷新' : '下拉刷新' }} </div> <div class="loading-text" v-if="isLoading"> 加载中... </div> <slot></slot> </div> </template> <script setup> import { ref } from 'vue' const threshold = 80 // 触发刷新的阈值 const startY = ref(0) const distance = ref(0) const isAnimating = ref(false) const isLoading = ref(false) const freshContainer = ref(null) const handleStart = (e) => { if (isLoading.value) return startY.value = e.touches[0].pageY isAnimating.value = false } const handleMove = (e) => { if (isLoading.value) return const currentY = e.touches[0].pageY distance.value = Math.min(currentY - startY.value, threshold * 1.5) if (distance.value > 0) { freshContainer.value.style.transform = `translateY(${distance.value}px)` } } const handleEnd = () => { if (isLoading.value) return isAnimating.value = true freshContainer.value.style.transform = 'translateY(0)' if (distance.value >= threshold) { isLoading.value = true // 触发刷新逻辑 } distance.value = 0 } </script> <style scoped> .wrap { position: relative; transition: none; } .wrap.animate { transition: transform 0.3s ease-out; } .hint-text, .loading-text { text-align: center; color: #999; padding: 10px 0; } </style>

优化点包括:

  • 添加位移阈值限制
  • 区分下拉和释放提示
  • 平滑的复位动画
  • 加载状态反馈

3. 组件通信:v-model与事件

为了让父组件控制加载状态和响应刷新事件,我们需要实现组件通信:

<template> <div :class="['wrap', { 'animate': isAnimating }]" ref="freshContainer" @touchstart="handleStart" @touchmove="handleMove" @touchend="handleEnd"> <div class="hint-text" v-if="distance > 0 && !modelValue"> {{ distance >= threshold ? '释放刷新' : '下拉刷新' }} </div> <div class="loading-text" v-if="modelValue"> 加载中... </div> <slot></slot> </div> </template> <script setup> import { ref, watch } from 'vue' const props = defineProps({ modelValue: { type: Boolean, default: false } }) const emit = defineEmits(['update:modelValue', 'refresh']) const threshold = 80 const startY = ref(0) const distance = ref(0) const isAnimating = ref(false) const freshContainer = ref(null) const handleStart = (e) => { if (props.modelValue) return startY.value = e.touches[0].pageY isAnimating.value = false } const handleMove = (e) => { if (props.modelValue) return const currentY = e.touches[0].pageY distance.value = Math.min(currentY - startY.value, threshold * 1.5) if (distance.value > 0) { freshContainer.value.style.transform = `translateY(${distance.value}px)` } } const handleEnd = () => { if (props.modelValue) return isAnimating.value = true freshContainer.value.style.transform = 'translateY(0)' if (distance.value >= threshold) { emit('update:modelValue', true) emit('refresh') } distance.value = 0 } watch(() => props.modelValue, (newVal) => { if (!newVal) { isAnimating.value = true freshContainer.value.style.transform = 'translateY(0)' } }) </script>

父组件使用示例:

<script setup> import { ref } from 'vue' import PullRefresh from './components/PullRefresh.vue' const items = ref([ { id: 1, text: '项目1' }, { id: 2, text: '项目2' } ]) const isLoading = ref(false) const handleRefresh = () => { setTimeout(() => { // 模拟数据加载 items.value.push({ id: items.value.length + 1, text: `项目${items.value.length + 1}` }) isLoading.value = false }, 1500) } </script> <template> <PullRefresh v-model="isLoading" @refresh="handleRefresh"> <div v-for="item in items" :key="item.id" class="item"> {{ item.text }} </div> </PullRefresh> </template> <style> .item { padding: 12px; border-bottom: 1px solid #eee; } </style>

4. 全局注册与插件化

为了在整个项目中方便使用,我们可以将组件注册为全局组件:

// src/components/PullRefresh/index.js import PullRefresh from './PullRefresh.vue' export default { install(app) { app.component('PullRefresh', PullRefresh) } }

然后在main.js中注册:

import { createApp } from 'vue' import App from './App.vue' import PullRefresh from '@/components/PullRefresh' const app = createApp(App) app.use(PullRefresh) app.mount('#app')

现在可以在任何组件中直接使用:

<template> <PullRefresh v-model="loading" @refresh="fetchData"> <!-- 内容 --> </PullRefresh> </template>

5. 高级功能扩展

自定义提示内容

通过插槽允许自定义提示内容:

<template> <div :class="['wrap', { 'animate': isAnimating }]" ref="freshContainer" @touchstart="handleStart" @touchmove="handleMove" @touchend="handleEnd"> <div v-if="distance > 0 && !modelValue" class="hint-area"> <slot name="hint" :distance="distance" :threshold="threshold"> <div class="hint-text"> {{ distance >= threshold ? '释放刷新' : '下拉刷新' }} </div> </slot> </div> <div v-if="modelValue" class="loading-area"> <slot name="loading"> <div class="loading-text"> 加载中... </div> </slot> </div> <slot></slot> </div> </template>

使用示例:

<PullRefresh v-model="loading" @refresh="fetchData"> <template #hint="{ distance, threshold }"> <div class="custom-hint"> 下拉进度: {{ Math.min(100, Math.floor(distance / threshold * 100)) }}% </div> </template> <template #loading> <div class="custom-loading"> <span class="spinner"></span> 数据加载中... </div> </template> <!-- 内容 --> </PullRefresh>

性能优化

对于长列表,可以添加防抖和节流:

import { throttle } from 'lodash-es' const handleMove = throttle((e) => { if (props.modelValue) return const currentY = e.touches[0].pageY distance.value = Math.min(currentY - startY.value, threshold * 1.5) if (distance.value > 0) { freshContainer.value.style.transform = `translateY(${distance.value}px)` } }, 16) // 约60fps

类型安全(TypeScript支持)

为组件添加类型定义:

// types/pull-refresh.d.ts import type { Ref } from 'vue' export interface PullRefreshProps { modelValue: boolean threshold?: number disabled?: boolean } export interface PullRefreshEmits { (e: 'update:modelValue', value: boolean): void (e: 'refresh'): void } export interface PullRefreshSlots { default?: () => VNode[] hint?: (props: { distance: number, threshold: number }) => VNode[] loading?: () => VNode[] }

然后在组件中使用:

<script setup lang="ts"> import type { Ref } from 'vue' interface Props { modelValue: boolean threshold?: number } const props = withDefaults(defineProps<Props>(), { threshold: 80 }) const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void (e: 'refresh'): void }>() </script>

6. 最佳实践与常见问题

移动端适配注意事项

  1. 防止页面滚动冲突

    const handleMove = (e) => { if (props.modelValue) return const currentY = e.touches[0].pageY distance.value = Math.min(currentY - startY.value, threshold * 1.5) if (distance.value > 0) { e.preventDefault() // 阻止默认滚动行为 freshContainer.value.style.transform = `translateY(${distance.value}px)` } }
  2. 兼容性处理

    .wrap { -webkit-overflow-scrolling: touch; overscroll-behavior: contain; }

常见问题解决

问题1:在iOS上滑动不流畅
解决方案:添加CSS硬件加速

.wrap { transform: translateZ(0); }

问题2:与页面其他滚动区域冲突
解决方案:检查滚动容器

const canPullRefresh = computed(() => { return window.scrollY <= 0 }) const handleMove = (e) => { if (!canPullRefresh.value) return // ... }

问题3:快速滑动时出现闪烁
解决方案:添加过渡结束监听

const handleTransitionEnd = () => { isAnimating.value = false } onMounted(() => { freshContainer.value.addEventListener('transitionend', handleTransitionEnd) }) onBeforeUnmount(() => { freshContainer.value?.removeEventListener('transitionend', handleTransitionEnd) })

7. 完整代码实现

以下是经过优化的完整组件代码:

<template> <div :class="['pull-refresh', { 'animating': isAnimating, 'disabled': disabled }]" ref="container" @touchstart="handleStart" @touchmove="handleMove" @touchend="handleEnd" @transitionend="handleTransitionEnd"> <div class="control-area"> <slot name="hint" :distance="distance" :threshold="threshold" :isLoading="modelValue"> <div v-if="distance > 0 && !modelValue" class="default-hint"> <svg class="arrow" :style="{ transform: `rotate(${distance >= threshold ? 180 : 0}deg)` }"> <path d="M7 10l5 5 5-5z"/> </svg> <span>{{ distance >= threshold ? '释放刷新' : '下拉刷新' }}</span> </div> <div v-if="modelValue" class="default-loading"> <svg class="spinner" viewBox="0 0 50 50"> <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"/> </svg> <span>加载中...</span> </div> </slot> </div> <slot></slot> </div> </template> <script setup> import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { throttle } from 'lodash-es' const props = defineProps({ modelValue: { type: Boolean, default: false }, threshold: { type: Number, default: 80 }, disabled: { type: Boolean, default: false } }) const emit = defineEmits(['update:modelValue', 'refresh']) const container = ref(null) const startY = ref(0) const distance = ref(0) const isAnimating = ref(false) const canRefresh = computed(() => !props.disabled && !props.modelValue) const handleStart = (e) => { if (!canRefresh.value) return startY.value = e.touches[0].pageY isAnimating.value = false } const handleMove = throttle((e) => { if (!canRefresh.value) return const currentY = e.touches[0].pageY const moveDistance = currentY - startY.value if (moveDistance > 0) { e.preventDefault() distance.value = Math.min(moveDistance, props.threshold * 1.5) container.value.style.transform = `translateY(${distance.value}px)` } }, 16) const handleEnd = () => { if (!canRefresh.value) return isAnimating.value = true container.value.style.transform = 'translateY(0)' if (distance.value >= props.threshold) { emit('update:modelValue', true) emit('refresh') } distance.value = 0 } const handleTransitionEnd = () => { isAnimating.value = false } onMounted(() => { container.value.addEventListener('transitionend', handleTransitionEnd) }) onBeforeUnmount(() => { container.value?.removeEventListener('transitionend', handleTransitionEnd) }) </script> <style scoped> .pull-refresh { position: relative; transition: none; } .pull-refresh.animating { transition: transform 0.3s ease-out; } .control-area { position: absolute; width: 100%; top: 0; left: 0; display: flex; justify-content: center; align-items: center; padding: 12px 0; } .default-hint, .default-loading { display: flex; align-items: center; gap: 8px; color: #666; font-size: 14px; } .arrow { width: 16px; height: 16px; transition: transform 0.2s; } .spinner { width: 20px; height: 20px; animation: rotate 1s linear infinite; } .spinner circle { stroke: currentColor; stroke-linecap: round; stroke-dasharray: 90, 150; stroke-dashoffset: 0; animation: dash 1.5s ease-in-out infinite; } @keyframes rotate { 100% { transform: rotate(360deg); } } @keyframes dash { 0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; } 50% { stroke-dasharray: 90, 150; stroke-dashoffset: -35; } 100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124; } } </style>
http://www.cnnetsun.cn/news/1562800.html

相关文章:

  • 公开信息整理|2026年3月29日:强对流预警、育儿补贴、医用级同位素量产与民生规则新变化
  • redis 部署方式(分布式)
  • 开源模拟器测试方法论:mGBA的准确性与稳定性保障实践
  • CVAT标注工具:5分钟搭建专业级计算机视觉数据标注平台
  • OpenClaw模型微调:提升Qwen3.5-4B-Claude特定任务准确率
  • 基于灰狼优化算法优化随机森林算法(GWO - RF)的数据分类预测
  • 3步让模糊视频变高清:AI超分工具实战指南
  • 807-本地账号密码管理工具
  • 从PID调参到系统建模:一个嵌入式工程师的自动控制原理实战复盘
  • 三步掌握HiGHS线性优化求解器:从入门到实战
  • Namesilo域名如何快速接入Cloudflare?5分钟搞定DNS解析迁移(附常见错误修复)
  • OpenRocket开源火箭设计工具:从仿真到实践的完整解决方案
  • 计算机毕业设计springboot校园志愿者管理系统的设计与实现 基于SpringBoot的高校义工服务智能管理平台研发 SpringBoot框架下大学生志愿服务信息化系统开发
  • 解决微信网页版访问难题:wechat-need-web的创新方案
  • 150T液压机设计全套图纸
  • 别急着编译!vLLM CPU版部署Qwen2,先搞懂这3个环境检测的‘潜规则’
  • [特殊字符] 实战记录:在 Rocky Linux 9.6 上“强行”离线安装 MongoDB 4.4.12
  • hadoop+spark+hive地铁智慧交通 地铁交通客流量预测系统 交通数据 地铁运营数据 交通轨道数据 可视化大屏
  • 面向对象编程基础:类与对象
  • ESXi主机添加必看:解决vCenter Server版本不兼容和HA报警的5个技巧
  • YOLOv9训练实战:用官方镜像快速训练自定义数据集
  • RexUniNLU效果展示:短视频弹幕‘求资源’‘打假’‘催更’等社区意图零样本识别
  • Vue3+Vite打包报错:Rollup failed to resolve import
  • GHelper终极教程:华硕笔记本性能优化完整指南,告别Armoury Crate臃肿体验
  • 985研究生研究:基于Comsol的裂隙岩体热-流-固耦合数值模拟建模技术,探索地热开采与超临...
  • 1.0 C#工控实战:多USB扫码枪数据精准采集与PLC通信方案
  • AppleALC终极解决方案:黑苹果音频兼容性完整指南
  • 告别杂乱布局!用PyVis的BarnesHut算法优化你的Neo4j知识图谱可视化
  • 保姆级教程:用POCO的NotificationQueue在C++里轻松玩转多线程任务队列
  • 别再死记硬背了!用这5个Thymeleaf实战小项目,彻底搞懂SpringBoot模板引擎