
一句话:图片优化的核心是「格式选型(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 ... />
八、面试延伸
- 「图片怎么优化?」
四个层面:① 格式——用<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)或 CSSaspect-ratio,占位必须占据与真图相同的空间。 -
「
srcset和sizes是怎么工作的?」
浏览器两步:① 先算「需要的 CSS 像素宽度」——按sizes里的媒体查询匹配,用第一个命中的长度(如(max-width: 600px) 100vw, 400px);② 再乘 DPR 得到「需要的物理像素」,然后从srcset里选「不小于该值且最接近」的候选(也会参考网络状况)。⚠️ 最常见的错误是「有srcset但没写sizes」——此时sizes默认是100vw,会导致大屏上误判为「要全宽」而下载过大的图。另外w描述符必须配sizes,x描述符用于固定 DPR(如1x/2x/3x的 Logo),两者不能混用。 -
「为什么 LCP 图片不能懒加载?」
因为loading="lazy"会让浏览器「推迟到布局/滚动位置确定后再请求」图片——而 LCP 图片往往就是首屏最大元素,推迟请求等于直接推迟 LCP。所以首屏图片应该用默认(eager)+fetchpriority="high",只有屏幕外的图片才用loading="lazy"(可以配fetchpriority="low")。⭐ 判断方法:「这张图在首屏内吗?」——在,就不要 lazy。 -
「
preload图片和字体有什么坑?」
两个经典坑:① 字体preload必须加crossorigin——因为字体请求是 CORS 请求,不带crossorigin会被视为「不同的资源」→ 下载两次。② 图片preload必须写imagesrcset/imagesizes——只写href的话,preload 阶段用「单一 URL」,很可能与你后面srcset选中的不是同一张图 → 也是下载两次。另外「别 preload 太多」——预加载会抢先占带宽,可能反而拖慢真正的 LCP 资源。 -
「字体怎么优化?」
五个决定:① 格式只要 WOFF2(比 WOFF 小约 30%);② 子集化(中文全量 5~10MB → 常用字几百 KB → 页面用字几十 KB),但要考虑「用户输入的字可能不在子集里」;③unicode-range按语言分片(浏览器只下载「页面实际用到的码点范围」);④font-display——swap(立刻用 fallback,字体到了就换,会跳动)或optional(100ms 内没到就本次不用,零跳动),不要用block(长时间 FOIT);⑤ 预加载首屏字体(带crossorigin),但只预加载一个子集。 -
「字体切换导致 CLS,怎么解决?」
根因是「fallback 字体与自定义字体的度量不同」(字宽、字高、行高),切换时文本重新排版 → 布局移动。解法是「度量对齐」:用@font-face的size-adjust(整体缩放)、ascent-override/descent-override/line-gap-override(覆盖上下伸部与行距)把 fallback 的度量改到「与自定义字体几乎一致」。实践中不用手算——next/font会自动生成这些值,也可以用fontaine这类工具从字体文件算出来。另一个更彻底的解法是font-display: optional(不切换就不会跳动),代价是慢网络下用户看到的是 fallback。 -
「首屏 LCP 图片『发现得太晚』怎么办?」
先分清两种原因:① 图片在 HTML 里但发现晚——通常是被「阻塞资源(CSS/同步 JS)挡在前面」或在 DOM 很深的位置。✅ 解法:<link rel="preload" as="image" imagesrcset imagesizes>+fetchpriority="high"+ 减少阻塞 JS;② 图片由 JS 插入(懒加载库、轮播、hydration 后才出现)——浏览器在 JS 执行前根本不知道它是 LCP。✅ 解法:把首屏那张图直接写进服务端渲染的 HTML(不要靠 JS 插入),或用 preload 提前声明。 -
「怎么保证图片『零 CLS』?」
三条:① 显式尺寸——写<img width height>(现代浏览器据此自动推导aspect-ratio)或 CSSaspect-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)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!