一行代码,修了两天:记一次 Chrome 自动缩放 bug 排查

前天早上,我照常打开自己的博客,准备瞅一眼有没有新评论。手机是 Android,Chrome 浏览器,输入 marxchou.com,回车。

页面出来了,但不对劲。

怎么说呢,就是感觉整个页面被人放大了一圈。标题大得离谱,文章卡片挤在一起,得用手指捏一下缩小,页面才恢复正常。我以为是自己手滑了,关掉重新打开,又来了。清缓存,没用。开无痕模式,还是这样。

我当时就愣住了。

不是哥们,我这两天啥也没改啊?怎么突然就炸了。

更骚的是,第二天我在电脑上也发现了这个问题。Chrome 打开,页面默认就是放大的。Ctrl + 0 也没用,得手动 Ctrl+- 缩小才能看。

搞技术的朋友都知道,前端 bug 里最让人头疼的就是这种 —— 你根本不知道从哪开始查,而且它还不是必现的,有时候好有时候坏。

好,既然没办法逃避,那就查吧。

第一轮:viewport,不是它

我当时心想,这还用说吗,肯定是 viewport 的问题。移动端页面放大,十有八九是 <meta name="viewport" content="width=device-width, initial-scale=1"> 这个标签没生效。

打开页面源码,嗯,标签好好的在那。initial-scale=1width=device-width,一点毛病没有。

那就不是 viewport。

Chrome页面异常放大,排版混乱

第二轮:CSS 溢出,也不是它

接下来我把目光投向了 CSS。项目里有个 scrollbar-gutter: stable 的样式,这玩意会在桌面上给滚动条预留空间。还有一些元素用了 100vw 做全宽突破,这两样碰到一起,很容易造成水平溢出。浏览器检测到内容比视口宽,可能就会调整缩放。

我把 100vw 全改成了 auto,把 scrollbar-gutter 也换成了 overflow-y: scroll。信心满满地部署上去。

没用。

问题还在。

第三轮:Astro View Transitions,还不是它

我寻思了一下我没寻思明白。那再换一个方向,会不会是 Astro 6 的 View Transitions 搞的鬼?Astro 6 有个 ClientRouter 组件,配合 @view-transition { navigation: auto } 这个 CSS,会在页面切换时做过渡动画。但这个动画在首次加载时会不会有 bug?

我把 ClientRouter 注释掉,把 @view-transition 也禁了。部署上去。

还是没用。

到这我有点懵了。常规手段全试过了,一个都不行。

三连排查 viewport、CSS、Astro 全部失败

第四轮:git bisect,核武器出动

我决定上核武器 ——git bisect。

可能有些朋友不太了解这玩意。简单说,git bisect 是一个二分查找工具。你告诉它一个「好的」commit 和一个「坏的」commit,它会帮你跳到中间位置,你测试一下告诉它是好是坏,它再跳到下一个中间位置。这样 23 个 commit,理论上 4 到 5 步就能找到出问题的那个。

我把排查范围锁定在 6 月 22 日到 6 月 24 日之间的 23 个提交。好的是 6 月 22 日的 7811418,坏的是最新的 28cbf5a。开始。

大概花了一个多小时,来回测了五六次,最后定位到了 ff86e09 这个 commit。

你猜这个提交改了啥?

它只新增了 12 个英文页面。没有修改任何一行现有的 CSS、HTML、JS 代码。

我当时真的就是三个问号。新增英文页面为什么会引起首页放大?它们之间有个屁的关系?

更诡异的是,我把英文页面全部删掉,问题就消失了。把英文页面加回来,问题又出现了。而且我确认过,带英文和不带英文的两个版本,渲染出来的 HTML、CSS、JS 完全一模一样。Playwright 自动化测试也显示一切正常,scale: 1hasHScroll: false

这已经不是技术问题了,这特么是玄学。

git bisect 二分查找锁定问题 commit

逐个排除,全部失败

我盯着屏幕沉默了大概两分钟。然后做了一个决定 —— 不管原理了,逐个排除。

我把所有能想到的可疑组件都试了一遍:LoadingIntro 全屏加载动画、--font-body vs --font-sans 的 CSS 变量层级、display=swap 的字体加载策略。每一个都像是正确答案,每一个最后都被证明是错的。

说真的,我从来没在一个 bug 上花过这么长时间。

突破:截图对比

最后我换了个思路。既然生产环境和本地环境不一样,那我不如用 Playwright 分别在两个版本上截图,肉眼对比一下到底哪里变了。

我切到 6 月 22 日正常的那版 c9c1762 截了一张,又切到最新的 main 截了一张。

两个截图文件大小不一样,838KB vs 839KB。差了 1KB,说明画面上确实有区别。

我又写了个脚本,把两个版本的关键元素尺寸全量出来对比。Hero 标题的字体大小、Body 的宽度、Main 容器的大小。对到 .page-wrapper 的时候,我发现了。

在 6 月 23 日的 15a2b1e 这个提交里,为了加侧边栏功能,BaseLayout 被重构了。所有页面的内容区域外面被套了一层 <div class="page-wrapper">。就是这个东西。

两张截图仅差 1KB,放大镜揭示 .page-wrapper 差异

.page-wrapper {
display: flex;
max-width: 1440px;
margin: 0 auto;
padding: 16px;
}

这个包装器对侧边栏页面是必要的 —— 它得用 grid 布局把侧边栏和主内容并排。但对首页这种没有侧边栏的页面,它只是在内容外面硬生生加了 16px 的 padding,还在 flex 列布局里塞了一个 flex 行容器。

Chrome 在首次渲染页面时,会基于 DOM 结构计算视口。.page-wrapper 这个多出来的 flex 容器打断了 #app 的正常 flex 列布局,Chrome 在计算时出了偏差,导致整个页面被错误地放大。

至于为什么英文页面会影响这个 bug 的出现频率,我现在也不知道。可能是 Astro 在有多语言路由时构建输出的 HTML 有一些微小差异,也可能是纯纯的巧合。但根因就是 .page-wrapper 没错。

修复

最终的修复简单得令人发指。把 .page-wrapper 改成只在有侧边栏时才使用,首页等普通页面恢复为原始结构,<main> 直接作为 #app 的子元素。

{
(showSidebar || isDoc) ? (
<div class="page-wrapper">
<Sidebar ... />
<main ...>...</main>
</div>
) : (
<main ...>...</main>
)
}

就这。一行代码。修了两天。

AI 的角色

虽然这整个过程真的很折磨人,但我还是想说一下 AI 在其中扮演的角色。这次排查,Claude Code 从头到尾都在旁边帮我。它帮我做了三件事。

第一,快速扫描。viewport meta 标签对不对、哪些地方用了 100vw、CSS Cascade Layers 的优先级怎么算的,这些我一个人翻要翻半天,它几秒钟就出结果了。

第二,Playwright 自动化。如果不是它在不同 commit 之间快速截图对比、量元素尺寸,我可能永远发现不了 .page-wrapper 的问题。838KB vs 839KB 的截图差异,人眼根本看不出来。

第三,它是那个冷静的搭档。当我已经崩溃到觉得「肯定不是这个」「试了也没用」的时候,它还是会说「试试看」,然后默默地又跑一轮测试。

但它也没法替我找到答案。真正的突破点,还是我决定用 git bisect、决定去对比截图、决定把 .page-wrapper 去掉试试。这些决策,是人在做。

AI 是加速器,不是方向盘。

AI 在后方助推,人在前方把握方向

总结

至于这次排查教会我的东西,最重要的就是 —— 间歇性 bug 的第一要务不是猜,是缩小范围。git bisect 这种工具平时可能半年用不到一次,但真到了要用的时候,它就是救命的东西。

还有一点,不要过度相信自己的直觉。我最开始以为是 viewport 标签,又以为是 100vw 溢出,每一个都那么像正确答案,每一个最后都被证伪。

代码不会骗人,但我们的猜测会。

对了,如果你的博客也有类似的「首次打开放大」问题,可以先看看是不是布局上多了什么不该有的包装容器。别像我一样绕了两天的弯路。