手把手实现一个高性能的Drawer抽屉组件
项目初期的技术选型
上个月在做一个移动端的后台管理工具,需求里有个“筛选面板”要从右侧滑出来,一开始我寻思用个简单的 CSS transform 做位移得了,结果产品甩了个设计稿过来,说“要那种原生 App 感觉的抽屉,能拖拽关闭,手指松开有回弹”。好家伙,这哪是简单动画的事儿。
我调研了几个方案: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,不然抽屉里的列表也滑不动了。
这里注意我踩过好几次坑:一开始我在 onTouchStart 就 e.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)'; // 不留歧义
}
}
`>
<p>另外加了个防抖,避免连续快速操作:</p></code></pre>javascript
onTouchEnd(e) {
if (this.isAnimating) return;
this.isAnimating = true;setTimeout(() => {
this.isAnimating = false;
}, 300);
}
>passive: false<h2>最终的解决方案</h2>
<p>最终上线的版本综合了上面所有调整:</p>
<ul>
<li>使用注册 touch 事件</li>preventDefault
<li>只在特定条件下调用</li>transform
<li>拖拽过程中动态设置,松手后靠 JS 判断是否关闭</li>will-change: transform` 和 GPU 加速,实测在低端机上也能保持 50fps+,算是勉强过关。
<li>加入防抖和样式重置兜底</li>
<li>mask 点击区域做了 20px 内边距,提升关闭容错率</li>
</ul>
<p>性能方面加了回顾与反思
现在回头看,有几个点其实还能优化:
- 拖拽的动效可以接入 spring 物理动画,现在纯线性判断太机械
- 没有做横向拖拽支持,如果以后要做左滑返回就得重构
- 键盘事件完全没考虑,PC 端访问时体验打折
不过项目交付时间紧,这些都先放着了。目前线上跑了两周,用户反馈基本正常,偶尔有安卓机上报一点点卡顿,但不影响使用。
最让我意外的是,这个组件后来被其他模块复用到了“消息通知侧栏”和“快捷菜单”,说明基础设计还算合理。
以上是我的项目经验,希望对你有帮助
这个 Drawer 抽屉组件不算完美,有些边界情况也没完全覆盖,比如横屏切换时的位置错位,但我相信大多数业务场景够用了。亲测有效,至少在我们当前的 H5 项目里跑得还算稳。
如果你有更好的实现方式,比如用 requestAnimationFrame 做拖拽追踪,或者基于 Hammer.js 封装,欢迎评论区交流。我也一直在找更优雅的解法。
前端就是这样,看着一个小功能,背后全是坑。写完一遍,才敢说“我大概知道怎么做了”。

暂无评论