如何浪费一天时间
昨天是个上线日,问题一个接一个,奋战到凌晨 4:44 才算告一段落。 收尾时还剩一个小尾巴——一个移动端的滚动 bug,看起来无足轻重。我安慰自己:睡一觉,早上半小时解决掉。 这篇日志记录的,就是这半小时是怎么变长的。
问题
项目基于 React + Chakra,做了一个升级弹窗 VipUpgradeModal。
桌面端表现完全正常。但在 iOS 的 不同浏览器上,弹窗内容无法滚动。
问题只出现在移动端,尤其是 iOS WebKit 浏览器Modal 内滚动失效。
答案
我开始专注在 VipUpgradeModal 组件内部 overflow、flex、scrollBehavior,但真正的问题根本不在这个组件。
真实原因是:这个弹窗有两个入口,行为不一样。
- 从 Header 打开 → 正常 (后来发现)
- 从个人中心的"VIP特权"按钮打开 → 无法滚动
两个入口用的是同一个组件,同一套代码,却有不同的表现。问题出在打开方式,而不是组件本身。
个人中心是一个全屏 Drawer。点击"VIP特权"时,Drawer 还开着,此时再叠一个 Modal——iOS WebKit 无法同时处理两层滚动锁,Modal 内部滚动直接失效。
从 Header 打开时,外层没有 Drawer,只有一层滚动锁,一切正常。
修复只需要两行:先关 Drawer,再延迟打开 Modal。
const handleOpenUpgradeModal = () => {
onDrawerClose();
setTimeout(() => {
onUpgradeModalOpen();
}, 250);
};
250ms 足够让 Drawer 的关闭动画和滚动锁释放完成。
但在找到这个真正原因之前,我们已经在组件内部前后折腾了 7 次。
下面完整记录这个过程——不只是为了分享最终答案,也为了记录这种"在错误方向上越走越深"的排查体验,以及最后如何跳出来的。
经过
最开始的方案很典型:
<Modal
scrollBehavior={isMobile ? 'outside' : 'inside'}
size={{ base: 'full', md: 'xl' }}
>
<ModalContent
maxH={isMobile ? '100vh' : '80vh'}
display="flex"
flexDirection="column"
>
<ModalHeader>...</ModalHeader>
<ModalBody
flex="1"
overflowY={isMobile ? 'visible' : 'auto'}
/>
<ModalFooter>...</ModalFooter>
</ModalContent>
</Modal>
设计思路:
- 桌面端:
scrollBehavior="inside",ModalBody 内部滚动 - 移动端:
scrollBehavior="outside",整个弹窗滚动,overflow-y: visible
理论上没问题,但实际结果是:iOS 上弹窗完全无法向下滚动。
一次次次的尝试
尝试 1:把 overflow: clip 改成 overflow: hidden
怀疑 Chakra 在移动端给 ModalContent 注入的:
overflow: clip;
是罪魁祸首。iOS 对 clip 的支持历来诡异,于是改成:
overflow: hidden;
结果:❌ 无效。
clip 和 hidden 本质上都会裁掉子元素的滚动区域,换汤不换药。
尝试 2:阻止背景滚动干扰
发现 blockScrollOnMount={!isMobile} 意味着移动端允许 body 滚动,而 iOS Safari 很容易把触摸事件透传给背景页面。于是改成:
blockScrollOnMount={true}
同时:
- 高度从
100dvh改回100% - 去掉
h={0} - 用
overscroll-behavior: contain替代touch-action: pan-y
结果:❌ ❌ 仍然无效。
背景滚动确实被阻止了,但弹窗内部还是无法滚动。
尝试 3:直接移除 overflow: hidden
既然 overflow: hidden 会裁掉滚动区域,那直接删掉。
结果:❌ ❌ ❌还是不行。
ModalContent 失去了高度约束,内容把容器直接撑开,maxH 失效,ModalBody 永远不会进入滚动状态。也就是说:overflow 必须存在,否则无法形成真正的滚动容器。
尝试 4:完全交给 Chakra
放弃手写布局,删除 display="flex"、flex="1"、minH={0}、overflowY,只保留:
<Modal scrollBehavior="inside">
<ModalContent maxH="100dvh">
...
</ModalContent>
</Modal>
希望 Chakra 自己处理。
结果:❌ ❌ ❌ ❌ 依旧失败。
Chakra 的内部实现本质仍然是:
.ModalContent { overflow: hidden; }
.ModalBody { overflow: auto; }
父级 hidden + 子级 auto,还是嵌套 overflow,绕不过去。
尝试 5:去掉 scrollBehavior,恢复手动 flex 布局
意识到 Chakra 的滚动逻辑和手写 flex 布局是两套系统在互相冲突,于是:
- 去掉
scrollBehavior - 恢复手动布局
<ModalContent display="flex" flexDirection="column">
<ModalBody flex="1" minH={0} overflowY="auto" />
</ModalContent>
结果:❌ ❌ ❌ ❌ ❌ 还是失败。
只要父元素存在 overflow: hidden,iOS 就会把子元素的滚动区域裁掉。
尝试 6:overflow: auto 改成 overflow: scroll
怀疑 iOS 对 overflow: auto 的识别不稳定,改成 overflow-y: scroll。
结果:❌ ❌ ❌ ❌ ❌ ❌ 无效。
问题根本不在 auto 和 scroll 的区别,而在于父元素的 overflow 已经让子元素的滚动区域彻底失效,改什么值都没用。
尝试 7:单一滚动容器 + sticky
彻底换思路:
- 不再使用嵌套滚动
- 整个
ModalContent自己滚动 - Header / Footer 用
position: sticky固定 ModalBody不参与任何 overflow 计算
<Modal
blockScrollOnMount={true}
size={{ base: 'full', md: 'xl' }}
>
<ModalOverlay />
<ModalContent
maxH={isMobile ? '100dvh' : '80vh'}
overflowY="auto"
sx={{
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
}}
>
<ModalHeader
position="sticky"
top={0}
zIndex={1}
bg={modalBg}
>
...
</ModalHeader>
<ModalBody>
{/* 普通内容,不参与滚动计算 */}
</ModalBody>
<ModalFooter
position="sticky"
bottom={0}
zIndex={1}
bg={modalBg}
>
...
</ModalFooter>
</ModalContent>
</Modal>
结果:✅ 成功一半。iOS Safari 可以正常滚动。
分析
问题的本质是:iOS Safari 对嵌套 overflow 的支持非常差。
典型失败结构:
.parent { overflow: hidden; }
.child { overflow: auto; }
桌面浏览器完全正常,但在 iOS 上,父元素的 overflow: hidden 会直接裁掉子元素的滚动区域,overflow-y: scroll 也毫无作用——因为 iOS 根本识别不到子元素存在可滚动区域。
Chakra UI 的 scrollBehavior="inside" 底层就是这个结构,GitHub 上也有相关 issue(#5889、#6131),本质是同一类嵌套 overflow 兼容问题。
方案的核心:只保留一个滚动容器。
.ModalContent { overflow-y: auto; }
Header / Footer 通过 position: sticky 实现固定,不再参与任何 overflow 计算。这样:
- 没有嵌套 overflow
- 没有 flex 高度竞争
- 不依赖 iOS 对复杂滚动层级的兼容
这是目前移动 Web 上最稳定的方案之一,不只适用于 Chakra,对任何 UI 框架都通用。
根因:Drawer + Modal 双层滚动锁
多次试错之后,弹窗内滚动确实修好了——但只是修好了从 Header 打开的情况。
从个人中心的"VIP特权"按钮打开,依然无法滚动。
两个入口,同一个组件,不同结果。这说明问题不在组件内部。
排查发现:个人中心是一个全屏 Drawer,点击"VIP特权"时 Drawer 还处于打开状态。
iOS WebKit 的机制是:每次打开一个需要滚动锁的覆盖层(Drawer、Modal、Sheet),都会对 body 施加一次滚动锁。两层叠加时,iOS 无法正确识别内层 Modal 的可滚动区域,触摸事件被外层 Drawer 的滚动锁拦截,内部滚动完全失效。
从 Header 打开没有这个问题,因为外层没有 Drawer,只有一层滚动锁。
这才是真正的根因。前面 7 次在组件内部的折腾,方向全都错了——锅根本不在 VipUpgradeModal 本身。
修复方式:先关 Drawer,再延迟打开 Modal。
const handleOpenUpgradeModal = () => {
onDrawerClose();
setTimeout(() => {
onUpgradeModalOpen();
}, 250); // 等待 Drawer 关闭动画和 body 滚动锁释放
};
250ms 足够让 Drawer 的关闭动画和滚动锁释放完成。加上前面对 VipUpgradeModal 内部的 overflow 修复(单一滚动容器 + sticky),两个问题都彻底解决。
技术总结
| 尝试 | 方向 | 结果 |
|---|---|---|
| 1 | overflow: clip → hidden |
❌ |
| 2 | blockScrollOnMount={true} |
❌ |
| 3 | 移除 overflow: hidden |
❌ |
| 4 | 完全交给 Chakra scrollBehavior |
❌ |
| 5 | 去掉 scrollBehavior,恢复手动 flex |
❌ |
| 6 | overflow: auto → scroll |
❌ |
| 7 | 单一滚动容器 + sticky | ✅(但只解决了一半) |
| 真正修复 | 先关 Drawer,再延迟打开 Modal | ✅ |
- iOS Safari 上,不要嵌套 overflow。 让滚动只发生在一层,用 sticky 固定头尾,内容区只负责展示。
- iOS 上不要叠加滚动锁。 Drawer 还开着就打开 Modal,两层滚动锁会让内部滚动完全失效。出现"同一组件不同入口表现不一样"时,先怀疑外层容器,而不是组件本身。