React Drawer抽屉组件的实战封装技巧与性能优化
又踩坑了,touchmove滚动失效
今天在搞一个移动端的 Drawer 抽屉组件,功能很简单:从右边滑出一个侧边栏。UI 搞完之后测试发现,在安卓机上抽屉里的内容根本滚不动,手指划来划去一点反应都没有。iOS 上倒是正常,这下我直接懵了。
第一反应是“是不是样式冲突了?”,赶紧打开 Chrome DevTools 连真机调试,查了一圈 overflow、touch-action、position,都没发现问题。然后想到可能是第三方库拦截了事件,我们用的是一个轻量级的自定义 drawer,没用 Ant Design 或 Vant 那种大而全的 UI 库,所以事件逻辑都是自己写的。
后来在监听 touchmove 的时候打了个 log,结果发现——事件压根没触发。不是被阻止了,而是连冒泡阶段都没进。这就离谱了。
折腾了半天才发现,问题出在抽屉打开时给 body 加了一个 preventDefault 的 touch 事件监听:
document.body.addEventListener('touchmove', e => {
e.preventDefault();
}, { passive: false });
这行代码本意是防止背景页面滚动,但它是全局拦截,不管你是滑抽屉还是滑页面,统统封杀。而 iOS 的 Safari 对 passive 默认处理更宽松,所以能侥幸通过,安卓 Chrome 就直接给你掐死了。
这里我踩了个坑:一开始以为只要把 passive: false 去掉就行,结果去掉后 preventDefault() 根本不生效,控制台还报警告:“Unable to preventDefault inside passive event listener”。这才想起来,现代浏览器默认把 touch 事件设为 passive,就是为了提升滚动性能,你不能随随便便就拦。
三种方案对比,我选了最简单的
当时脑子里蹦出三个解决思路:
- 1. 监听抽屉内部滚动,动态切换 body 是否锁定
- 2. 给抽屉容器加
touch-action: auto,配合事件委托放行 - 3. 改用 CSS
overscroll-behavior控制回弹
第一个方案最常见,也最麻烦。你需要监听抽屉内元素的 scrollTop,判断是否到顶/到底,再决定要不要让 body 接手滚动。写起来一堆边界条件,而且嵌套滚动容器时容易失灵。试了半小时,放弃。
第二个方案看起来优雅,但实际上 touch-action 在 Android 浏览器支持得稀烂,尤其是低端机。有同事反馈某个华为机型完全无视这个属性,照样锁死。passive event + 条件性 preventDefault 理论上可行,但兼容性风险太高,上线前不敢赌。
最后我用了第三种——其实也不是新技术,只是以前一直没重视:overscroll-behavior。
核心代码就这几行
解决方案出乎意料地简单。先把那行暴力 preventDefault 干掉,换成纯 CSS 控制:
.drawer-open {
overscroll-behavior: contain;
}
.drawer-content {
height: 100vh;
overflow-y: scroll;
-webkit-overflow-scrolling: touch; /* 兼容 iOS 弹性滚动 */
}
然后在 JS 中只控制类名切换:
function openDrawer() {
document.body.classList.add('drawer-open');
}
function closeDrawer() {
document.body.classList.remove('drawer-open');
}
就这么几行,问题解决了。现在抽屉内部可以自由滚动,滚到底了也不会触发背景页面的滚动,而且没有 JS 拦截带来的卡顿感。关键是,不同机型表现一致,再也不用担心某个三星手机突然抽风。
这里注意我之前踩过好几次坑:必须确保 .drawer-content 自身是可滚动的,并且设置了明确的高度或最大高度。如果它依赖父元素撑开,而父元素又没设置 height: 100%,那滚动容器根本建立不起来,overscroll-behavior 自然无效。
我的结构大概是这样的:
<div class="drawer" v-show="visible">
<div class="drawer-mask" @click="close"></div>
<div class="drawer-panel">
<div class="drawer-header">标题</div>
<div class="drawer-content">
<!-- 可滚动内容 -->
<div v-for="item in 100" :key="item">条目 {{ item }}</div>
</div>
</div>
</div>
对应的样式:
.drawer-panel {
position: fixed;
top: 0;
right: 0;
width: 80%;
max-width: 400px;
height: 100%;
background: white;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawer-panel.active {
transform: translateX(0);
}
.drawer-content {
height: calc(100vh - 60px); /* 减去 header 高度 */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
JS 控制 active 类即可实现动画和滚动分离管理。
谁更灵活?谁更省事?
你说 overscroll-behavior 灵活吗?其实不如 JS 控制灵活。比如你想在某些条件下允许背景滚动(像微信小程序的下拉刷新),那就还得回到事件拦截的老路上。但对我们这种常规 drawer 来说,它的语义足够清晰,行为足够稳定,反而更适合长期维护。
另一个好处是性能。没有 JS 参与滚动判断,主线程更干净,尤其在低端安卓机上滑动更顺。之前用 preventDefault 的时候,偶尔会出现“手指抬起来了还在惯性滚动”的延迟断触现象,现在完全没有了。
当然,这个方案也不是完美。改完后发现一个小问题:在部分 Android 机型上,快速上下滑动时会有一点点透传的抖动感,像是背景轻微闪了一下。查了应该是渲染层合成的问题,加了个 transform: translateZ(0) 强制升层才缓解。但这属于个别现象,不影响整体可用性。
踩坑提醒:这三点一定注意
如果你也打算用这套方案,这几个细节一定要留意:
- 别忘了移除所有全局 touchmove preventDefault,哪怕是在特定 flag 下做的也不行,很容易误伤
- .drawer-content 必须有明确可滚动高度,不能靠内容撑开,否则无法触发滚动机制
- Android 低版本(<6)不支持 overscroll-behavior,如果你还要兼容这些老系统,建议降级使用 JS 方案 + feature detect
我自己做了一个简单的特性检测来兜底:
const hasOverscroll = 'overscrollBehavior' in document.documentElement.style;
if (!hasOverscroll) {
// 回退到 addEventListener 方案,但加上条件判断
const handleTouchMove = (e) => {
if (!isInDrawer(e.target)) {
e.preventDefault();
}
};
document.body.addEventListener('touchmove', handleTouchMove, { passive: false });
}
这样至少保证老设备还能用,新设备享受更好的体验。
fetch 请求的例子顺便提一嘴
项目里有个需求是抽屉打开时加载用户消息列表,我顺手写了这么个请求:
async function loadMessages() {
try {
const res = await fetch('https://jztheme.com/api/messages', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token
}
});
return await res.json();
} catch (err) {
console.error('加载消息失败', err);
return [];
}
}
虽然跟 drawer 本身没关系,但放在 openDrawer 里调用的时候要注意防抖,不然每次开关都请求一次,接口压力挺大。后来改成“只首次打开时加载”,缓存数据。
以上是我踩坑后的总结
这个问题看似小,但前后耽误了我将近一天时间,中间还拉着同事一起排查,结果最后发现是自己当初图省事加了那行 e.preventDefault() 埋下的雷。现在回头看,前端的手势处理真的不能“一刀切”,越是看起来简单的交互,越容易在不同设备上翻车。
overscroll-behavior 算是个冷门但实用的属性,推荐大家在做 modal、drawer、popup 这类组件时优先考虑。至少比满屏的 stopPropagation 和 preventDefault 看着舒服。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案欢迎评论区交流。

暂无评论