独立开发中·📚18 篇日记·📝最近更新:2026年07月12日
📱

iPhone白屏4小时:ResizeObserver差点杀了我

上线第三天用户反馈iOS白屏,调试两小时才发现ResizeObserver死循环。

水哥
2026-07-117 分钟阅读

一切从那个白屏开始

上线后的第三天,有个用户在群里私聊我:"水哥,你的平台在 iPhone 上是白的。"

我第一反应:不可能。我在 Chrome DevTools 的手机模拟器里测过,一切正常。

然后我借了朋友的 iPhone 打开……真的白屏。 完全空白,连 loading 都没有。那一刻汗都下来了。

调试的两小时地狱

没有远程调试工具在手,我只能靠猜测。排查过程大概是这样:

猜疑 1:网络问题?
  → 排查:WiFi 和 4G 都白屏
  → 排除

猜疑 2:JS 加载失败?
  → 排查:其他平台正常,同一套代码
  → 排除

猜疑 3:React 报错导致白屏?
  → 排查:生产环境没有报错日志
  → 无法确认

猜疑 4:是不是某个浏览器 API?
  → 终于发现了!resizeObserver!

真凶:ResizeObserver loop limit exceeded

我在首页用了 ResizeObserver 来监听一个动态高度的组件:

const [heroHeight, setHeroHeight] = useState(0)

useEffect(() => {
  const observer = new ResizeObserver((entries) => {
    setHeroHeight(entries[0].contentRect.height)
    // 这里触发了 state 更新
    // state 更新又触发了重新渲染
    // 重新渲染又触发了 ResizeObserver
    // → 无限循环!
  })
  observer.observe(ref.current!)
  return () => observer.disconnect()
}, [])

在 Chrome 桌面端,这个死循环被浏览器静默处理了。但在 iOS Safari 里,它直接抛出 ResizeObserver loop limit exceeded 错误,而且 React 的错误边界捕获不到这个错误,直接白屏。

解决问题的过程

第一版修复(失败了)

我以为加个条件判断就行:

const observer = new ResizeObserver((entries) => {
  const newHeight = entries[0].contentRect.height
  // 只在高度变化时更新
  setHeroHeight(prev => {
    if (Math.abs(prev - newHeight) > 10) return newHeight
    return prev
  })
})

好了一天,又有用户反馈偶尔白屏。因为 iOS Safari 会在页面加载时触发多次 resize 事件(键盘弹出/收回、滚动条出现/消失都会触发)。

第二版修复(用 requestAnimationFrame 节流)

let rafId: number

const observer = new ResizeObserver((entries) => {
  cancelAnimationFrame(rafId)
  rafId = requestAnimationFrame(() => {
    const newHeight = entries[0].contentRect.height
    setHeroHeight(prev =>
      Math.abs(prev - newHeight) > 10 ? newHeight : prev
    )
  })
})

requestAnimationFrame 保证了每帧最多触发一次回调,基本消除了无限循环的可能。稳定了。

终极方案:CSS Container Queries

后来我干脆把动态高度计算去掉了,改用了 CSS Container Queries:

.hero-container {
  container-type: inline-size;
  container-name: hero;
}

@container hero (min-width: 600px) {
  .hero-content {
    flex-direction: row;
  }
}

CSS Container Queries 是 2023 年才广泛支持的特性,但 iOS Safari 从 16.0 就开始支持了。 我的目标用户基本都在用 iOS 16+,可以放心用。

我学到的教训

1. 永远不要相信 Chrome DevTools 的模拟器

DevTools 的手机模拟器用的是 Chrome 渲染引擎,不是 iOS Safari。很多只在 Safari 里出现的 bug,模拟器里完全看不出来。

必须用真机测试。 省不了。

2. ResizeObserver 的正确用法

// ❌ 最危险:直接在回调里 setState
new ResizeObserver(() => setState())

// ⚠️ 勉强可以:比较后再 setState
new ResizeObserver(() => {
  if (changed) setState()
})

// ✅ 正确:加 requestAnimationFrame 节流
new ResizeObserver(() => {
  cancelAnimationFrame(rafId)
  rafId = requestAnimationFrame(() => setState())
})

// ✅✅ 最推荐:如果可以,用 CSS 代替 JS
// Container Queries 或 Grid/Flexbox 自适应

3. 建立真机测试流程

我在迁移后给自己定了一条铁律:

上线前检查清单:
□ Chrome 桌面端 (Windows + Mac)
□ Firefox 桌面端
□ Safari 桌面端
□ iOS Safari (真机)
□ 微信内置浏览器 (iOS + Android)
□ Chrome Android (真机)

虽然每次上线前都要测一轮,但比起上线后用户告诉我"白屏了",这点时间太值了。

一个有意思的发现

在排查的时候发现,微信内置浏览器的内核在 Android 上是 Chromium,在 iOS 上是 WKWebView(和 Safari 一样)。 这意味着同一个 bug,Android 微信和 iOS 微信的表现可能完全不同。

所以如果你做的产品用户基本在微信里打开,必须同时测 iOS 和 Android 的微信环境。

写在最后

这个 bug 前前后后花了我四个多小时。最崩溃的时候,我几乎想放弃 iPhone 用户了——反正安卓占比更高。

但数据告诉我:我的用户里 iOS 占了 35%。丢掉 35% 的用户,还做什么平台?

移动端兼容性不是什么高深的技术问题,它只是需要耐心和流程。 熬过调试的黑暗,才能看到稳如磐石的页面。

相关文章