切页与首屏
预取与视图过渡的切页改造,加上 CLS 修复与 content-visibility 长文渲染的实录
切页与首屏
这篇是本站性能改造的合并记录,一半讲切页,一半讲首屏。
切页要解决两件事:快,点击之后到新页面出现之间的等待,能靠预取和预加载消掉大半;顺,新旧画面切换的观感,默认实现有一个明显的空白低谷,要靠视图过渡改掉。首屏管的是页面内容:静态站的 HTML 到得很快,卡住用户的往往是浏览器把 DOM 变成像素的过程——布局不稳定,内容在你眼前跳一下(CLS);或者渲染耗时,几千个节点的 layout 和 paint 让切页白屏一秒(长文重灾区)。
两部分有交接点:骨架屏既是慢导航的观感底线,也是动态内容的占位保险。
切页提速:把等待消掉
排查:预取为什么一次都没发生
“觉得切页挺快的”和”确认切页已经快到极限”是两回事。
排查这个博客的加载链路时发现一个尴尬的事实:站内预取(prefetch)配置了默认策略,但实际处于完全空转状态。
悬停、按下、滚入视口,预取请求一次都没发过。
现象是 Performance/Network 面板里悬停站内链接时没有任何”提前加载”的请求。
对照 Astro 的预取实现读了一遍逻辑,发现预取生效需要同时满足两个条件,而这个站两个都不满足:
- 预取是”按链接选择器匹配”的:只有带
data-astro-prefetch属性的链接,或者全站prefetchAll: true开启时,策略才会作用于链接。这个站的模板链接没有一处标注该属性,而prefetchAll落在默认值 false。匹配条件不成立; - 只配了
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 生命周期的两站。
完整的事件序列值得列一遍,因为每个站点级挂件都要挑自己的一站下车:
astro:before-preparation:导航开始,新页 HTML 开始拉取。骨架屏在这里上岗;astro:after-preparation:HTML 已就绪、开始解析;astro:before-swap:新旧 DOM 交换前。骨架在这里下岗,动画接管画面;astro:after-swap:交换完成、新页面在屏。依赖document全局状态的逻辑在这里重新同步。本站的 i18n 语言绑定就挂在这里:模块级单例不会随换页重新执行,<html lang>换了之后必须手动读一次;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 的最大单一来源。
修复思路是换掉触发重排的属性,只保留合成器属性:
- 基础字号固定 100px,适配只用
transform: scale()完成。transform 不触发 layout,改动不计入 CLS,也不引发周围内容回流; - 居中交给容器的 translate 定位,JS 只叠加 scale 一个变量;
- 适配完成前元素
visibility: hidden。占位仍在、尺寸稳定,避免”未缩放的大字号文本”闪现那一帧; - 缩放比设上限(约 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 是为了首屏不白等字体,跳变则要另想办法:
- 对 12KB 的字体子集直接
preload,把”回退 → 真身”的窗口压缩到几乎不可见。preload 字体记得加crossorigin,否则浏览器会按匿名模式重新拉一遍,预加载等于白做; - 子集化同样服务 CLS:中文字体动辄数 MB,字体越大、“回退 → 真身”的窗口期越长,跳变越容易被看见。子集越小,窗口越窄;
- 还有个更现代的手段:
@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;}- 标题豁免:标题本身渲染成本几乎为零(没有公式没有代码块),直接让它们保持完整渲染。这样锚点目标永远被精确测量,跳转落点必准;
- 坑一的
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 与渲染耗时检查变成上线前的固定动作,比事后救火便宜得多。