手把手实现高性能Slider滑块组件的实战经验

シ新云 组件 阅读 2,266
赞 14 收藏
二维码
手机扫码查看
反馈

又踩坑了,移动端滑块拖不动

昨天改一个商品筛选页的滑块组件,本来以为就是个简单的 range input 替换,结果在真机上一测,手指拖动根本没反应——不是卡顿,是完全没触发。我一开始还以为是 z-index 被盖住了,查了半天发现压根不是这回事。

手把手实现高性能Slider滑块组件的实战经验

这个滑块是用来选价格区间的,UI 要求自定义样式,所以不能直接用原生 input[type=”range”]。我手写了一个基于 div 的滑块,用 touchstarttouchmovetouchend 来处理手势。本地 Chrome 模拟器跑得飞起,但一上 iOS 和安卓真机,touchmove 就不触发了,或者只触发一次就断了。

折腾了半天,原来是滚动冲突

我一开始怀疑是事件监听没加对,反复检查了 addEventListener 的参数,确认用了 { passive: false }(因为要调用 preventDefault())。后来试了下在 touchmove 里打 log,发现第一次 move 能进,第二次就没了。

这时候我才意识到:页面本身是可滚动的!当我在滑块上左右拖的时候,系统判定这是“可能想上下滚动页面”,于是把后续的 touch 事件吞掉了,优先交给浏览器默认滚动行为。特别是在 iOS Safari 上,这种策略特别激进。

网上搜了一圈,主流方案有三种:

  • 给滑块容器加 touch-action: none
  • touchstart 里立刻调用 preventDefault()
  • 动态阻止页面滚动(比如给 body 加 overflow hidden)

我先试了第一种:touch-action: none。这玩意儿理论上能告诉浏览器“别管默认手势了,全交给我处理”。加完之后,滑块确实能拖了,但副作用很大——整个区域不能再上下滚动了,哪怕你垂直滑也不行。用户如果刚好在滑块区域想滑动页面,就卡住了。不行,体验太差。

第二种方案,在 touchstart 里直接 e.preventDefault()。这招在部分安卓机上有效,但在 iOS 上照样失效。而且严格来说,touchstart 阶段还不知道用户是要横向拖滑块还是纵向滚页面,提前 prevent 掉可能误伤正常滚动。

最终方案:只在横向移动时阻止默认行为

折腾到快下班,我决定换个思路:不一开始就阻止,而是在 touchmove 判断方向,只有横向位移明显大于纵向时,才调用 preventDefault()。这样既能保证滑块流畅拖动,又不影响页面正常滚动。

核心逻辑是:记录 touchstart 的初始坐标,在 touchmove 中计算 dx 和 dy,如果 |dx| > |dy|,说明用户大概率想拖滑块,这时候再阻止默认滚动。

这里我踩了个坑:必须在 touchmove第一个回调里就调用 preventDefault(),否则 iOS 会认为你放弃了控制权,后续事件就收不到了。所以不能等“移动了一段距离”再判断,而是第一次 move 就要决策。

代码大概长这样:

let startX, startY;
let isPrevented = false;

slider.addEventListener('touchstart', (e) => {
  const touch = e.touches[0];
  startX = touch.clientX;
  startY = touch.clientY;
  isPrevented = false; // 重置状态
}, { passive: false });

slider.addEventListener('touchmove', (e) => {
  if (isPrevented) return; // 已经阻止过就不用再处理

  const touch = e.touches[0];
  const dx = touch.clientX - startX;
  const dy = touch.clientY - startY;

  // 如果横向移动幅度大于纵向,认为是想拖滑块
  if (Math.abs(dx) > Math.abs(dy)) {
    e.preventDefault(); // 关键!阻止页面滚动
    isPrevented = true;
    // 这里处理滑块位置更新逻辑
    updateSliderPosition(touch.clientX);
  }
}, { passive: false });

slider.addEventListener('touchend', () => {
  isPrevented = false;
});

注意两点:

  • passive: false 必须加,否则 preventDefault() 会被忽略(现代浏览器默认 passive 为 true 以提升滚动性能)
  • isPrevented 标志位避免重复调用 preventDefault(),虽然多次调用也没事,但逻辑更清晰

顺便处理了 mouse 事件兼容

光搞定 touch 不行,桌面端还得支持鼠标拖拽。我本来想用 pointer events 一统江湖,但考虑到老项目兼容性,还是分开写了 mouse 事件:

// 鼠标事件处理(简化版)
let isDragging = false;

slider.addEventListener('mousedown', (e) => {
  isDragging = true;
  updateSliderPosition(e.clientX);
  e.preventDefault();
});

document.addEventListener('mousemove', (e) => {
  if (isDragging) {
    updateSliderPosition(e.clientX);
  }
});

document.addEventListener('mouseup', () => {
  isDragging = false;
});

这里注意 mouse 事件要绑在 document 上,否则鼠标移出滑块区域就断了。touch 事件不用,因为手指离开屏幕自然触发 touchend。

还有一点小瑕疵,但能接受

实测后发现,在极少数低端安卓机上,快速斜向滑动时偶尔会“漏掉”一次 preventDefault,导致页面轻微滚动一下。不过不影响滑块功能,用户一般也察觉不到。如果追求完美,可以加个阈值,比如 |dx| > |dy| + 10 再阻止,但我嫌麻烦就没搞——毕竟业务优先,能用就行。

另外,滑块的边界检测也得做,别让用户拖出轨道。这个比较简单,算一下 clientX 相对于滑块轨道的位置,clamp 到 [0, trackWidth] 就行。

完整可运行的核心代码

下面是一个最小可运行的滑块示例(省略样式):

<div class="slider-container">
  <div class="slider-track">
    <div class="slider-thumb" id="thumb"></div>
  </div>
</div>
.slider-container {
  width: 300px;
  height: 40px;
  position: relative;
}
.slider-track {
  width: 100%;
  height: 4px;
  background: #ddd;
  border-radius: 2px;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}
.slider-thumb {
  width: 24px;
  height: 24px;
  background: #007aff;
  border-radius: 50%;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(-50%, -50%);
  cursor: pointer;
}
const thumb = document.getElementById('thumb');
const track = thumb.parentElement;
const container = track.parentElement;

let startX, currentX;
let isDragging = false;
let isTouchPrevented = false;

// 更新滑块位置
function updatePosition(clientX) {
  const rect = track.getBoundingClientRect();
  const minX = rect.left;
  const maxX = rect.right;
  const x = Math.max(minX, Math.min(maxX, clientX));
  const percent = (x - minX) / (maxX - minX);
  thumb.style.left = ${percent * 100}%;
}

// Touch 事件
container.addEventListener('touchstart', (e) => {
  isDragging = true;
  isTouchPrevented = false;
  const touch = e.touches[0];
  startX = touch.clientX;
  currentX = touch.clientX;
}, { passive: false });

container.addEventListener('touchmove', (e) => {
  if (!isDragging) return;
  
  const touch = e.touches[0];
  const dx = touch.clientX - startX;
  const dy = touch.clientY - (touch.clientY); // 这里简化,实际应记录 startY
  
  // 简化判断:只要横向有移动就阻止(实际项目建议记录 startY)
  if (Math.abs(dx) > 5) {
    if (!isTouchPrevented) {
      e.preventDefault();
      isTouchPrevented = true;
    }
    updatePosition(touch.clientX);
  }
}, { passive: false });

container.addEventListener('touchend', () => {
  isDragging = false;
  isTouchPrevented = false;
});

// Mouse 事件
container.addEventListener('mousedown', (e) => {
  isDragging = true;
  updatePosition(e.clientX);
  e.preventDefault();
});

document.addEventListener('mousemove', (e) => {
  if (isDragging) {
    updatePosition(e.clientX);
  }
});

document.addEventListener('mouseup', () => {
  isDragging = false;
});

注意:上面 touchmove 的 dy 计算简化了,实际应该像前文那样记录 startY。这里为了代码简洁做了妥协,但核心逻辑没问题。

总结一下

移动端滑块拖不动,90% 是因为和页面滚动冲突。解决方案不是粗暴地全局禁止滚动,而是智能判断手势方向,在横向拖动时及时调用 preventDefault()。关键点就两个:一是 passive: false,二是在 touchmove 第一次回调就决策。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。这个技巧其实也适用于其他需要精细控制 touch 行为的场景,比如自定义下拉刷新、横向滚动列表等。后续有空再聊聊怎么优化滑块的动画性能吧。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论