在现代 Web 应用中,拖拽排序是一种非常直观的交互方式,广泛应用于任务管理、列表重排等场景。本文将详细介绍如何使用原生 JavaScript 结合 HTML5 的拖拽 API 实现一个完整的拖拽排序功能,并解析其核心原理和实现细节。
功能概述
我们将实现一个具有以下特点的拖拽排序组件:
- 支持鼠标拖拽元素改变顺序
- 拖拽过程中提供视觉反馈
- 实时更新元素序号
- 数据与视图同步更新
- 简洁美观的 UI 设计
最终效果如下:
- 拖拽时元素会有轻微放大和阴影效果
- 被拖拽元素经过其他元素时,目标元素会显示背景色变化
- 松开鼠标后,所有元素会平滑调整到新位置
- 控制台会输出排序后的结果
实现步骤详解
1. HTML 结构设计
首先我们需要创建基础的 HTML 结构,包含一个容器、标题、排序列表和提示文字:
<div class="container">
<h1>拖拽排序示例</h1>
<div class="sortable-list" id="sortable-list"></div>
<p class="instructions">提示:点击并拖动元素来改变顺序</p>
</div>
其中sortable-list
将作为我们的可排序列表容器,列表项将通过 JavaScript 动态生成。
2. CSS 样式设计
为了让拖拽过程有良好的视觉反馈,我们需要设计相应的 CSS 样式:
/* 基础样式 */
body {
font-family: system-ui, sans-serif;
background-color: #f9fafb;
margin: 0;
padding: 2rem;
}
.container {
max-width: 600px;
margin: 0 auto;
}
/* 列表和列表项样式 */
.sortable-list {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sortable-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
background-color: white;
cursor: grab;
transition: all 0.2s ease;
}
/* 拖拽状态样式 */
.sortable-item:active {
cursor: grabbing;
}
.sortable-item.dragging {
opacity: 0.5;
transform: scale(1.02);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: none;
}
.sortable-item.drag-over {
background-color: #eff6ff;
}
关键样式说明:
.sortable-item
:定义列表项的基本样式.dragging
:拖拽过程中应用的样式,包括透明度、缩放和阴影.drag-over
:当被拖拽元素经过时,目标元素的样式变化
3. JavaScript 核心逻辑
3.1 数据模型与渲染函数
首先定义我们的数据模型和渲染函数:
// 数据源
const items = [
{ id: 1, title: '项目规划' },
{ id: 2, title: 'UI设计' },
{ id: 3, title: '前端开发' },
{ id: 4, title: '后端开发' },
{ id: 5, title: '测试验证' },
{ id: 6, title: '部署上线' },
]
const sortableList = document.getElementById('sortable-list')
// 渲染列表项
function renderItems() {
sortableList.innerHTML = ''
items.forEach((item, index) => {
const listItem = document.createElement('div')
listItem.className = 'sortable-item'
listItem.draggable = true // 设置为可拖拽
listItem.dataset.index = index // 存储当前索引
// 列表项内容
listItem.innerHTML = `
<div class="item-content">
<div class="item-number">${index + 1}</div>
<span>${item.title}</span>
</div>
<div class="handle">
<!-- 拖拽手柄图标 -->
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H3M9 12H7M13 12H11M17 12H15M21 12H19M5 18H3M9 18H7M13 18H11M17 18H15M21 18H19M5 6H3M9 6H7M13 6H11M17 6H15M21 6H19" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
`
// 绑定拖拽事件
listItem.addEventListener('dragstart', handleDragStart)
listItem.addEventListener('dragover', handleDragOver)
listItem.addEventListener('dragleave', handleDragLeave)
listItem.addEventListener('drop', handleDrop)
listItem.addEventListener('dragend', handleDragEnd)
sortableList.appendChild(listItem)
})
}
renderItems
函数负责根据数据动态生成列表项,并为每个列表项绑定拖拽相关事件。
3.2 拖拽事件处理
拖拽排序功能主要依赖以下五个事件:
- dragstart:开始拖拽元素时触发
- dragover:拖拽元素经过目标元素时触发
- dragleave:拖拽元素离开目标元素时触发
- drop:在目标元素上释放拖拽元素时触发
- dragend:拖拽操作结束时触发
下面是这些事件的具体实现:
// 拖拽过程中保存的变量
let draggedItem = null // 被拖拽的元素
let startIndex = null // 拖拽开始时的索引
/**
* 处理拖拽开始事件
*/
function handleDragStart(e) {
draggedItem = this
startIndex = parseInt(this.dataset.index)
this.classList.add('dragging') // 添加拖拽样式
e.dataTransfer.setData('text/plain', startIndex) // 存储初始索引
}
/**
* 处理拖拽经过事件
*/
function handleDragOver(e) {
e.preventDefault() // 必须调用,否则无法触发drop事件
if (this !== draggedItem) { // 排除被拖拽元素自身
this.classList.add('drag-over') // 添加经过样式
// 获取目标元素位置信息
const rect = this.getBoundingClientRect()
const y = e.clientY - rect.top // 鼠标在目标元素内的Y坐标
// 根据鼠标位置决定插入方向
if (y < rect.height / 2) {
// 鼠标在上半部分,插入到目标元素前面
sortableList.insertBefore(draggedItem, this)
} else {
// 鼠标在下半部分,插入到目标元素后面
sortableList.insertBefore(draggedItem, this.nextSibling)
}
updateIndexes() // 更新所有元素的索引
}
}
/**
* 处理拖拽离开事件
*/
function handleDragLeave() {
this.classList.remove('drag-over') // 移除经过样式
}
/**
* 处理放置事件
*/
function handleDrop(e) {
e.preventDefault()
this.classList.remove('drag-over') // 移除经过样式
}
/**
* 处理拖拽结束事件
*/
function handleDragEnd() {
// 移除所有拖拽相关样式
document.querySelectorAll('.sortable-item').forEach((item) => {
item.classList.remove('dragging', 'drag-over')
})
// 更新数据
const endIndex = parseInt(draggedItem.dataset.index)
if (startIndex !== endIndex) { // 只有位置发生变化时才更新
// 从原位置移除,插入到新位置
const [removed] = items.splice(startIndex, 1)
items.splice(endIndex, 0, removed)
console.log('排序已更新:', items.map((item) => item.title))
}
}
3.3 索引更新函数
当元素位置发生变化时,需要更新所有元素的索引和显示的序号:
/**
* 更新所有项目的索引显示
*/
function updateIndexes() {
document.querySelectorAll('.sortable-item')
.forEach((item, index) => {
item.dataset.index = index // 更新数据索引
// 更新显示的序号
item.querySelector('.item-number').textContent = index + 1
})
}
3.4 初始化
最后,调用renderItems
函数初始化列表:
// 初始渲染项目列表
renderItems()
实现原理分析
整个拖拽排序的工作流程可以总结为以下几个步骤:
-
准备阶段:页面加载时,
renderItems
函数根据数据源生成列表,并为每个列表项绑定拖拽事件。 -
拖拽开始:用户点击并开始拖动元素时,
handleDragStart
函数被调用,记录初始状态并添加拖拽样式。 -
拖拽过程:当拖拽元素经过其他元素时,
handleDragOver
函数根据鼠标位置实时调整被拖拽元素的位置,并更新所有元素的索引。 -
拖拽结束:用户松开鼠标时,
handleDragEnd
函数被调用,移除拖拽样式,并根据最终位置更新数据源。
核心技术点:
- 使用
draggable="true"
属性使元素可拖拽 - 通过
insertBefore
方法动态调整元素位置 - 利用
dataTransfer
在拖拽过程中传递数据 - 维护数据与视图的同步
优化建议
这个基础实现已经可以满足大部分拖拽排序需求,你还可以根据实际情况进行以下优化:
- 添加动画效果:使用 FLIP 动画技术使元素位置变化更加平滑
- 触摸设备支持:添加触摸事件处理,支持移动设备
- 性能优化:使用事件委托减少事件监听器数量
- 可配置化:将功能封装成组件,支持自定义样式和回调函数
- 边界处理:优化拖拽到列表边缘时的体验
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>拖拽排序示例</title>
<style>
body {
font-family: system-ui, sans-serif;
background-color: #f9fafb;
margin: 0;
padding: 2rem;
}
.container {
max-width: 600px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #1f2937;
}
.sortable-list {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sortable-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
background-color: white;
cursor: grab;
transition: all 0.2s ease;
}
.sortable-item:active {
cursor: grabbing;
}
.sortable-item.dragging {
opacity: 0.5;
transform: scale(1.02);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: none;
}
.sortable-item.drag-over {
background-color: #eff6ff;
}
.item-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.item-number {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background-color: #dbeafe;
color: #1e40af;
font-weight: 600;
}
.handle {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
color: #9ca3af;
}
.handle svg {
width: 1.25rem;
height: 1.25rem;
}
.instructions {
text-align: center;
margin-top: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="container">
<h1>拖拽排序示例</h1>
<div class="sortable-list" id="sortable-list">
<!-- 可拖拽元素将通过JavaScript动态生成 -->
</div>
<p class="instructions">提示:点击并拖动元素来改变顺序</p>
</div>
<script>
const items = [
{ id: 1, title: '项目规划' },
{ id: 2, title: 'UI设计' },
{ id: 3, title: '前端开发' },
{ id: 4, title: '后端开发' },
{ id: 5, title: '测试验证' },
{ id: 6, title: '部署上线' },
]
const sortableList = document.getElementById('sortable-list')
function renderItems() {
sortableList.innerHTML = ''
items.forEach((item, index) => {
const listItem = document.createElement('div')
listItem.className = 'sortable-item'
listItem.draggable = true
listItem.dataset.index = index
listItem.innerHTML = `
<div class="item-content">
<div class="item-number">${index + 1}</div>
<span>${item.title}</span>
</div>
<div class="handle">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H3M9 12H7M13 12H11M17 12H15M21 12H19M5 18H3M9 18H7M13 18H11M17 18H15M21 18H19M5 6H3M9 6H7M13 6H11M17 6H15M21 6H19" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
`
listItem.addEventListener('dragstart', handleDragStart) //开始拖动元素时
listItem.addEventListener('dragover', handleDragOver) //拖动的元素在目标元素上方时
listItem.addEventListener('dragleave', handleDragLeave) //拖动的元素离开目标元素时
listItem.addEventListener('drop', handleDrop) //拖动的元素在目标元素上释放时
listItem.addEventListener('dragend', handleDragEnd) //拖拽操作结束时
// 将列表项元素添加到可排序列表中- listItem: 要添加的列表项元素
sortableList.appendChild(listItem)
})
}
// 拖拽过程中保存的变量
let draggedItem = null
let startIndex = null
/**
* 处理拖拽开始事件
* 设置被拖拽元素和初始索引,添加拖动样式
* @param {DragEvent} e - 拖拽事件对象
*/
function handleDragStart(e) {
draggedItem = this
startIndex = parseInt(this.dataset.index)
this.classList.add('dragging')
e.dataTransfer.setData('text/plain', startIndex)
}
/**
* 处理拖拽经过事件
* 确定放置位置并更新DOM顺序
* @param {DragEvent} e - 拖拽事件对象
*/
function handleDragOver(e) {
e.preventDefault()
if (this !== draggedItem) {
this.classList.add('drag-over')
const rect = this.getBoundingClientRect()
const y = e.clientY - rect.top
if (y < rect.height / 2) {
sortableList.insertBefore(draggedItem, this)
} else {
sortableList.insertBefore(draggedItem, this.nextSibling)
}
updateIndexes()
}
}
/**
* 处理拖拽离开事件
* 移除拖拽经过样式
*/
function handleDragLeave() {
this.classList.remove('drag-over')
}
/**
* 处理放置事件
* 移除拖拽经过样式
* @param {DragEvent} e - 拖拽事件对象
*/
function handleDrop(e) {
e.preventDefault()
this.classList.remove('drag-over')
}
/**
* 处理拖拽结束事件
* 移除所有拖拽样式,更新数据数组顺序
*/
function handleDragEnd() {
document.querySelectorAll('.sortable-item').forEach((item) => {
item.classList.remove('dragging', 'drag-over')
})
const endIndex = parseInt(draggedItem.dataset.index)
if (startIndex !== endIndex) {
const [removed] = items.splice(startIndex, 1)
items.splice(endIndex, 0, removed)
console.log(
'排序已更新:',
items.map((item) => item.title)
)
}
}
/**
* 更新所有项目的索引显示
* 根据当前DOM顺序重新设置每个项目的索引和序号
*/
function updateIndexes() {
document
.querySelectorAll('.sortable-item')
.forEach((item, index) => {
item.dataset.index = index
item.querySelector('.item-number').textContent =
index + 1
})
}
// 初始渲染项目列表
renderItems()
</script>
</body>
</html>
总结
本文详细介绍了如何使用原生 JavaScript 和 HTML5 拖拽 API 实现拖拽排序功能。通过监听拖拽事件,我们可以实时调整元素位置并同步更新数据,从而实现直观的排序交互。