☰
原生JS实现div鼠标滚轮横向滚动与拖拽平滑滚动
2026/10/14 22:14:09 网站建设 项目流程

这个需求我在项目里碰到了好几次,每次都有同事直接问“怎么用鼠标滚轮让div横着滚”。浏览器默认行为里,鼠标滚轮对应的是纵向滚动,横向滚动得按住Shift才行,但这个交互对很多不熟悉快捷键的用户来说根本不知道。而且数据大屏、表格超宽展示、横向时间轴这类场景越来越多,横向滚动已经不是“锦上添花”而是“刚需”了。

这篇文章我会把实现思路完整拆开,从最朴素的wheel事件映射,到带缓动的平滑滚动,再到拖拽滚动,最后聊一聊我在实际项目中踩过的坑。代码全部原生JavaScript实现,不依赖任何框架,你拿到任何项目里都能直接用。

1. 需求来源与核心难点:为什么div不能直接用滚轮横向滚动

1.1 哪些场景最需要横向滚动的div

先说场景,因为理解需求才能写对代码。我自己碰到最多的是这三类:

第一类是数据可视化大屏。大屏的宽度往往是1920甚至更高,但有些业务数据表格的列数特别多,比如设备监控列表、供应链订单明细,横向总宽度能到4000px以上。设计上又不允许换行压缩,这时候容器必然溢出,需要一个横向滚动区。

第二类是横向时间轴。活动运营页面、项目管理看板里经常有“近三个月里程碑”这种设计,节点从左往右排,容器宽度超出视口。用户习惯用滚轮往下翻,发现在这个区域里滚轮没反应,体验就很割裂。

第三类是图片/卡片走廊。电商首页的“为你推荐”、视频网站的横向推荐位,都是一排卡片横向排布,鼠标移入时希望能横向滚动浏览,而不是只能点箭头按钮。

这些场景的共同痛点是:用户手里的鼠标滚轮默认只处理纵向滚动,而内容却横着摆。我们要做的,就是接管浏览器的滚轮行为,把“纵向的滚动意图”翻译成“横向的滚动效果”。

1.2 滚轮事件到滚动位置的完整链路

要理解怎么实现,得先搞清楚浏览器里滚轮和滚动之间的关系。

鼠标滚动时,浏览器派发的是wheel事件。这个事件对象上有两个核心属性:

  • deltaY:纵向滚动量,鼠标滚轮往下滚是正值,往上滚是负值。
  • deltaX:横向滚动量,一些支持横向滚动的鼠标(比如轨迹球、带横向滚轮的鼠标)会产生这个值,普通鼠标这个值基本是0。

浏览器的默认行为是:把deltaY交给页面的滚动机制,去修改“祖先滚动容器”的scrollTop。而div元素要想横向滚动,必须设置overflow-x: scroll或auto,滚动位置由scrollLeft属性控制。这个属性和scrollTop是平行的,浏览器默认不会把deltaY映射到scrollLeft上。

所以我们的任务很明确:在wheel事件里读取deltaY(也可能有deltaX),阻止默认的纵向滚动,把值累加到目标div的scrollLeft上。这就是整个方案的底层逻辑,后面的所有优化都是在这个基础上做的。

2. 第一个能跑的版本:wheel事件监听与scrollLeft直接映射

2.1 拿到delta值,才能谈“转换”

先写一个最简陋但能用的版本。假设页面上有一个id="scroller"的div,宽度固定,内部内容超宽,overflow-x: auto。

const scroller = document.getElementById('scroller'); scroller.addEventListener('wheel', function(event) { // 阻止浏览器默认的纵向滚动行为 event.preventDefault(); // 直接把纵向滚动量加到横向滚动位置上 scroller.scrollLeft += event.deltaY; }, { passive: false });

这段代码的原理非常直接:每次滚动鼠标滚轮,就拿到deltaY(比如往下滚一格大约是100左右),把它加到scrollLeft上,内容就横向移动了。

注意两点。第一,event.preventDefault()必须调用,否则浏览器会同时滚动页面的外层容器,导致横向滚动的同时页面也在上下跳动,体验非常差。第二,监听器选项里必须写{ passive: false }——因为从Chrome 56开始,wheel事件默认是passive: true,意味着浏览器假定你不会调用preventDefault,就算调用了也会被忽略,并会在控制台打印警告。这个参数平时没人注意,但在这里不写就必然出bug。

这个版本的体验很粗糙:滚动是一格一格的,没有惯性,没有缓动,但核心逻辑已经通了。

2.2 把deltaX也考虑进去

有的鼠标(苹果的Magic Mouse、部分罗技型号)在触摸表面横向滑动时会产生deltaX。如果只处理deltaY,这类设备的横向滑动会失效。最简单的处理是两者都加上:

scroller.addEventListener('wheel', function(event) { event.preventDefault(); scroller.scrollLeft += event.deltaY + event.deltaX; }, { passive: false });

不过这个写法有个问题:两台不同的设备在同一个页面上操作,可能一个滚动量大一个滚动量小,手感不一致。比较稳的做法是做一个比例系数调节:

const scale = 1; // 根据需要调整,比如0.8让滚动更轻,1.5让滚动更快 scroller.scrollLeft += (event.deltaY + event.deltaX) * scale;

2.3 如何防止外层页面跟着滚动

直接监听滚轮并preventDefault,解决了功能问题,但引入了一个新的交互冲突:如果鼠标在横向滚动区域里,用户本来是想上下滚动页面的,结果页面纹丝不动,只有div内容在横着移动。这个在某些场景下是符合预期的(比如大屏内嵌报表),但在另一些场景下会很恼人。

业界比较常见的处理思路是:只在用户滚轮意图明显是“横向”的时候拦截。怎么判断意图?看deltaX和deltaY的绝对值大小——如果横向滚动的位移量明显大于纵向,就视为横向意图。Shift键按下的情况也当作横向意图(浏览器原生行为就是按住Shift把纵向滚动转为横向)。

scroller.addEventListener('wheel', function(event) { const deltaX = Math.abs(event.deltaX); const deltaY = Math.abs(event.deltaY); const isHorizontalIntent = deltaX > deltaY || event.shiftKey; if (isHorizontalIntent) { event.preventDefault(); scroller.scrollLeft += event.deltaY + event.deltaX; } // 否则不拦截,让页面正常上下滚动 }, { passive: false });

这个版本比无脑拦截友好得多,它只在用户确实想横向滚的时候才接管。真实项目中我一般直接采用这个版本作为基础。

3. 滚轮方案之外的交互补充:拖拽横向滚动实现与细节打磨

光有滚轮还不够。很多用户面对横向滚动区域,第一反应是“按住鼠标左键拖动”。尤其在大屏和触屏笔记本上,拖拽几乎是本能操作。所以完整的横向滚动方案,滚轮和拖拽至少要有一个。

3.1 按下、移动、松开三件套

拖拽的核心逻辑是记录鼠标按下时的起始位置和容器的起始scrollLeft,然后随着鼠标移动更新scrollLeft。

const scroller = document.getElementById('scroller'); let isDragging = false; let startX = 0; let startScrollLeft = 0; scroller.addEventListener('mousedown', function(event) { if (event.button !== 0) return; // 只响应左键 isDragging = true; startX = event.clientX; startScrollLeft = scroller.scrollLeft; // 拖拽时禁止文本选中 scroller.classList.add('dragging'); }); document.addEventListener('mousemove', function(event) { if (!isDragging) return; const deltaX = event.clientX - startX; scroller.scrollLeft = startScrollLeft - deltaX; }); document.addEventListener('mouseup', function() { isDragging = false; scroller.classList.remove('dragging'); });

几个关键细节:

  • mousemove和mouseup绑定在document而不是容器上。如果只绑在容器上,鼠标一旦移出容器范围就丢了事件,拖拽体验会突然中断。
  • 记录的是startScrollLeft,不是直接在当前scrollLeft上加减。用差值计算可以避免多次累加导致的误差累积。
  • 鼠标右移、内容向左移动,所以scrollLeft = startScrollLeft - deltaX,方向别搞反。

3.2 拖拽时的两个“鬼畜”问题:文本选中与原生图片拖拽

拖拽方案一跑起来,马上会遇到两个恶心问题。

第一个是文本选中。鼠标拖动时,浏览器会默认选中div里的文字,视觉上出现大片蓝色高亮,非常掉价。解决办法是加CSS:

.scroller.dragging { user-select: none; cursor: grabbing; } .scroller { cursor: grab; }

第二个问题是图片的原生拖拽。如果div里有<img>标签,默认情况下图片本身是可以被拖走的,拖拽过程中会出现一个半透明的图片残影,和我们的滚动逻辑互相干扰。需要给图片禁用默认拖拽行为:

.scroller img { pointer-events: none; }

或者用draggable="false"属性逐个禁用。视情况选一种,我个人建议CSS全局禁用更省事。

3.3 加一个“速度感”:拖拽释放后的惯性滚动

直接拖拽是“指哪打哪”的,手指一停内容马上停,在长列表场景下会觉得太生硬。想要有惯性,可以在mouseup时记录最后一段时间的移动速度,然后用requestAnimationFrame让scrollLeft按速度衰减。

let lastMoveTime = 0; let lastMoveX = 0; let velocity = 0; document.addEventListener('mousemove', function(event) { if (!isDragging) return; const now = Date.now(); const dt = now - lastMoveTime; if (dt > 0) { velocity = (event.clientX - lastMoveX) / dt; // 像素/毫秒 } lastMoveTime = now; lastMoveX = event.clientX; // ... 原有scrollLeft更新逻辑 }); document.addEventListener('mouseup', function() { isDragging = false; // 开始惯性动画 if (Math.abs(velocity) > 0.1) { animateInertia(); } }); function animateInertia() { // 每帧让scrollLeft += velocity * frameDuration,velocity逐步衰减 function step() { if (Math.abs(velocity) < 0.05) return; scroller.scrollLeft += velocity * 16; // 假设每帧16ms velocity *= 0.95; // 衰减系数 requestAnimationFrame(step); } requestAnimationFrame(step); }

衰减系数0.95是个经验值,想要更长的滑行距离就调大到0.98,想要更短就调小。这个属于手感调节的范畴,每家的业务风格不一样,没有标准答案。

4. 让滚动有“质感”:requestAnimationFrame平滑滚动的实现思路

前面第2节的scrollLeft += event.deltaY版本能用,但滚动是瞬时的、割裂的,像翻页一样一格一格蹦,和现代操作系统的平滑滚动体验差了十万八千里。要让滚动有质感,核心思路是:不直接修改scrollLeft,而是维护一个“目标值”和一个“当前值”,每帧让当前值向目标值靠近。

4.1 为什么直接赋值会显得生硬

滚轮事件每次产生的deltaY是一个离散的脉冲,事件触发频率很高(高刷新率鼠标可能每几十毫秒就触发一次),但如果直接累加到scrollLeft,就会产生“跳动感”。因为每次累加都是即时生效的,相当于把连续运动的输入信号做了无滤波的硬输出。

理想的效果应该像用手指推一个箱子——你给一个力,箱子平滑滑动,慢慢停下。这需要引入时间维度的插值。最常见的方式是线性插值(lerp),也叫指数平滑。

4.2 目标值 + 当前值的双值动画模型

let targetScrollLeft = 0; let currentScrollLeft = 0; let isAnimating = false; scroller.addEventListener('wheel', function(event) { event.preventDefault(); targetScrollLeft += event.deltaY; // 限制目标范围 const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth; targetScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScrollLeft)); if (!isAnimating) { currentScrollLeft = scroller.scrollLeft; isAnimating = true; animate(); } }, { passive: false }); function animate() { // 每次向目标靠近一定比例,比如20% currentScrollLeft += (targetScrollLeft - currentScrollLeft) * 0.2; // 如果距离足够小,直接收敛 if (Math.abs(targetScrollLeft - currentScrollLeft) < 0.5) { currentScrollLeft = targetScrollLeft; scroller.scrollLeft = currentScrollLeft; isAnimating = false; return; } scroller.scrollLeft = currentScrollLeft; requestAnimationFrame(animate); }

这个模型有几个好处:

第一,滚轮事件的输入频率和动画帧率解耦了。哪怕1秒内滚轮触发了100次事件,动画依然稳定在60fps运行。第二,从动到停是渐变的,0.2的逼近系数让滚动有一种“柔和跟手”的感觉。第三,代码结构清晰,后面要加缓动函数、加速度控制都在这一个函数里改。

0.2这个系数意味着当前值与目标值的差距每帧缩小80%。如果你想让滚动跟手一点,可以调到0.3~0.35;想要更绵长的滑动感,可以降到0.1~0.15。在实际调的时候我一般先用0.2,再根据产品反馈微调。

4.3 把缓动从“线性逼近”升级为“物理模拟”

线性逼近的手感虽然平滑,但缺少“推一把滑出去”的物理感。想要更接近原生滚动,可以在目标值的基础上引入速度计算,每个帧用速度驱动位移,速度再受摩擦力衰减。这个就是我前面拖拽惯性那块用的模型。两个模型的本质区别是:

  • lerp模型:目标永远存在,无限逼近但需要手动收敛。
  • 物理模型:不设目标,靠初始速度自然滑行,摩擦力决定滑行距离。

在实际产品中,我测试下来的最优体验是:滚轮输入用lerp模型(因为滚轮是持续输入,不需要滑行),拖拽释放用物理模型(因为拖拽释放后需要滑行)。两者可以共存在同一个容器上。

5. 我在真实项目中踩过的坑:边界情况与异常处理

基础功能和动画都有了,但真实环境远比demo复杂。下面这几个坑,是我在多个实际项目中逐一踩过的,按危险程度排序。

5.1 嵌套滚动容器的“滚动抢动”问题

最常见的情况是:横向滚动div外面套着一个纵向可滚动的页面。如果我们在横向div里鼠标滚轮,preventDefault会同时禁用外层的纵向滚动——这是有意为之的,但如果横向div的内容已经滚到头了呢?用户继续滚,预期是外层页面开始滚动,但实际是页面纹丝不动,内容也没任何反应,交互直接僵死。

正确的处理是:在wheel处理里判断是否还能继续横向滚动。如果横向已经处于边界,就不调用preventDefault,把滚动事件放行给外层容器。

scroller.addEventListener('wheel', function(event) { const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth; const atLeftEdge = scroller.scrollLeft <= 0 && event.deltaY < 0; const atRightEdge = scroller.scrollLeft >= maxScrollLeft && event.deltaY > 0; if (atLeftEdge || atRightEdge) { // 不拦截,让事件继续传递 return; } event.preventDefault(); targetScrollLeft += event.deltaY; // ... 后续动画逻辑 }, { passive: false });

这个边界判断看起来很基础,但很容易被忽略。不处理的话,用户在大屏页面上操作时,一旦横滚到头滚轮就“卡死”,这种感觉比没有横向滚动还糟糕。

5.2 macOS触摸板与鼠标滚轮的delta差异

同一个event.deltaY,在不同设备上数值差异巨大。普通鼠标滚轮一格大约100~120,而MacBook触摸板的双指滑动,一次可能只有2~5。如果代码里写死了滚动系数,就会出现“鼠标用户滚得飞快、触摸板用户滚得像乌龟”的体验割裂。

解决方案是使用event.deltaMode来判断delta值的单位。deltaMode有三个取值:

  • 0:像素单位(触摸板和一些高精度鼠标)
  • 1:行单位(大部分传统鼠标)
  • 2:页单位(很少见)

行单位的100并不等于像素单位的100。需要做归一化:如果deltaMode === 1,把deltaY乘以一个行高经验值,比如16或24像素,再参与计算。

function normalizeDelta(delta, deltaMode) { switch (deltaMode) { case 1: // 行 → 像素 return delta * 16; case 2: // 页 → 像素 return delta * window.innerHeight; default: return delta; } }

这样一来,不同设备上的滚动量就大致可比较了。这个细节是很多同类教程不提的,但实际效果差异巨大。

5.3 Shift键与浏览器原生横向滚动的冲突

浏览器其实内置了一个“按住Shift+滚轮=横向滚动”的行为。但问题是,这个原生行为在不同浏览器里触发细节不一样:Chrome里它会修改滚动方向,但Firefox和Safari有时会乱套。当我们自己实现了横向滚动后,如果用户恰好按着Shift,会出现“双重横向滚动”——我们的代码滚一次,浏览器原生行为又滚一次,结果内容飞速横移。

解决办法是在wheel处理里明确判断event.shiftKey,如果按下了Shift,就剥离原生行为,只执行我们自己的逻辑:

if (event.shiftKey) { event.preventDefault(); // 禁止原生Shift横滚 // 走我们自己的横向滚动逻辑 targetScrollLeft += event.deltaY; // 注意:Shift时主要看deltaY }

如果不加这个处理,Shift+滚轮会变成灾难。

5.4 动态内容变化时的边界重置

还有一个隐蔽的坑:横向滚动容器的内容可能是动态加载的。比如表格翻页后列宽变化、图片懒加载后宽度撑开,都会改变scrollWidth。如果我们的targetScrollLeft和currentScrollLeft是缓存在变量里的,内容变窄后目标值可能大于新的最大值,动画会把容器滚到超出边界的位置。

每次滚轮事件触发时重新计算maxScrollLeft,并且在requestAnimationFrame循环里也实时检查边界:

function animate() { const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth; if (currentScrollLeft > maxScrollLeft) { currentScrollLeft = maxScrollLeft; isAnimating = false; } // ... }

另外,Vue或React项目里如果用了v-if切换内容,记得在切换完成后重新初始化targetScrollLeft和currentScrollLeft,否则会出现动画从旧值继续跑的灵异现象。

6. 让方案更完整:自动方向识别、自定义滚动条与工程封装

最后分享三个我实际用到的工程化扩展,让这套方案从“能用”变成“好用”。

6.1 自动识别“该不该开启横向滚动模式”

不要给页面上所有div都开启横向滚动。更好的做法是写一个通用函数,在初始化时判断容器是否真的存在横向溢出:

function initHorizontalScroll(container) { const hasHorizontalOverflow = container.scrollWidth > container.clientWidth; if (!hasHorizontalOverflow) return; // 绑定wheel、拖拽等事件 bindWheel(container); bindDrag(container); }

这个判断逻辑要放在容器内容渲染完成之后(比如setTimeout(0)、requestAnimationFrame或框架的nextTick)再执行,否则拿到的scrollWidth是旧值。

6.2 按住某键才启用的“临时横向滚动模式”

有些场景不希望滚轮直接拦截,而是想让用户按住空格键或某个自定义键时,滚轮才切换为横向滚动。这样既保留了正常纵向滚动的页面体验,又能在需要时横向浏览。

实现思路很简单:维护一个isHorizontalMode变量,在keydown和keyup里切换状态,wheel处理时判断该变量。用空格键的话注意event.code === 'Space',同时要preventDefault阻止空格触发的页面滚动。

6.3 自定义滚动条与横向滚动的配合

横向滚动区域默认的浏览器滚动条非常丑,而且很多时候我们希望隐藏原生滚动条,用自定义样式替代。隐藏原生滚动条的兼容写法:

.scroller { scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE/Edge */ } .scroller::-webkit-scrollbar { height: 0; display: none; }

隐藏之后,滚动只能靠滚轮和拖拽驱动。此时滚轮方案就不是增强,而是核心交互了。用户滑动滚轮时必须反馈灵敏,所以建议把第4节的lerp动画系数调大一点,让滚动更跟手。

还有一种做法是不隐藏滚动条,但把它样式化。Chrome支持的情况下可以用:

.scroller::-webkit-scrollbar { height: 8px; } .scroller::-webkit-scrollbar-thumb { background: #999; border-radius: 4px; }

不管选哪种,都要在真实设备上测一遍触摸板滑动和鼠标滚轮的手感。代码写完之后,最容易被忽略的一步,是回到产品场景里实际用一下,把起始端的边界卡顿、尾部的惯性收敛这些细节调顺了,这个功能才算真正交付。

整体下来,横向滚动这个需求的实现其实不复杂,核心就是wheel事件和scrollLeft之间的映射,以及一个平滑动画模型。复杂度基本都藏在边界处理上。我把这些年碰到的坑都写出来了,你把代码按章节组装起来,加上自己的手感系数,直接用到项目里不会有大问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询