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. 用户体验:边界约束、状态反馈、视觉设计等细节
相关推荐
IT_陈寒15 分钟前
SpringBoot性能翻倍秘籍:从自动配置到JVM调优的7个实战技巧
前端·人工智能·后端
叮咚前端27 分钟前
vue3笔记
前端·javascript·笔记
薛定谔的算法43 分钟前
面试官问箭头函数和普通函数的区别?这才是面试官最想听到的
前端·javascript·面试
pepedd86444 分钟前
AI Coding 最佳实践-从零到一全栈项目编写
前端·aigc·trae
砂糖橘加盐44 分钟前
非 AI 时代前端是如何设计一个组件的
前端·javascript·vue.js
艾小码1 小时前
告别JavaScript类型转换的坑:从隐式陷阱到显式安全指南
前端·javascript
LEAFF1 小时前
性能优化工具Lighthouse操作指南
前端
Cache技术分享1 小时前
176. Java 注释 - 类型注释和可插入类型系统
前端·后端
粥里有勺糖1 小时前
视野修炼-技术周刊第125期 | nano-banana
前端·github·aigc
菠萝+冰1 小时前
手机上访问你电脑上的前端项目
前端·智能手机