在移动端应用开发中,一个优秀的"回到顶部"组件不仅能提升用户体验,还体现了前端工程师对细节的把控能力。本文将深入解析一个功能完备的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开发的多个关键技术点:
- 现代API运用 :使用
IntersectionObserver
替代传统scroll监听 - 触摸交互处理:完善的触摸事件处理和拖拽逻辑
- 动画效果实现 :自定义缓动函数和
requestAnimationFrame
动画 4. - 性能优化:防抖、资源释放、CSS优化等手段 5.
- 用户体验:边界约束、状态反馈、视觉设计等细节