跳到主要内容

切页与首屏

预取与视图过渡的切页改造,加上 CLS 修复与 content-visibility 长文渲染的实录

周五 9月 25 2026
8349 字 · 30 分钟

切页与首屏

这篇是本站性能改造的合并记录,一半讲切页,一半讲首屏。

切页要解决两件事:快,点击之后到新页面出现之间的等待,能靠预取和预加载消掉大半;顺,新旧画面切换的观感,默认实现有一个明显的空白低谷,要靠视图过渡改掉。首屏管的是页面内容:静态站的 HTML 到得很快,卡住用户的往往是浏览器把 DOM 变成像素的过程——布局不稳定,内容在你眼前跳一下(CLS);或者渲染耗时,几千个节点的 layout 和 paint 让切页白屏一秒(长文重灾区)。

两部分有交接点:骨架屏既是慢导航的观感底线,也是动态内容的占位保险。

切页提速:把等待消掉

排查:预取为什么一次都没发生

“觉得切页挺快的”和”确认切页已经快到极限”是两回事。

排查这个博客的加载链路时发现一个尴尬的事实:站内预取(prefetch)配置了默认策略,但实际处于完全空转状态。

悬停、按下、滚入视口,预取请求一次都没发过。

现象是 Performance/Network 面板里悬停站内链接时没有任何”提前加载”的请求。

对照 Astro 的预取实现读了一遍逻辑,发现预取生效需要同时满足两个条件,而这个站两个都不满足:

  1. 预取是”按链接选择器匹配”的:只有带 data-astro-prefetch 属性的链接,或者全站 prefetchAll: true 开启时,策略才会作用于链接。这个站的模板链接没有一处标注该属性,而 prefetchAll 落在默认值 false。匹配条件不成立;
  2. 只配了 defaultStrategy(触发策略:hover/tap/viewport),却没有打开任何”哪些链接参与”的开关。策略配得再好,没有候选链接也是空转。

修复是两行配置:

prefetch: {
prefetchAll: true, // 所有站内 <a> 都参与预取
defaultStrategy: "hover", // 悬停 80ms 后开始
},

选 hover 而不是 viewport 的理由:桌面端”阅读 → 移向链接 → 点击”之间有几百毫秒的自然富余,悬停 80ms 后启动预取正好填进这段时间,点击到达时下一页多半已经在缓存里。

触屏没有真正的 hover,Astro 会把 mouseenter 合成为点击时触发,等价于”点了才预取”,不劣于不配。

弱网和省流量模式(saveData)下浏览器内部会自动跳过,不会白耗用户流量。

触发策略其实一共四档,激进程度递增:hover(悬停约 80ms 后取,赌的是有意向的点击)。tap(按下即取,把网络时间藏进按下到抬起的间隙,移动端比 hover 更早动手)。viewport(链接滚入视口就取,用 IntersectionObserver 监听。最激进也最容易浪费,列表页一滚过就是一串请求)。load(页面空闲后把站内链接全取一遍,等于给整站预热)。

这站选 hover 是在命中率和浪费之间取平衡:列表页一屏十几个链接,viewport 会把九成预取花在永远不会被点的链接上。

验证方法:Network 面板勾上”禁用缓存”再打开,悬停任意站内链接,应立即出现一条对目标页 HTML 的请求。

修复前后从 0 条到悬停必有请求,预取链路才算真正接通。

三类”提前加载”的分工

排查过程中把三个经常混用的词理了一遍,它们回答的是三个不同的问题:

prefetch:“下一页大概率会被访问,提前拿”。

作用对象是下一页的 HTML(或资源),赌的是用户行为。

预取的请求是低优先级的,不抢占当前页渲染。

适用:站内导航链接、分页、搜索结果页。

preload:“本页马上就要用它,别等解析到才拿”。

作用对象是当前页的关键资源,把发现时机从”CSS 解析到那一行”提前到”head 一开始”。

典型如首屏大图 <link rel="preload" as="image" fetchpriority="high">、关键字体。

preload 用错对象(预加载了实际不用的资源)会在控制台被警告,且浪费带宽。

modulepreload:“本页稍后要执行这个 JS 模块及其依赖图”。

专门针对 ES 模块:不止取文件,还解析并预取它的 import 依赖树。

这是它和普通 preload 的本质区别。

modulepreload 补上水合前的空窗

静态站框架普遍有个不太显眼的空窗:页面 HTML 到了、渲染了,但交互组件(岛屿)要等 JS 模块下载完才水合。

这个站的水合器脚本在 HTML 靠后位置才声明组件地址,浏览器发现”哦这里还有个模块要下载”时,网络可能已经空闲了一小段。

白白拉长了可交互时间。

修复是在页面里加一小段脚本:扫一遍 DOM 里所有岛屿节点,把它们声明的组件地址和渲染器地址逐个注入 <link rel="modulepreload">。

这样岛屿模块的下载从”水合器执行到才发现”提前到”HTML 解析到 head 就开始”。

实测产物里 modulepreload 数量从 0 变为按岛屿数注入,首屏岛屿的水合等待不再包含一个额外的网络往返。

一个反例

给代码字体加 <link rel="preload" as="font"> 时有个经典陷阱:字体请求默认走匿名(CORS)模式,preload 也必须带 crossorigin 属性。

两边模式一致才能命中同一份缓存。

漏掉的话浏览器发两个请求。预加载的那份躺着不用,实际使用时再拉一次,预加载完全白做。

控制台不会报错,只有 Network 里两条字体请求能暴露它。

什么不该预取

prefetchAll: true 是把双刃剑:它把所有站内链接都变成了预取候选,包括那些不该被提前 GET 的。

预取本质是对 URL 发一次 GET,而”GET 一定安全”是个被广泛默许、偶尔被违反的假设:

  • 有副作用的 GET:“退出登录”链接、计数/签到链接、“标记已读”链接。预取一发,副作用提前发生,用户还没点,状态已经变了。这类链接要么改成 POST 按钮,要么显式排除预取(Astro 里给链接标注 data-astro-prefetch="false");
  • 一次性 URL:带验证 token、nonce 的链接被预取等于消耗掉一次性资源,真正点击时反而失败;
  • 大而低频的目标:几十 MB 的资源页、绝大多数用户不会点到的深链。预取它们是拿真实流量为小概率事件投保。load 策略(全站预热)要掂量站内链接总量再开。

判断标准只有一条:预取的 URL 必须 GET 幂等、无一次性消耗、体积值得。

三个条件任一不满足就显式排除。

prefetchAll 加排除名单,比逐个标注开启省心得多。默认安全、例外显式。

量一量效果

验证优化有没有生效,Network 面板的正确姿势是勾选 Preserve log(预取发生在导航前,不保留日志会被清掉)再走一遍”悬停 → 点击”。

预取请求会以 prefetch 相关标识出现在点击之前,点击后的导航请求若命中缓存则显示极短耗时或内存/磁盘缓存来源。

更量化的做法是对比前后两次 Navigation Timing:预取生效时,下一页导航的等待时间(TTFB 段)应明显缩短。

因为 HTML 已经在缓存里,导航开始时不用再等网络。

注意预取改善的是下一页的导航速度,对本页的 LCP 没有影响,别在错误的指标上找收益。

预取与缓存池

预取的资源落进 HTTP 缓存后,受益的不只是下一次点击。

同一 URL 的再次访问(返回列表页、前进后退)直接命中,不再发请求。

反过来看浪费的账:预取命中率永远小于 1,没被点的那部分是净支出。

所以选策略本质是在给”命中率 × 提速收益”和”浪费的流量”定价,四档策略真正的比较坐标在这。

站上还有 Service Worker 管缓存的话,预取要和它当成一对来设计,见缓存与搜索那篇里 Service Worker 的部分。

切换顺滑:把空白低谷改掉

快的问题解决后,剩下的体验瑕疵反而更显眼了。

Astro 的 <ClientRouter /> 让导航变成浏览器代管的页面切换。URL 照变、HTML 照样是完整页面,但观感接近单页应用。

不过”接上就顺滑”是错觉:默认动画下有一个明显的空白低谷。

旧画面淡出、新画面淡入,中段两层都半透明,用户看到的是一瞬间的”内容被清空”。

先看清默认动画为什么难看

Astro 默认用浏览器原生 ::view-transition-old(root) 和 ::view-transition-new(root) 做交叉淡入淡出。

原生的 keyframes 是两层并行半透明:中段旧页 50% 透明度、新页 50% 透明度叠在一起。

背景色穿透,正文文字互相透出,视觉上就是”内容闪没了一帧”。

页面越重(新 HTML 还在路上),这个低谷越长。

修复分两段:加载期的空白用骨架屏挡,切换期的低谷用动画曲线改掉。

骨架屏

骨架屏是一块覆盖主内容区的占位动画(灰块 shimmer),目的不是好看,是把”加载中”表达成”占位在等”而不是”内容消失了”。

实现上有三个决定体验的细节:

一是只盖会换的区域。

骨架绝对定位在主内容列内部,导航栏、侧栏这些跨页不变的元素留在遮罩外面。

它们本来就 transition:persist 跨页复用,盖住它们反而制造”全页重载”的错觉。

分区域占位是骨架屏和全屏 loading 的本质区别。

二是 80ms 延迟显示。

预取(前文的配置)命中的导航从发起到就绪可能只有几十毫秒。这种”瞬时切换”如果也闪一下骨架,等于每页都报告”我很慢”。

延迟 80ms 再显示骨架:预取命中的导航直接完成,骨架根本不出现。只有真正慢的加载(未预取、弱网、返回键)才让它上岗。

三是占位镜像真实布局。

占位块按真实页面的卡片形态画(头图带 + 标题行 + 文本行),加载完成时新内容”接上”占位而不是跳一下。

同时这也在为 CLS 保险(占位尺寸稳定)。

时序上挂在 Astro 的导航事件上:astro:before-preparation(新页开始拉取)显示,astro:before-swap(新 DOM 就绪)隐藏。

过渡动画

低谷的根源是两层并行淡出淡入。

既然如此,干脆不让旧层动:

::view-transition-old(root) {
animation: none; /* 旧画面原地保持 */
}
::view-transition-new(root) {
animation: frosti-vt-fade-in 240ms ease-in both; /* 新画面从上方缓入盖住 */
}

旧画面保持完全不透明直到切换结束,新画面以 240ms 缓入覆盖它。

任何时刻画面上都有完整内容,要么是旧的,要么是正在盖上来的新的。

注意自定义 keyframes 里刻意不用 plus-lighter 混合(那是默认交叉淡入的发源地)。

体感上这个方案还有个附带好处:因为旧层静止,页面切换的”视觉锚点”稳定,眼睛不会跟着内容一起抖。

持久化

骨架屏之外,真正决定”像不像单页应用”的是跨页元素。

transition:persist 让指定元素在换页时不重建 DOM。

本站把骨架本身、Live2D 挂件、音乐播放器都标了 persist:切页时它们原地不动,播放中的音乐不断、看板娘不闪。

这个标记要克制使用,判断标准就一条:这个元素是否在”每一页都存在且状态连续”才有意义。

给普通内容加 persist 只会制造新旧内容串位的诡异画面。

局部过渡

::view-transition-old(root) 管的是整页快照,但过渡组不止一层。

给元素设 view-transition-name,它就会从整页快照里脱离出来,成为一组独立的旧/新快照,浏览器单独为它做过渡。

这正是”共享元素过渡”的机关:列表页的卡片和详情页的头图起同一个名字,换页时浏览器捕捉两处的位置尺寸差,自动生成一段位移缩放动画。

文章卡片”飞”成详情页题图的那种效果,一行 CSS 命名就完成,没有任何 JS。

用它的纪律和 persist 类似:名字要全局唯一、每页至多一组。

两个元素抢同一个 view-transition-name,过渡会直接报错终止(整页动画失效),好在错误信息明确。

MPA 场景还想启用跨文档过渡(普通链接跳转也走动画),需要再加一条 CSS 声明 @view-transition { navigation: auto; },并要求两边同源。

它让本文的所有方案(骨架、旧层静止)同样作用于纯 MPA 导航。

导航事件的一生

骨架挂的两个事件只是 ClientRouter 生命周期的两站。

完整的事件序列值得列一遍,因为每个站点级挂件都要挑自己的一站下车:

  1. astro:before-preparation:导航开始,新页 HTML 开始拉取。骨架屏在这里上岗;
  2. astro:after-preparation:HTML 已就绪、开始解析;
  3. astro:before-swap:新旧 DOM 交换前。骨架在这里下岗,动画接管画面;
  4. astro:after-swap:交换完成、新页面在屏。依赖 document 全局状态的逻辑在这里重新同步。本站的 i18n 语言绑定就挂在这里:模块级单例不会随换页重新执行,<html lang> 换了之后必须手动读一次;
  5. astro:page-load:一切就绪(首次加载也会触发一次)。需要”每个新页面都跑一遍”的逻辑挂这里,比如广告位:换页后新的 <ins> 元素要重新 push 才会请求填充。

把逻辑挂错一站的症状很典型:挂早了读不到新 DOM,挂晚了闪一帧旧状态,挂在模块顶层则只在第一次加载执行。

换两次页就”神秘失效”。

尊重”减少动态”偏好

过渡动画是给”没提出异议的人”准备的。

系统开启减弱动态效果(prefers-reduced-motion,晕动症与注意障碍用户的常用设置)时,大幅度位移缩放动画是直接的生理负担,应该整段关闭:

@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}

关闭后换页退化为瞬时切换。正是”默认动画难看”问题的原点,但对选择减少动态的用户,稳定、可预期比”顺滑”重要得多。

骨架屏不受影响(它是状态表达,不是装饰动画),该在还在。

浏览器支持与降级

视图过渡是较新的浏览器能力,但降级路径是免费送的:不支持 ::view-transition 的旧浏览器里,ClientRouter 自动退回普通 MPA 跳转。

功能无损、URL 照变,只是少了动画。

所以接入它的风险下限就是”什么都没发生”,值得先接再调。

CLS 修复实录

CLS(Cumulative Layout Shift,累积布局偏移)是 Core Web Vitals 里最”欺负人”的指标。

LCP 慢了看时间就知道,CLS 出问题却是”内容在你眼前跳一下”。

先统一测量方法:DevTools 的 Performance 面板录一次页面加载,Experience 一行里紫色的 Layout Shift 区域点开就能看到被移动的元素和得分来源;或者用 Lighthouse 跑分数看回归。

顺手把算分公式看懂

优化前值得弄明白浏览器怎么给偏移打分,否则不知道改什么才有效。

每次偏移的得分 = 影响分数 × 距离分数。

前者是”视口里有多少内容被移动了”(不稳定元素加上被它挤动的元素,占视口面积的比例),后者是”移动了多远”(位移距离除以视口最大边)。

总 CLS 取的不是累加和,而是会话窗口内的最大值。

一串间隔不超过 1 秒的偏移算一个窗口,窗口最长 5 秒,整页取所有窗口里最高的那个。

还有一条豁免:用户输入后 500ms 内发生的偏移不计入(点按钮后内容变化是预期行为)。

这些细节决定了优化优先级:先把”大面积 × 远距离”的偶发偏移掐掉,一串小抖动反而是次要的。

修复一:首图大字标题的字号缩放

首页和各频道页的头图上有一个占满宽度的大字标题(HOME、GAMES 这类),要求按容器宽度自适应、单行不截断。

最初的实现是测量文本宽度后改 font-size 来适配。

问题在于 font-size 是布局属性:字体加载完成后要重新测量、重新缩放,实测 “HOME” 这个词的渲染宽度从 51px 跳到 584px,前后的两次重排把整页内容顶得上下乱跳。

测出来它就是全站 CLS 的最大单一来源。

修复思路是换掉触发重排的属性,只保留合成器属性:

  1. 基础字号固定 100px,适配只用 transform: scale() 完成。transform 不触发 layout,改动不计入 CLS,也不引发周围内容回流;
  2. 居中交给容器的 translate 定位,JS 只叠加 scale 一个变量;
  3. 适配完成前元素 visibility: hidden。占位仍在、尺寸稳定,避免”未缩放的大字号文本”闪现那一帧;
  4. 缩放比设上限(约 1.92 倍),防止短文本在宽屏上被放得离谱。

修复后这一项的偏移归零。

规则一:一切”按容器缩放内容”的需求都用 transform,别用 font-size/width/height。

为什么 transform 的移动不计入 CLS,值得多说一句:CLS 衡量的是布局盒位置的变化。

元素在文档流里占了多大地、从哪个坐标开始。

transform 是渲染最后一步的视觉变换,布局盒纹丝没动,浏览器不认为”布局不稳定”。

这也划清了能用的边界:transform 适合移动、缩放视觉,一旦”看起来移走的东西”其实还要参与文档流(比如折叠面板展开把下方内容顶开),布局确实变了,CLS 照算。

那种场景该做的是给展开后的尺寸预留空间。

修复二:广告位与异步注入内容

接入 AdSense 后性能面板里出现了新的偏移:广告脚本晚到,ins 元素被注入或填充时,下方所有内容被顶下去。

广告是第三方异步的,加载时机完全不可控,唯一可靠的解法是给它预留恒定的位子:

  • 广告位组件渲染时就输出一个固定 min-height 的占位容器,广告填充前后外部尺寸不变;
  • 内容列在骨架屏阶段也按真实卡片的尺寸画占位(圆角卡片 + 头图带 + 文本行),加载完成时新内容”覆盖”占位而不是”挤开”它。

规则二:任何不由你的首屏 HTML 决定大小的内容——广告、嵌入 iframe、懒加载图片、异步拉取的推荐列表——都必须预留尺寸。

图片是 width/height 属性加 CSS 的 aspect-ratio,广告和 iframe 是固定 min-height,没有例外。

修复三:字体到达后的字形跳变

代码块用了自托管等宽字体,font-display: swap 下先渲染回退字体、字体到达后替换。

字形宽度的差异让代码块内容发生一次横向跳动。

swap 是为了首屏不白等字体,跳变则要另想办法:

  1. 对 12KB 的字体子集直接 preload,把”回退 → 真身”的窗口压缩到几乎不可见。preload 字体记得加 crossorigin,否则浏览器会按匿名模式重新拉一遍,预加载等于白做;
  2. 子集化同样服务 CLS:中文字体动辄数 MB,字体越大、“回退 → 真身”的窗口期越长,跳变越容易被看见。子集越小,窗口越窄;
  3. 还有个更现代的手段:@font-face 的 size-adjust / ascent-override 描述符,把回退字体的度量校准到接近真身。即便替换仍会发生,跳动的幅度本身也被压小了。

规则三:关键字体 preload + 尽量子集化,让”字体交换”发生在用户看清页面之前。

修复四:动态插入内容的位置感

检查清单里”动态插入的内容”值得单独成节,因为它是回访型 CLS 的主力。

页面已经稳定,一段 banner、一个 toast、一条”猜你喜欢”从天而降,把已经读到一半的正文顶下去。

插入位置决定一切。

追加到视口下方(无限滚动的下一页)是安全的。它下面没有内容可推。

插入视口上方或中间必然推挤现有内容。

唯一无代价的顶插是悬浮层:position: fixed 的元素脱离文档流,盖在内容上而不是挤开内容。

所以 toast、cookie 提示条这类临时通知,正确形态是悬浮层或”视口外占位”,错误形态是插进文档流的顶部。

无法脱离文档流又必须顶插的内容(比如付费墙提示),就回到规则二:给它预留恒定空间,内容到达前后外部尺寸不变。

现场与实验室

Lighthouse 跑出来的是实验室分数:模拟设备、模拟网络、一次加载。

真实用户的 CLS 用现场数据说话。

Chrome 会把真实用户的 Core Web Vitals 上报进 CrUX 数据集,Search Console 和 PageSpeed Insights 展示的就是它,评分口径取每个指标现场分布的 75 百分位(p75)。

两套数字经常对不上:实验室一切正常、现场分却难看,多半是真实设备(低端安卓)与模拟环境(桌面节流)的差异。

优化时以现场分定输赢、以实验室面板找凶手。

前者告诉你”有多糟、谁在糟”,后者才能逐帧回放。

想在自家站点看原始数据,几十行脚本就够。浏览器把每次偏移作为条目暴露出来:

new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue; // 用户输入后 500ms 内的偏移,浏览器本就不计入
console.log("shift:", e.value, e.sources?.map((s) => s.node));
}
}).observe({ type: "layout-shift", buffered: true });

拿到的是每条偏移的分数和肇事节点(sources),比 Performance 面板更适合做长期采集。

挂在埋点里跑几天,哪类页面、哪个组件在真实用户那里制造偏移,一目了然。

完整打分还差”会话窗口聚合”那一步,做监控时补上即可。

content-visibility:跳过视口外渲染

布局稳定了,切页白屏的另一半原因还在:这个博客有不少万字级长文。

深度学习笔记带着几百个 KaTeX 公式,教程文里几十个代码块。

切到这类页面时,HTML 早就到了(静态站,传输不是瓶颈),卡的是浏览器把几千个 DOM 节点变成像素的过程。

实测一个含 359 个公式的重数学页,切页后的浏览器渲染耗时约 1.6 秒,这段时间里页面是白的。

content-visibility: auto 是专门为这个场景准备的 CSS 属性:让浏览器跳过视口外内容的渲染。

收益立竿见影,但它在长文 + 目录锚点的场景里埋了一个不显眼的坑。

方案:按块跳过视口外渲染

#content > * {
content-visibility: auto;
contain-intrinsic-size: auto 480px;
}

原理一句话:设了 content-visibility: auto 的元素,如果不在视口附近,浏览器跳过它的 layout/paint(内容仍存在于 DOM 和无障碍树中,Ctrl+F 查找、锚点定位都还在),只按 contain-intrinsic-size 给的占位尺寸参与布局。

滚动临近时才真正渲染。

只作用在文章的直接子元素(段落、标题、代码块、figure)上,粒度刚好:一个段落离屏就省一段的渲染,不需要更细。

支持面已经不是问题:Chromium 85 起就有,Firefox 125 与 Safari 18 先后在 2024 年跟进,三大内核都已就位。

它是渐进增强的典型。老内核不认识这个属性,长文照常全量渲染,只是省不了那部分渲染时间。

收益为什么这么大

首屏只占长文的很小一部分。

一篇 3 万字的文章,视口内的内容可能只有 5%,剩下 95% 的段落、公式、代码块全部跳过了首次 layout 和 paint。

KaTeX 公式是重灾区。每个公式一段嵌套很深的 DOM,359 个公式的页面省下的就是这 1.6 秒里的大头。

而且这是纯 CSS,没有任何 JS 参与检测滚动位置,也没有懒加载那种”滚动到才加载”的闪烁。

收益怎么量

上属性之前先量一次基线,免得”感觉快了”。

DevTools Performance 面板录一次”打开长文”的加载,看两处:主线程火焰图里 Recalculate Style / Layout / Paint 三段的总时长(切页渲染卡顿就藏在这里),以及是否有几百毫秒的 Long Task。

然后在 Styles 面板里临时关掉 content-visibility 那条规则,再录一次。

两次的渲染耗时差就是它的真实收益。

本站那个 359 公式页面,关掉属性后渲染时间回到 1.6 秒量级,开着则只剩零头。

注意对照组要同一台机器、同一批缓存状态,公式数量不同的两篇文章没有可比性。

与懒加载图片的叠加

content-visibility 管渲染,loading="lazy" 管网络,两者叠在同一个长文里是常态,配合规则要心里有数。

懒加载图片的触发条件是”接近视口”,而视口位置又依赖布局高度。

布局高度在离屏块被跳过时靠 contain-intrinsic-size 估算。

于是出现一条依赖链:占位高度准 → 滚动条位置准 → 懒加载触发时机准。

占位偏差大时,症状是图片”成批迟到”或”提前猛拉”。

缓解手段和坑二同源:contain-intrinsic-size 带 auto 关键字让滚过的块高度收敛,图片一律补 width/height 属性(尺寸已知时浏览器不用等图片下载就能排好版)。

这两条做到位后,叠加行为就基本无感了。

坑一:contain-intrinsic-size 忘了 auto 关键字

contain-intrinsic-size 给离屏元素提供占位尺寸。

最容易写的版本是:

contain-intrinsic-size: 480px; /* 有坑 */

写死一个值的后果:每个离屏块都被当作 480px 高。

而真实块的高度分布极其悬殊。普通段落 40–80px,一个代码块或大表格几百到上千 px。

于是页面的总滚动高度是错的,滚动条比例是错的,接下来所有依赖高度的东西都跟着错。

正确的写法是 auto 480px:auto 关键字让浏览器记住元素上一次渲染出的真实尺寸,一旦渲染过就用真实值参与布局,480px 只是首遇时的兜底。

滚动一遍之后,占位高度就基本收敛到真实高度了。

坑二:目录锚点跳转落点漂移

这个坑比坑一隐蔽得多,而且症状诡异:点目录里的某个小节,跳过去的位置差了大半屏,而且越靠后的标题偏得越多。

定位思路:锚点跳转是浏览器按元素位置滚动的。

设了 content-visibility: auto 的标题如果从未渲染过,它参与布局的高度是占位估算值。

估算和真实差多少,落点就偏多少。

从页面顶部往下,误差逐块累积,所以”越靠后偏差越大”。

修复分两层:

/* 标题自身不参与内容可见性,保证锚点目标始终能被精确测量与定位 */
#content > :is(h1, h2, h3, h4, h5, h6) {
content-visibility: visible;
}
  1. 标题豁免:标题本身渲染成本几乎为零(没有公式没有代码块),直接让它们保持完整渲染。这样锚点目标永远被精确测量,跳转落点必准;
  2. 坑一的 auto 关键字也是同一问题的缓解。被滚过的块高度会收敛到真实值,误差只出现在”从未到过”的深水区。

两个一起上之后,锚点漂移消失,收益不受影响(豁免的只有标题,占渲染成本的比例可以忽略)。

代价与边界

content-visibility 不是免费的:

  • 滚动条会跳:占位高度与真实高度的差在滚动过程中逐步修正,滚动条位置会有轻微抖动。auto 关键字能大幅缓解但首次访问的长文仍会有感;
  • Ctrl+F 查找:现代浏览器会自动临时渲染匹配项,体验基本无损,但极老的浏览器内核行为不一;
  • 打印:打印时视口概念失效,要确认打印样式的表现,必要时 @media print 里关掉。

它的适用边界也明确:列表型、流式、块间结构简单的长内容收益最大;块间强耦合(比如大量相互依赖的绝对定位)的场景要逐块验证。

还有两个容易混淆的兄弟值,顺便分清:content-visibility: hidden 是”彻底不渲染”。

跳过渲染且从无障碍树和查找里移除,适合”确定暂时不显示”的折叠面板。

auto 则保留无障碍树和 Ctrl+F 可达性,只是不画像素。

给长文用的必须是 auto,用错成 hidden 的症状是”页面看着正常,但全文搜索搜不到后半篇”。

粒度上也有讲究:作用在每个段落太细(元素多、浏览器维护跳过状态本身有开销),作用在整篇文章太粗(视口内永远有内容,等于没跳过)。

直接子元素一级刚好,这是本站方案选它的原因。

切页检查清单

  • 先确认预取链路真的在工作(悬停看请求),“感觉快”不等于链路接通;
  • Astro 场景:prefetchAll: true + hover 策略是站内导航的免费提速,弱网自动降级;预取 URL 必须幂等、无一次性消耗、体积值得;
  • prefetch 服务下一页,preload 服务本页关键资源,modulepreload 管模块依赖图。按问题选工具;字体 preload 必带 crossorigin;
  • <ClientRouter /> 交给 Astro 代管导航,URL 与历史记录自动正确;
  • 骨架屏:只盖内容区、80ms 延迟、镜像真实布局、挂 before-preparation / before-swap 事件;
  • 过渡动画:旧层 animation: none、新层缓入覆盖,不用 plus-lighter;prefers-reduced-motion 下整体关闭;
  • persist 只给跨页常驻元素(播放器、挂件、骨架),内容元素绝不 persist;view-transition-name 全局唯一;
  • 回归检查:弱网(Slow 4G)下走一遍主导航。骨架出现得体、无空白帧、无内容跳变,才算过关。

这一整套做完,MPA 的切页观感达到了”接近 SPA 但没有 SPA 的复杂度”的平衡点:每一页仍是完整 HTML、可直链、可被爬虫完整阅读,用户看到的是连续的画面。

首屏检查清单

两部分的规则放进同一张清单,上线前过一遍:

  • 图片/视频都有尺寸属性或 aspect-ratio;
  • 广告位、嵌入框有恒定占位;
  • 动态插入的内容插在视口外、用悬浮层,或预留了空间;
  • 内容缩放用 transform,不用布局属性;
  • 关键字体 preload + 子集化 +(可选)size-adjust 校准;
  • 单页应用切页用固定尺寸骨架屏,而不是”内容从无到有”;
  • 长文开启 content-visibility: auto 且 contain-intrinsic-size 带 auto;
  • 锚点目标(标题)用 content-visibility: visible 豁免;
  • @media print 下确认两套属性的表现。

切页相关的偏移还有一层容易漏:视图过渡本身。换页动画若让新旧内容的容器尺寸不一致,动画过程中的”跳”也会被记进 CLS,这正是前文骨架屏与过渡方案要一并考虑的地方。

最后一点经验:每次加新功能(上广告、加挂件、换字体、写更长的文章)都可能引入新的偏移源或渲染热点。把 Performance 面板的 Layout Shift 与渲染耗时检查变成上线前的固定动作,比事后救火便宜得多。


感谢您的阅读!如果可以,给俺点些关注吧~

切页与首屏

周五 9月 25 2026
8349 字 · 30 分钟