去年有个项目里提了个需求:一个横向卡片列表,桌面端没有触摸屏,产品经理要求“鼠标按住列表就能左右拖动”,而且希望手感尽量接近移动端的滑动列表。我翻了一下现有的轮播插件,要么太重,要么样式耦合太深;用原生 JS 写一个面向对象的拖动列表,反而是最省事、最可控的方案。
这个案例的核心不复杂:监听mousedown、mousemove、mouseup,把鼠标的横向位移换算成容器的scrollLeft,再用一个类把状态和事件封装起来。今天把这套实现拆开讲,包括为什么用面向对象、核心原理、完整代码、点击冲突处理、越界问题、销毁机制,以及我实际使用中踩过的坑。
1. 为什么我会用面向对象来写这个拖动列表
1.1 全局变量版本的痛
先说我最早写的一版,那时候不想动脑子,直接在页面顶部写了几个全局变量:
let isDragging = false; let startX = 0; let startScrollLeft = 0;然后对着第一个列表容器绑定事件。写完之后发现页面上一共有三个同类列表,我只能把事件处理函数复制三份,或者用一个querySelectorAll循环去绑定。如果还要给某个列表单独设置不同的拖动阈值、不拦截其中的某个按钮点击,代码就开始变得很难看。
更麻烦的是状态管理。全局变量意味着同一时间只能有一个拖动状态。虽然大多数场景下用户一次只会拖一个列表,但只要你稍不注意,就可能出现两个列表实例互相覆盖状态的情况。而且这类代码通常没有destroy,页面进入 SPA 切换路由后,事件监听还挂在旧容器上,内存泄漏就这么出来了。
用面向对象重构之后,每个列表都是一个独立对象,状态存在对象内部,方法通过this访问,不会再出现 A 列表的拖动状态把 B 列表的状态覆盖掉的问题。
1.2 类封装之后的效果
这个案例里的类用起来非常简单:
const productList = new DraggableList({ container: document.querySelector('.product-list') }); const storyList = new DraggableList({ container: document.querySelector('.story-list'), threshold: 8 });两个列表互不干扰。一个列表被销毁后,另一个还是正常工作的。每个实例的内部状态,例如“当前是否按下”“起点坐标”“起始 scrollLeft”,全部存在于实例属性里,不会跑到全局作用域里去污染其他代码。
你可能会说,用闭包也可以做到类似的封装效果。确实可以,但闭包方案在需要支持多个实例、需要暴露destroy方法、需要被其他模块引用时,反而没有类那么直观。类是 JS 初学者也能一眼看懂的“对象模板”,把事件处理函数、状态字段、清理逻辑放在一起,职责边界清楚很多。
1.3 类和普通函数的边界
这里不是要强行“面向对象”。这个案例本质上是把一个交互过程拆成“按下、移动、松开、清理”四个阶段,用类把这些阶段和对应状态绑定起来,比较自然。如果你更喜欢用函数式写法,完全可以写成createDraggableList(container, options)返回一个对象,效果类似。
但我的建议是:当交互状态有两个以上,或者需要提供销毁、重置这类生命周期方法时,用类比裸函数更好维护。拖动列表虽然是几十行代码的小功能,但它有完整生命周期,用对象封装是合理的,不是杀鸡用牛刀。
2. 拖动列表的核心:scrollLeft 与 mouse 事件配合
2.1 拖动的主角不是坐标,而是 scrollLeft
很多第一次写这个功能的人会想着去改卡片的transform: translateX()或者直接改容器的left值。其实根本不用。横向列表本身就是一个可以滚动的容器,浏览器已经提供了scrollLeft这个属性,它就是容器在水平方向上的滚动位置。
核心逻辑只有三步:
mousedown时记录鼠标的clientX和容器当前的scrollLeft。mousemove时计算鼠标位移dx = clientX - startClientX。- 把容器的
scrollLeft设置为startScrollLeft - dx。
为什么是减号,不是加号?可以这样理解:你按住内容往右拖,内容应该往右移动,但可视窗口相对内容是在往左移动,所以滚动位置要减小。反过来,往左拖,dx是负值,startScrollLeft - dx会变大,列表内容往左移动。
container.addEventListener('mousedown', (e) => { startScrollLeft = container.scrollLeft; startX = e.clientX; isDragging = true; }); window.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; container.scrollLeft = startScrollLeft - dx; });这只是一个原型。真实场景里还要处理鼠标移出容器、点击和拖动冲突、文本选中等问题,下面会一个一个解决。
2.2 mousemove 和 mouseup 为什么要绑在 window 上
我见过很多新手把mousemove和mouseup绑定在列表容器上,结果拖动过程中只要鼠标稍微移到容器外面,事件就断了,列表停在原地;更尴尬的是在容器外松开鼠标,mouseup根本没触发,isDragging永远是true,下次再按下时状态错乱。
正确做法是把mousemove和mouseup绑定到window上,因为鼠标只要还在浏览器窗口内,事件就能收到。就算鼠标移出了列表容器,只要没离开窗口,拖动依旧有效:
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp);另外还要补两个保险:鼠标移出浏览器窗口时取消状态,页面切到其他标签页时取消状态。否则用户按住列表,然后切到别的窗口松开鼠标,页面回来之后还以为是按住状态,体验很怪。
2.3 CSS 的 smooth 和 snap 会拖后腿
这个坑非常隐蔽。如果你的容器写了这样一条样式:
.scroll-list { scroll-behavior: smooth; }拖动时每次给scrollLeft赋值,浏览器都会做“平滑滚动动画”。结果就是你鼠标已经停了,列表还在慢慢往前蹭,或者你拖动的速度明明很快,列表却像被什么东西黏住了一样。
解决方案是在拖动状态下强制使用auto:
.scroll-list.is-dragging { scroll-behavior: auto; }更稳妥的做法是默认就别给这个容器写scroll-behavior: smooth,等真正需要“点击按钮滚动到某张卡片”时,用scrollTo({ left: target, behavior: 'smooth' })临时调用平滑行为。
同样的道理,scroll-snap-type: x mandatory也会在拖动过程中反复把列表“吸”到某个位置,让你感觉像在跟浏览器打架。如果产品确实要保留吸附效果,可以在拖动开始加一个 class 把scroll-snap-type设为none,松手后再恢复。
3. 可直接落地的 DraggableList 类实现
3.1 构造参数和事件绑定
下面是我实际用过的实现,去掉了项目里的业务样式,只保留交互逻辑。你可以直接复制使用。
class DraggableList { constructor(options) { this.container = options.container; this.threshold = options.threshold ?? 5; this.axis = options.axis ?? 'x'; this.ignoreSelector = options.ignoreSelector ?? ''; this.suppressClick = options.suppressClick ?? true; this._isMouseDown = false; this._isDragging = false; this._startClientX = 0; this._startClientY = 0; this._startScrollLeft = 0; this._startScrollTop = 0; this._movedDistance = 0; this._suppressClickFlag = false; this._onMouseDown = this._onMouseDown.bind(this); this._onMouseMove = this._onMouseMove.bind(this); this._onMouseUp = this._onMouseUp.bind(this); this._onClickCapture = this._onClickCapture.bind(this); this._onDragStart = this._onDragStart.bind(this); this._onVisibilityChange = this._onVisibilityChange.bind(this); this._bindEvents(); } _bindEvents() { this.container.addEventListener('mousedown', this._onMouseDown); this.container.addEventListener('click', this._onClickCapture, true); this.container.addEventListener('dragstart', this._onDragStart); window.addEventListener('mousemove', this._onMouseMove); window.addEventListener('mouseup', this._onMouseUp); document.addEventListener('mouseleave', this._onMouseUp); document.addEventListener('visibilitychange', this._onVisibilityChange); } _unbindEvents() { this.container.removeEventListener('mousedown', this._onMouseDown); this.container.removeEventListener('click', this._onClickCapture, true); this.container.removeEventListener('dragstart', this._onDragStart); window.removeEventListener('mousemove', this._onMouseMove); window.removeEventListener('mouseup', this._onMouseUp); document.removeEventListener('mouseleave', this._onMouseUp); document.removeEventListener('visibilitychange', this._onVisibilityChange); } _onMouseDown(e) { if (e.button !== 0) return; if (this._isMouseDown) return; if (this.ignoreSelector && e.target.closest(this.ignoreSelector)) return; this._isMouseDown = true; this._isDragging = false; this._startClientX = e.clientX; this._startClientY = e.clientY; this._startScrollLeft = this.container.scrollLeft; this._startScrollTop = this.container.scrollTop; this._movedDistance = 0; } _onMouseMove(e) { if (!this._isMouseDown) return; const dx = e.clientX - this._startClientX; const dy = e.clientY - this._startClientY; const distance = Math.max(Math.abs(dx), Math.abs(dy)); if (!this._isDragging) { if (distance < this.threshold) return; if (this.axis === 'x' && Math.abs(dy) > Math.abs(dx)) { this._cancelDrag(); return; } if (this.axis === 'y' && Math.abs(dx) > Math.abs(dy)) { this._cancelDrag(); return; } this._isDragging = true; this.container.classList.add('is-dragging'); } this._movedDistance = distance; this.container.scrollLeft = this._startScrollLeft - dx; this.container.scrollTop = this._startScrollTop - dy; if (e.cancelable) { e.preventDefault(); } } _onMouseUp() { if (!this._isMouseDown) return; const wasDragging = this._isDragging; const movedDistance = this._movedDistance; this._isMouseDown = false; this._isDragging = false; this.container.classList.remove('is-dragging'); if (wasDragging && movedDistance >= this.threshold && this.suppressClick) { this._suppressClickFlag = true; setTimeout(() => { this._suppressClickFlag = false; }, 0); } } _cancelDrag() { this._isMouseDown = false; this._isDragging = false; this.container.classList.remove('is-dragging'); } _onClickCapture(e) { if (!this._suppressClickFlag) return; e.preventDefault(); e.stopPropagation(); } _onDragStart(e) { e.preventDefault(); } _onVisibilityChange() { if (document.hidden) { this._cancelDrag(); } } destroy() { this._cancelDrag(); this._unbindEvents(); } }配套的样式只要这几行:
.card-list { overflow-x: auto; cursor: grab; user-select: none; -webkit-user-select: none; scroll-behavior: auto; scroll-snap-type: none; } .card-list.is-dragging { cursor: grabbing; } .card-list input, .card-list textarea { user-select: text; }实例化:
const list = new DraggableList({ container: document.querySelector('.card-list') });3.2 类里几个方法分别做了什么
构造器里做了几件事:收集配置、初始化状态、把事件处理函数用bind绑定好、调用_bindEvents。这里比较关键的是bind。如果不做这一步,事件回调里的this会指向触发事件的元素,而不是类实例,很多初学者在这里卡住。
_bindEvents统一管理所有监听。注意mousemove和mouseup挂在window上,不是容器上,原因前面已经说过。
_onMouseDown只监听鼠标左键,同时支持ignoreSelector。比如列表项里有某个按钮,它的点击需要特殊处理,你可以在实例化时传入.no-drag,按下这个区域时直接不进入拖动逻辑。
_onMouseMove是整个类的核心。它先计算位移,如果位移没超过阈值就不进入拖动状态,避免误触发。axis配置可以限制拖动方向,我默认是x,也就是横向列表,如果按住后用户明显在纵向移动,就放弃这次拖动,让浏览器正常处理页面纵向滚动。
_onMouseUp负责清理状态。如果进入过拖动状态并且位移超过阈值,就设置一个短暂的点击抑制标志。这是为了处理“拖动后触发了 click”的问题。
3.3 点击和拖动是怎么区分的
这个案例里我用了“阈值”方案:mousedown之后,如果移动距离小于 5 像素,就认为是一次点击;如果大于 5 像素,就认为是一次拖动。
为什么不是用mousemove次数判断?因为鼠标移动频率不稳定,快速划过可能只触发一两次事件,慢速拖动可能触发几十次。用位移距离判断更符合直觉。
真正难处理的是浏览器会在mouseup之后补发一个click事件。如果列表项里有链接,拖完列表后手指/鼠标一松,链接可能被误触跳转。我的做法是在容器上用捕获阶段监听click:
this.container.addEventListener('click', this._onClickCapture, true);当_suppressClickFlag为true时,直接preventDefault和stopPropagation,阻止这个 click 继续传播到列表项里的链接或按钮上。
这个标志用setTimeout(..., 0)重置,是因为click事件在mouseup之后立刻触发,setTimeout的宏任务会在click触发之后再执行,所以刚好能拦到那一次误触点击,又不会影响下一次真正的点击。
4. 从能用变好用:点击冲突、越界、销毁与多实例
4.1 越界 clamp 与边缘阻尼
浏览器本身会把scrollLeft限制在0和scrollWidth - clientWidth之间。大多数情况下,你直接赋值给scrollLeft,它不会飞到空的区域,所以不做 clamp 也可以。
但如果你想让拖到边界时有一点点“阻尼感”,可以在赋值前手动计算:
const maxScroll = this.container.scrollWidth - this.container.clientWidth; let next = this._startScrollLeft - dx; if (next < 0) { next = next * 0.3; } else if (next > maxScroll) { next = maxScroll + (next - maxScroll) * 0.3; } this.container.scrollLeft = next;这段代码的效果是:拖动超出边界后,列表不会一下子被卡住,而是会表现出一种“往那边推不动,但稍微有点缓冲”的感觉。注意这里的0.3只是一个阻尼系数,你可以自己调。
我的经验是不要在这个功能上过度设计。很多产品经理想要的只是“不要出现空白区域”,没有必须做橡皮筋回弹。做简单 clamp 反而更可控。
4.2 destroy 不只是装饰
如果这个页面只是在传统服务端渲染的页面里用一次,destroy看起来确实可有可无。但现在很多项目都是前端路由切换,页面上某块 DOM 会被移除或者替换,如果不销毁旧实例,事件监听就留在那些已经没有显示的 DOM 上,轻则内存泄漏,重则因为 DOM 被移除后 classList 操作报错。
destroy方法里我做了两件事:
destroy() { this._cancelDrag(); this._unbindEvents(); }_cancelDrag是防止实例销毁时正处于拖动状态,先把状态和 class 清理干净。_unbindEvents移除所有监听。这样单页应用里切换路由后,再也不会出现莫名其妙的 mousemove 回调。
4.3 多实例与动态列表
页面上一组同类卡片,直接各 new 一个实例就行:
const a = new DraggableList({ container: document.querySelector('.a') }); const b = new DraggableList({ container: document.querySelector('.b') });因为每个实例都绑定自己的容器,所以互不干扰。如果列表是动态渲染出来的,可以在数据渲染完成后再创建实例。如果列表容器本身被替换了,记得先调用旧实例的destroy,再对新容器创建实例。
还有一个小细节:如果容器内部使用了img这类默认可拖拽的元素,不处理的话,用户拖动列表时会触发浏览器原生的图片拖拽,列表拖不动不说,图片还会变成半透明跟手移动。这个问题在代码里已经处理了,_onDragStart会在容器捕获到dragstart时直接preventDefault。
5. 后续扩展:惯性滑动、移动端兼容和性能优化
5.1 惯性滑动怎么加
鼠标拖动和触屏滑动有一个体验上的差别:触屏滑动松手后常常自带惯性,鼠标拖动没有。如果你想让桌面端也接近移动端手感,可以自己加一个简单的惯性动画。
思路是在拖动过程中记录最近的滚动位置和时间戳,松手时计算最后的滚动速度,然后用requestAnimationFrame让滚动速度逐渐衰减:
let lastScrollLeft = 0; let lastTime = 0; // mousemove 里记录 lastScrollLeft = container.scrollLeft; lastTime = e.timeStamp; // mouseup 里计算速度 const dt = Date.now() - lastTime; const velocity = (container.scrollLeft - lastScrollLeft) / dt; function animate() { const speed = velocity * 16; velocity *= 0.92; container.scrollLeft += speed; if (Math.abs(speed) > 0.5) { requestAnimationFrame(animate); } } animate();这段代码只是一个最小模型,真实项目中最好再加一个isAnimating状态,避免一次松手后重复启动动画,并且在动画过程中用户再次按下鼠标时要停止动画。惯性滑动的细节不少,但这个功能不是本文的核心,先提个方向。
5.2 顺手支持触屏
如果你不想区分鼠标和触屏,可以考虑把mouse事件统一成 Pointer Events。Pointer Events 是更现代的 API,pointerdown、pointermove、pointerup不仅能处理鼠标,还能处理触摸和触控笔。
用 Pointer Events 时,别忘了在容器上加touch-action,声明哪些手势交给浏览器处理。比如横向列表希望垂直方向滚动交给浏览器,水平方向拖动交给 JS,可以写:
.card-list { touch-action: pan-y; }这样你在触屏上垂直滑动页面不会受影响,水平滑动列表会触发 JS 拖动。如果你只是做桌面端,用原来的 mouse 三件套完全足够,不必强行升级。
5.3 mousemove 的节奏控制
浏览器触发mousemove的频率本身并不慢,但如果你在回调里直接写container.scrollLeft = ...,每个事件都会触发一次布局重算。列表里卡片数量少还好,卡片多了之后,尤其是每张卡片都有阴影、背景图、滤镜的情况下,可能会感到不跟手。
可以用requestAnimationFrame做一次合并写入:
let rafId = null; let targetScrollLeft = 0; function onMouseMove(e) { targetScrollLeft = startScrollLeft - dx; if (rafId === null) { rafId = requestAnimationFrame(applyScroll); } } function applyScroll() { rafId = null; container.scrollLeft = targetScrollLeft; }这个优化不会改变交互逻辑,但能把一帧里多次mousemove产生的写入合并成一次,确实能减少不必要的布局抖动。如果列表结构很简单,不写也没关系;我的习惯是先写简单版本,线上测到卡顿了再加 rAF,不要一开始就把代码复杂化。
最后说点实际的。这个案例看着只有几十行,但事件绑定、状态管理、点击与拖动的冲突、生命周期清理这些前端基本功都被串起来了。如果你照着我这个实现跑一遍,最可能出问题的地方就是 CSS 里的scroll-behavior或scroll-snap没关干净,其次是拖动结束后没有做 click 抑制。这两条处理好了,这个拖动列表基本就能安稳上线。