起因:只有 iPhone 会“滚出一片空白”
这次问题一开始很像一个普通的移动端懒加载 Bug。
博客在 Android 和桌面浏览器上都正常,但到了 iPhone Safari,只要在一篇代码较多的长文章里快速上下滑动,就会出现一个非常奇怪的现象:
- 页面滚动条还在继续移动;
- 顶部导航栏和 Safari 自己的 UI 都正常;
- 文章正文却会突然变成一整块背景色;
- 停止滚动后,正文又会整片补绘回来。
第一反应自然是“正文是不是做了懒加载”“是不是目录栏还在移动端偷偷运行”“是不是某个 content-visibility 或动画导致内容没有及时渲染”。
但后来逐帧看录屏后,现象其实更接近 iOS WebKit 的 checkerboarding / missing tiles:页面结构已经在那里,滚动位置也在变化,只是浏览器的绘制与栅格化没有及时跟上。
这也解释了为什么 Android 基本正常,而 iOS 特别明显。
第一轮:先把外围嫌疑一个个排除
排查这种问题,最怕一口气改十处代码。因为最后即使“好了”,也不知道到底是哪一个修改起作用。
所以这次后来采用的办法很简单:严格做 A/B,一次只改一个变量。
1. 移动端右侧 TOC
之前移动端虽然通过 CSS 隐藏了右侧目录,但对应 JavaScript 仍可能继续:
- 扫描标题;
- 创建
IntersectionObserver; - 计算目录位置;
- 监听滚动。
于是先把移动端 TOC 的初始化彻底停掉,同时把 Pagefind、OverlayScrollbars、PhotoSwipe 等功能改成真正的按需加载。
这一步对整体性能有帮助,但 没有解决 iOS 快速滚动时的大面积空白。
2. content-visibility、动画和 GPU 层
接着检查了文章正文中的:
content-visibility;contain;will-change;translateZ(0);- 入场动画;
- 导航栏
backdrop-filter。
这些东西都可能让 WebKit 建立更多独立绘制层。
逐步简化以后,问题依旧存在。有一版甚至因为强行让整篇超长正文始终参与绘制,体感反而更严重。
这时可以基本确认:不是“内容没加载”,而更像“内容太重,Safari 来不及画”。
3. 超高圆角卡片与 overflow:hidden
文章页原先存在多层:
#swup-container└── overflow-hidden └── 文章外框 └── overflow-hidden + border-radius └── #post-container.card-base └── overflow-hidden + border-radius对一个几万像素高的长页面来说,这种层层裁剪看起来也很可疑。
于是做了一版测试:移动端只取消这些长容器的裁剪,其他全部不动。
结果:没有改善。
又排除一个方向。
真正的突破口:把代码块全部隐藏后,居然不卡了
这篇文章有一个很突出的特点:代码非常多,而且有一个接近 200 行的 Go 代码块。
代码块使用 Expressive Code 渲染。视觉效果很好,但它并不是简单的:
<pre><code>...</code></pre>而是会生成:
- frame;
- 行容器;
- 行号;
- 每一行里的大量语法 token
<span>; - 高亮、标记、复制按钮等附加结构。
一段接近 200 行的代码,最后可能变成几千个 DOM 节点。
为了验证它是不是核心因素,做了一个最极端的测试:
手机端直接把所有代码块
display:none。
结果非常明显:
原来那种快速滑动后整屏空白、停下再补绘的现象基本消失。
这一步是整个排障过程里最关键的证据。
第二轮 A/B:到底是代码滚动容器,还是语法高亮 DOM?
知道“代码块有问题”还不够,因为代码块同时包含两个容易影响 Safari 的因素:
- 复杂的 Expressive Code DOM;
- 代码块内部的
overflow:auto嵌套滚动。
实验一:保留高亮,只取消代码块内部滚动
把代码重新显示出来,保留完整语法高亮,但在手机端取消 480px 限高和内部滚动,让代码全部展开。
结果:
空白问题重新出现。
所以嵌套滚动不是主因。
实验二:把 Expressive Code 扁平化成普通 <pre><code>
接下来把复杂 DOM 全部移除,只留下普通代码文本。
结果:
不卡了。
但视觉效果明显下降,而且第一次实现时还把行号一起读进了代码文本,出现了:
188}189
190func main() {这种“行号和代码被拆成两行”的奇怪效果。
后来虽然修正了文本提取,但普通代码块依然不好看。
实验三:保留 Expressive Code 外壳,只删除 token span
又尝试了一个更保守的方案:顶部栏、行号、frame 都保留,只把每行内部最重的 token 节点清掉。
结果依然会复现卡顿。
到这里,方向已经很清楚了:
对 iOS Safari 来说,长正文里常驻多个 Expressive Code 组件本身就是一个足够强的绘制压力源。
并不一定只有 200 行的代码才有问题,多个 7 行、11 行、17 行的小代码块叠加,同样会增加 WebKit 的 paint / raster 压力。
最终方案:手机端“代码卡片 + 全屏代码阅读器”
既然真正的问题不是代码内容,而是“复杂代码组件长期常驻在几万像素高的正文页面里”,最终就没有必要继续在 CSS 上硬扛。
更合理的思路是:
正文负责阅读,代码按需查看。
最终移动端分成两档。
短代码:转成轻量代码框
8 行以内的命令或配置仍然直接展示,例如:
systemctl daemon-reloadsystemctl enable --now image-uploader但正文中的 Expressive Code live DOM 会被移除,替换成一个轻量代码框,只保留必要的:
- 深色背景;
- 语言标签;
- 复制按钮;
- 普通代码文本。
长代码:变成代码卡片
超过 8 行的代码,不再直接塞进正文,而是显示成轻量卡片:
┌──────────────────────────────┐│ </> GO · 198 行 ││ ││ package main ││ import ( ││ ... ││ ││ 查看代码 → │└──────────────────────────────┘卡片本身只有极少量 DOM,对长文章几乎没有额外压力。
点击“查看代码”后,再进入独立的全屏代码阅读器。
全屏阅读器按需加载
完整语法高亮并没有被放弃。
它只是从“常驻正文”改成:
- 用户点击代码卡片;
- 临时创建全屏阅读器;
- 插入完整的高亮代码;
- 阅读器自己负责横向、纵向滚动;
- 关闭后销毁重型 DOM;
- 回到正文原来的滚动位置。
这就把最关键的问题拆开了:
以前:长正文 + 多个完整 Expressive Code + 页面滚动
现在:长正文 + 轻量代码卡片 ↓ 点击时独立全屏代码阅读器 + 单个完整代码块最终实机测试中,正文快速上下甩动已经不再出现之前那种大面积空白。
全屏阅读器又踩了两个小坑
正文流畅以后,全屏代码查看器还经历了两次修正。
1. 行号和代码错位
最初直接搬运 Expressive Code 的结构,阅读器里形成了两层滚动容器,行号和代码布局也被破坏。
表现为:
1package main2import (同时手指上下滑时只要稍微带一点横向动作,代码就会整体偏过去。
最终改成:
- 阅读器正文只有 一个滚动层;
- 每行固定为“行号 gutter + code”同行布局;
- 横向和纵向都由同一个容器负责。
2. 全屏代码没有颜色
后来又发现全屏阅读器虽然结构正常,但部分代码看起来像纯白文本。
原因之一是 Expressive Code / Shiki 的 token 颜色通过 CSS 变量保存,代码被搬到原 Markdown 树以外以后,对应主题规则没有可靠命中。
于是给全屏阅读器作用域重新恢复 token 的主题颜色变量。
另外,文章里有一段 caddyfile,当前高亮器并不直接识别这个语言标识,因此又给它增加了一个近似的 Shiki alias,映射到 nginx 语法规则。
它不可能 100% 理解 Caddyfile 语义,但至少不会整段退化为纯文本。
另一个完全独立的问题:桌面端为什么点一次页面却闪两下?
移动端问题解决后,又发现电脑版导航还有一个很怪的现象:
点击一次“传送门”或文章,页面会像刷新两遍一样闪烁。
最开始怀疑是:
- Swup 自己有一次淡出 / 淡入;
- 页面还有一套
onload-animation; - 两层动画叠加造成“双闪”。
所以先把视觉动画全部关掉。
结果:还是闪。
这时录屏里的地址栏给出了关键线索。
它不是单纯视觉闪烁,而是真的出现了类似:
/portal/ ↓/ ↓/portal/也就是说,一次点击里 URL 真正发生了多次历史记录 / 页面导航变化。
Swup 的真正坑:配置了两个容器,但其中一个并不总存在
检查 Astro 配置后发现:
swup({ containers: ["main", "#toc"], // ...})也就是说,每次 Swup 导航都要求同时替换:
main;#toc。
但页面布局原先是这样写的:
{siteConfig.toc.enable && headings.length > 0 && ( <div> ... <div id="toc"> <TOC headings={headings} /> </div> </div>)}问题就出在这里:
只有文章有 headings 时,
#toc才存在。
首页、传送门、归档等页面可能完全没有 #toc 节点,但 Swup 全局配置又明确要求它是一个需要替换的容器。
于是不同页面之间的 Swup 容器结构并不一致。
这也比“CSS 动画”更能解释为什么地址栏会发生真实的目标页 → 根目录 → 目标页跳转。
最终修复
办法非常简单:
所有页面始终保留 #toc 节点。
没有目录时,不删除这个容器,只让它保持空内容:
<div id="toc" class="w-full h-full transition-swup-fade"> {siteConfig.toc.enable && headings.length > 0 && ( <TOC headings={headings} /> )}</div>这样对 Swup 来说,每个页面的容器结构始终一致:
main 永远存在#toc 永远存在实机再次测试后,桌面端的双闪和 URL 二次跳转消失。
这个坑其实很值得记住:
用了 Swup / PJAX 这类局部页面替换方案以后,被声明为 container 的节点,最好在所有参与导航的页面中保持稳定存在。不要让它跟着业务内容条件渲染掉。
顺手做掉的几个优化
这次排障过程中还顺便清理了几处长期隐患。
头像不再打包 2MB+ PNG
原来的头像 PNG 超过 2MB,对一个静态博客来说完全没有必要。
现在直接改为使用独立图床上的 WebP:
https://img.bijiy.com/2026/09/img-1789278943991983319.webp这样既减小仓库与构建产物,也让头像走现有图床/CDN 链路。
搜索、图片查看器、滚动条都按需加载
Pagefind、PhotoSwipe、OverlayScrollbars 这类功能都不是首屏必须项,因此尽量改成真正的 lazy load,而不是“CSS 隐藏但 JavaScript 已经全部跑起来”。
这不一定是本次 iOS bug 的根因,但对博客整体首屏和移动端资源占用都更合理。
这次最有价值的不是某一行代码,而是排障方法
回头看,这个问题其实很容易被误判。
“滚动后内容空白”会让人自然想到:
- 懒加载;
- IntersectionObserver;
content-visibility;- DOM 被隐藏;
- 网络资源没加载完。
但真正的问题却是:内容早就在 DOM 里了,只是 iOS WebKit 来不及把它画出来。
如果没有做那次“把所有代码块完全隐藏”的极端 A/B,很可能还会继续在外围 CSS 上浪费很多时间。
这次留下几个以后可以直接复用的原则:
- 先确认是加载问题,还是绘制问题。 DOM 已存在但画不出来,与懒加载完全是两条排查路线。
- 一次只改一个变量。 否则“修好了”也不知道为什么好了。
- 极端 A/B 很有价值。 怀疑代码块,就先全部
display:none;怀疑高亮,就退化成纯文本。先证明相关性,再谈优雅方案。 - 移动端不必机械复制桌面交互。 200 行代码在 6 英寸屏幕上直接展开,本来就未必是最佳体验。
- PJAX/Swup 的容器必须结构稳定。 条件渲染一个被声明为替换目标的容器,很容易制造难以解释的历史记录与重载问题。
- 性能优化最终最好转化成产品设计。 “代码卡片 + 全屏阅读器”不是降级,而是比原方案更适合手机的阅读方式。
最开始只是想解决一个 iPhone 滚动卡顿,最后反而把博客的移动端代码阅读方式重新设计了一遍。
这大概也是折腾独立博客最有意思的地方:问题看起来总是从一个小地方开始,最后逼着你把整个链路真正弄明白。