React Drawer抽屉组件的实战封装技巧与性能优化

梓宸 组件 阅读 2,619
赞 9 收藏
二维码
手机扫码查看
反馈

又踩坑了,touchmove滚动失效

今天在搞一个移动端的 Drawer 抽屉组件,功能很简单:从右边滑出一个侧边栏。UI 搞完之后测试发现,在安卓机上抽屉里的内容根本滚不动,手指划来划去一点反应都没有。iOS 上倒是正常,这下我直接懵了。

React Drawer抽屉组件的实战封装技巧与性能优化

第一反应是“是不是样式冲突了?”,赶紧打开 Chrome DevTools 连真机调试,查了一圈 overflowtouch-actionposition,都没发现问题。然后想到可能是第三方库拦截了事件,我们用的是一个轻量级的自定义 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 这类组件时优先考虑。至少比满屏的 stopPropagationpreventDefault 看着舒服。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案欢迎评论区交流。

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

暂无评论