如何浪费一天时间

昨天是个上线日,问题一个接一个,奋战到凌晨 4:44 才算告一段落。 收尾时还剩一个小尾巴——一个移动端的滚动 bug,看起来无足轻重。我安慰自己:睡一觉,早上半小时解决掉。 这篇日志记录的,就是这半小时是怎么变长的。

分享
如何浪费一天时间
Photo by Amaury Gutierrez / Unsplash

问题

项目基于 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 ✅
  1. iOS Safari 上,不要嵌套 overflow。 让滚动只发生在一层,用 sticky 固定头尾,内容区只负责展示。
  2. iOS 上不要叠加滚动锁。 Drawer 还开着就打开 Modal,两层滚动锁会让内部滚动完全失效。出现"同一组件不同入口表现不一样"时,先怀疑外层容器,而不是组件本身。

阅读更多

为了平凡的生活

为了平凡的生活

为了翻拣垃圾的孩子,和他们的梦想。 为了那些枯萎的树木, 为了那些如纸牌般倒塌的危楼。 为了那些安眠药, 和那些漫长而失眠的夜晚。 为了那些被困在原地的人, 为了改变那些腐朽而停滞的头脑。 为了那些被加速的人生, 为了恐慌,焦虑,和没有尽头的比较。 为了那些被迫接受的规则, 为了那些被强加的指令, 为了那些看不见的手, 我们成长, 成功, 不断向前。 加速学习, 加速工作, 加速赚钱, 加速消费, 加速体验, 加速衰老。 好像停下来, 就会落在后面。 我想慢下来。 想要一份普通的工作, 不用害怕失去。 想让孩子好好长大, 让老人慢慢变老。 想在傍晚回家, 吃一顿晚饭。 想让夜晚只是夜晚, 而不是焦虑、失眠和安眠药。 想让树长在那里, 而不是倒在推土机经过的地方。 想让房子能够站得住, 住进去,可以安心睡觉。 想让一个人活着, 不需要不停证明自己活得有价值。 我们曾经以为, 生活应该越来越精彩。 后来发现, 真正难得的是它能够保持正常。 没有突然坍塌的房子, 没有突然失去的工作, 没有突然降临的疾病,