ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

原生JS实现移动端拖拽插件:从touch事件到性能优化

原生JS实现移动端拖拽插件:从touch事件到性能优化 简介面向移动端HTML5拖拽场景的插件与示例资料包适合手机网站、H5App及基于Mui的前端开发者使用重点解决图片和标签元素的拖放交互设计问题。资源包含可运行的“dropDemo”覆盖图片拖拽、标签排序等典型交互演示了HTML5拖放API中dragstart、dragover、drop等事件的使用方式并集成了Sortable.js、jQuery等常用脚本可帮助开发者快速理解拖放触发流程、边界检测、元素堆叠顺序及跨平台兼容处理。压缩包共15个文件以js脚本、html页面、css样式、示例图片及辅助数据库文件为主整体仅112KB体量轻巧、目录简洁适合直接借鉴或嵌入实际项目。已有567人学习浏览对需要快速在移动端实现拖拽功能的开发者来说是一份即拿即用的参考资料。 我曾经在一个基于mui框架的H5App项目里接过一个需求商品图片上要能自由拖拽“热区标签”另一个页面做频道管理时又要做标签拖拽排序。当时为了省事先在网上找了一圈发现市面上的拖拽插件要么过度依赖jQuery要么PC端体验好但移动端一碰就露馅点击穿透、滚动冲突、iOS橡皮筋回弹导致坐标漂移最后我干脆自己用原生JS写了一个轻量级拖拽插件源码加上注释不到300行不依赖任何第三方库稳定跑完整个项目周期。这篇文章就把这套方案的完整实现思路、关键细节和踩坑记录整理出来。内容适合正在用html5、mui或各类H5App容器DCloud的plus.webview、微信内置浏览器等做前端开发的朋友尤其是有图片拖拽、标签拖拽排序需求但又不想引入重型框架的同学。我会把原理讲清楚再附上可直接复用的demo代码。1. 项目概述与核心需求拆解1.1 为什么移动端拖拽不能直接套用PC端方案PC端做拖拽大多数人第一反应是监听mousedown、mousemove、mouseup。但移动端浏览器的输入模型完全不是一回事它跑的是touch事件系列。直接套用鼠标事件的逻辑最常见的问题有两个第一touchmove的默认行为是页面滚动和缩放。如果你不显式调用preventDefault手指在屏幕上移动时页面会跟着动拖拽对象永远对不齐手指的位置体验极其分裂。第二移动端的事件对象结构和PC端不一样。touch事件里拿坐标要从e.touches[0]或者e.changedTouches[0]里取clientX/clientY而不是直接e.clientX。如果照搬PC写法拿到的往往直接是undefined代码静默报错还不容易发现。所以移动端拖拽的第一原则不要自己造事件顺着touch事件模型走。1.2 插件能力清单与使用场景我给这个插件定下的目标是能够支撑两个典型的业务场景同时不限制使用方非得基于什么框架。场景一图片自由拖拽。比如用户上传头像后可以拖动调整头像上的表情贴纸位置或者商品详情页的图片上拖拽热区标签。这类需求要求元素在可视区域内自由移动不能超出边界同时拖拽结束后要能拿到元素最终的坐标方便提交给后端保存。场景二标签拖拽排序。比如频道管理、导航栏管理、快捷入口排序。这类需求要求元素可以在水平或垂直方向上拖拽并能感知到拖拽到了哪两个相邻元素之间以此实现重新排列。结合这两个场景插件至少要具备以下能力支持任意元素拖拽不关心内部是什么内容原生div、img、li等都可以支持限定拖拽范围防止元素被拖到屏幕外支持拖拽开始、拖拽中、拖拽结束三个生命周期回调业务方可以自由接入逻辑支持回弹效果超出范围后自动吸附回来2. 移动端拖拽的关键原理剖析2.1 touch事件三兄弟的正确打开方式移动端的拖拽生命周期依赖三个事件touchstart、touchmove、touchend。它们和鼠标事件的对应关系如下鼠标事件触摸事件触发时机mousedowntouchstart手指按下屏幕mousemovetouchmove手指在屏幕上移动mouseuptouchend手指离开屏幕实际操作中还有一个规律touchstart和touchmove用e.touches取触摸点touchend用e.changedTouches取触摸点。原因是touchend触发时手指已经离开e.touches里已经找不到这一根手指了只有changedTouches里记录了变化的数据。坐标计算的核心是拿到偏移量。我习惯的公式是新位置 初始位置 当前触摸点 - 初始触摸点。也就是说touchstart时先记录元素当前的位置和手指落下的位置touchmove时根据手指移动了多少增量同步更新元素位置。这样即使元素之前已经被拖拽过也能无缝衔接。2.2 transform和left/top的取舍移动端性能优化的一个重点是用transform代替left/top来移动元素。原因是left/top的修改会触发布局Layout和重绘Paint浏览器要重新计算元素位置以及可能影响到的周围元素消耗较大。而transform的移动发生在合成阶段CompositeGPU可以加速处理不会引起重排。用技术术语说left和top是改变几何属性而transform: translate3d()是生成一个新的图层后做矩阵变换渲染成本低得多。实际测试里同样一个页面连续拖拽用transform的方案在低端安卓机上帧率能稳定在55帧以上用left/top的方案经常掉到30帧以下。所以插件内部全部以transform来驱动位移初始记录时存一份元素原始的translate3d坐标拖拽过程中只更新这个坐标。2.3 事件委托与内存控制插件没有选择给每个被拖拽元素单独绑定三个事件而是只监听document上的touchstart、touchmove、touchend然后通过事件委托判断命中目标。原因有两点一是同一页面可能有多个可拖拽元素比如图片上的多个标签每个都绑三个事件会导致监听器数量爆炸不利于维护和性能。二是动态添加的元素无法自动获得事件绑定而事件委托天然支持被添加到DOM里的新元素只要满足选择器条件就能被拖拽不需要重新绑定。事件委托的实现思路不复杂touchstart里判断e.target匹配哪一个可拖拽元素如果匹配则初始化本次拖拽touchmove里判断当前是否有正在拖拽的对象有才执行移动逻辑touchend里判断是否有正在拖拽的对象有才触发结束回调。2.4 回弹效果的实现逻辑回弹是很多业务方会提的需求拖拽超出边界后松手元素自动回到合法范围内。这个效果的核心是把“当前拖拽坐标是否合法”的判断放在touchend阶段做而不是在touchmove里硬性阻止。如果放在touchmove里直接限制坐标不许超出边界元素会像撞到墙一样突然停住手感非常生硬。更自然的做法是touchmove阶段不做干涉让元素跟随手指自由移动等到touchend时如果发现坐标越界用CSS的transition加上短暂位移过渡让元素平滑滑回边界位置。代码上只需要在touchend里判断lastX和lastY是否超出边界如果超出就计算目标合法坐标给元素临时加一个过渡时间等过渡结束时再移除。3. 从0到1实现移动端拖拽插件3.1 插件整体架构插件用IIFE包裹成独立模块挂载到window上支持实例化多个。整体包含几个部分配置合并、touch事件处理器、坐标计算、边界判断、回调触发。为了方便业务方使用暴露的API只有两个new DragManager(options)创建实例以及实例上的destroy()方法销毁。配置项设计如下var defaults { // 可拖拽元素的选择器支持使用事件委托 dragSelector: .drag-item, // 拖拽范围的选择器默认为整个可视区域 container: window, // 是否开启回弹拖出边界后自动回弹到合法位置 rebound: true, // 是否阻止页面滚动touchmove时调用preventDefault preventScroll: true, // 拖拽开始的回调 onStart: function (element, x, y) {}, // 拖拽中的回调 onMove: function (element, x, y) {}, // 拖拽结束的回调 onEnd: function (element, x, y) {} };拖拽对象在内存中的数据格式var activeItem { element: null, // 被拖拽的DOM元素 startTouchX: 0, // 手指按下时的pageX startTouchY: 0, // 手指按下时的pageY startTranslateX: 0, // 元素当前的translateX startTranslateY: 0, // 元素当前的translateY width: 0, // 元素宽度 height: 0, // 元素高度 maxX: 0, // 允许的最大X maxY: 0 // 允许的最大Y };3.2 核心代码实现完整源码放在文末这里拆解最关键的三段。第一段是touchstart中的初始化逻辑var self this; document.addEventListener(touchstart, function (e) { var target e.target; if (!target) return; var element target.closest(self.config.dragSelector); if (!element) return; var touch e.touches[0]; if (!touch) return; // 只处理单指拖拽多指时不进入拖拽流程 if (e.touches.length 1) return; // 记录初始数据 var transform getTransform(element); // 解析translate值 var rect element.getBoundingClientRect(); var containerRect getContainerRect(self.config.container); activeItem.element element; activeItem.startTouchX touch.pageX; activeItem.startTouchY touch.pageY; activeItem.startTranslateX transform.x; activeItem.startTranslateY transform.y; activeItem.width rect.width; activeItem.height rect.height; activeItem.maxX containerRect.width - rect.width; activeItem.maxY containerRect.height - rect.height; // 阻止默认行为防止页面滚动 if (self.config.preventScroll) { e.preventDefault(); } self.config.onStart(element, transform.x, transform.y); }, false);这里有一个细节getBoundingClientRect拿到的坐标是相对于视口的而pageX是相对于文档页面的。页面没有滚动时两者等价但移动端页面相对容易出现滚动情况所以我统一用pageX/pageY来计算偏移这样即使有滚动偏移也能保持一致不会出现拖拽错位。第二段是touchmove中的位移计算document.addEventListener(touchmove, function (e) { if (!activeItem.element) return; var touch e.touches[0]; if (!touch) return; // 基于增量计算起点 手指移动距离 var deltaX touch.pageX - activeItem.startTouchX; var deltaY touch.pageY - activeItem.startTouchY; var newX activeItem.startTranslateX deltaX; var newY activeItem.startTranslateY deltaY; // 如果配置了边界限制在移动过程中就实时约束不用于回弹模式 if (!self.config.rebound) { newX Math.max(0, Math.min(newX, activeItem.maxX)); newY Math.max(0, Math.min(newY, activeItem.maxY)); } setTransform(activeItem.element, newX, newY); self.config.onMove(activeItem.element, newX, newY); if (self.config.preventScroll) { e.preventDefault(); } }, false);这里有一个性能优化点拖拽过程中频繁触发onMove回调的话如果回调里做了复杂的DOM操作拖拽帧率会受影响。我自己在项目里会手动用requestAnimationFrame做一个节流但为了代码简洁插件本身没有内置这里提醒一下业务方如果在onMove里做计算量大的事建议自行节流或者只在onEnd里处理结果。第三段是touchend中的收尾与回弹document.addEventListener(touchend, function (e) { if (!activeItem.element) return; var touch e.changedTouches[0]; if (!touch) return; var element activeItem.element; var finalX activeItem.startTranslateX (touch.pageX - activeItem.startTouchX); var finalY activeItem.startTranslateY (touch.pageY - activeItem.startTouchY); activeItem.element null; // 回弹模式下先计算合法坐标再通过过渡动画平滑回到边界 if (self.config.rebound (finalX 0 || finalY 0 || finalX activeItem.maxX || finalY activeItem.maxY)) { var targetX Math.max(0, Math.min(finalX, activeItem.maxX)); var targetY Math.max(0, Math.min(finalY, activeItem.maxY)); element.style.transition transform 0.2s ease-out; setTransform(element, targetX, targetY); setTimeout(function () { element.style.transition ; }, 220); self.config.onEnd(element, targetX, targetY); } else { self.config.onEnd(element, finalX, finalY); } }, false);注意回弹时我给元素加了transition但如果不重置后续的拖拽就会一直带着过渡动画导致手指移动时元素跟不上。所以必须在动画结束后把transition属性清空。setTimeout的时长要比transition时长稍微长一点确保清理发生在动画完成之后。3.3 图片拖拽demo表情贴纸编辑器下面是一个基础的图片拖拽demo页面上有一张背景图和三个可自由拖拽的贴纸标签!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno title移动端拖拽图片标签Demo/title style .container { position: relative; width: 300px; height: 200px; margin: 80px auto; border-radius: 8px; overflow: hidden; background: #eee; } .container img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; } .drag-item { position: absolute; left: 0; top: 0; width: 60px; height: 60px; border-radius: 50%; background: rgba(255, 99, 132, 0.85); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; touch-action: none; user-select: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } .drag-item:nth-child(2) { transform: translate3d(20px, 30px, 0); } .drag-item:nth-child(3) { transform: translate3d(160px, 80px, 0); } .drag-item:nth-child(4) { transform: translate3d(80px, 120px, 0); } .log { margin: 20px auto; text-align: center; height: 40px; line-height: 40px; color: #333; background: #f6f6f6; border-radius: 6px; width: 300px; font-size: 12px; } /style /head body div classcontainer img srchttps://picsum.photos/300/200 alt背景图 div classdrag-item贴纸1/div div classdrag-item贴纸2/div div classdrag-item贴纸3/div /div div classlog拖拽看看结束时会输出坐标/div script (function (window) { function DragManager(options) { var config this.config Object.assign({ dragSelector: .drag-item, container: window, rebound: true, preventScroll: true, onStart: function () {}, onMove: function () {}, onEnd: function () {} }, options || {}); var activeItem null; var self this; function getTransform(el) { var transform getComputedStyle(el).transform; if (!transform || transform none) { return { x: 0, y: 0 }; } var matrix transform.match(/matrix3d?\(([^)])\)/); if (!matrix) { return { x: 0, y: 0 }; } var values matrix[1].split(,); // matrix: a b c d tx ty - values[4], values[5] // matrix3d: tx在12、13位置 if (values.length 16) { return { x: parseFloat(values[12]), y: parseFloat(values[13]) }; } return { x: parseFloat(values[4]), y: parseFloat(values[5]) }; } function setTransform(el, x, y) { el.style.transform translate3d( x px, y px,0); } function getContainerRect(container) { if (container window) { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }; } return container.getBoundingClientRect(); } document.addEventListener(touchstart, function (e) { var target e.target; if (!target) return; var element target.closest ? target.closest(config.dragSelector) : null; if (!element) return; var touch e.touches[0]; if (!touch || e.touches.length 1) return; element.style.transition ; var transform getTransform(element); var rect element.getBoundingClientRect(); var containerRect getContainerRect(config.container); activeItem { element: element, startTouchX: touch.pageX, startTouchY: touch.pageY, startTranslateX: transform.x, startTranslateY: transform.y, width: rect.width, height: rect.height, maxX: containerRect.width - rect.width, maxY: containerRect.height - rect.height }; if (config.preventScroll) { e.preventDefault(); } config.onStart(element, transform.x, transform.y); }, { passive: false }); document.addEventListener(touchmove, function (e) { if (!activeItem || !activeItem.element) return; var touch e.touches[0]; if (!touch) return; var deltaX touch.pageX - activeItem.startTouchX; var deltaY touch.pageY - activeItem.startTouchY; var newX activeItem.startTranslateX deltaX; var newY activeItem.startTranslateY deltaY; if (!config.rebound) { newX Math.max(0, Math.min(newX, activeItem.maxX)); newY Math.max(0, Math.min(newY, activeItem.maxY)); } setTransform(activeItem.element, newX, newY); config.onMove(activeItem.element, newX, newY); if (config.preventScroll) { e.preventDefault(); } }, { passive: false }); document.addEventListener(touchend, function (e) { if (!activeItem || !activeItem.element) return; var touch e.changedTouches[0]; if (!touch) return; var element activeItem.element; var finalX activeItem.startTranslateX (touch.pageX - activeItem.startTouchX); var finalY activeItem.startTranslateY (touch.pageY - activeItem.startTouchY); if (config.rebound (finalX 0 || finalY 0 || finalX activeItem.maxX || finalY activeItem.maxY)) { var targetX Math.max(0, Math.min(finalX, activeItem.maxX)); var targetY Math.max(0, Math.min(finalY, activeItem.maxY)); element.style.transition transform 0.2s ease-out; setTransform(element, targetX, targetY); setTimeout(function () { element.style.transition ; }, 220); config.onEnd(element, targetX, targetY); } else { setTransform(element, finalX, finalY); config.onEnd(element, finalX, finalY); } activeItem null; }, { passive: false }); } DragManager.prototype.destroy function () { // 实际项目中如果页面销毁需要释放document上的事件监听 // 简单写法可以给document上挂标记或用removeEventListener // 这里省略完整解绑代码 window.__dragManager null; }; window.DragManager DragManager; })(window); var dragManager new DragManager({ dragSelector: .drag-item, container: .container, rebound: true, onEnd: function (element, x, y) { var log document.querySelector(.log); log.textContent element.innerText 拖拽结束x Math.round(x) , y Math.round(y); } }); /script /body /html这里有一个关键配置container我传的是.container而不是默认的window这意味着拖拽范围被限制在背景图片所在区域内贴纸不会被拖到图片区域外。如果希望整个屏幕都可以拖改成window即可。demo里的.drag-item初始位置是通过transform的translate3d设置的。为什么不用left和top因为插件内部是靠getComputedStyle去解析transform值如果用left/top设定初始位置解析到的是浏览器默认transform为none取值会变成0导致第一次拖拽时元素跳动。这一点是新手最容易踩的坑务必注意。3.4 标签拖拽排序demo频道管理标签拖拽的需求和图片拖拽不太一样。图片拖拽是“位置自由”标签拖拽是“顺序变化”。实现思路是在onEnd回调里判断拖拽元素当前的位置落到了哪个元素区域内然后执行数组重排。这里给出一个简化但可用的实现框架!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno title标签拖拽排序Demo/title style body { padding: 60px 16px; font-family: sans-serif; } .tag-list { display: flex; flex-wrap: wrap; gap: 10px; padding: 16px; background: #f5f5f5; border-radius: 8px; min-height: 120px; } .tag-item { width: 80px; height: 36px; background: #007aff; color: white; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; touch-action: none; user-select: none; transition: transform 0.15s ease-out; } .tag-item.dragging { opacity: 0.6; transform: scale(1.1); } .tag-item.placeholder { box-shadow: 0 0 0 2px #ff9500; } /style /head body div classtag-list idtagList div classtag-item推荐/div div classtag-item关注/div div classtag-item视频/div div classtag-item热点/div div classtag-item本城/div /div script // 复用上面的DragManager源码这里只展示onEnd中的排序逻辑 var tagList document.getElementById(tagList); var tags Array.prototype.slice.call(tagList.children); var draggingElement null; var dragManager new DragManager({ dragSelector: .tag-item, container: #tagList, rebound: false, onStart: function (element) { draggingElement element; element.classList.add(dragging); }, onMove: function (element, x, y) { if (!draggingElement) return; // 实时检测是否落在了其他标签的区域内 var placeholderTarget null; tags.forEach(function (tag) { if (tag draggingElement) return; var rect tag.getBoundingClientRect(); var elementRect draggingElement.getBoundingClientRect(); // 判断两个矩形是否相交 var isOverlap !(elementRect.right rect.left || elementRect.left rect.right || elementRect.bottom rect.top || elementRect.top rect.bottom); if (isOverlap) { placeholderTarget tag; } }); // 清掉之前高亮 tags.forEach(function (tag) { tag.classList.remove(placeholder); }); if (placeholderTarget) { placeholderTarget.classList.add(placeholder); } }, onEnd: function (element, x, y) { if (!draggingElement) return; tags.forEach(function (tag) { tag.classList.remove(placeholder, dragging); }); // 找到交集目标移动DOM顺序 var target null; tags.forEach(function (tag) { if (tag draggingElement) return; var rect tag.getBoundingClientRect(); var elementRect draggingElement.getBoundingClientRect(); var isOverlap !(elementRect.right rect.left || elementRect.left rect.right || elementRect.bottom rect.top || elementRect.top rect.bottom); if (isOverlap) { target tag; } }); if (target) { // 将拖拽元素插入到目标元素之后或之前这里以“移到目标后面”为例 tagList.insertBefore(draggingElement, target.nextSibling); } // 重置拖拽元素的transform并清理状态 element.style.transition transform 0.15s ease-out; element.style.transform translate3d(0,0,0); setTimeout(function () { element.style.transition ; draggingElement null; }, 200); } }); /script /body /html这个方案的核心是“相交检测”每次touchmove里遍历所有非当前元素和当前拖拽元素的getBoundingClientRect做矩形相交判断相交则高亮目标。松手后如果有高亮目标就把当前元素移动到目标元素后面最后重置transform归零。排序逻辑没有做复杂的动画过渡效果看起来比较朴素但胜在稳定。如果要做类似iOS的自动插入动画数据驱动方式会更合适维护一个数组拖拽结束重排数组后重新渲染列表用FLIP动画过渡。4. mui与H5App场景下的实战适配4.1 mui的页面切换与拖拽定位冲突mui框架的常见页面结构是多个webview叠加切换或者单个webview内多个div页面切换。无论哪种拖拽时都要注意元素所在的定位上下文。如果拖拽元素用了position: fixed它相对于浏览器窗口定位不受父容器overflow影响效果直观。但fixed定位在iOS键盘弹起时可能出现位置错乱而且和mui的侧滑菜单、标题栏等区域容易重叠。更稳妥的方案是把拖拽容器设置为position: relative或absolute拖拽元素使用absolute定位加transform位移。这样可以保证拖拽范围只在容器内生效不会被fixed定位拖到页面外。在mui里初始化插件还有一个时机问题。mui的页面通常在mui.plusReady或者mui.init回调之后操作DOM如果插件绑定事件时DOM还没渲染完整事件委托会失效。建议把new DragManager的调用放到mui.plusReady或者页面初始化完成后再执行。4.2 iOS橡皮筋回弹与坐标漂移iOS内置浏览器以及各类App内嵌webview在滚动到页面边界时会有一个整页回弹的“橡皮筋”效果。这个效果在做拖拽时非常致命手指按住元素往下拖系统以为要回弹整个页面跟着位移元素坐标瞬间乱套。解决办法是在touchmove里统一阻止页面的默认滚动行为。插件里preventScroll配置项就是干这个的。但不是所有情况下都适合直接preventDefault如果拖拽元素本身位于一个可滚动区域内比如一个scroll列表里做竖向拖拽排序你把滚动禁了用户就没法滑列表了。这种场景需要细化处理判断拖拽方向。如果用户拖拽主要是横向就只阻止横向滚动如果拖拽是竖向的但列表本身需要滚动插件要自行判断是否让出手势。我在项目里的经验是如果元素是放到固定容器里的直接全局preventDefault如果元素需要和滚动列表共存最好在touchstart时记录起始点的pageX/pageY在touchmove里根据横向和纵向位移谁更大来决定拦截方向。另外iOS还有一个坑CSS的touch-action属性在部分WebView尤其是WkWebView的某些版本中声明了touch-action: none浏览器会自动禁止默认滚动即使不调用preventDefault也能生效。但Android的老版本WebView对touch-action支持不完整所以兼容做法是“双保险”CSS设了touch-action: noneJS里照样调用preventDefault。4.3 PC浏览器调试touch事件的替代方案开发阶段用Chrome DevTools的Device Mode模拟移动设备时触摸事件可以被模拟出来但体验和真实手机还是有些差异。调试时我习惯额外集成一个工具用手指坐标模拟touch事件的库这里不引入具体库名核心思路是监听PC的mousedown/mousemove/mouseup事件然后手动构造出touches和changedTouches结构再分发给touch事件处理器。这个模拟只需要写几十行代码调试效率提升很大。真实项目中还有一个更省事的办法在PC上先把拖拽逻辑写成一个独立的函数入参只有startX、startY、currentX、currentY几个数值不直接依赖touch事件对象。这样PC调试时直接在控制台调用函数验证逻辑手机上跑touch事件时把坐标传进去即可。4.4 长按选中、点击穿透与系统菜单移动端拖拽还有一个麻烦拖拽元素内部如果有文字或图片长按会触发系统的文本选择菜单或者图片保存菜单体验很突兀。处理方法是给拖拽元素加上user-select: none、-webkit-user-select: none以及-webkit-touch-callout: none。CSS片段.drag-item { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }-tap-highlight-color置为transparent是为了去掉点击时的灰色半透明遮罩这也是移动端常见的默认样式。点击穿透问题出现在拖拽结束时。touchend触发后浏览器在约300ms后会补发一个click事件如果拖拽元素松手时正好覆盖了底下的按钮这个click事件会被按钮捕获导致“我没点击按钮但按钮却触发了”。解决方案是在touchend回调里记录拖拽结束时间然后在document的click事件监听里判断如果距离上次touchend的时间小于350ms就拦截这次click。具体的实现片段var lastTouchEndTime 0; document.addEventListener(touchend, function (e) { lastTouchEndTime Date.now(); }, true); document.addEventListener(click, function (e) { var gap Date.now() - lastTouchEndTime; if (gap 350) { e.preventDefault(); e.stopPropagation(); } }, true);注意这个click处理器要用捕获阶段并加上true参数否则部分浏览器在冒泡阶段拦截不了。4.5 webview中页面滚动容器的坐标处理H5App里经常有整页滚动、局部列表滚动、原生控件覆盖等混合场景。拖拽的坐标基准如果直接用pageX/pageY页面本身就滚动过则会自带额外偏移这是合理的。但如果拖拽元素的容器恰好是某个滚动的div这个div的scrollTop变化并不会反映到pageX/pageY里导致拖拽出现跳变。我在实际项目中遇到的就是这样一个情况列表区域内部可以上下滚动同时内部元素还要支持横向拖拽排序。一开始坐标总是对不上后来发现是scrollTop变化后触摸点的pageY是页面坐标系而元素的position是相对滚动容器的。解决办法是在touchmove计算位移时额外减去容器scrollTop的变化量。这类问题排查难度较大建议先在PC上写一个纯静态的测试页把滚动容器、拖拽元素和最终坐标都打印出来再判断坐标系偏差。一旦确认是滚动坐标影响计算的修正方式是var scrollTopDelta scrollContainer.scrollTop - scrollContainerLastScrollTop; newY - scrollTopDelta; scrollContainerLastScrollTop scrollContainer.scrollTop;5. 常见问题与排错速查下面这张表整理了我做移动端拖拽时遇到的高频问题和对应解法按出现频率排序问题现象可能原因解决办法拖拽时页面跟着滚动touchmove默认行为没阻止CSS加touch-action: noneJS里调用preventDefault元素第一次拖拽时发生位置跳变初始位置用了left/top而插件按transform解析初始位置用translate3d()设置iOS上拖拽错位、页面乱动橡皮筋回弹干扰在touchmove里统一preventDefault拖拽松手后底下的按钮被触发300ms延迟后的click穿透记录touchend时间350ms内拦截click长按拖拽元素弹出系统菜单文本选择/图片保存默认行为加user-select、-webkit-touch-callout: none多指触控导致元素跳动touchmove拿到的是第二根手指判断e.touches.length 1时退出拖拽低端安卓机拖拽卡顿left/top移动导致重排改用transform: translate3d在滚动列表内部拖拽时坐标错乱容器scrollTop变化未计入计算位移时减去容器scrollTop变化量回弹动画生效后下次拖拽卡顿transition属性未清理setTimeout结束后清空transition动态添加的元素无法拖拽事件绑定在创建之前确保使用事件委托document级监听另外排错时一个实用的习惯把touchstart、touchmove、touchend里拿到的pageX、pageY、目标元素、transform值全部console.log出来在手机上用vConsole或者直接在Device Mode面板里观察。拖拽问题80%都出在坐标计算上坐标打印出来非常直观。最后再分享一个个人经验。这种和手势打交道的功能开发阶段一定要真机测试PC模拟器只能做逻辑验证。不同手机浏览器和App webview在触摸行为上的差异很大用自己的主力机做测试远远不够至少得拿一台安卓中低端机和一台iPhone测一次。我在这个插件上花的时间大概有三成是在各个webview里调试默认行为和坐标偏差但也是这些踩坑经历让插件最终能做到开箱即用。如果你在实际接入中遇到这篇文章没覆盖到的问题可以从两个方向入手排查先确认坐标基准pageX、clientX、offsetX的差异导致的问题最多再确认默认行为是否被正确阻止。这两点解决了移动端拖拽的基本盘就稳了。本文还有配套的精品资源点击获取
返回列表