外观
本文简单介绍 怎么制作一个可以将内部元素像图片一样拖拽查看,通过鼠标滚轮可以放大缩小的组件。
祖传开篇:作者水平有限,可能比较简陋,也或许有些错误,欢迎指正。
环境要求:
基本介绍
在 Vue 应用中,我们经常需要实现一些交互性强的 UI 组件,比如可拖拽、可缩放的容器。本文将介绍如何使用 Vue 3 和 TypeScript 实现一个功能相对完善的可拖拽缩放组件
特点
- 按住空格键可以拖动内容
- 支持鼠标滚轮缩放
- 拖动和缩放时内部元素不可操作
- 平滑的动画效果
- 支持自定义控制面板
环境要求
- node 18+
- vue 3.5+
- typescript 5.5+
- vite 5.0+
以上是虚假的要求,真实的要求:无
组件的基本结构
vue
<template>
<div ref="dragContainer" class="my-drag">
<!-- 控制面板 -->
<div class="drag-controls">
<button>重置视图</button>
<div class="drag-info">提示信息</div>
</div>
<!-- 内容容器 -->
<div ref="contentWrapper" class="content-wrapper">
<slot></slot>
</div>
</div>
</template>
组件由两个主要部分组成:
- 控制面板:包含重置按钮和提示信息
- 内容容器:通过插槽接收用户传入的内容
状态管理
使用 reactive 管理组件的状态:
ts
const state = reactive({
isSpacePressed: false, // 空格键是否按下
isDragging: false, // 是否正在拖动
startX: 0, // 拖动起始点X坐标
startY: 0, // 拖动起始点Y坐标
translateX: 0, // X轴平移量
translateY: 0, // Y轴平移量
scale: 1, // 缩放比例
minScale: 0.5, // 最小缩放比例
maxScale: 3, // 最大缩放比例
animationFrameId: 0, // 动画帧ID
currentX: 0, // 当前鼠标X坐标
currentY: 0, // 当前鼠标Y坐标
})
键盘事件处理
实现空格键按下和释放的事件处理:
ts
// 监听键盘事件
function handleKeyDown(e: KeyboardEvent) {
if (e.code === 'Space' && !state.isSpacePressed && isMouseInContainer.value) {
e.preventDefault()
state.isSpacePressed = true
if (dragContainer.value) {
dragContainer.value.style.cursor = 'grab'
}
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.code === 'Space') {
e.preventDefault()
state.isSpacePressed = false
if (dragContainer.value) {
dragContainer.value.style.cursor = 'default'
}
if (state.isDragging) {
state.isDragging = false
// 取消动画帧请求
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
state.animationFrameId = 0
}
}
}
}
具体实现原理
- 空格键按下时,设置 isSpacePressed 为 true,并设置拖动容器的 cursor 为 grab,表示可以拖动。
- 当空格键被按下时,组件会给内容容器添加一个特殊的 CSS 类 no-events
- pointer-events: none 的作用:
- 使元素及其子元素不再接收任何鼠标事件(点击、悬停、拖拽等)
- 鼠标事件会"穿透"这个元素,被下层元素捕获
- 元素视觉上仍然可见,但无法与之交互
- 当空格键按下且用户点击鼠标时,由于内部元素已经不接收鼠标事件,事件会被外层容器捕获
鼠标事件处理
拖拽实现
ts
// 当鼠标按下时
function handleMouseDown(e: MouseEvent) {
if (state.isSpacePressed) {
e.preventDefault()
state.isDragging = true
state.currentX = e.clientX
state.currentY = e.clientY
state.startX = e.clientX - state.translateX
state.startY = e.clientY - state.translateY
if (dragContainer.value) {
dragContainer.value.style.cursor = 'grabbing'
}
// 开始动画循环
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
}
state.animationFrameId = requestAnimationFrame(updateDragAnimation)
}
}
// 当鼠标意动时
function handleMouseMove(e: MouseEvent) {
if (state.isDragging && state.isSpacePressed) {
e.preventDefault()
// 只更新当前鼠标位置,实际变换在动画帧中处理
state.currentX = e.clientX
state.currentY = e.clientY
}
}
// 当松开鼠标按键时
function handleMouseUp() {
if (state.isDragging) {
state.isDragging = false
if (dragContainer.value) {
dragContainer.value.style.cursor = state.isSpacePressed ? 'grab' : 'default'
}
// 取消动画帧请求
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
state.animationFrameId = 0
}
}
}
动画更新
使用 requestAnimationFrame 实现平滑动画:
ts
// 动画帧更新函数
function updateDragAnimation() {
if (state.isDragging) {
// 添加防抖动逻辑
const newX = state.currentX - state.startX
const newY = state.currentY - state.startY
// 只有当位置变化超过阈值时才更新
if (Math.abs(newX - state.translateX) > 0.5 || Math.abs(newY - state.translateY) > 0.5) {
state.translateX = newX
state.translateY = newY
updateTransform()
}
state.animationFrameId = requestAnimationFrame(updateDragAnimation)
}
}
滚轮缩放实现
ts
// 处理滚轮缩放
function handleWheel(e: WheelEvent) {
if (state.isSpacePressed) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
const newScale = Math.max(state.minScale, Math.min(state.maxScale, state.scale + delta))
// 计算鼠标位置相对于容器的偏移
const rect = dragContainer.value?.getBoundingClientRect()
if (!rect) return
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
// 调整缩放中心点为鼠标位置
const scaleRatio = newScale / state.scale
const dx = mouseX - mouseX * scaleRatio
const dy = mouseY - mouseY * scaleRatio
state.translateX = state.translateX * scaleRatio + dx
state.translateY = state.translateY * scaleRatio + dy
state.scale = newScale
// 使用 requestAnimationFrame 更新变换
requestAnimationFrame(() => {
updateTransform()
})
}
}
重置与更新变换
ts
// 更新变换
function updateTransform() {
if (contentWrapper.value) {
contentWrapper.value.style.transform = `translate3d(${state.translateX}px, ${state.translateY}px, 0) scale(${state.scale})`
}
}
// 重置变换
function resetTransform() {
if (contentWrapper.value) {
contentWrapper.value.style.transition = 'transform 0.3s ease-out'
requestAnimationFrame(() => {
state.translateX = 0
state.translateY = 0
state.scale = 1
updateTransform()
setTimeout(() => {
if (contentWrapper.value) {
contentWrapper.value.style.transition = ''
}
}, 300)
})
}
}
注册和移除事件
ts
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// 初始化时应用硬件加速并确保初始位置正确
if (contentWrapper.value) {
contentWrapper.value.style.willChange = 'transform'
contentWrapper.value.style.transform = 'translate3d(0, 0, 0) scale(1)'
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
}
})
完整代码
最后将整合到一起,再加入一些可以自定义的插槽,补充一些样式。得到完整的代码:
vue
<template>
<div
ref="dragContainer"
class="my-drag"
@mousedown="handleMouseDown"
@wheel="handleWheel"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="drag-controls" v-if="isTip">
<slot name="tip">
<button @click="resetTransform" class="reset-btn">重置视图</button>
<div class="drag-info">按住空格键 + 鼠标拖动查看,滚轮缩放</div>
</slot>
</div>
<div ref="contentWrapper" class="content-wrapper" :class="{ 'no-events': state.isSpacePressed }">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, onMounted, onUnmounted, reactive} from 'vue'
const dragContainer = ref<HTMLElement | null>(null)
const contentWrapper = ref<HTMLElement | null>(null)
const isMouseInContainer = ref(false) // 新增:跟踪鼠标是否在容器内
const {isTip} = defineProps({
isTip: {
type: Boolean,
default: true,
},
})
const state = reactive({
isSpacePressed: false,
isDragging: false,
startX: 0,
startY: 0,
translateX: 0,
translateY: 0,
scale: 1,
minScale: 0.5,
maxScale: 3,
// 新增:动画帧请求ID
animationFrameId: 0,
// 新增:当前鼠标位置
currentX: 0,
currentY: 0,
})
// 新增:鼠标进入容器事件
function handleMouseEnter() {
isMouseInContainer.value = true
}
// 新增:鼠标离开容器事件
function handleMouseLeave() {
isMouseInContainer.value = false
// 如果鼠标离开容器,确保重置状态
if (state.isSpacePressed) {
state.isSpacePressed = false
state.isDragging = false
if (dragContainer.value) {
dragContainer.value.style.cursor = 'default'
}
}
}
// 监听键盘事件
function handleKeyDown(e: KeyboardEvent) {
if (e.code === 'Space' && !state.isSpacePressed && isMouseInContainer.value) {
e.preventDefault()
state.isSpacePressed = true
if (dragContainer.value) {
dragContainer.value.style.cursor = 'grab'
}
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.code === 'Space') {
e.preventDefault()
state.isSpacePressed = false
if (dragContainer.value) {
dragContainer.value.style.cursor = 'default'
}
if (state.isDragging) {
state.isDragging = false
// 取消动画帧请求
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
state.animationFrameId = 0
}
}
}
}
// 处理鼠标事件
function handleMouseDown(e: MouseEvent) {
if (state.isSpacePressed) {
e.preventDefault()
state.isDragging = true
state.currentX = e.clientX // 先设置当前位置
state.currentY = e.clientY
state.startX = e.clientX - state.translateX
state.startY = e.clientY - state.translateY
if (dragContainer.value) {
dragContainer.value.style.cursor = 'grabbing'
}
// 开始动画循环
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
}
state.animationFrameId = requestAnimationFrame(updateDragAnimation)
}
}
function handleMouseMove(e: MouseEvent) {
if (state.isDragging && state.isSpacePressed) {
e.preventDefault()
state.currentX = e.clientX
state.currentY = e.clientY
}
}
// 新增:动画帧更新函数
function updateDragAnimation() {
if (state.isDragging) {
// 添加防抖动逻辑
const newX = state.currentX - state.startX
const newY = state.currentY - state.startY
// 只有当位置变化超过阈值时才更新
if (Math.abs(newX - state.translateX) > 0.5 || Math.abs(newY - state.translateY) > 0.5) {
state.translateX = newX
state.translateY = newY
updateTransform()
}
state.animationFrameId = requestAnimationFrame(updateDragAnimation)
}
}
function handleMouseUp() {
if (state.isDragging) {
state.isDragging = false
if (dragContainer.value) {
dragContainer.value.style.cursor = state.isSpacePressed ? 'grab' : 'default'
}
// 取消动画帧请求
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
state.animationFrameId = 0
}
}
}
// 处理滚轮缩放
function handleWheel(e: WheelEvent) {
if (state.isSpacePressed) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
const newScale = Math.max(state.minScale, Math.min(state.maxScale, state.scale + delta))
// 计算鼠标位置相对于容器的偏移
const rect = dragContainer.value?.getBoundingClientRect()
if (!rect) return
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
// 调整缩放中心点为鼠标位置
const scaleRatio = newScale / state.scale
const dx = mouseX - mouseX * scaleRatio
const dy = mouseY - mouseY * scaleRatio
state.translateX = state.translateX * scaleRatio + dx
state.translateY = state.translateY * scaleRatio + dy
state.scale = newScale
// 使用 requestAnimationFrame 更新变换
requestAnimationFrame(() => {
updateTransform()
})
}
}
// 更新变换
function updateTransform() {
if (contentWrapper.value) {
// 使用 translate3d 启用硬件加速
contentWrapper.value.style.transform = `translate3d(${state.translateX}px, ${state.translateY}px, 0) scale(${state.scale})`
}
}
// 重置变换
function resetTransform() {
// 添加平滑过渡效果
if (contentWrapper.value) {
contentWrapper.value.style.transition = 'transform 0.3s ease-out'
// 在下一帧应用变换
requestAnimationFrame(() => {
state.translateX = 0
state.translateY = 0
state.scale = 1
updateTransform()
// 重置完成后移除过渡效果
setTimeout(() => {
if (contentWrapper.value) {
contentWrapper.value.style.transition = ''
}
}, 300)
})
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// 初始化时应用硬件加速并确保初始位置正确
if (contentWrapper.value) {
contentWrapper.value.style.willChange = 'transform'
// 确保初始状态下内容在可见区域
contentWrapper.value.style.transform = 'translate3d(0, 0, 0) scale(1)'
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 取消可能存在的动画帧请求
if (state.animationFrameId) {
cancelAnimationFrame(state.animationFrameId)
}
})
</script>
<style scoped>
.my-drag {
position: relative;
overflow: hidden;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: #f9f9f9;
touch-action: none; /* 防止触摸设备上的默认行为 */
}
.drag-controls {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
}
.reset-btn {
padding: 4px 8px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.reset-btn:hover {
background-color: #f0f0f0;
}
.drag-info {
font-size: 12px;
color: #666;
background-color: rgba(255, 255, 255, 0.8);
padding: 4px 8px;
border-radius: 4px;
}
.content-wrapper {
transform-origin: 0 0;
/* 移除过渡效果,完全由 requestAnimationFrame 控制动画 */
/* transition: transform 0.05s ease-out; */
position: relative;
width: 100%;
height: 100%;
/* 添加硬件加速 */
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.no-events {
pointer-events: none;
}
</style>
使用用法
vue
<MyDrag class="drag-box">
<MyLoading @click="loading=!loading"></MyLoading>
<button @click="globalLoadingFun">展示全局loading</button>
<iframe src="https://downmaple.github.io/code-note/" style="width: 100%;height: 100%"></iframe>
</MyDrag>
总结
本文介绍了如何使用 Vue 3 和 TypeScript 实现一个功能完善的可拖拽缩放组件。通过合理使用 requestAnimationFrame、硬件加速等技术,我们实现了平滑的拖拽和缩放效果。同时,通过具名插槽和 props 配置,使组件具有良好的扩展性和可定制性。
这种组件在图片预览、大型数据可视化、地图应用等场景中非常有用,可以提供更好的用户交互体验。
注意:暂不支持直接嵌套 iframe 组件,在 myDrag 组件中嵌套 iframe 会导致鼠标事件无法正确判断,这是因为 iframe 创建了一个独立的上下文,当鼠标进入 iframe 时,父文档的鼠标事件会丢失。 可以自行增加 iframe 嵌套的处理方法,或者将鼠标移入当前组件事件去掉,这样就可以嵌套 iframe 了。