React Drawer抽屉组件的深度实践与常见陷阱避坑指南
移动端滚动穿透,这回彻底搞定了
昨天又被移动端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的频率,既能保证响应性又能避免性能问题。
以上是我踩坑后的总结,这个方案目前在我司项目中运行稳定。如果你有更好的方案欢迎评论区交流。

暂无评论