这一节只谈布局占位
这一节讨论的是媒体从「还不知道多大」到「已经画出来」这段时间里,页面如何保持稳定。它不讨论图片压缩、格式选择与 CDN,那些属于资源管线;也不重复 3.1 的块模型和 3.2 的 sanitize,只在两者之间补上布局这一层。
内容端最常见的性能投诉不是「图片加载慢」,而是「看着看着跳了一下」。慢是等待,跳是失控。用户正在读第三段,图片加载完成把内容往下推,手指还停在原来的位置,点中的是另一条内容。这种体验问题在指标上叫 CLS(Cumulative Layout Shift),在工程上通常只有一个根因:渲染前不知道媒体的最终尺寸。
CLS 不是图片加载慢,而是尺寸未知
CLS 的计算可以简化成两个比例的乘积:
- 影响比例:发生位移的元素在视口里占多大面积。
- 位移比例:元素移动了多远,相对于视口高度。
对于一个全宽图片,面积比例退化成高度比例。假设视口高 800px,图片最终高 300px,渲染前高度是 0,图片顶部在视口顶部:
- 影响比例 = 300 / 800 = 0.375
- 位移比例 = 300 / 800 = 0.375
- 单次得分 = 0.375 × 0.375 = 0.140625
如果渲染前就预留了 300px 的盒子,前后高度一致,位移为 0,得分就是 0。这就是占位盒的全部意义:不是让图片更快,而是让布局在图片到达之前就确定。
请注意,CLS 不是只由图片引起。字体替换、广告位注入、评论区异步展开都会贡献位移。本节只处理媒体,因为它是内容端最大、最可预测的一类位移源。
占位盒:从固有尺寸到容器高度
占位盒的输入只有两个:媒体的固有宽高,以及它将要放入的容器宽度。输出是一个确定的高度。
高度 = ceil(容器宽度 ÷ (固有宽度 ÷ 固有高度))
例如 640×360 的图片放进 320px 宽的卡片,比例是 16:9,高度就是 180px。这里用 ceil 而不是四舍五入,是为了避免最终图片比占位盒高出一个亚像素,再次触发微小位移。
三个边界必须显式处理:
- 固有尺寸缺失或非法。 宽或高为 0、负数、
NaN时,不能做除法,回退到 16:9。回退比例是一个产品决定:竖图、长图、截图都可能不符合 16:9,但一个稳定的默认值比一个随机的高度好。 - 容器宽度尚未测量。 首帧布局前容器宽度可能是 0,算出来的高度也是 0。正确做法是用 CSS
aspect-ratio先撑住,或者在布局完成后、首次绘制前同步测量;不能等到图片onload再补高度。 - 超长图。 一张 1:10 的长截图如果完整预留,会占掉好几屏。产品上通常给一个最大高度,超出部分裁剪,并提供「查看原图」。这是一个明确的取舍:用裁剪换稳定,而不是让长图把整条信息流推走。
头像是一个特例:它的容器宽度通常就是最终宽度,比例固定为 1:1。代码里用 role 区分,避免头像也走通用比例计算。
首屏优先级:eager 不是 all
有了占位盒,下一个问题是哪些媒体应该立刻加载。
一个常见错误是把首屏所有图片都标成 eager。结果是十几张图同时抢带宽,真正承担 LCP 的封面图反而被排在后面。正确的做法是分层:
- 首屏范围内、位置最靠前的封面:
eager+fetchPriority="high"。 - 首屏范围内、但排在后面的图片:
eager+auto,让浏览器自己调度。 - 首屏之外的图片:
lazy+low。
代码里的 planMediaLoad 用 scrollY + viewportHeight × (1 + eagerMargin) 作为 eager 边界,默认多预取半屏。这个余量不是为了「多加载」,而是为了在快速滚动时避免白屏;它必须是一个可配置参数,而不是写死的魔法数。
懒加载的触发条件则要看上下两个边界。shouldLoad 同时判断:
top <= scrollY + viewportHeight + rootMargin
bottom >= scrollY - rootMargin
只判断下边界是不够的。用户从详情返回、浏览器恢复滚动位置时,视口可能直接落在图片下方;如果只判断「是否接近视口底部」,这些图片会被误判为不需要加载,用户往回滑时看到一片空白。上边界让「刚滑过去不远」的图片仍然有机会加载。
失败降级:保留盒子,换内容
图片加载失败时,最糟的处理是把占位盒删掉。盒子一删,下方内容立刻上移,位移比图片没加载还大。正确的降级是盒子不动,内容替换:
- 封面失败:显示渐变底 + 标题首字,保持原比例。
- 视频失败:显示海报或播放器占位,保持 16:9。
- 头像失败:显示用户名首字母的圆形占位。
重试也要有预算。代码里给了两次机会,间隔 500ms 和 2000ms,之后标记为 exhausted。无限重试会把失败请求变成背景噪音,也会让用户看到反复闪烁的占位;两次之后仍然失败,应该展示一个明确的「点击重试」,把决定权交回用户。
一个可计算的占位模块
下面的实现属于 L1:只做比例、几何和优先级计算,不碰 DOM,也不发请求。
// file: src/render/lazy-media.ts
// tier: L1
// verify: node --test test/render/lazy-media.test.ts
export interface IntrinsicSize {
width: number;
height: number;
}
export type MediaRole = "cover" | "inline" | "video" | "avatar";
export interface MediaSlot {
id: string;
intrinsic: IntrinsicSize;
role: MediaRole;
offsetTop: number;
alt: string;
}
export interface ReservedBox {
width: number;
height: number;
aspectRatio: number;
}
export interface Viewport {
scrollY: number;
height: number;
}
export interface LoadHint {
id: string;
loading: "eager" | "lazy";
fetchPriority: "high" | "auto" | "low";
estimatedHeight: number;
}
export interface FailurePlan {
keepReservedBox: boolean;
fallback: "gradient" | "poster" | "initial";
height: number;
label: string;
retryAfterMs: number | null;
exhausted: boolean;
}
export const FALLBACK_RATIO = 16 / 9;
export const RETRY_DELAYS: readonly number[] = [500, 2000];
export function aspectRatio(size: IntrinsicSize): number {
if (!Number.isFinite(size.width) || !Number.isFinite(size.height)) return FALLBACK_RATIO;
if (size.width <= 0 || size.height <= 0) return FALLBACK_RATIO;
return size.width / size.height;
}
export function reserveBox(
size: IntrinsicSize,
containerWidth: number,
maxHeight = Number.POSITIVE_INFINITY,
): ReservedBox {
const safeWidth = Number.isFinite(containerWidth) ? Math.max(0, Math.floor(containerWidth)) : 0;
const ratio = aspectRatio(size);
const natural = safeWidth / ratio;
const cap = Number.isFinite(maxHeight) ? Math.max(0, Math.floor(maxHeight)) : Number.POSITIVE_INFINITY;
const height = Math.min(Math.ceil(natural), cap);
return { width: safeWidth, height, aspectRatio: Number(ratio.toFixed(4)) };
}
export function estimatedHeightFor(slot: MediaSlot, containerWidth: number): number {
if (slot.role === "avatar") return Math.max(0, Math.floor(containerWidth));
return reserveBox(slot.intrinsic, containerWidth).height;
}
export interface ShiftInput {
viewportHeight: number;
rectTop: number;
heightBefore: number;
heightAfter: number;
}
export function layoutShiftScore(input: ShiftInput): number {
const viewportHeight = Math.max(1, input.viewportHeight);
const top = Math.max(0, input.rectTop);
const before = top + Math.max(0, input.heightBefore);
const after = top + Math.max(0, input.heightAfter);
if (before === after) return 0;
const visibleTop = Math.min(top, viewportHeight);
const visibleBottom = Math.min(Math.max(before, after), viewportHeight);
const impact = Math.max(0, visibleBottom - visibleTop) / viewportHeight;
const distance = Math.min(1, Math.abs(after - before) / viewportHeight);
return Number((impact * distance).toFixed(6));
}
export function planMediaLoad(
slots: MediaSlot[],
viewport: Viewport,
containerWidth: number,
eagerMargin = 0.5,
): LoadHint[] {
const eagerUntil = viewport.scrollY + viewport.height * (1 + eagerMargin);
let firstEager = true;
return slots.map((slot) => {
const isEager = slot.offsetTop <= eagerUntil;
const fetchPriority = isEager && firstEager ? "high" : isEager ? "auto" : "low";
if (isEager) firstEager = false;
return {
id: slot.id,
loading: isEager ? "eager" : "lazy",
fetchPriority,
estimatedHeight: estimatedHeightFor(slot, containerWidth),
};
});
}
export function shouldLoad(
slot: MediaSlot,
viewport: Viewport,
containerWidth: number,
rootMargin = 200,
): boolean {
const top = slot.offsetTop;
const bottom = top + estimatedHeightFor(slot, containerWidth);
return top <= viewport.scrollY + viewport.height + rootMargin
&& bottom >= viewport.scrollY - rootMargin;
}
export function failurePlan(
slot: MediaSlot,
containerWidth: number,
attempt: number,
): FailurePlan {
const index = Math.max(0, Math.floor(Number.isFinite(attempt) ? attempt : 0));
const retryAfterMs = index < RETRY_DELAYS.length ? RETRY_DELAYS[index] : null;
const fallback = slot.role === "avatar" ? "initial" : slot.role === "video" ? "poster" : "gradient";
return {
keepReservedBox: true,
fallback,
height: estimatedHeightFor(slot, containerWidth),
label: slot.alt.trim() === "" ? "媒体加载失败" : slot.alt.trim(),
retryAfterMs,
exhausted: retryAfterMs === null,
};
}
代码里有四个设计选择。
比例归一化与兜底放在同一个函数
aspectRatio 是所有计算的入口。非法尺寸在这里被统一处理成 16:9,后面的 reserveBox、estimatedHeightFor 不需要重复判断。兜底只写一次,行为才可预测。
高度计算与最大高度截断分开表达
reserveBox 先算自然高度,再用 maxHeight 截断。截断发生在最后一步,调用方可以清楚地看到「自然高度是多少、实际预留是多少」。如果一开始就把两者混在一起,排查长图问题时很难判断是比例错了还是截断错了。
优先级是纯函数,不是渲染副作用
planMediaLoad 接收媒体列表、视口和容器宽度,返回每个媒体的 loading 与 fetchPriority。它不直接操作 <img>,因此可以在 Node 里用固定输入验证。渲染层只负责把返回值映射成属性,测试和线上行为用的是同一套判断。
失败计划保留几何信息
failurePlan 返回的 height 与 reserveBox 一致,渲染层可以直接用它维持占位。fallback 区分 gradient、poster、initial 三种视觉降级,retryAfterMs 为 null 表示不再自动重试。几何与视觉分开,失败时就不会出现「换了图但高度变了」的二阶抖动。
测试与真实输出
$ node --test test/render/lazy-media.test.ts
✔ 宽高比正常计算,非法尺寸回退到 16:9 (0.476542ms)
✔ 占位盒按容器宽度换算高度,并可被最大高度截断 (0.148166ms)
✔ 占位高度与最终高度一致时 CLS 为 0 (0.088125ms)
✔ 没有占位时按可视比例与位移比例计算 CLS (0.554667ms)
✔ 首屏媒体 eager 且首张高优先级,屏幕外保持 lazy (0.681ms)
✔ 懒加载触发条件尊重 rootMargin 与上下边界 (0.148709ms)
✔ 加载失败保留占位盒,并按次数给出重试节奏 (0.143333ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 122.7625
七条用例覆盖:比例兜底、占位盒换算与截断、占位一致时 CLS 为 0、无占位时的位移分数、首屏优先级、懒加载上下边界,以及失败重试节奏。它们全部是纯函数断言,没有浏览器,也没有真实图片。
其中两条用例值得注意。第一,layoutShiftScore 在 rectTop 超出视口时返回 0,因为不可见元素的位移不计入 CLS;第二,shouldLoad 对「刚滑过去不远」的图片返回 true,对「远在上方」的图片返回 false,这正是滚动恢复场景需要的区分。
一次首屏抖动的复盘
「回声」信息流的第一版用 <img> 直接渲染,CSS 是 width: 100%; height: auto。图片没有写宽高,浏览器在图片到达前只能按 0 高度排版。加载完成后,每张图把下方内容往下推一次。
当时的线上表现(以下为示例值,不是本机实测):信息流 CLS 约 0.28,用户反馈集中在「点错卡片」和「读到一半跳走」。我们把一次会话的位移拆开看,发现贡献最大的不是封面,而是正文里的第二、第三张图:它们更靠下,图片加载更晚,位移发生时用户已经在阅读。
修复分三步:
- 服务端在图片块里补
width和height,客户端用aspect-ratio预留盒子。 - 首屏第一张封面改为
eager+high,其余图片lazy,减少同时到达的图片数量。 - 图片失败时保留盒子,显示渐变占位,不再
display: none。
修复后同一口径的 CLS 降到约 0.04(示例值)。真正起作用的不是某一行 CSS,而是尺寸信息在渲染前就已经存在。如果 3.1 的图片块没有带固有宽高,这一节的所有计算都无法在首帧之前完成。
复盘还发现一个隐蔽问题:占位盒的宽度来自一个在 useEffect 里测量的值,首帧是 0,第二帧才变成真实宽度。结果占位盒先按 0 高度渲染,再按 320px 重排,自己制造了一次位移。修复方式是把宽度测量提前到布局阶段,或者直接用 CSS aspect-ratio 让浏览器在样式计算时就知道高度。
与前后两节的接口
这一节不生产内容,也不决定内容是否安全,它只消费两个上游事实:
- 3.1 的
ImageBlock提供width和height。没有它们,占位盒只能用默认比例猜。 - 3.2 的 sanitize 保证
src是安全 URL,并且保留width、height属性。占位模块不重复做 URL 校验。
数据流因此是:解析块文档 → 过滤 URL → 为每个媒体计算占位盒与优先级 → 渲染。占位计算发生在渲染之前,而不是图片加载之后,这是整条链路能稳住 CLS 的前提。
小结
CLS 的端上来源通常不是图片慢,而是渲染前不知道媒体尺寸。用固有宽高算出占位盒,首屏分层设置 eager 与优先级,懒加载同时判断上下边界,失败时保留盒子并限制重试次数,就能把大部分位移消灭在首帧之前。
到这里,内容从「结构化表示」到「安全渲染」再到「稳定布局」走完了一轮。下一章进入内容的生产端:上传如何分片、并发如何控制、进度语义如何定义。
阅读导航:上一节:3.2 渲染边界与 sanitize:谁能写 HTML · 下一节:4.1 上传编排:分片、并发与进度语义 。
常见错误与边界
- 图片不写宽高,只靠
height: auto。图片到达前高度为 0,必然推动下方内容。 - 占位盒用四舍五入而不是向上取整。亚像素差异仍可能触发微小位移。
- 首屏所有图片都设
eager。带宽竞争会拖慢真正承担 LCP 的封面。 - 懒加载只判断下边界。滚动恢复时,视口上方的图片会被误判为不需要加载。
- 图片失败就
display: none。盒子塌陷造成的位移比图片没加载更大。 - 失败后无限重试。请求噪音、占位闪烁,用户体验更差。
- 用异步测量的容器宽度算首帧占位。测量值到达前高度是 0,自己制造一次位移。
- 把占位盒高度写死成固定像素。不同容器宽度、不同屏幕密度下都会错位。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。