如何浪费一天时间

昨天是个上线日,问题一个接一个,奋战到凌晨 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;

结果:❌ 无效。

cliphidden 本质上都会裁掉子元素的滚动区域,换汤不换药。


尝试 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

结果:❌ ❌ ❌ ❌ ❌ ❌ 无效。

问题根本不在 autoscroll 的区别,而在于父元素的 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,两层滚动锁会让内部滚动完全失效。出现"同一组件不同入口表现不一样"时,先怀疑外层容器,而不是组件本身。

Read more

舒适区里什么都长不出来

舒适区里什么都长不出来

Time is the most important asset. Time does not equal money. Time equals life.  ——Zeno & Carol Zeno Rocha 是 shadcn/ui 的作者,也是 Resend 的创始人,参与过 Vercel、v0.dev 等产品的构建。上面这段话,是他对时间和生命的理解。 我们从小被灌输「时间就是金钱」,但 Zeno 说的是:你花掉的时间,是你字面意义上的生命在消耗。这是两件完全不同的事。钱花了可以再挣,但命没有这个选项。 他的创作理念是 Create. Share. Repeat——持续创造,不囤积,我很喜欢这种方式。

开源的意义

开源的意义

最近在用 Trae SOLO,有一种很奇妙的感觉——想法刚冒出来,服务已经跑起来了。这让我开始认真思考一个问题:开源的意义,到底是什么? 传统意义上,开源解决的是效率问题。代码难写,一个人写不完,所以大家共享代码、集思广益、快速迭代。这是开源存在的经济学基础——通过聚合全球开发者的零散时间,对抗单个组织的能力瓶颈。 但现在,Trae SOLO、Claude Code 这类自主 AI 代理的出现,让这个前提开始动摇。当一个没有工程背景的产品经理也能把想法直接落地成生产服务,"人力不够"这个问题,已经不成立了。 代码生成的边际成本,正在趋近于零。 效率提升的麻烦 按理说,代码生成变快了,开源应该繁荣才对。但现实恰恰相反——大量 AI 生成的低质量 PR 正在淹没开源维护者。研究数据显示,AI 辅助代码产生的缺陷率约为人类代码的 1.

稀缺的执行力

稀缺的执行力

最近看到一个55岁的人写给30、40岁人的话,其中一句: “你越是拖延改变,改变就会变得越痛苦且代价越高。” 我没有特别大的感触——不是因为这句话不对,而是因为我早就活在这句话的另一面了。 我是一个行动力很强的人。有想法,当天就开始动。不确定,就先试。做错了,再调。这件事本身没什么了不起,但在AI这个时代,它突然变成了一种稀缺能力。 想法这个东西,从来不值钱 我身边不缺聪明人。 有人跟我聊过一个方向,我觉得不错,问他打算什么时候开始。他说,再等等,想清楚了再动。三个月后我们再聊,他还在”想”。又过了三个月,这个方向已经有人做出来了,还跑通了。 这不是个例。这几乎是一种普遍现象。 AI出来之后,这个问题被放大了十倍。工具门槛低了,信息差小了,一个普通人能做到的事情多了很多。照理说,应该有更多人去试、去做。但我观察到的恰恰相反——很多人花在”研究怎么用AI”上的时间,远远多于真正用AI做出任何东西的时间。 想法变得更廉价了,行动依然稀缺。