前端兼容性难题实战:从CSS到JavaScript的跨浏览器适配经验

南宫兴瑞 组件 阅读 1,273
赞 12 收藏
二维码
手机扫码查看
反馈

又踩坑了,iOS Safari 里 fixed 元素滚动抖动

上周改一个移动端的弹窗组件,本来以为就是个简单的 fixed 定位加个 overlay,结果在 iOS Safari 上一测,整个页面疯狂抖动,像是喝多了似的。安卓和桌面浏览器都正常,就 iOS 独一份出问题。折腾了半天,差点以为是 React 的 setState 触发了什么奇怪的重排。

前端兼容性难题实战:从CSS到JavaScript的跨浏览器适配经验

一开始我以为是 z-index 冲突或者 transform 引起的合成层问题,毕竟以前遇到过类似情况。于是先试了各种组合:加 transform: translateZ(0)will-change: transform、甚至给 body 加 overflow: hidden 防止滚动穿透。结果都没用,反而有时候更卡了。最离谱的是,有时候弹窗打开时,背景页面会突然跳到顶部,关掉又跳回去,用户体验直接崩了。

后来我冷静下来,用真机连上 Safari 的 Web Inspector(这玩意儿调试移动端兼容性真的救命),发现每次滚动时,fixed 元素的位置在不停重算,而且 body 的 scrollY 在疯狂变化。这时候我才意识到:**iOS Safari 在处理 fixed 元素时,如果页面本身可滚动,会触发一种“弹性滚动”机制,导致 fixed 元素的位置被反复修正,从而产生视觉抖动**。

查了下资料,原来从 iOS 13 开始,Safari 对 fixed 定位做了调整,为了配合“pull-to-refresh”这类手势,它会在用户滚动时临时把 fixed 元素当作 absolute 处理,等滚动结束再恢复。但这个过程在某些布局下就会出现位置跳跃,尤其是当页面高度超过一屏、且有动态内容插入时。

三种方案对比,我选了最简单的

网上搜了一圈,主流解法大概有三种:

  • 方案一:滚动时动态设置 body 的 position: fixed,并记录 scrollTop,防止页面滚动
  • 方案二:用 transform 模拟 fixed 效果,完全避开原生 fixed
  • 方案三:禁用页面滚动,同时修复 iOS 的弹性滚动行为

方案二听起来很酷,但改造成本太高,我这个弹窗组件已经嵌在很多地方了,全改 transform 不现实。方案一我之前试过,但有个坑:当你设置 body { position: fixed } 时,iOS 会自动把页面滚动回顶部(因为 fixed 定位脱离文档流了),所以必须手动保存和恢复 scrollTop。而且如果页面里有 input,focus 时 iOS 会自动滚动到输入框,这时候 scrollTop 就乱了。

最后我选了方案三,但做了点优化。核心思路是:**在弹窗打开时,阻止页面滚动,同时通过 CSS 抑制 iOS 的弹性滚动效果**。

核心代码就这几行

关键在于两个地方:一个是 JS 控制 body 的 overflow 和 position,另一个是 CSS 里加一个 -webkit-overflow-scrolling: auto(虽然这个属性已经废弃,但在 iOS 上仍有奇效)。

先看 JS 部分(我用的是 React,但逻辑通用):

// 打开弹窗时
function lockBodyScroll() {
  const body = document.body;
  const scrollY = window.scrollY;

  // 保存当前滚动位置,并设置 body 为 fixed
  body.style.position = 'fixed';
  body.style.top = -${scrollY}px;
  body.style.width = '100%'; // 防止宽度塌陷
  body.style.overflow = 'hidden'; // 阻止滚动

  // 存储 scrollY 到全局或 state,方便关闭时恢复
  window.__scrollY = scrollY;
}

// 关闭弹窗时
function unlockBodyScroll() {
  const body = document.body;
  const scrollY = window.__scrollY || 0;

  body.style.position = '';
  body.style.top = '';
  body.style.width = '';
  body.style.overflow = '';

  // 恢复滚动位置
  window.scrollTo(0, scrollY);
}

这里注意我踩过好几次坑:**必须设置 body.style.width = '100%'**,否则在 iOS 上由于 body 脱离文档流,宽度可能变成内容宽度,导致页面横向晃动。另外,window.scrollTo 必须在样式清除后再调用,否则可能无效。

然后是 CSS 补丁:

/* 抑制 iOS 弹性滚动 */
body.locked {
  -webkit-overflow-scrolling: auto !important;
  overscroll-behavior: contain;
}

其实 overscroll-behavior: contain 在较新版本的 iOS 上已经能解决大部分问题,但为了兼容老机型,我还是加上了 -webkit-overflow-scrolling: auto。虽然 MDN 说这个属性已废弃,但实测在 iOS 15 以下仍然有效。

最终在组件里这样用:

useEffect(() => {
  if (isOpen) {
    lockBodyScroll();
    document.body.classList.add('locked');
  } else {
    unlockBodyScroll();
    document.body.classList.remove('locked');
  }

  // 清理
  return () => {
    if (isOpen) {
      unlockBodyScroll();
      document.body.classList.remove('locked');
    }
  };
}, [isOpen]);

踩坑提醒:这三点一定注意

1. **input 聚焦问题**:如果弹窗里有 input,iOS 在 focus 时会自动滚动页面。这时候即使你锁了 body,系统还是会尝试滚动。我的 workaround 是在 input focus 时再强制一次 window.scrollTo(0, 0),虽然有点 hack,但亲测有效。

2. **地址栏影响**:iOS Safari 地址栏会动态收起/展开,导致 viewport 高度变化。如果你的弹窗高度是 100vh,可能会被截断。建议改用 100dvh(dynamic viewport height),但要注意兼容性。稳妥起见,我直接用 calc(100vh - env(safe-area-inset-top)) 这种写法。

3. **嵌套滚动容器**:如果你的弹窗内部有可滚动区域(比如长列表),记得给那个容器加 overflow: auto; -webkit-overflow-scrolling: touch;,否则在 iOS 上滚动会卡顿。别问我怎么知道的,调了两小时才发现是少了这一行。

改完之后,抖动基本消失,只有在极少数低端机型上还有轻微跳动,但无大碍。毕竟完美兼容所有设备是不可能的,能 cover 95% 的场景就够了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。这个技巧的拓展用法还有很多,比如用在底部导航栏、悬浮按钮等 fixed 元素上,后续会继续分享这类博客。

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

暂无评论