原生 JavaScript 实现拖拽排序功能:从基础到优化

在现代 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 拖拽事件处理

拖拽排序功能主要依赖以下五个事件:

  1. dragstart:开始拖拽元素时触发
  2. dragover:拖拽元素经过目标元素时触发
  3. dragleave:拖拽元素离开目标元素时触发
  4. drop:在目标元素上释放拖拽元素时触发
  5. 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()

实现原理分析

整个拖拽排序的工作流程可以总结为以下几个步骤:

  1. 准备阶段:页面加载时,renderItems函数根据数据源生成列表,并为每个列表项绑定拖拽事件。

  2. 拖拽开始:用户点击并开始拖动元素时,handleDragStart函数被调用,记录初始状态并添加拖拽样式。

  3. 拖拽过程:当拖拽元素经过其他元素时,handleDragOver函数根据鼠标位置实时调整被拖拽元素的位置,并更新所有元素的索引。

  4. 拖拽结束:用户松开鼠标时,handleDragEnd函数被调用,移除拖拽样式,并根据最终位置更新数据源。

核心技术点:

  • 使用draggable="true"属性使元素可拖拽
  • 通过insertBefore方法动态调整元素位置
  • 利用dataTransfer在拖拽过程中传递数据
  • 维护数据与视图的同步

优化建议

这个基础实现已经可以满足大部分拖拽排序需求,你还可以根据实际情况进行以下优化:

  1. 添加动画效果:使用 FLIP 动画技术使元素位置变化更加平滑
  2. 触摸设备支持:添加触摸事件处理,支持移动设备
  3. 性能优化:使用事件委托减少事件监听器数量
  4. 可配置化:将功能封装成组件,支持自定义样式和回调函数
  5. 边界处理:优化拖拽到列表边缘时的体验

完整代码 

<!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 实现拖拽排序功能。通过监听拖拽事件,我们可以实时调整元素位置并同步更新数据,从而实现直观的排序交互。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值