news 2026/10/17 1:34:45

jquery.fn.sortable拖拽插件实战:列表排序、嵌套与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
jquery.fn.sortable拖拽插件实战:列表排序、嵌套与避坑指南

简介:sortable.js是一款基于jQuery的轻量级拖拽排序插件,为HTML列表及常见元素提供流畅的拖放排序能力,适用于项目管理、待办事项等需要交互调整顺序的前端场景。该插件包为最新版,共5个文件,包含开发版与压缩版JS脚本及一个HTML示例页面,压缩包整体仅19KB,体积小巧可直接引入项目;目前已有617人学习下载。资源内提供开发版与压缩版源码,便于生产环境使用或阅读实现,同时支持start、sort、stop、update等事件接口,并可通过API初始化、更新和禁用排序。示例页面演示了初始化、事件监听与排序结果获取等核心用法,有助于快速掌握配置选项和回调逻辑。整体适合初中级前端开发者,可在此基础上扩展数据同步与自定义样式,提升列表交互体验。

1. 为什么要用 sortable.js 拖拽插件:不是所有排序都要手写

搜 sortable.js 拖拽插件的人,多半不是新项目里缺一个列表排序,而是老后台里躺着几套 jQuery 页面,突然要在任务列表、卡片墙、菜单层级里做拖拽排序。直接手写 HTML5 拖拽看着简单,但拖完要重新排队、跨列表移动、touch 端双击缩放,一套下来能把人拖到怀疑人生。jquery.fn.sortable 这个插件最省心的地方是:你给它一个容器,它帮你把鼠标/触摸事件、占位元素、目标位置计算都包掉,最后只输出一个排序后的 DOM。适合 jQuery 项目里做树形菜单、看板列、自定义表单字段排序,也适合从零开始但不想折腾原生拖拽的页面。如果你项目里已经引了 jQuery 又想要稳定排序能力,这份资源确实比重新写事件循环省力得多。

2. sortable.js 的原理与选型:为什么它比手写 drag 事件省心

2.1 手写 HTML5 拖拽时最常见的翻车点

很多人一开始会先用原生 HTML5 的 draggable 属性,因为浏览器嘴上说支持,实际坑很多。举个我当年写的一个极简例子:

const list = document.querySelector('#myList'); list.addEventListener('dragstart', (e) => { draggingItem = e.target; e.dataTransfer.effectAllowed = 'move'; }); list.addEventListener('dragover', (e) => { e.preventDefault(); const afterElement = getDragAfterElement(list, e.clientY); if (afterElement == null) { list.appendChild(draggingItem); } else { list.insertBefore(draggingItem, afterElement); } });

这个getDragAfterElement需要自己算每个子元素的位置、间距、阈值,而且一旦列表里有浮动、嵌套、滚动容器,坐标计算立刻失真。更麻烦的是移动端 touch 事件并不等同于 mouse 事件,真机上经常拖不动。这个代码块里的dragover只是示意,真正项目里还得处理dragend、drop、dataTransfer数据清理,三五十行起步。

jquery.fn.sortable 做的事情正好相反:它监听 mousedown/mousemove/mouseup,移动端监听 touch 相关事件,然后在容器内动态创建一个“占位节点”。每次移动时,它对比光标位置与现有项的位置,决定把占位节点插到哪,松手后把被拖的节点落到占位节点的地方。所有排序逻辑都在一个内部状态机里,你不需要关心insertBefore的时机。

2.2 jquery.fn.sortable 的运行逻辑

很多 jQuery 插件对应的是“无依赖版 Sortable.js”,两者常被混着搜。但我们这里说的是jquery.fn.sortable,它扩展的是$.fn,即 jQuery 对象方法,调用形式是$('.list').sortable(...)。它内部维护了两类关键状态:一类是当前拖拽项本身,另一类是拖拽时插入的placeholder节点。

拖拽开始时会先克隆一份被拖元素的宽高占位,然后把原元素变成绝对定位,跟着光标跑。这个过程里,原元素会挂在document.body上避免被容器overflow裁掉。容器的每一个可落点位置会被算出临时“阈值线”,占位节点就插到离光标最近的那条线前。松手时,原元素从 body 移回容器,替换掉占位节点,然后触发回调。这个逻辑的好处是:排序只在内存和 DOM 之间做关键移动,不反复操作整棵节点树,性能比拖到哪插到哪的原生写法稳定很多。

我一般会把tolerance参数设成pointer,因为默认的element模式是按鼠标进入目标元素的边界来计算位置的,在嵌套列表中经常差半个身位;pointer模式直接按光标 y 坐标算插入点,体感上更跟手。

2.3 最新版的获取、引入与前后依赖

回到这份资源本身,里面通常包含两个文件:jquery.fn.sortable.js和jquery.fn.sortable.min.js。前者适合开发时打断点看源码,后者是压缩版,适合上线直接引入。常见做法是直接从官方发布包里拿dist目录下的 min 文件,不建议到某些聚合站点下载七年前的版本,老版本在高版本 jQuery 和安卓 WebView 下会有事件绑定问题。

引入顺序很关键,和大多数 jQuery 插件一样,必须先加载 jQuery,再加载插件:

<script src="/js/jquery.min.js"></script> <script src="/js/jquery.fn.sortable.min.js"></script>

这里有个隐藏依赖:插件内部$.fn.sortable在初始化时会读取当前元素集合,如果你引入时把两个文件顺序写反,控制台会直接报$(...).sortable is not a function。另外,该插件不需要额外样式表,但要给 placeholder 写一点 CSS,否则拖拽过程中看不到落点:

.sortable-placeholder { background: #e6f7ff; border: 1px dashed #1890ff; height: 40px; margin-bottom: 4px; }

这个 CSS 不写也能动,但体验很怪,别人看你页面会以为鼠标把列表“吞了”。新版本对 jQuery 版本的兼容范围一般在 1.8 到 3.x,我实际在 jQuery 3.5.1 下跑过,没有报错。

3. 把 sortable.js 用起来:初始化、参数与事件回调

3.1 最小可运行的排序列表

先看最基础的一个用法。假设页面里有一个任务列表:

<ul id="taskList"> <li>$('#taskList').sortable({ group: 'task', itemSelector: 'li', placeholderClass: 'sortable-placeholder' });

这段代码里,group用来标识这一组可排序容器,同组列表之间可以互相拖;itemSelector告诉插件哪些子元素是可以拖的,默认会取容器子元素;placeholderClass对应我们在 CSS 里定义的占位节点样式。初始化返回值仍然是 jQuery 对象,所以可以继续链式调用。

注意,这里用itemSelector而不是某些教程里写的items,是因为 jquery.fn.sortable 的 API 设计里item是选择器、itemSelector在某些版本里是别名。如果你在某次使用时发现参数没生效,先查一下当前版本源码里用了哪个键名,不要盲目照抄。最省事的办法是只传item,因为它兼容性最稳。

3.2 参数表与常见配置

下表是我项目里用下来最有用的几个参数,覆盖绝大多数列表排序场景:

参数类型作用我的推荐值
groupstring同组才能互相拖拽给每个看板列一个共享值
itemstring可拖拽子元素选择器> li或具体的.card-item
placeholderDOM/jQuery/string拖拽时显示的占位节点高度与被拖项一致
handlestring只有该选择器命中的区域能发起拖拽.drag-handle
tolerancestring光标与目标元素相交方式pointer
dropOnEmptyboolean是否允许拖到空容器true
delaynumber鼠标按下多久后才开始拖,单位毫秒视列表项内按钮而定
distancenumber鼠标移动多少像素才算拖拽5 ~ 10
containmentstring/HTMLElement限制拖拽范围父容器选择器

delay和distance的作用容易被忽略。当一个 li 里面有按钮或者 A 标签时,用户只是点一下,结果手指一动列表就飘了,很烦。设distance: 8能保证移动超过 8px 才开始拖,按钮点击不会误触发。

dropOnEmpty建议显式设为true,否则拖到空白列表时不会插入,跨看板挪任务时经常发现“拖进去了但松手又弹回来”。这个参数默认值不是所有版本都一样,所以不要赌,写上。

3.3 事件回调:onDragStart、onDrop、onChange

回调是排序业务的核心。比如拖拽结束后要把当前顺序保存到后端,就得在onDrop里收集数据。下面是一个可运行的示例:

$('#taskList').sortable({ group: 'task', itemSelector: 'li', onDragStart: function($item, container, _super) { _super($item, container); console.log('开始拖', $item.data('id')); }, onChange: function($item, container, _super) { _super($item, container); console.log('位置变了', $item.data('id')); }, onDrop: function($item, container, _super) { _super($item, container); const ids = container.el.children().map(function() { return $(this).data('id'); }).get(); $('#result').text(JSON.stringify(ids)); } });

这里每个回调都调用了_super参数,这是这套插件里最容易忽略的一个细节。_super是插件内部继续执行默认逻辑的入口,比如清除拖拽状态、恢复节点位置。如果你不调,拖拽动画和样式经常卡死在半路。container.el是被拖拽目标最终所在容器,不是原始容器;跨列表拖动时,用这个属性拿的才是最新列表。

我建议在开发阶段把onChange的$item.data('id')打印出来,观察拖拽过程中是否每过一行都触发。如果只触发一次,说明itemSelector没匹配到所有子项,或者列表里混入了不可拖节点。

3.4 嵌套列表和跨容器拖动

做菜单层级或者卡片看板时,列表不是平铺的,而是容器套容器。插件能处理嵌套,但初始化要分两层做:

<ol class="board" id="board"> <li> <div class="column-title">待办</div> <ol class="card-list"> <li class="card">开发 A</li> <li class="card">开发 B</li> </ol> </li> </ol>

初始化时外层 board 和内层 card-list 都需要调用 sortable:

$('.board').sortable({ group: 'board', item: '> li', dropOnEmpty: true, tolerance: 'pointer' }); $('.card-list').sortable({ group: 'board', item: '.card', dropOnEmpty: true, tolerance: 'pointer' });

两个容器用同一个group: 'board',这样才能把任务卡片从内层列表拖到另一个内层列表。如果不写group,插件默认每个容器独立,跨列表拖拽会被拒。嵌套里最需要注意的是item选择器,外层用> li只拿到列头,二层用.card拿卡片,否则拖卡片的时候可能把整个列都拖走。

还有一个容易踩的细节:外层列表里也有 li,如果两层都初始化了,外层拖拽会尝试拖整个列,内层拖拽会拖卡片,事件都生效。这符合预期,但如果你想让外层列头不可拖,就给外层设置handle: '.column-title'。

3.5 拖拽手柄、禁用元素与动态列表

很多管理后台里的排序项上会有操作按钮,用户想点“编辑”而不是拖拽。解决办法是把按钮从拖拽热区里摘出去:

$('#menuList').sortable({ item: '> li', handle: '.drag-handle', cancel: '.action-btn, .no-drag' });

handle限定只有.drag-handle区域能发起拖拽;cancel告诉插件,即使用了item选择器,命中的子元素里如果包含.action-btn,也不能作为拖拽目标。注意,cancel在部分版本里写法是exclude,如果发现按钮还是能拖,看下版本源码。

动态列表是后台项目最常见的场景。用 AJAX 加载新数据后,直接把新节点append进列表,如果不做处理,新节点可能拖不了。原因是拖拽事件在插件初始化时绑定到可拖项上,新节点没有这些绑定。通常我会在渲染后调一次destroy再重新初始化:

function reInitSortable() { if ($('#menuList').data('sortable')) { $('#menuList').sortable('destroy'); } $('#menuList').sortable({ item: '> li', handle: '.drag-handle' }); }

这里的'destroy'是插件的销毁方法,调用后会解绑内部事件。之后再执行一次初始化,新节点就都恢复正常了。

4. 避坑与常见问题:从 placeholder 抖动到事件失效的排查

4.1 拖拽时 placeholder 对不上位置

现象:拖着列表项上下移动,占位节点忽上忽下,或者插到两个元素中间时整个列表跟着抖。

原因:最常见的有两个。一个是tolerance用默认element,按目标元素边界计算插入点,在 margin 较大或子元素高度不一致时会乱;另一个是占位节点高度不对,导致插入位置被撑开后又回弹。

解决:把tolerance改成pointer,并把 placeholder 的高度设为被拖元素的实际高度。另外检查是否有transition动画加在 li 上,拖拽过程中动画会让位置计算产生延迟。我一般这样写:

$('#taskList').sortable({ item: '> li', tolerance: 'pointer', placeholder: '<li class="sortable-placeholder"></li>', onDragStart: function($item, container, _super) { $('.sortable-placeholder').height($item.outerHeight()); _super($item, container); } });

placeholder是自定义占位节点,不用插件默认生成。onDragStart里动态设置占位高度,比 CSS 里写死高度更稳。

4.2 初始化后$(...).sortable is not a function

现象:页面控制台报Uncaught TypeError: $(...).sortable is not a function,但文件明明引入了。

原因:三个方向排查。第一,jQuery 被加载了两次,第二次加载覆盖掉第一次挂载到window上的插件方法;第二,插件加载在 jQuery 前面;第三,引入的是非压缩版jquery.fn.sortable.js,但该文件依赖某个辅助模块没被加载。

解决:在浏览器控制台执行window.jQuery.fn.sortable,如果输出undefined,说明插件没挂载成功。我处理过的一个真实项目里,页面底部又引了一个 jQuery 插件,但它自带一份 jQuery,结果把前后的都覆盖了。排查方式是打开 Network 面板看当前页面上实际加载了几个 jQuery 文件,保留一个,其他全部删掉。

4.3 点击里面的按钮把整行拖走

现象:列表项里的“编辑”“删除”按钮,点击时鼠标稍微动一下,整个列表项就被拖走。

原因:按钮本身处于item选择器匹配范围内,插件默认把整个元素都当作拖拽热区,没有排除交互控件。

解决:给按钮区域加.no-drag类,并在初始化里配置cancel: '.no-drag'。如果看到依然能拖,就把delay: 100也加上,让拖拽必须按住 100ms 后才生效,这样快速点击不会触发。

4.4 touch 设备上拖不动

现象:手机浏览器或者平板里,用手指按住列表项滑动,列表跟页面滚动冲突,要么拖不动,要么一拖就触发浏览器回弹。

原因:这类插件对 touch 事件的处理通常依赖于不支持 passive 事件监听的浏览器版本,或者需要额外设置触摸移动阈值;另一个高频原因是页面上下滚动与拖拽手势没做区分。

解决:设置distance: 5,让插件至少要移动 5px 才进入拖拽。同时在 touchmove 事件里做一次preventDefault,但要小心这会阻断页面滚动。常见做法是只在拖拽开始时禁止滚动,插件一般会在内部处理;如果实测没有,就在容器上监听touchmove:

$('.sortable-list').on('touchmove', function(e) { if ($('.sortable-placeholder').length) { e.preventDefault(); } });

只有出现 placeholder 时说明正在拖拽,才拦截滚动。这个方案能兼容大多数低版本 WebView。

4.5 动态加入节点后拖不了新元素

现象:用 AJAX 往列表里加了一个 li,刚加进去的节点样式位置都对,但拖它完全没反应。

原因:拖拽事件绑定在初始化时已有的子节点上,新节点没有经历事件绑定,自然无法拖动。这不能怪插件,它的设计目标是容器级管理,不负责监听未来节点。

解决:按 3.5 里说的,在渲染完新数据后销毁重建。更省事的写法是封装一个公共初始化函数,每次发送完请求就调用一次。

function refreshList() { const $list = $('#menuList'); if ($list.data('sortable')) { $list.sortable('destroy'); } $list.sortable({ item: '> li', handle: '.handle' }); }

注意data('sortable')是插件初始化后写入的标志,销毁后这个标志会删除。用它判断当前是否需要销毁,比不停地初始化要安全。

5. 进阶用法:把排序结果序列化回后端并用构建工具集成

5.1 拖拽结束拿到顺序 ID

我最常用的做法是把顺序结果存进一个隐藏字段,提交表单时一起发送:

function getSortIds($container) { return $container.children().map(function() { return $(this).data('id'); }).get(); } $('#taskList').on('sortupdate', function() { const ids = getSortIds($('#taskList')); $('#sortOrderField').val(ids.join(',')); });

这里用到了sortupdate事件,它是部分版本支持的自定义事件,触发时机在排序结束之后。如果你的版本不触发,就回到onDrop回调里收集。后端只需解析逗号分隔的 ID 串,再按数组顺序更新排序字段。

5.2 恢复上次排序状态

刷新页面后要按后端返回的顺序还原列表,最简单的方式是重新排序 DOM:

const savedOrder = ['103', '101', '102']; savedOrder.forEach(id => { $('#taskList').append( $('#taskList').find(`[data-id="${id}"]`) ); });

这段代码把元素按 ID 顺序重新 append,jQuery 在 append 已存在元素时不会复制,而是把它移动到末尾,正好实现排序。我在几个后台模块里都用的这个方案,不需要清空重绘,也不会闪屏。

5.3 在 Webpack/Vite 里正确引插件

如果你在老项目里局部用构建工具,直接import可能会报插件找不到 jQuery。插件代码通常假设jQuery是全局变量。在 Webpack 里可以这样处理:

const webpack = require('webpack'); module.exports = { plugins: [ new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery' }) ] };

ProvidePlugin 会把模块里的$自动替换成jquery的引用。如果你用的是 Vite,可以在 vite.config.js 里配置define或者直接用脚本标签加载插件。我个人的习惯是:只要这个项目还依赖 jQuery,就老老实实把插件放全局脚本,不要强行模块化,否则收益不大还浪费时间。

这套插件我用到现在,最值钱的习惯不是把参数背得多熟,而是每换一次版本都会写一个包含嵌套列表、touch 模拟、动态渲染的测试页面,先把这三个场景跑通了再集成业务。每次这样花十分钟,能省掉后面几十次线上反馈。希望这份经验能帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:34:22

Zephyr内存池Memory Pool深度解析:多级块机制与实战避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:47

React useState 常见陷阱与最佳实践:异步更新、闭包与不可变数据

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:06

IT项目沟通管理实战:从沟通矩阵到决策记录,打通信息流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:33:00

两串锂电池主动均衡方案:MP2642GR-Z与dsPIC33FJ256GP710A实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:32:59

双串锂电池主动均衡设计:MP2642GR-Z与STM32F091RC协同实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:32:13

锂电串联电池组主动均衡原理与MP2642GR-Z工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华