📅 我们继续 50 个小项目挑战!------ DragNDrop 组件
仓库地址:https://gitee.com/hhm-hhm/50days50projects.git

构建一个支持拖拽交互的图片拖放组件。该组件允许用户将一张图片从一个容器拖动并释放到另一个"空位"中,并带有视觉反馈(如悬停高亮、背景变化等)。
🌀 组件目标
- 创建多个"空位"容器
- 默认展示一张可拖动的图片
- 支持拖拽交互并投放到任意空位
- 投放后更新对应位置的图片状态
- 拖拽过程中提供视觉反馈(如悬停样式)
- 使用 TailwindCSS快速构建现代 UI 界面
🔧 DragNDrop.tsx组件实现
TypeScript
import React, { useState } from 'react'
const DragNDrop: React.FC = () => {
const [filledIndex, setFilledIndex] = useState<number>(0)
const [isHovered, setIsHovered] = useState<boolean[]>(Array(5).fill(false))
const imageUrls = [
'https://picsum.photos/id/10/150/150',
'https://picsum.photos/id/11/150/150',
'https://picsum.photos/id/12/150/150',
'https://picsum.photos/id/13/150/150',
'https://picsum.photos/id/14/150/150',
]
const empties = Array.from({ length: 5 }, (_, i) => i)
// 拖拽开始:设置被拖拽的元素标识(这里用 filledIndex)
const dragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData('text/plain', filledIndex.toString())
e.dataTransfer.effectAllowed = 'move'
}
const dragEnd = () => {
// 可选:添加拖拽结束效果(如重置样式)
}
const dragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault() // 必须阻止默认行为才能触发 drop
}
const dragEnter = (index: number) => {
setIsHovered((prev) => {
const newState = [...prev]
newState[index] = true
return newState
})
}
const dragLeave = (index: number) => {
setIsHovered((prev) => {
const newState = [...prev]
newState[index] = false
return newState
})
}
const dragDrop = (index: number, e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
const draggedIndexStr = e.dataTransfer.getData('text/plain')
const draggedIndex = parseInt(draggedIndexStr, 10)
if (!isNaN(draggedIndex) && draggedIndex !== index) {
setFilledIndex(index)
}
// 清除所有 hover 状态
setIsHovered(Array(5).fill(false))
}
return (
<div className="flex h-screen items-center justify-center overflow-hidden bg-gray-900">
{empties.map((_, index) => (
<div
key={index}
className={`m-2 h-36 w-36 border-4 border-black bg-white ${
isHovered[index] ? 'border-dashed border-black bg-gray-800' : ''
}`}
onDragOver={dragOver}
onDragEnter={() => dragEnter(index)}
onDragLeave={() => dragLeave(index)}
onDrop={(e) => dragDrop(index, e)}>
{index === filledIndex && (
<div
className="h-full w-full cursor-move bg-cover transition-all duration-200 ease-in-out"
style={{ backgroundImage: `url(${imageUrls[index]})` }}
draggable
onDragStart={dragStart}
onDragEnd={dragEnd}
/>
)}
</div>
))}
<div className="fixed right-20 bottom-5 text-2xl text-red-500">CSDN@Hao_Harrision</div>
</div>
)
}
export default DragNDrop
🔧 转换说明
| 功能 | Vue 3 (Composition API) | React + TS |
|---|---|---|
| 响应式状态 | const filledIndex = ref(0) const isHovered = ref([...]) |
const [filledIndex, setFilledIndex] = useState(0) const [isHovered, setIsHovered] = useState([...]) |
| 列表渲染 | v-for="(empty, index) in empties" |
{empties.map((_, index) => <div key={index}>...)} |
| 动态 class | :class="[isHovered[index] && 'border-dashed ...']" |
使用模板字符串或条件表达式: className={... ${isHovered[index] ? 'border-dashed bg-gray-800' : ''}} |
| 事件绑定 | @dragenter="dragEnter(index)" |
onDragEnter={() => dragEnter(index)} |
| 事件对象类型 | 自动推导 | 显式标注:e: React.DragEvent<HTMLDivElement> |
| 阻止默认行为 | @dragover.prevent |
e.preventDefault() 必须在 onDragOver 中手动调用 |
| 拖拽数据传递 | 无显式设置(逻辑隐含) | 必须通过 e.dataTransfer.setData('text/plain', value) 传递 |
| 获取拖拽数据 | 无 | e.dataTransfer.getData('text/plain') |
| 内联样式 | :style="{ backgroundImage: url(...) }" |
style={``{ backgroundImage: \url(${url})` }}` |
⚠️ 常见差异与注意事项
1. HTML5 拖拽在 React 中必须显式处理
- Vue 的
.prevent修饰符自动阻止默认行为; - React 中 必须手动调用
e.preventDefault()在onDragOver和onDrop中,否则drop事件不会触发。
2. 状态更新不可变性
-
Vue 可直接修改
isHovered.value[index] = true; -
React 必须使用 不可变更新 :
TypeScriptsetIsHovered(prev => { const newState = [...prev]; newState[index] = true; return newState; });
3. 事件处理器传参方式不同
- Vue:
@dragenter="dragEnter(index)"直接传参; - React:需用箭头函数包裹:
onDragEnter={() => dragEnter(index)}。
4. draggable 属性
- Vue:
draggable="true"(字符串) - React:
draggable(布尔属性,写成<div draggable />即可,但写draggable={true}也合法)
5. CSS 过渡效果
- Vue 使用
<style scoped>定义[draggable='true']; - React 中建议:
-
方式一:在全局 CSS 中定义(如
index.css); -
方式二(推荐):直接用 Tailwind 类实现过渡:
className="transition-all duration-200 ease-in-out"
-
✅ 最佳实践建议
| 场景 | 推荐做法 |
|---|---|
| 拖拽标识传递 | 使用 dataTransfer.setData('text/plain', id) 传递唯一标识 |
| 防止无效移动 | 在 drop 中判断 if (from !== to) 再更新状态 |
| hover 状态管理 | 使用数组记录每个格子的悬停状态,确保精准控制 |
| 图片 URL 管理 | 将 imageUrls 作为常量或 props,避免硬编码 |
| 无障碍与 UX | 添加 cursor-move、user-select: none 提升体验 |
🎨 TailwindCSS 样式重点讲解
| 类名 | 作用 |
|---|---|
h-screen, items-center, justify-center |
全屏高度 + 内容居中布局 |
overflow-hidden |
防止内容溢出 |
bg-sky-500 |
设置背景颜色为浅蓝色 |
h-36, w-36 |
设置每个容器的宽高为 36(9rem) |
m-2 |
设置外边距为 2(0.5rem) |
border-4, border-black |
黑色边框 |
bg-white / bg-gray-800 |
默认和悬停状态下的背景颜色 |
border-dashed |
悬停时边框变为虚线 |
cursor-pointer |
设置图片区域为可点击 |
bg-cover |
图片背景自适应填充 |
transition |
添加拖拽过程中的平滑过渡动画 |
| [🎯 TailwindCSS 样式说明] |
🦌 路由组件 + 常量定义
router/index.tsx中 children数组中添加子路由
TypeScript
{
path: '/',
element: <App />,
children: [
...
{
path: '/DragNDrop',
lazy: () =>
import('@/projects/DragNDrop.tsx').then((mod) => ({
Component: mod.default,
})),
},
],
},
constants/index.tsx 添加组件预览常量
TypeScript
import demo21Img from '@/assets/pic-demo/demo-21.png'
省略部分....
export const projectList: ProjectItem[] = [
省略部分....
{
id: 21,
title: 'Drag-and-drop Occupation',
image: demo21Img,
link: 'DragNDrop',
},
🚀 小结
进一步扩展的功能推荐:
- ✅ 支持多张图片同时拖动
- ✅ 支持图片预览拖拽(不立即改变原图位置)
- ✅ 拖拽时高亮目标容器边界
- ✅ 支持触摸设备拖拽交互(移动端适配)
- ✅ 封装为可复用组件(支持 props 传入图片列表)
📅 明日预告: 我们将完成DrawApp组件,创建一个画板具有调节画笔粗细的功能,并且能够一键清除画板上的内容。🚀
原文链接:https://blog.csdn.net/qq_44808710/article/details/149103822
每天造一个轮子,码力暴涨不是梦!🚀