陌上人如玉
公子世无双

5.15 图片与字体优化

5.15 图片与字体优化

一句话:图片优化的核心是「格式选型(AVIF/WebP 降级链)+ srcset/sizes 选对尺寸 + LCP 图不懒加载 + 用尺寸占位防 CLS」,字体优化的核心是「WOFF2 + 子集化 + font-display 选值 + 用 size-adjust 把 fallback 的度量对齐以防 CLS」。

一、格式选型:一条降级链

格式 适合 相对 JPEG 体积 浏览器支持 注意
AVIF 照片、大图 约 -50%(最好) Chrome 85+ / Firefox 93+ / Safari 16+ 编码慢(构建期做),解码也稍慢
WebP 照片、插图 约 -25%~-35% 现代浏览器基本全绿 有损/无损/透明都支持
JPEG 照片(兜底) 基准 全部 无透明
PNG 需要透明/无损的图 大 全部 照片用它极不划算
SVG 图标、简单插画 极小(矢量) 全部 ⚠️ 复杂 SVG 可能比位图还大,且要消毒
GIF 动图(兜底) 大 全部 用 <video> + WebM/MP4 替代动画更好
<!-- ✅ 标准降级链:AVIF → WebP → JPEG -->
<picture>
  <source type="image/avif" srcset="hero-800.avif 800w, hero-1600.avif 1600w" sizes="100vw" />
  <source type="image/webp" srcset="hero-800.webp 800w, hero-1600.webp 1600w" sizes="100vw" />
  <img
    src="hero-1600.jpg"
    srcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
    sizes="100vw"
    width="1600" height="900"
    alt="产品主图"
  />
</picture>
⭐⭐ 三个「格式选择」的判断点:

① 【AVIF 值得做吗?】
   值得——但要在【构建期预生成】(编码慢,不能运行时转)。
   ⚠️ 注意:AVIF 对「已经很小的图」收益有限(小图的开销更多在请求上);
      对「大照片」收益最大(往往能砍一半)。
   ⭐ 实践:用图片 CDN 的 URL 参数按需出 AVIF(自己生成会显著拖慢构建)。

② 【动图不要用 GIF】
   GIF 是 256 色 + 无帧间压缩 → 同样内容比 WebM/MP4 大 5~10 倍。
   ✅ 用 `<video autoplay muted loop playsinline>` + `<source type="video/webm">`

③ 【图标不要用字体图标】
   字体图标(iconfont)会「整包下载所有图标」,且无法 tree-shake。
   ✅ 用 SVG(内联或 sprite),只包含用到的图标(见 5.14)

二、srcset / sizes:这里最容易出错

<!-- ✅ 正确的响应式图片(w 描述符 + sizes) -->
<img
  src="photo-800.jpg"
  srcset="
    photo-400.jpg   400w,
    photo-800.jpg   800w,
    photo-1200.jpg 1200w,
    photo-1600.jpg 1600w
  "
  sizes="
    (max-width: 600px) 100vw,
    (max-width: 1200px) 50vw,
    600px
  "
  alt="..."
/>
⭐⭐⭐ 浏览器是怎么选图的(理解这个才不会写错):

   ① 先算「这个图片在页面上会占多少 CSS 像素」:
      按 `sizes` 里的媒体查询逐个匹配,用【第一个匹配】的那个长度
      上面的例子:
        · 视口 ≤ 600px → 100vw(全宽)
        · 视口 ≤ 1200px → 50vw
        · 否则 → 600px
   ② 再乘「设备像素比(DPR)」→ 得到「需要的物理像素宽度」
      如「视口 800px 的 2× 屏 → 50vw = 400 CSS px → 需要 800 物理 px」
   ③ 从 `srcset` 里选「≥ 需要值且最接近的那个」(浏览器也会参考网络状况)

   ⚠️⚠️ 最常见的错误:**写了 `srcset` 但忘了 `sizes`(或写错)**
      · `sizes` 缺失时**默认是 `100vw`**
        → 在大屏上会误以为「要全宽」→ 下载过大的图(浪费带宽、拖慢 LCP)
      · `sizes` 与「实际 CSS 宽度」不符
        → 同样会选错(写大了浪费、写小了模糊)

   ⭐ 判据:`sizes` 应该与你 CSS 里「这个元素的宽度规则」一致。
      最保险的做法是把「图片的宽度也写成 CSS 变量/固定值」,两边对齐。
// ✅ 「sizes 与 CSS 对齐」的可靠写法(用容器约束 + 显式 sizes)
function ResponsiveImage() {
  return (
    <div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
      {/* 容器最宽 960px(减去 padding ≈ 928px)→ sizes 直接写这个区间 */}
      <img
        src="photo-800.jpg"
        srcSet="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
        sizes="(max-width: 992px) calc(100vw - 32px), 928px"
        width={928}
        height={522}
        alt="..."
      />
    </div>
  );
}
<!-- ⭐ x 描述符:用于「固定 DPR」的场景(不需要 sizes) -->
<img src="logo.png" srcset="logo.png 1x, logo@2x.png 2x, logo@3x.png 3x" alt="Logo" />
<!-- 适合:图标、头像等「不需要随视口变化」的图 -->

<!-- ⚠️ 不要在同一个 srcset 里混用 w 和 x(无效) -->

三、加载策略:谁是 LCP,谁不懒加载

<!-- ✅ 首屏 LCP 图片:不懒加载 + 高优先级 -->
<img
  src="hero.jpg"
  width="1600" height="900"
  fetchpriority="high"        <!-- ⭐ 提示浏览器「优先下载」 -->
  decoding="async"
  alt="主视觉"
/>

<!-- ✅ 首屏以下的图片:懒加载 -->
<img src="card.jpg" width="400" height="300" loading="lazy" decoding="async" alt="..." />

<!-- ✅ 首屏 LCP 图片也可以用 preload(⚠️ 要带 imagesrcset/imagesizes,别只写 href) -->
<link
  rel="preload"
  as="image"
  imagesrcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
  imagesizes="100vw"
  fetchpriority="high"
/>
⭐⭐ 四条「加载策略」的铁律:

① 【LCP 图片绝对不能 `loading="lazy"`】
   ⚠️ 这是最伤 LCP 的错误:懒加载会让浏览器「等布局算完才去请求」,
      而 LCP 图片往往就是首屏最大元素 → LCP 直接变差
   ✅ 首屏图片:默认 eager + `fetchpriority="high"`
   ✅ 屏幕外的图片:`loading="lazy"`

② 【`decoding="async"` 是好默认】
   解码在后台线程进行,不阻塞渲染。
   `decoding="sync"` 会让解码阻塞该帧的渲染(除特殊情况不要用)。

③ 【`preload` 图片要带 `imagesrcset` / `imagesizes`】
   否则浏览器会在「preload 阶段」用「单一 URL」(拿不到响应式的能力),
   而且很可能与你后面的 `srcset` 选中**不同的图** → **下载两份**(净亏)
   ⭐ 只有在「LCP 图片确定且加载时机很晚(如它由 JS 插入)」时才值得 preload。

④ 【`fetchpriority` 的正确用法】
   · `high`:LCP 图片 / 关键首屏资源
   · `low`:屏幕外图片、装饰图
   · 未指定:默认
   ⚠️ 「全都设 high」等于「都没设」——只会让带宽竞争更混乱
⭐⭐ LCP 图片「加载时机很晚」的两种情况(对应不同解法):

① 【图片在 HTML 里,但「发现得晚」】
   原因:它在「很深的位置」、或被「阻塞资源(CSS/同步 JS)」挡在前面。
   ✅ 解法:`<link rel="preload">`(带 imagesrcset)、把 `fetchpriority="high"` 加上、
      减少阻塞 JS

② 【图片由 JS 插入(如轮播/懒加载库/SSR 后的 hydration)】
   原因:浏览器在「JS 执行前」根本不知道有这个图。
   ✅ 解法:把「首屏那张」直接写进 HTML(服务端渲染),
      或至少用 `<link rel="preload">` 提前声明

四、占位与防 CLS:尺寸必须显式

<!-- ✅ 最可靠:显式 width/height 属性(浏览器会自动推导 aspect-ratio) -->
<img src="a.jpg" width="800" height="450" alt="..." />
<!-- 现代浏览器会据此设置 aspect-ratio: 800 / 450,图片加载前就占好位 -->
/* ✅ 如果布局需要图片自适应宽度,用 CSS aspect-ratio */
.thumb {
  width: 100%;
  aspect-ratio: 16 / 9;      /* 与真实比例一致,否则图片会被拉伸/裁切 */
  object-fit: cover;
  background: #f0f0f0;        /* 占位底色(LQIP 的退化方案) */
}
// ✅ 三种「占位方案」的效果与成本对比

// ① 纯色/灰底占位(成本≈0,观感一般)
<div style={{ background: dominantColor }} />

// ② LQIP:极小的图(如 20px 宽)放大后模糊(成本≈1KB,观感好)
<img src={lqipDataUrl} style={{ filter: 'blur(12px)', transform: 'scale(1.05)' }} />

// ③ BlurHash:一段字符串解码成渐变(成本≈30 字节,观感中)
<div style={{ backgroundImage: `url(${decodeBlurHash(hash, 32, 32)})` }} />
⭐⭐ 关键:**占位必须「占据与最终图片相同的空间」**,
   否则「占位 → 真图」的替换会让布局跳动(CLS 上升)。

   顺序是:① 先定尺寸(`width`/`height` 或 `aspect-ratio`)
          ② 再谈占位样式(底色 / 模糊)
          ③ 最后才是淡入动画

五、字体优化:五个决定

/* ✅ 一个「现代且不跳」的字体声明 */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont-subset.woff2') format('woff2');
  font-weight: 400 700;              /* ⭐ 变量字体:一个文件覆盖多个字重 */
  font-style: normal;
  font-display: swap;                /* ⭐ 先用 fallback 显示,字体到了再换 */
  unicode-range: U+0000-00FF, U+4E00-9FFF;   /* ⭐ 只在这些码点范围内使用它 */
}

/* ⭐ 关键:让 fallback 的「度量」与自定义字体接近 → 减少切换时的跳动 */
@font-face {
  font-family: 'MyFontFallback';
  src: local('Arial');
  size-adjust: 105.3%;               /* 缩放整体尺寸 */
  ascent-override: 90%;              /* 覆盖上伸部 */
  descent-override: 22.4%;           /* 覆盖下伸部 */
  line-gap-override: 0%;             /* 覆盖行间距 */
}

body {
  font-family: 'MyFont', 'MyFontFallback', system-ui, sans-serif;
}
⭐⭐⭐ 五个决定(每个都有「为什么」):

① 【格式:只发 WOFF2】
   WOFF2(Brotli 压缩)比 WOFF 小约 30%,比 TTF/OTF 更小。
   TTF/OTF/EOT 只在「需要支持 IE」时才有意义(现在基本不需要)。
   ⭐ 只写一行 `src: url(...woff2)` 即可(现代浏览器全支持)。

② 【子集化(subset):只带用到的字符】
   中文字体的全量动辄 5~10MB;子集化到「常用 3500 字」能降到几百 KB,
   再到「页面实际用到的字」能降到几十 KB。
   ✅ 构建期用 `subset-font` / `fonttools` / `glyphhanger` 生成
   ⚠️ 子集化的风险:用户输入的字(如搜索、评论)可能不在子集里 → 会 fallback
      → 解法:把「用户生成内容」的字体交给系统字体(或用更宽的子集)

③ 【`unicode-range`:按语言分片】
   把「拉丁」与「中文」放在不同 `@font-face` 里用 `unicode-range` 区分:
   · 纯中文页面里的英文单词会走「拉丁字体」(观感更好、加载更少)
   · 浏览器只会下载「页面上真正用到的那些码点范围」对应的文件
   ⭐ 这是「多语言站点的字体体积」最有效的优化

④ 【`font-display`:五个值的取舍】
   | 值 | 阻塞期 | 交换期 | 效果 |
   | --- | --- | --- | --- |
   | `block` | ~3s | 无限 | 长时间「空白文字」(FOIT)→ ❌ 体验差 |
   | `swap` | ~0ms | 无限 | 立刻用 fallback,字体到了就换 → ⚠️ 会跳动(CLS/FOUT) |
   | `fallback` | ~100ms | ~3s | 折中;3s 后没到就用 fallback 不再换 |
   | `optional` | ~100ms | **无** | ⭐ **不会跳动**(100ms 内没到就本次不用自定义字体) |
   | `auto` | 浏览器决定 | — | 通常等同 `block` |

   ⭐ 选择建议:
     · 「品牌字体是体验核心」→ `swap` + 用 `size-adjust` 对齐 fallback 度量(减少跳动)
     · 「绝对不想看到任何跳动」→ `optional`(代价:慢网络下用户永远看到 fallback)
     · 「正文可读性优先」→ `swap`

⑤ 【预加载字体(`preload`)——必须带 `crossorigin`】
   <link rel="preload" href="/fonts/myfont-subset.woff2" as="font" type="font/woff2" crossorigin>
   ⚠️⚠️ 字体请求是 **CORS 请求**,`preload` 时**必须**加 `crossorigin`,
        否则浏览器会认为「预加载的资源与实际请求的不是同一个」→ **下载两次**
        (这几乎是最经典的「preload 坑」)
   ⚠️ 而且「只预加载真正在首屏用到的那一个(通常一个子集)」,
      预加载太多字体会与 LCP 图片抢带宽。
/* ✅ 用 font-metric-overrides 让系统字体「看起来像」自定义字体(防 CLS 的关键) */
/* 现代方案:用工具自动生成(Next.js 的 next/font/local 会自动算这些值) */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;           /* 让 fallback 的字号视觉上与 Inter 一致 */
  ascent-override: 90.2%;
  descent-override: 22.5%;
  line-gap-override: 0%;
}

/* ⭐ 原理:CLS 的字体切换跳动来自「两者行高/字宽不同」,
   把 fallback 的度量调到「与自定义字体几乎一致」→ 切换时几乎不移动 */

六、完整实现:一个 220 行的图片组件

// SmartImage.tsx —— 自动 srcset / 防 CLS / LQIP / 优先级 / 淡入 / 降级
import React, { useState, useRef, useEffect, useCallback } from 'react';

export interface SmartImageProps {
  /** 原始图片路径(用于生成 srcset 的基准,如 '/img/photo.jpg') */
  src: string;
  alt: string;
  /** 图片的固有宽高比(必填,用于防 CLS) */
  width: number;
  height: number;
  /** 生成指定宽度的 URL(默认用「目录/文件-宽度.ext」约定,或走图片 CDN) */
  buildUrl?: (src: string, width: number, format?: 'avif' | 'webp' | 'jpg') => string;
  /** 参与 srcset 的宽度档位 */
  widths?: number[];
  /** 传给 img 的 sizes(⭐ 必须与实际 CSS 宽度一致) */
  sizes?: string;
  /** 是否首屏关键图(LCP) */
  priority?: boolean;
  /** 占位色(也可传 data url / blurhash 解码结果) */
  placeholderColor?: string;
  /** 这个图片是否支持 AVIF/WebP(默认 true,用 picture 做降级链) */
  modernFormats?: boolean;
  className?: string;
  style?: React.CSSProperties;
  onClick?: () => void;
}

const DEFAULT_WIDTHS = [320, 480, 640, 800, 1200, 1600, 2000];

/** 默认 URL 生成器:/img/a.jpg + 800 → /img/a-800.jpg */
function defaultBuildUrl(src: string, width: number, format?: 'avif' | 'webp' | 'jpg') {
  const dot = src.lastIndexOf('.');
  const base = dot >= 0 ? src.slice(0, dot) : src;
  const ext = format === 'jpg' ? (dot >= 0 ? src.slice(dot + 1) : 'jpg') : (format ?? 'jpg');
  return `${base}-${width}.${ext}`;
}

export function SmartImage({
  src,
  alt,
  width,
  height,
  buildUrl = defaultBuildUrl,
  widths = DEFAULT_WIDTHS,
  sizes = '100vw',
  priority = false,
  placeholderColor = '#f2f2f2',
  modernFormats = true,
  className,
  style,
  onClick,
}: SmartImageProps) {
  const [loaded, setLoaded] = useState(false);
  const [failed, setFailed] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  // ⭐ 只使用「不小于固有宽度」的档位(避免生成过小的图),并始终包含固有宽度
  const candidates = React.useMemo(() => {
    const set = new Set<number>(widths.filter((w) => w >= Math.min(width, widths[widths.length - 1]!)));
    set.add(width);
    return [...set].sort((a, b) => a - b);
  }, [widths, width]);

  const srcSetFor = useCallback(
    (format?: 'avif' | 'webp' | 'jpg') =>
      candidates.map((w) => `${buildUrl(src, w, format)} ${w}w`).join(', '),
    [candidates, buildUrl, src]
  );

  // ⭐ 缓存命中(图片已在缓存里)时,onLoad 可能「早于」effect 注册 → 主动补一次检查
  useEffect(() => {
    const el = imgRef.current;
    if (el?.complete && el.naturalWidth > 0) setLoaded(true);
  }, []);

  const aspectRatio = `${width} / ${height}`;

  return (
    <span
      className={className}
      style={{
        display: 'block',
        position: 'relative',
        aspectRatio,                                // ⭐ 防 CLS 的核心
        width: '100%',
        backgroundColor: loaded ? 'transparent' : placeholderColor,
        overflow: 'hidden',
        ...style,
      }}
      onClick={onClick}
    >
      {failed ? (
        // ⭐ 降级:显示占位块 + alt 文本(不要显示「破图」图标)
        <span
          style={{
            position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
            color: '#888', fontSize: 12, textAlign: 'center', padding: 8,
          }}
        >
          {alt || '图片加载失败'}
        </span>
      ) : modernFormats ? (
        <picture>
          <source type="image/avif" srcSet={srcSetFor('avif')} sizes={sizes} />
          <source type="image/webp" srcSet={srcSetFor('webp')} sizes={sizes} />
          <img
            ref={imgRef}
            src={buildUrl(src, width, 'jpg')}
            srcSet={srcSetFor()}
            sizes={sizes}
            alt={alt}
            width={width}
            height={height}
            // ⭐ LCP 图:不懒加载 + high;其它图:lazy + low
            loading={priority ? 'eager' : 'lazy'}
            fetchPriority={priority ? 'high' : 'low'}
            decoding="async"
            onLoad={() => setLoaded(true)}
            onError={() => setFailed(true)}
            style={{
              position: 'absolute', inset: 0,
              width: '100%', height: '100%',
              objectFit: 'cover',
              opacity: loaded ? 1 : 0,
              transition: 'opacity 240ms ease-out',
            }}
          />
        </picture>
      ) : (
        <img
          ref={imgRef}
          src={buildUrl(src, width, 'jpg')}
          srcSet={srcSetFor()}
          sizes={sizes}
          alt={alt}
          width={width}
          height={height}
          loading={priority ? 'eager' : 'lazy'}
          fetchPriority={priority ? 'high' : 'low'}
          decoding="async"
          onLoad={() => setLoaded(true)}
          onError={() => setFailed(true)}
          style={{
            position: 'absolute', inset: 0,
            width: '100%', height: '100%',
            objectFit: 'cover',
            opacity: loaded ? 1 : 0,
            transition: 'opacity 240ms ease-out',
          }}
        />
      )}
    </span>
  );
}
/** ============ 用法:首屏 LCP 图 + 列表图 ============ */
export function ProductPage() {
  return (
    <main>
      {/* ⭐ 首屏主图:priority → eager + fetchpriority=high,绝不 lazy */}
      <SmartImage
        src="/img/hero"
        alt="产品主视觉"
        width={1600}
        height={900}
        sizes="100vw"
        priority
      />

      {/* ⭐ 搭配 preload(⚠️ 注意 imagesrcset/imagesizes 要和组件里的一致) */}
      {/* 在文档 <head> 里:
        <link rel="preload" as="image" imagesrcset="/img/hero-800.webp 800w, /img/hero-1600.webp 1600w"
              imagesizes="100vw" fetchpriority="high" /> */}

      {/* 列表图:懒加载 + low 优先级 + 固定比例防 CLS */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16 }}>
        {Array.from({ length: 12 }, (_, i) => (
          <SmartImage
            key={i}
            src={`/img/card-${i}`}
            alt={`商品 ${i + 1}`}
            width={600}
            height={600}
            sizes="(max-width: 600px) 50vw, 240px"
          />
        ))}
      </div>
    </main>
  );
}
⭐⭐ 这个组件体现了五个「实践要点」:

① 【`aspect-ratio` + 外层 span 固定尺寸】→ 零 CLS
② 【`picture` 降级链】→ AVIF → WebP → JPEG(现代浏览器自动选最好的)
③ 【`sizes` 作为 props】→ 强制调用方「显式说明宽度」,
   从源头避免「忘了写 sizes → 默认 100vw → 下载过大图」
④ 【`priority` 一个开关决定 `loading` 与 `fetchpriority`】
   → 避免「首屏图被 lazy」(这是最常见的 LCP 杀手)
⑤ 【缓存命中时的 `complete` 补检】→ 避免「图片已在缓存但淡入动画不触发」
   ⚠️ 这是「Image 组件」的经典 bug:onLoad 在 effect 之前就触发了

七、本篇特有的坑

<!-- ① LCP 图片加了 loading="lazy"(最伤 LCP 的错误) -->
<img src="hero.jpg" loading="lazy" />                 <!-- ❌ -->
<img src="hero.jpg" fetchpriority="high" />            <!-- ✅ -->

<!-- ② 写了 srcset 忘了 sizes(默认 100vw → 大屏下载过大图) -->
<img srcset="a-400.jpg 400w, a-1600.jpg 1600w" />     <!-- ❌ 缺 sizes -->
<img srcset="a-400.jpg 400w, a-1600.jpg 1600w" sizes="(max-width: 600px) 100vw, 400px" />  <!-- ✅ -->

<!-- ③ preload 字体忘了 crossorigin → 下载两次 -->
<link rel="preload" href="/f.woff2" as="font">                                     <!-- ❌ -->
<link rel="preload" href="/f.woff2" as="font" type="font/woff2" crossorigin>       <!-- ✅ -->

<!-- ④ preload 图片只写 href(没写 imagesrcset)→ 可能与 srcset 选中不同的图,下载两份 -->
<link rel="preload" as="image" href="hero.jpg">        <!-- ⚠️ -->
<link rel="preload" as="image" imagesrcset="..." imagesizes="100vw">   <!-- ✅ -->

<!-- ⑤ 图片没有 width/height(加载后撑开 → CLS) -->
<img src="a.jpg" alt="..." />                          <!-- ❌ -->
<img src="a.jpg" width="800" height="450" alt="..." /> <!-- ✅ -->
⑥ 把「所有图片」都设 fetchpriority="high"
   → 等于没有优先级(带宽竞争更乱)→ 只给 LCP 图设 high

⑦ 用「字体图标」代替 SVG
   → 整包下载 + 无法 tree-shake(见 5.14)

⑧ 中文字体不做子集化(几 MB 的字体阻塞渲染)
   ✅ 按「常用 3500 字」或「页面实际用字」子集化 + `unicode-range` 分片

⑨ font-display 用 `block`(长时间 FOIT:文字空白 3 秒)
   ✅ `swap` 或 `optional`(配合 size-adjust 对齐度量)

⑩ 用 `swap` 但不做 size-adjust(字体切换时布局跳动 → CLS)
   ✅ 补 `@font-face` 的 metric overrides(或直接用 next/font 这类自动化的方案)

⑪ 「用户生成内容」用了「页面用字子集」的字体(缺字 → 大量 fallback)
   ✅ 正文用系统字体,或子集覆盖「常用字 + 数字 + 标点」

⑫ SVG 直接内联「用户上传的 SVG」(XSS 风险)
   ✅ 消毒(去掉 script/事件/外链)或用 `<img src="x.svg">`

⑬ 用 CSS `background-image` 放「内容图片」(不可被索引、无 alt、影响 LCP 计算)
   ✅ 内容图片用 `<img>`;纯装饰才用 background

⑭ 图片 CDN 参数写错导致「每次请求都产生新缓存键」
   (如把「时间戳」放进 URL)→ CDN 命中率归零
   ✅ URL 只包含「影响内容的参数」(宽度/格式/质量)

⑮ 只优化「体积」不看「解码成本」
   ⚠️ 超大图(如 8000×6000)即使压缩后不大,**解码也会很贵**(且占用大量内存)
   ✅ 用 `srcset` 保证「下载的分辨率不超过显示所需」;服务端 limit 最大尺寸

⑯ 忘记 `alt`(无障碍 + SEO + 图片失败时的提示)
   ⭐ 装饰图用 `alt=""`(明确告诉辅助技术「忽略它」),不要省略

⑰ LQIP 用「原图缩小后 base64 内联」但缩得不够小
   ✅ 20~32px 宽、质量 20~30、WebP 编码 → 通常 < 1KB

⑱ 图片加载失败显示「破图」而不是降级
   ✅ `onError` → 显示占位块 + alt 文本
// ⑱ 的完整处理
const [failed, setFailed] = useState(false);
// ...
onError={() => setFailed(true)}

// 渲染时
failed ? <FallbackBox text={alt} /> : <img ... />

八、面试延伸

  1. 「图片怎么优化?」
    四个层面:① 格式——用 <picture> 搭 AVIF → WebP → JPEG 的降级链(AVIF 相对 JPEG 约省 50%);动图用 <video> 而不是 GIF;图标用 SVG 而不是字体图标。② 尺寸——srcset + sizes(⭐ sizes 必须有且与实际 CSS 宽度一致,否则默认 100vw 会下载过大的图)。③ 加载——LCP 图绝不懒加载,用 fetchpriority="high";屏幕外用 loading="lazy" + fetchpriority="low";decoding="async"。④ 防 CLS——显式 width/height(浏览器自动推 aspect-ratio)或 CSS aspect-ratio,占位必须占据与真图相同的空间。

  2. 「srcset 和 sizes 是怎么工作的?」
    浏览器两步:① 先算「需要的 CSS 像素宽度」——按 sizes 里的媒体查询匹配,用第一个命中的长度(如 (max-width: 600px) 100vw, 400px);② 再乘 DPR 得到「需要的物理像素」,然后从 srcset 里选「不小于该值且最接近」的候选(也会参考网络状况)。⚠️ 最常见的错误是「有 srcset 但没写 sizes」——此时 sizes 默认是 100vw,会导致大屏上误判为「要全宽」而下载过大的图。另外 w 描述符必须配 sizes,x 描述符用于固定 DPR(如 1x/2x/3x 的 Logo),两者不能混用。

  3. 「为什么 LCP 图片不能懒加载?」
    因为 loading="lazy" 会让浏览器「推迟到布局/滚动位置确定后再请求」图片——而 LCP 图片往往就是首屏最大元素,推迟请求等于直接推迟 LCP。所以首屏图片应该用默认(eager)+ fetchpriority="high",只有屏幕外的图片才用 loading="lazy"(可以配 fetchpriority="low")。⭐ 判断方法:「这张图在首屏内吗?」——在,就不要 lazy。

  4. 「preload 图片和字体有什么坑?」
    两个经典坑:① 字体 preload 必须加 crossorigin——因为字体请求是 CORS 请求,不带 crossorigin 会被视为「不同的资源」→ 下载两次。② 图片 preload 必须写 imagesrcset/imagesizes——只写 href 的话,preload 阶段用「单一 URL」,很可能与你后面 srcset 选中的不是同一张图 → 也是下载两次。另外「别 preload 太多」——预加载会抢先占带宽,可能反而拖慢真正的 LCP 资源。

  5. 「字体怎么优化?」
    五个决定:① 格式只要 WOFF2(比 WOFF 小约 30%);② 子集化(中文全量 5~10MB → 常用字几百 KB → 页面用字几十 KB),但要考虑「用户输入的字可能不在子集里」;③ unicode-range 按语言分片(浏览器只下载「页面实际用到的码点范围」);④ font-display——swap(立刻用 fallback,字体到了就换,会跳动)或 optional(100ms 内没到就本次不用,零跳动),不要用 block(长时间 FOIT);⑤ 预加载首屏字体(带 crossorigin),但只预加载一个子集。

  6. 「字体切换导致 CLS,怎么解决?」
    根因是「fallback 字体与自定义字体的度量不同」(字宽、字高、行高),切换时文本重新排版 → 布局移动。解法是「度量对齐」:用 @font-face 的 size-adjust(整体缩放)、ascent-override/descent-override/line-gap-override(覆盖上下伸部与行距)把 fallback 的度量改到「与自定义字体几乎一致」。实践中不用手算——next/font 会自动生成这些值,也可以用 fontaine 这类工具从字体文件算出来。另一个更彻底的解法是 font-display: optional(不切换就不会跳动),代价是慢网络下用户看到的是 fallback。

  7. 「首屏 LCP 图片『发现得太晚』怎么办?」
    先分清两种原因:① 图片在 HTML 里但发现晚——通常是被「阻塞资源(CSS/同步 JS)挡在前面」或在 DOM 很深的位置。✅ 解法:<link rel="preload" as="image" imagesrcset imagesizes> + fetchpriority="high" + 减少阻塞 JS;② 图片由 JS 插入(懒加载库、轮播、hydration 后才出现)——浏览器在 JS 执行前根本不知道它是 LCP。✅ 解法:把首屏那张图直接写进服务端渲染的 HTML(不要靠 JS 插入),或用 preload 提前声明。

  8. 「怎么保证图片『零 CLS』?」
    三条:① 显式尺寸——写 <img width height>(现代浏览器据此自动推导 aspect-ratio)或 CSS aspect-ratio;② 占位与真图「占据相同空间」——无论是纯色、LQIP(20~32px 的模糊小图)还是 BlurHash,都必须放在「尺寸已固定」的容器里;③ 顺序要正确——先定尺寸,再谈占位样式,最后才是淡入动画(动画只改 opacity,不改尺寸)。⚠️ 常见反例:「先渲染 <img> 不设尺寸,加载后再撑开」——这必然产生 CLS。

一句话速记

图片:格式走「AVIF → WebP → JPEG 降级链」(<picture>,AVIF 约省 50%),动图用 <video>、图标用 SVG;尺寸用 srcset + sizes(sizes 必须写且与实际 CSS 宽度一致,缺失时默认 100vw 会下载过大图;w 必须配 sizes,x 用于固定 DPR);加载上「LCP 图绝不 loading="lazy"」+ fetchpriority="high" + decoding="async",屏幕外才 lazy/low;preload 图片必须带 imagesrcset/imagesizes(否则下载两份),preload 字体必须带 crossorigin(字体是 CORS 请求,否则下载两份);防 CLS 靠「显式 width/height 或 aspect-ratio + 占位占据相同空间」。字体:只发 WOFF2 + 子集化 + unicode-range 分片 + font-display: swap/optional(别用 block)+ 用 size-adjust/ascent-override 对齐 fallback 度量防跳动,预加载首屏字体一个即可(带 crossorigin)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.15 图片与字体优化

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫