一切从那个白屏开始
上线后的第三天,有个用户在群里私聊我:"水哥,你的平台在 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% 的用户,还做什么平台?
移动端兼容性不是什么高深的技术问题,它只是需要耐心和流程。 熬过调试的黑暗,才能看到稳如磐石的页面。