《互联网产品的客户端架构·内容、社交与实时互动》3.3 媒体懒加载与占位:CLS 的端上来源

图片把正文撑开的那一刻,CLS 就已经发生。本节把占位盒、首屏媒体优先级、懒加载触发条件与失败降级拆成可计算的纯函数,用固有宽高和可视区域算出布局位移分数,复盘一次信息流图片加载导致误触的抖动事故与修复顺序,并给出与结构化块模型、sanitize 的接口边界。

这一节只谈布局占位

这一节讨论的是媒体从「还不知道多大」到「已经画出来」这段时间里,页面如何保持稳定。它不讨论图片压缩、格式选择与 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 而不是四舍五入,是为了避免最终图片比占位盒高出一个亚像素,再次触发微小位移。

三个边界必须显式处理:

  1. 固有尺寸缺失或非法。 宽或高为 0、负数、NaN 时,不能做除法,回退到 16:9。回退比例是一个产品决定:竖图、长图、截图都可能不符合 16:9,但一个稳定的默认值比一个随机的高度好。
  2. 容器宽度尚未测量。 首帧布局前容器宽度可能是 0,算出来的高度也是 0。正确做法是用 CSS aspect-ratio 先撑住,或者在布局完成后、首次绘制前同步测量;不能等到图片 onload 再补高度。
  3. 超长图。 一张 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,用户反馈集中在「点错卡片」和「读到一半跳走」。我们把一次会话的位移拆开看,发现贡献最大的不是封面,而是正文里的第二、第三张图:它们更靠下,图片加载更晚,位移发生时用户已经在阅读。

修复分三步:

  1. 服务端在图片块里补 width 和 height,客户端用 aspect-ratio 预留盒子。
  2. 首屏第一张封面改为 eager + high,其余图片 lazy,减少同时到达的图片数量。
  3. 图片失败时保留盒子,显示渐变占位,不再 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,自己制造一次位移。
  • 把占位盒高度写死成固定像素。不同容器宽度、不同屏幕密度下都会错位。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查