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: none和user-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. 用户体验:边界约束、状态反馈、视觉设计等细节
相关推荐
用户461169657286 分钟前
网站 HTTPS 不稳定?证书排查指南
前端
n876518 分钟前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
keyipatience19 分钟前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
xifangge20251 小时前
基于 JavaScript 实现 CNC 数控切削参数与螺纹公差纯前端核算引擎
开发语言·前端·javascript
wangruofeng1 小时前
2026 Web 技术选型指南,AI 时代如何选择适合项目的技术栈
前端·aigc·ai编程
honkun61 小时前
vue 表格组件 vxe-table 修改滚动条样式,修改滚动条样式,统一宽度、颜色与自动隐藏
前端·javascript·vue.js
爱勇宝2 小时前
研究数字孪生的这半年,做了个 Demo,也有了一些新想法
前端·程序员
不可能片场2 小时前
工程实践 一个缓存把选片从十分钟压到一分钟
前端·electron
前端小万2 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试
FRing032 小时前
JavaScript入门到精通二十四(正则表达式、元字符、修饰符)
开发语言·前端·javascript·正则表达式