解决iOS Safari兼容性问题的实战经验分享

博主宁宁 移动 阅读 747
赞 15 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个项目是个移动端的 H5 活动页,主要面向 iOS 用户,目标是做一个流畅的手势滑动画廊。一开始我们考虑用 Swiper,毕竟这玩意儿稳、文档全、社区大。但后来产品提了个需求:滑动过程中要叠加视差动画,还要根据 touch 位置动态调整背景模糊度——这种定制化程度,Swiper 的 API 显得有点重,改起来费劲。

解决iOS Safari兼容性问题的实战经验分享

最后我决定自己撸一个轻量级 touch 控制器,核心就监听 touchstart、touchmove、touchend,配合 CSS transform 做位移。思路简单,代码也不多,本地调试在 Chrome 模拟器上跑得挺顺。心想:这不稳了?结果一上真机,iOS Safari 直接给我整不会了。

又踩坑了,touchmove滚动失效

第一次在 iPhone 上测试,发现手指滑动时页面会跟着上下滚动,画廊的横向滑动断断续续,甚至直接卡住。查了下 event.preventDefault(),加了,结果更糟——整个页面都不能滚动了,连 body 都被锁死。

这里注意我踩过好几次坑:在 iOS Safari 里,对 touchmove 事件直接调用 preventDefault() 会被系统视为“你接管了所有手势”,于是默认滚动行为就被禁用了。而且一旦你在某个元素上阻止了默认行为,哪怕只一次,Safari 就可能认为你要做自定义手势,进而影响整个页面的滚动表现。

后来翻 MDN 和一些老外的博客,才搞明白:必须动态判断是否真的需要阻止默认行为。比如横向滑动时才阻止,纵向滑动就放行,让页面正常滚动。

核心代码就这几行

最终我写了这么个 touch 处理器,只贴关键部分:

let startX = 0;
let currentX = 0;
const threshold = 10; // 触发横向滑动的最小偏移
let isHorizontal = false;

element.addEventListener('touchstart', (e) => {
  const touch = e.touches[0];
  startX = touch.clientX;
  currentX = startX;
  isHorizontal = null;
});

element.addEventListener('touchmove', (e) => {
  const touch = e.touches[0];
  currentX = touch.clientX;
  const diffX = currentX - startX;

  // 第一次移动时判断方向
  if (isHorizontal === null) {
    const diffY = e.touches[0].clientY - startY;
    isHorizontal = Math.abs(diffX) > Math.abs(diffY);
  }

  if (isHorizontal) {
    e.preventDefault(); // 只有横向才阻止默认行为
    // 更新元素 transform
    element.style.transform = translateX(${diffX}px);
  }
  // 如果是纵向,不阻止,默认交给页面滚动
}, { passive: false });

这里最关键的是 passive: false。默认情况下,现代浏览器会把 touchmove 设为 passive(被动事件),意味着你不能在里面调用 preventDefault()。如果不显式设为 false,控制台会警告,而且 preventDefault 无效。这点在 iOS Safari 上特别严格。

三种主流 touchmove 处理方案

项目中我试过三种方式:

  • 方案一:全程 preventDefault —— 简单粗暴,但页面滚动全废,用户体验极差,直接否掉。
  • 方案二:用 CSS scroll-snap + overflow-x: scroll —— 利用原生滚动和 snap 效果,理论上性能最好。但在 iOS 低版本(比如 iOS 12)上兼容性差,snap 表现不稳定,某些机型滑一半就停,回弹异常。
  • 方案三:动态判断方向 + 条件阻止 —— 就是上面那套。虽然代码多点,但控制粒度细,兼容性也最好,最终选了它。

最大的坑:性能问题

解决了事件拦截,新的问题来了:滑动过程中明显卡顿,FPS 掉到 30 以下。用 Safari 开发者工具远程调试一看,主线程被 style recalc 占满了。

原因出在频繁修改 element.style.transform 上。虽然 transform 本身走 GPU,但通过 JS 不断赋值字符串,还是会触发重排探测,尤其当父元素有复杂样式或动画时。

解决方案是改用 requestAnimationFrame 节流,并缓存 DOM 查询:

let ticking = false;

function updateTransform(diffX) {
  if (!ticking) {
    requestAnimationFrame(() => {
      element.style.transform = translateX(${diffX}px);
      ticking = false;
    });
    ticking = true;
  }
}

这样确保每一帧最多更新一次,性能立马回升到 50+ FPS。亲测有效。

还有个小问题没彻底解决

到现在为止,还有一个小毛病:快速滑动后松手,没有惯性动效。用户期望有个继续滑一段再停止的感觉,但现在是“手一抬,立刻停”,显得生硬。

我也尝试过用 velocity 计算释放时的速度,然后用 CSS transition 模拟惯性,但 iOS Safari 对动态插入 transition 的支持不太稳定,有时动画不触发,调试半天也没找出规律。最后考虑到这个功能不是核心路径,就暂时放弃了。

目前的结论是:如果要做完整惯性滚动,不如上 Hammer.js 或者封装更好的库。但我们项目要求轻量,所以妥协了。

最终的解决方案

综合下来,最终上线的方案是:

  • 动态判断 touch 方向,仅横向滑动时阻止默认行为
  • 使用 requestAnimationFrame 节流样式更新
  • 将画廊容器设为 contain: layout,减少重排范围
  • CSS 层面启用硬件加速:transform: translateZ(0)will-change: transform

顺便提一句,接口数据是从 https://jztheme.com/api/slides 获取的,纯前端渲染,无 SSR,所以首屏加载速度也得控制。图片懒加载做了,但那是另一个故事了。

回顾与反思

这个功能开发加调试总共花了三天,比我预估的多了一倍。开始没想到 iOS Safari 对 touch 事件这么敏感,一个 preventDefault 就能把你带进沟里。

做得好的地方是:最终体验在主流 iPhone 机型上都比较流畅,内存占用也稳定;没引入额外依赖,bundle size 几乎没涨。

还能优化的:

  • 惯性滑动可以再研究下,或许用 CSS scroll-behavior: smooth 配合原生滚动试试?
  • touch 判断逻辑可以抽成 Hook 或小模块,方便复用
  • 边界回弹效果现在是硬编码,其实可以做成可配置参数

总的来说,这次踩的坑集中在 iOS 平台的“反直觉”行为上。很多在 Chrome 上正常的逻辑,在 Safari 里就是不行。唯一的办法就是真机测试,越早越好。

以上是我个人对这个 touch 控制器的完整实践总结

有更优的实现方式欢迎评论区交流。这类移动端交互的问题挺多,后续还会分享一些关于 iOS 输入框唤起键盘导致布局错乱的骚操作。希望这次的经验对你有帮助,至少别像我一样在 preventDefault 上浪费两小时。

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

暂无评论