Table组件开发中遇到的性能瓶颈与渲染优化实践

Dev · 一鸣 组件 阅读 2,826
赞 27 收藏
二维码
手机扫码查看
反馈

又踩坑了,Table里嵌套滚动区域 touchmove 失效

今天上线前测到一个诡异问题:Table组件里某列渲染了一个自定义的「标签选择器」,里面用了横向滚动容器(overflow-x: auto),结果在 iOS Safari 上完全滑不动——手指一碰就直接触发了 Table 整体的 vertical scroll,横向滚动压根没响应。

Table组件开发中遇到的性能瓶颈与渲染优化实践

第一反应是「肯定是 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 滚动冲突问题的完整讲解,有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
Mr.宁蒙
Mr.宁蒙 Lv1
不同浏览器盒模型表现一致吗?
点赞 1
2026-02-16 18:25