手把手实现一个高性能的Drawer抽屉组件

令狐佳佳 组件 阅读 1,474
赞 12 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月在做一个移动端的后台管理工具,需求里有个“筛选面板”要从右侧滑出来,一开始我寻思用个简单的 CSS transform 做位移得了,结果产品甩了个设计稿过来,说“要那种原生 App 感觉的抽屉,能拖拽关闭,手指松开有回弹”。好家伙,这哪是简单动画的事儿。

手把手实现一个高性能的Drawer抽屉组件

我调研了几个方案:Vue 的 Transition 组件、第三方库 like vuesax 和 drawer.js,还试了下用 gesture handler 做手势。最后还是决定手写一个 Drawer 抽屉组件,主要是因为项目本身没引入任何 UI 框架,加个重型库有点杀鸡用牛刀。而且这种交互逻辑其实不复杂,自己控更灵活,也方便后续扩展。

核心代码就这几行

我的思路很简单:用 transform: translateX 控制显隐,加上 transition 做缓动,再绑定 touch 事件处理拖拽。DOM 结构也很干净:

<div class="drawer" :class="{ 'drawer-open': isOpen }">
  <div class="drawer-mask" @click="close"></div>
  <div class="drawer-panel" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
    <!-- 抽屉内容 -->
    <slot></slot>
  </div>
</div>

CSS 部分也不难:

.drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  pointer-events: none;
  z-index: 1000;
}

.drawer-panel {
  position: absolute;
  top: 0;
  right: 0;
  width: 80%;
  max-width: 400px;
  height: 100%;
  background: #fff;
  box-shadow: -4px 0 12px rgba(0,0,0,0.1);
  transform: translateX(100%);
  transition: transform 0.3s ease;
  pointer-events: auto;
  will-change: transform;
}

.drawer-open .drawer-panel {
  transform: translateX(0);
}

.drawer-mask {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0,0,0,0.4);
  opacity: 0;
  transition: opacity 0.3s ease;
}

.drawer-open .drawer-mask {
  opacity: 1;
}

JS 逻辑主要在 Vue 的 methods 里实现:

export default {
  data() {
    return {
      isOpen: false,
      startY: 0,
      currentY: 0,
      lastDirection: 0 // 1 下滑,-1 上滑
    };
  },
  methods: {
    open() {
      this.isOpen = true;
    },
    close() {
      this.isOpen = false;
    },
    onTouchStart(e) {
      this.startY = e.touches[0].clientY;
      this.currentY = this.startY;
    },
    onTouchMove(e) {
      if (!this.isOpen) return;
      const deltaY = e.touches[0].clientY - this.startY;
      const panel = e.target.closest('.drawer-panel');
      
      // 只允许向下滑动时触发拖拽
      if (deltaY > 0) {
        e.preventDefault(); // 关键!阻止页面滚动
        panel.style.transform = translateX(0) translateY(${deltaY}px);
        this.currentY = e.touches[0].clientY;
        this.lastDirection = 1;
      }
    },
    onTouchEnd(e) {
      const deltaY = this.currentY - this.startY;
      if (deltaY > 80) {
        this.close();
      } else {
        // 回弹
        const panel = e.target.closest('.drawer-panel');
        panel.style.transform = '';
      }
    }
  }
};

又踩坑了,touchmove滚动失效

写完一测,iOS 上滑动抽屉的时候,底下的页面居然还在跟着滚……明明加了 e.preventDefault() 啊?折腾了半天发现,只有在 onTouchMove 里调用了 preventDefault 才能真正阻断默认行为,但问题是——你不能无差别拦截所有 touchmove,不然抽屉里的列表也滑不动了。

这里注意我踩过好几次坑:一开始我在 onTouchStarte.preventDefault(),结果 Safari 直接报 warning:“Unable to preventDefault inside passive event listener”,然后手势全废。

后来调整了方案:只在用户向下拖拽(准备关闭)且已经打开的状态下才拦截。关键是给 touchmove 注册事件时得声明 { passive: false },不然默认是 passive,preventDefault 不生效:

mounted() {
  this.$el.addEventListener('touchmove', this.onTouchMove, { passive: false });
},
beforeDestroy() {
  this.$el.removeEventListener('touchmove', this.onTouchMove);
}

这个细节坑了我快半天,文档写的不清不楚,最后还是在 MDN 上翻到的说明。

谁更灵活?谁更省事?

我也试过用第三方库,比如 vuesax 的 vs-drawer。确实功能全,支持多方向、锁 body 滚动、支持嵌套。但问题也明显:体积大,样式难定制,而且它那个拖拽关拉动量计算太“猛”,松手后直接飞出去,体验反而不好。

我自己这套虽然简陋点,但胜在轻量,整个组件文件不到 200 行,改起来随心所欲。比如我们有个场景是在抽屉里放了个表单,需要点击输入框时不触发关闭,只需要在 onTouchMove 里加个判断:

onTouchMove(e) {
  const target = e.target;
  if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
    return; // 不拦截表单元素的触摸
  }
  // 其他逻辑...
}

最大的坑:Android 机型兼容性

本以为搞定 iOS 就万事大吉,结果 QA 在华为 P30 上测试发现:快速滑动两次后,抽屉卡住不动,transform 值错乱。排查发现是 DOM 节点复用导致的样式残留——因为在 onTouchEnd 我用了 panel.style.transform = '' 来恢复,但某些 Android 浏览器对空字符串的处理不一致,有的会保留上次计算值。

解决方案是明确设置回初始状态:

onTouchEnd(e) {
const deltaY = this.currentY - this.startY;
const panel = e.target.closest('.drawer-panel');

if (deltaY > 80) {
this.close();
} else {
panel.style.transform = 'translateX(0)'; // 不留歧义
}
}
`&gt;
&lt;p&gt;另外加了个防抖,避免连续快速操作:&lt;/p&gt;</code></pre>javascript
onTouchEnd(e) {
if (this.isAnimating) return;
this.isAnimating = true;

setTimeout(() => {
this.isAnimating = false;
}, 300);
}
>

<h2>最终的解决方案</h2>
<p>最终上线的版本综合了上面所有调整:</p>
<ul>
<li>使用
passive: false 注册 touch 事件</li>
<li>只在特定条件下调用
preventDefault</li>
<li>拖拽过程中动态设置
transform,松手后靠 JS 判断是否关闭</li>
<li>加入防抖和样式重置兜底</li>
<li>mask 点击区域做了 20px 内边距,提升关闭容错率</li>
</ul>
<p>性能方面加了
will-change: transform` 和 GPU 加速,实测在低端机上也能保持 50fps+,算是勉强过关。

回顾与反思

现在回头看,有几个点其实还能优化:

  • 拖拽的动效可以接入 spring 物理动画,现在纯线性判断太机械
  • 没有做横向拖拽支持,如果以后要做左滑返回就得重构
  • 键盘事件完全没考虑,PC 端访问时体验打折

不过项目交付时间紧,这些都先放着了。目前线上跑了两周,用户反馈基本正常,偶尔有安卓机上报一点点卡顿,但不影响使用。

最让我意外的是,这个组件后来被其他模块复用到了“消息通知侧栏”和“快捷菜单”,说明基础设计还算合理。

以上是我的项目经验,希望对你有帮助

这个 Drawer 抽屉组件不算完美,有些边界情况也没完全覆盖,比如横屏切换时的位置错位,但我相信大多数业务场景够用了。亲测有效,至少在我们当前的 H5 项目里跑得还算稳。

如果你有更好的实现方式,比如用 requestAnimationFrame 做拖拽追踪,或者基于 Hammer.js 封装,欢迎评论区交流。我也一直在找更优雅的解法。

前端就是这样,看着一个小功能,背后全是坑。写完一遍,才敢说“我大概知道怎么做了”。

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

暂无评论