
这个需求我在项目里碰到了好几次每次都有同事直接问“怎么用鼠标滚轮让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值才能谈“转换”先写一个最简陋但能用的版本。假设页面上有一个idscroller的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; }或者用draggablefalse属性逐个禁用。视情况选一种我个人建议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 * frameDurationvelocity逐步衰减 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之间的映射以及一个平滑动画模型。复杂度基本都藏在边界处理上。我把这些年碰到的坑都写出来了你把代码按章节组装起来加上自己的手感系数直接用到项目里不会有大问题。