React Drawer抽屉组件的深度实践与常见陷阱避坑指南

慕容钰岩 组件 阅读 2,606
赞 22 收藏
二维码
手机扫码查看
反馈

移动端滚动穿透,这回彻底搞定了

昨天又被移动端Drawer组件的滚动穿透问题给折腾惨了,写这篇记录一下完整的解决过程。其实之前也遇到过,但每次都是临时应付过去,这次终于下定决心彻底解决。

React Drawer抽屉组件的深度实践与常见陷阱避坑指南

问题现象就是这样的:打开Drawer之后,在里面滚动内容,底部页面也会跟着滚动。在iOS Safari上尤其明显,Android Chrome也会有问题。查了下发现这是移动端浏览器的通病,因为iOS的弹性滚动机制导致的。

各种方案试了一轮

一开始我想着偷懒,直接百度了一下现成的方案。看到最多的就是给body加overflow: hidden,这招确实管用,但有明显副作用:页面会重新渲染布局,导航栏位置可能发生变化,用户体验很不好。而且关闭Drawer的时候还有个布局抖动的问题。

折腾了半天发现,真正要解决的是滚动事件冒泡问题。当Drawer内的内容滚动到顶部或底部时,如果继续滚动,事件就会冒泡到底层页面,导致底层页面也开始滚动。这就是所谓的滚动穿透。

后来试了下preventDefault的方式,这个是正解,但需要考虑很多边界情况。比如什么时候该阻止默认行为,什么时候不应该阻止。特别是在嵌套滚动容器的情况下,判断逻辑会更复杂。

核心代码就这几行

经过多次测试和优化,最终的核心代码如下:

// Drawer组件内部的滚动处理
class DrawerScrollHandler {
  constructor(drawerElement) {
    this.drawer = drawerElement;
    this.startY = 0;
    this.init();
  }

  init() {
    // 触摸开始记录初始位置
    this.drawer.addEventListener('touchstart', (e) => {
      this.startY = e.touches[0].clientY;
    });

    // 触摸移动时判断是否需要阻止默认行为
    this.drawer.addEventListener('touchmove', (e) => {
      if (!this.shouldPreventTouchMove(e)) {
        return;
      }
      
      e.preventDefault(); // 阻止默认的滚动行为
    }, { passive: false }); // 注意passive设置为false才能阻止preventDefault
  }

  shouldPreventTouchMove(e) {
    const currentY = e.touches[0].clientY;
    const diff = currentY - this.startY;
    
    // 获取当前滚动状态
    const scrollTop = this.drawer.scrollTop;
    const scrollHeight = this.drawer.scrollHeight;
    const clientHeight = this.drawer.clientHeight;

    // 向下滑动且已在顶部,或者向上滑动且已在底部
    const atTop = scrollTop <= 0 && diff > 0;
    const atBottom = scrollTop + clientHeight >= scrollHeight && diff < 0;

    return atTop || atBottom;
  }
}

这个方案的关键在于准确判断滚动容器的状态。只有当滚动到边界位置时才阻止默认行为,这样既解决了滚动穿透问题,又保持了正常的滚动体验。

这里有个坑需要注意:passive参数必须设为false,否则preventDefault不会生效。现代浏览器默认passive为true是为了性能优化,但这样就无法阻止默认行为了。

CSS配合也很重要

光靠JavaScript还不够,CSS也要配合一下:

.drawer-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
}

.drawer-content {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 80%;
  max-width: 320px;
  background: white;
  overflow-y: auto; /* 关键:启用垂直滚动 */
  -webkit-overflow-scrolling: touch; /* iOS平滑滚动 */
}

/* 滚动条样式 */
.drawer-content::-webkit-scrollbar {
  width: 6px;
}

.drawer-content::-webkit-scrollbar-track {
  background: #f1f1f1;
}

.drawer-content::-webkit-scrollbar-thumb {
  background: #ccc;
  border-radius: 3px;
}

重点是那句-webkit-overflow-scrolling: touch,这能让iOS设备上的滚动更流畅。不过这个属性已经被标记为废弃了,但在实际开发中还是很有必要的。

边界情况的处理

上面的基础版本在大多数情况下都能工作,但实际项目中还会遇到一些特殊情况。比如内部有多个滚动区域,或者Drawer内容高度不固定的情况。

后来我又加了一个增强版本,专门处理这些复杂场景:

// 增强版滚动处理器
class EnhancedDrawerScrollHandler extends DrawerScrollHandler {
  constructor(drawerElement) {
    super(drawerElement);
    this.nestedScrollElements = [];
  }

  // 注册嵌套滚动元素
  registerNestedScroll(element) {
    this.nestedScrollElements.push(element);
  }

  shouldPreventTouchMove(e) {
    // 检查是否有活动的子滚动元素
    for (let element of this.nestedScrollElements) {
      if (this.isElementScrolling(element)) {
        return false; // 子元素在滚动时不阻止
      }
    }

    return super.shouldPreventTouchMove(e);
  }

  isElementScrolling(element) {
    const rect = element.getBoundingClientRect();
    const touchY = e.touches[0].clientY;
    
    // 检查触摸点是否在目标元素内
    if (touchY >= rect.top && touchY <= rect.bottom) {
      const scrollTop = element.scrollTop;
      const scrollHeight = element.scrollHeight;
      const clientHeight = element.clientHeight;
      
      return !(scrollTop === 0 || scrollTop + clientHeight >= scrollHeight);
    }
    
    return false;
  }
}

这段代码主要是为了处理Drawer内部还有其他滚动区域的场景。虽然用得不多,但碰到了就能省不少调试时间。

兼容性考虑

这套方案在主流移动端浏览器上都测试通过了,包括iOS Safari 12+和Android Chrome 60+。老版本浏览器可能会有些问题,但我司产品基本不支持太老的版本,所以暂时没做降级处理。

如果需要支持更多浏览器,可以考虑检测touch事件支持情况,然后降级到鼠标事件。不过现在移动端基本都有touch支持了,这个需求应该不太紧急。

踩坑提醒:这几点一定要注意

  • passive: false参数是关键,忘记设置就无法阻止默认行为
  • 要区分真正的滚动穿透和正常滚动,不能一概而论地阻止所有touchmove
  • iOS的弹性滚动很特殊,测试时一定要在真机上验证
  • Drawer关闭时记得清理事件监听器,避免内存泄漏

还有一个小问题是,在某些极端情况下(比如快速上下滑动),还是会有轻微的滚动穿透现象。不过概率很低,用户基本感觉不到,就暂时不管了。

性能优化

为了防止频繁的DOM操作影响性能,我还加了节流处理:

function throttle(func, delay) {
  let timeoutId;
  let lastExecTime = 0;
  
  return function (...args) {
    const currentTime = Date.now();
    
    if (currentTime - lastExecTime > delay) {
      func.apply(this, args);
      lastExecTime = currentTime;
    } else {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => {
        func.apply(this, args);
        lastExecTime = Date.now();
      }, delay - (currentTime - lastExecTime));
    }
  };
}

// 在touchmove事件中应用节流
this.drawer.addEventListener('touchmove', throttle((e) => {
  if (!this.shouldPreventTouchMove(e)) {
    return;
  }
  e.preventDefault();
}, 16), { passive: false });

这里用了16ms的节流间隔,大概相当于60fps的频率,既能保证响应性又能避免性能问题。

以上是我踩坑后的总结,这个方案目前在我司项目中运行稳定。如果你有更好的方案欢迎评论区交流。

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

暂无评论