Table组件开发中遇到的性能瓶颈与渲染优化实践
又踩坑了,Table里嵌套滚动区域 touchmove 失效
今天上线前测到一个诡异问题:Table组件里某列渲染了一个自定义的「标签选择器」,里面用了横向滚动容器(overflow-x: auto),结果在 iOS Safari 上完全滑不动——手指一碰就直接触发了 Table 整体的 vertical scroll,横向滚动压根没响应。
第一反应是「肯定是 preventDefault 搞的鬼」,毕竟 Table 自己做了 touchstart/touchmove 的拦截来优化滚动性能。但翻了下代码,我压根没在 Table 里手动调用过 event.preventDefault(),只用了 passive: true 注册了监听器……这就有点懵了。
折腾了半天发现,问题不在 Table 本身,而在于我给那个横向滚动容器加了个 touch-action: none ——为了禁掉默认的缩放和双击,结果顺手把它整个 touch 行为都干掉了。iOS 上一旦设置了 touch-action: none,连最基本的 scroll-x 都会失效,而且这个行为没法靠 JS 拦截回来,浏览器直接不派发 touchmove 事件了。
后来试了下发现,只要改成 touch-action: pan-x 就行了。但问题来了:Table 组件是通用的,不可能为每一列写死 touch-action;而且有些列可能要支持 click、长按、拖拽排序……不能一刀切。
所以得让 Table 在捕获 touch 事件时,「识别出当前 touch 目标是不是一个可滚动的子容器」,如果是,就别拦它,让它自己处理;如果不是,才由 Table 接管 vertical scroll。
核心逻辑就三步:目标检测 + 条件拦截 + fallback 回退
我原先 Table 的滚动逻辑是这样的(简化版):
element.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
}, { passive: true });
element.addEventListener('touchmove', (e) => {
const currentY = e.touches[0].clientY;
const diff = currentY - startY;
// 判断是否在垂直方向移动且超出阈值
if (Math.abs(diff) > 5 && isScrollable(element)) {
e.preventDefault(); // ❌ 这里太粗暴了
}
}, { passive: false });
问题就出在这个 e.preventDefault() 上:它不管三七二十一,只要进了这个判断就干掉事件,导致子容器的 pan-x 彻底失能。
改法很简单:先查 touch 目标,再决定要不要拦。
element.addEventListener('touchmove', (e) => {
const touch = e.touches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
// ✅ 关键判断:如果目标元素自身支持横向滚动,就放过它
if (target && shouldAllowHorizontalScroll(target)) {
return; // 让它自己处理
}
const currentY = touch.clientY;
const diff = currentY - startY;
if (Math.abs(diff) > 5 && isScrollable(element)) {
e.preventDefault();
}
}, { passive: false });
那 shouldAllowHorizontalScroll 怎么写?我一开始想用 getComputedStyle 查 overflow-x,但发现有些组件是用 JS 模拟滚动(比如用 transformX + requestAnimationFrame),光看 CSS 不靠谱。最后定了个务实策略:只认明确打了标记的元素。
我在所有需要横向滚动的子组件上加了个 data-scrollable-x 属性:
<div class="tag-selector" data-scrollable-x>
<span>标签1</span>
<span>标签2</span>
<span>标签3</span>
</div>
对应判断函数就很轻量:
function shouldAllowHorizontalScroll(el) {
if (!el) return false;
if (el.hasAttribute('data-scrollable-x')) return true;
// 向上遍历父级,最多 3 层(避免性能问题)
let parent = el.parentElement;
let depth = 0;
while (parent && depth < 3) {
if (parent.hasAttribute('data-scrollable-x')) {
return true;
}
parent = parent.parentElement;
depth++;
}
return false;
}
这个方案亲测有效,iOS Safari、Chrome Android、Mac Safari 全部 OK。唯一的小瑕疵是:如果用户快速滑动横向容器时,手指稍微偏移了一点点,划出了 data-scrollable-x 区域,Table 就会抢走事件,导致横向滚动突然卡一下。不过实际测试下来,这种场景极少,用户几乎感知不到——比起完全不能滑,这已经够用了。
顺便提一句:为什么不用 event.composedPath()?试过,但在某些微信内置浏览器里兼容性拉胯,path 里一堆 #text 节点,匹配不稳定。还是手动向上查三层最稳。
还有个隐藏坑:passive 和 preventDefault 冲突必须显式声明
这里我踩了个坑——一开始我把 touchmove 改成 { passive: false },但忘了把 touchstart 也改成 { passive: false }。结果 Chrome 控制台疯狂报 warning:
Unable to preventDefault inside passive event listener due to target being treated as passive.
原因是:浏览器看到 touchstart 是 passive 的,就默认整个 touch 流程都该是 passive,你却在 touchmove 里 call preventDefault,它就懵了。所以两个监听器必须保持一致。
改完之后,完整初始化代码是这样:
let startY = 0;
const handleTouchStart = (e) => {
startY = e.touches[0].clientY;
};
const handleTouchMove = (e) => {
const touch = e.touches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
if (target && shouldAllowHorizontalScroll(target)) {
return;
}
const currentY = touch.clientY;
const diff = currentY - startY;
if (Math.abs(diff) > 5 && element.scrollHeight > element.clientHeight) {
e.preventDefault();
}
};
element.addEventListener('touchstart', handleTouchStart, { passive: false });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
CSS 方面也补了一刀,确保横向滚动容器有明确的滚动能力:
[data-scrollable-x] {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch; /* iOS 必加 */
scrollbar-width: none; /* Firefox */
}
[data-scrollable-x]::-webkit-scrollbar {
display: none;
}
结语
以上是我踩坑后的总结。这个方案不是最优雅的(比如没做 scroll direction prediction),但足够简单、稳定、易维护,上线后没再出过问题。如果你项目里 Table 嵌套了轮播、时间轴、横向菜单之类需要局部滚动的内容,这套逻辑大概率能直接复用。
当然,如果你们团队用的是 React/Vue,可以封装成 hook 或指令,原理是一样的:别急着 preventDefault,先问一句「这事儿是不是该交给子元素干?」
另外,jztheme.com 上有个 demo 页面(https://jztheme.com/demo/table-scroll)展示了这个 Table 和横向滚动容器共存的效果,代码也是公开的,欢迎去看源码细节。
这个技巧的拓展用法还有很多,比如结合 pointer-events: none 做更细粒度的事件穿透,或者加 throttle 防止频繁触发 elementFromPoint。后续会继续分享这类博客。
以上是我个人对这个 Table 滚动冲突问题的完整讲解,有更优的实现方式欢迎评论区交流。
