Vue移动端"回到顶部"组件深度解析:拖拽、动画与性能优化实践

在移动端应用开发中,一个优秀的"回到顶部"组件不仅能提升用户体验,还体现了前端工程师对细节的把控能力。本文将深入解析一个功能完备的Vue移动端"回到顶部"组件,涵盖拖拽交互、动画效果、性能优化等多个技术要点。 在移动端会有一个痛点,就是拖拽的dom元素触发不了点击事件,这个组件完美的解决了这个难题。

组件概述

这是一个基于Vue 3 Composition API开发的移动端"回到顶部"组件,支持拖拽定位、智能显示/隐藏、平滑动画等特性。组件使用了vant UI库的Icon组件,并结合原生DOM操作实现核心功能。

核心功能解析

1. 智能显示/隐藏机制

组件通过IntersectionObserver API实现智能显示/隐藏,这是现代浏览器推荐的高性能方案:

javascript 复制代码
const isFirstScreen = ref(true) 
let observer = null 
onMounted(() => {
  // 移动端更灵敏的检测方式
  const sentinel = document.createElement('div')
  sentinel.className = 'first-screen-sentinel'
  document.querySelector("#qisList").appendChild(sentinel)
  
  sentinel.style.position = 'absolute'
  sentinel.style.top = '50vh' // 移动端提前更多
  sentinel.style.height = '1px'
  sentinel.style.width = '100%'
  sentinel.style.pointerEvents = 'none'
  // 添加以下样式避免影响布局
  sentinel.style.visibility = 'hidden'
  
  setTimeout(() => {
    observer = new IntersectionObserver((entries) => {
      if (isRefreshing.value) return
      clearTimeout(debounceTimer)
      debounceTimer = setTimeout(() => {
        isFirstScreen.value = entries[0].isIntersecting
        console.log("isFirstScreen.value", isFirstScreen.value)
      }, 100)
    }, {
      threshold: 0,
      rootMargin: '0px 0px 0px 0px' // 移动端更大的触发区域
    })
    
    observer.observe(sentinel)
  }, 1000)
  
  // 加载保存的位置
   const savedPos = localStorage.getItem('appBackToTopPos')
   if (savedPos) {
     const pos = JSON.parse(savedPos)
     position.value = pos
   }
})

这种实现方式相比传统的scroll事件监听具有更好的性能表现,避免了频繁的重排重绘。

2. 拖拽交互实现

组件支持用户自定义位置,通过触摸事件实现拖拽功能:

javascript 复制代码
const handleTouchStart = (e) => {
  if (e.touches.length > 1) return // 忽略多指操作
  
  const touch = e.touches[0]
  touchId.value = touch.identifier
  isDragging.value = false // 初始状态设为false
  
  dragStartPos.value = {
    x: touch.clientX - position.value.x,
    y: touch.clientY - position.value.y,
    startX: touch.clientX,
    startY: touch.clientY,
    time: Date.now()
  }
  
  dragEl.value.style.transition = 'none'
}

通过记录触摸起始位置和元素当前位置的差值,实现精准的拖拽定位。同时采用5px的阈值判断,区分点击和拖拽操作。

3. 边界检测与位置约束

在拖拽过程中,组件对元素位置进行了边界约束,确保不会拖出可视区域:

javascript 复制代码
// 边界检查 
const boundedX = Math.max(0, Math.min(moveX, window.innerWidth - dragEl.value.offsetWidth))
const boundedY = Math.max(70, Math.min(moveY, window.innerHeight - dragEl.value.offsetHeight))
    
position.value = { x: boundedX, y: boundedY }

Y轴最小值设为70px,避免与顶部状态栏重叠,提升了用户体验。

4. 平滑滚动动画

组件使用requestAnimationFrame配合缓动函数实现平滑滚动:

javascript 复制代码
const scrollToTop = () => {
  // 使用更流畅的滚动方式
  const start = document.querySelector("#qisList").scrollTop
  const startTime = performance.now()
  
  const animateScroll = (time) => {
    const elapsed = time - startTime
    const progress = Math.min(elapsed / 300, 1) // 300ms动画时间
    const ease = easeOutCubic(progress)
    
    document.querySelector("#qisList").scrollTo(0, start * (1 - ease))
    
    if (progress < 1) {
      requestAnimationFrame(animateScroll)
    }
  }
  
  requestAnimationFrame(animateScroll)
}

// 缓动函数
const easeOutCubic = (t) => {
  return 1 - Math.pow(1 - t, 3)
}

相比直接使用scrollTo({top: 0, behavior: 'smooth'}),自定义动画提供了更好的控制力和一致性。

性能优化策略

1. 防抖处理

IntersectionObserver的回调进行了防抖处理,避免频繁的状态更新:

javascript 复制代码
clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { isFirstScreen.value = entries[0].isIntersecting }, 100) 

2. 资源释放

在组件销毁时正确释放观察器和定时器:

javascript 复制代码
onUnmounted(() => { if (observer) observer.disconnect() clearTimeout(debounceTimer) })

3. CSS优化

使用touch-action: noneuser-select: none避免不必要的浏览器行为,提升交互响应速度。

组件扩展性

组件通过defineExpose暴露了setRefreshing方法,支持外部控制组件状态:

javascript 复制代码
defineExpose({
  setRefreshing: (refreshing) => {
    isRefreshing.value = refreshing
    // 如果开始刷新,暂时隐藏回到顶部按钮
    if (refreshing) {
      isFirstScreen.value = true
    }
  }
})

这使得组件能与下拉刷新等其他功能良好协作。

样式设计亮点

组件采用了现代化的设计语言:

css 复制代码
.back-to-top { position: fixed; width: 40px; height: 40px; background: rgb(0, 78, 255); border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); display: flex; justify-content: center; align-items: center; cursor: pointer; z-index: 1000; touch-action: none; user-select: none; transition: all 0.3s ease; } 

圆角设计、阴影效果和过渡动画营造了良好的视觉体验,:active伪类提供了即时的交互反馈。

完整代码

javascript 复制代码
<template>
  <div
    v-if="!isRefreshing"
    ref="dragEl"
    class="back-to-top"
    :style="{opacity: isFirstScreen ? 0 : 1, ...positionStyle}"
    @touchstart="handleTouchStart"
    @touchmove.passive="handleTouchMove"
    @touchend="handleTouchEnd"
    @click="handleClick"
  >
    <Icon name="back-top" color="#fff" size="30px" />
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, computed, defineExpose } from 'vue'
import {Icon} from "vant"

defineExpose({
  setRefreshing: (refreshing) => {
    isRefreshing.value = refreshing
    // 如果开始刷新,暂时隐藏回到顶部按钮
    if (refreshing) {
      isFirstScreen.value = true
    }
  }
})

// 第一屏检测
const isFirstScreen = ref(true)
let observer = null
let debounceTimer = null
const isRefreshing = ref(false)

onMounted(() => {
  // 移动端更灵敏的检测方式
  const sentinel = document.createElement('div')
  sentinel.className = 'first-screen-sentinel'
  document.querySelector("#qisList").appendChild(sentinel)
  
  sentinel.style.position = 'absolute'
  sentinel.style.top = '50vh' // 移动端提前更多
  sentinel.style.height = '1px'
  sentinel.style.width = '100%'
  sentinel.style.pointerEvents = 'none'
  // 添加以下样式避免影响布局
  sentinel.style.visibility = 'hidden'
  
  setTimeout(() => {
    observer = new IntersectionObserver((entries) => {
      if (isRefreshing.value) return
      clearTimeout(debounceTimer)
      debounceTimer = setTimeout(() => {
        isFirstScreen.value = entries[0].isIntersecting
        console.log("isFirstScreen.value", isFirstScreen.value)
      }, 100)
    }, {
      threshold: 0,
      rootMargin: '0px 0px 0px 0px' // 移动端更大的触发区域
    })
    
    observer.observe(sentinel)
  }, 1000)
  
  // 加载保存的位置
//   const savedPos = localStorage.getItem('appBackToTopPos')
//   if (savedPos) {
//     const pos = JSON.parse(savedPos)
//     position.value = pos
//   }
})

onUnmounted(() => {
  if (observer) observer.disconnect()
  clearTimeout(debounceTimer)
})

// 位置状态
const position = ref({
  x: window.innerWidth - 70,
  y: window.innerHeight - 70
})

const positionStyle = computed(() => ({
  left: `${position.value.x}px`,
  top: `${position.value.y}px`
}))

// 拖动逻辑
const dragEl = ref(null)
const isDragging = ref(false)
const dragStartPos = ref({ x: 0, y: 0 })
const touchId = ref(null)

const handleTouchStart = (e) => {
  if (e.touches.length > 1) return // 忽略多指操作
  
  const touch = e.touches[0]
  touchId.value = touch.identifier
  isDragging.value = false // 初始状态设为false
  
  dragStartPos.value = {
    x: touch.clientX - position.value.x,
    y: touch.clientY - position.value.y,
    startX: touch.clientX,
    startY: touch.clientY,
    time: Date.now()
  }
  
  dragEl.value.style.transition = 'none'
}

const handleTouchMove = (e) => {
  if ([null, undefined, ''].includes(touchId.value)) return
  
  const touch = Array.from(e.touches).find(t => t.identifier === touchId.value)
  if (!touch) return
  
  const moveX = touch.clientX - dragStartPos.value.x
  const moveY = touch.clientY - dragStartPos.value.y
  
  // 移动超过5px才认为是拖动
  if (!isDragging.value) {
    const dx = Math.abs(touch.clientX - dragStartPos.value.startX)
    const dy = Math.abs(touch.clientY - dragStartPos.value.startY)
    if (dx > 5 || dy > 5) {
      isDragging.value = true
    }
  }
  
  if (isDragging.value) {
    // 边界检查
    const boundedX = Math.max(0, Math.min(moveX, window.innerWidth - dragEl.value.offsetWidth))
    const boundedY = Math.max(70, Math.min(moveY, window.innerHeight - dragEl.value.offsetHeight))
    
    position.value = { x: boundedX, y: boundedY }
    // e.preventDefault() // 阻止滚动
  }
}

const handleTouchEnd = (e) => {
  if ([null, undefined, ''].includes(touchId.value)) return
  
  // 保存位置
//   localStorage.setItem('appBackToTopPos', JSON.stringify(position.value))
  
  dragEl.value.style.transition = 'all 0.3s ease'
  touchId.value = null
  
  // 如果是快速轻点且移动距离小,则认为是点击
  if (!isDragging.value && Date.now() - dragStartPos.value.time < 200) {
    handleClick()
  }
}

// 点击处理
const handleClick = () => {
  if (isDragging.value) return
  
  scrollToTop()
}

const scrollToTop = () => {
  // 使用更流畅的滚动方式
  const start = document.querySelector("#qisList").scrollTop
  const startTime = performance.now()
  
  const animateScroll = (time) => {
    const elapsed = time - startTime
    const progress = Math.min(elapsed / 300, 1) // 300ms动画时间
    const ease = easeOutCubic(progress)
    
    document.querySelector("#qisList").scrollTo(0, start * (1 - ease))
    
    if (progress < 1) {
      requestAnimationFrame(animateScroll)
    }
  }
  
  requestAnimationFrame(animateScroll)
}

// 缓动函数
const easeOutCubic = (t) => {
  return 1 - Math.pow(1 - t, 3)
}
</script>

<style scoped>
.back-to-top {
  position: fixed;
  width: 40px;
  height: 40px;
  background: rgb(0, 78, 255);
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  z-index: 1000;
  touch-action: none;
  user-select: none;
  transition: all 0.3s ease;
  color: #333;
  -webkit-tap-highlight-color: transparent;
}

.back-to-top:active {
  transform: scale(0.95);
  opacity: 0.9;
}
</style>

总结

这个"回到顶部"组件展示了移动端UI开发的多个关键技术点:

  1. 现代API运用 :使用IntersectionObserver替代传统scroll监听
  2. 触摸交互处理:完善的触摸事件处理和拖拽逻辑
  3. 动画效果实现 :自定义缓动函数和requestAnimationFrame动画 4.
  4. 性能优化:防抖、资源释放、CSS优化等手段 5.
  5. 用户体验:边界约束、状态反馈、视觉设计等细节
相关推荐
李剑一1 小时前
为了免受再来一刀的痛苦,我耗时两天开发了一款《提肛助手》
前端·vue.js·rust
红尘散仙1 小时前
使用 Tauri Plugin-Store 实现 Zustand 持久化与多窗口数据同步
前端·rust·electron
沙白猿1 小时前
npm启动项目报错“无法加载文件……”
前端·npm·node.js
tyro曹仓舒1 小时前
彻底讲透as const + keyof typeof
前端·typescript
蛋黄液1 小时前
【黑马程序员】后端Web基础--Maven基础和基础知识
前端·log4j·maven
睡不着的可乐2 小时前
uniapp 支付宝小程序 扩展组件 component 节点的class不生效
前端·微信小程序·支付宝
前端小书生2 小时前
React Router
前端·react.js
_大学牲2 小时前
Flutter Liquid Glass 🪟魔法指南:让你的界面闪耀光彩
前端·开源
Miss Stone2 小时前
css练习
前端·javascript·css
Nicholas682 小时前
flutter视频播放器video_player_avfoundation之FVPVideoPlayer(二)
前端