文章

图片优化策略深度解析:从格式选型到懒加载实现的全链路性能指南

图片优化策略深度解析:从格式选型到懒加载实现的全链路性能指南

一句话概括

图片优化是现代Web性能优化中投入产出比最高的单项工作——一个典型内容型网站中图片占页面总下载量的60-80%,合理的优化策略可以在不影响视觉质量的前提下将图片体积减少80%以上,直接从TTFB之后到LCP的整个区间产生显著影响。

背景与意义

2025年HTTP Archive的数据显示,一个典型的电商详情页平均传输2.3MB的图片数据,占页面总重量的72%。更令人震惊的是,其中36%的图片是”不必要的”——要么视口之外(用户根本看不到),要么尺寸远超实际展示需要。

同时,图片格式领域发生了重大变化:AVIF(基于AV1编码)在主流浏览器中的支持率已超过92%,而JPEG XL也逐渐进入Chrome实验性支持。这些格式相比WebP还能再节省20-35%的体积,压缩效率的提升曲线依然陡峭。

1
2
3
4
图片格式的演进:
GIF (1987) → JPEG (1992) → PNG (1996) → WebP (2010) → AVIF (2019) → JPEG XL (2021)
  -    有损:1%-5%    无损:高         有损:-30%      有损:-50%      有损:-60%
  256色   中等品质    Web 2.0时代    Google推动    Netflix推动     JPEG后继

对于前端工程师来说,图片优化不是一个”要不要做”的问题,而是”怎么做才系统化”的问题。单点优化(比如只用WebP)可能带来30%的节省,但系统性优化(格式+响应式+懒加载+CDN+预加载时序控制一起上)可以带来80%以上的节省。

概念与定义

核心概念

有损压缩(Lossy Compression): 丢弃部分视觉数据来减小文件大小。人眼对不同频率信号的敏感度不同——对亮度敏感,对颜色细节不敏感。有损压缩利用这个特性丢弃人眼不敏感的信息。

无损压缩(Lossless Compression): 不丢失任何像素数据。适用于需要保持精确像素的场景(如UI设计稿、图标、医疗影像)。

响应式图片(Responsive Images): 根据不同设备和视口大小,加载不同尺寸的图片。核心是srcsetsizes属性。

懒加载(Lazy Loading): 延迟加载视口之外的图片,只在图片即将进入视口时才加载。

图片格式对比

格式有损压缩无损压缩动图透明度浏览器支持最佳场景
JPEG100%照片、复杂色彩图片
PNG-8100%图标、插画(≤256色)
PNG-32100%需要透明度的照片
GIF有限100%动图(256色限制)
WebP97%现代场景通用(2026年)
AVIF92%照片(最佳压缩率)
JPEG XL实验性JPEG的无损替换

最小示例:从一张图开始的优化

场景:电商首页的Banner图

原始图片:hero-banner.jpg,4000×2000px,2.5MB

第一步:适当裁剪尺寸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// resize-image.js - 使用sharp调整图片尺寸
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

async function optimizeHeroImage() {
  const input = './src/images/hero-banner.jpg';
  const outputDir = './dist/images';
  fs.mkdirSync(outputDir, { recursive: true });
  
  // 分析:首页Banner在1440px显示器上实际展示宽度约为1440px(全屏)
  // 在移动端375px视口上展示约为375px
  // 我们不需要4000px的原始分辨率
  
  // 生成不同分辨率版本
  const sizes = [
    { width: 480, suffix: 'xs' },   // 移动端
    { width: 768, suffix: 'sm' },   // 平板
    { width: 1200, suffix: 'md' },  // 桌面较小
    { width: 2000, suffix: 'lg' },  // Retina桌面
  ];
  
  for (const { width, suffix } of sizes) {
    // WebP格式(现代浏览器首选)
    await sharp(input)
      .resize(width, undefined, { fit: 'cover', position: 'center' })
      .webp({ quality: 75, effort: 6 })
      .toFile(path.join(outputDir, `hero-${suffix}.webp`));
    
    // AVIF格式(最佳压缩率)
    await sharp(input)
      .resize(width, undefined, { fit: 'cover', position: 'center' })
      .avif({ quality: 60, effort: 7 })
      .toFile(path.join(outputDir, `hero-${suffix}.avif`));
    
    // JPEG回退(旧浏览器)
    await sharp(input)
      .resize(width, undefined, { fit: 'cover', position: 'center' })
      .jpeg({ quality: 80, mozjpeg: true })
      .toFile(path.join(outputDir, `hero-${suffix}.jpg`));
  }
  
  // 统计优化效果
  console.log('\n📊 图片优化统计:');
  const original = fs.statSync(input).size;
  console.log(`原始: ${(original / 1024 / 1024).toFixed(2)} MB`);
  
  for (const { width, suffix } of sizes) {
    const webpSize = fs.statSync(path.join(outputDir, `hero-${suffix}.webp`)).size;
    const avifSize = fs.statSync(path.join(outputDir, `hero-${suffix}.avif`)).size;
    const jpgSize = fs.statSync(path.join(outputDir, `hero-${suffix}.jpg`)).size;
    
    console.log(`\n  ${width}px 版本:`);
    console.log(`    WebP: ${(webpSize / 1024).toFixed(1)} KB (${((1 - webpSize/original) * 100).toFixed(1)}% 节省)`);
    console.log(`    AVIF: ${(avifSize / 1024).toFixed(1)} KB (${((1 - avifSize/original) * 100).toFixed(1)}% 节省)`);
    console.log(`    JPEG: ${(jpgSize / 1024).toFixed(1)} KB (${((1 - jpgSize/original) * 100).toFixed(1)}% 节省)`);
  }
}

optimizeHeroImage();

第二步:在前端中使用响应式图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<!-- responsive-hero.html - 响应式Banner -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  
  <!-- 预加载桌面端Banner(LCP元素) -->
  <link rel="preload" href="/images/hero-md.webp" as="image" 
        media="(min-width: 768px)" fetchpriority="high">
  <link rel="preload" href="/images/hero-xs.webp" as="image"
        media="(max-width: 767px)" fetchpriority="high">
  
  <style>
    /* 防止布局偏移(CLS) */
    .hero-wrapper {
      position: relative;
      width: 100%;
      aspect-ratio: 2 / 1; /* 宽高比固定 */
      overflow: hidden;
      background: #f0f0f0; /* 占位背景色 */
    }
    
    .hero-wrapper img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  </style>
</head>
<body>
  <div class="hero-wrapper">
    <!-- 使用 <picture> 实现格式降级 -->
    <picture>
      <!-- AVIF(最佳压缩率,支持度92%以上) -->
      <source 
        type="image/avif"
        srcset="
          /images/hero-xs.avif 480w,
          /images/hero-sm.avif 768w,
          /images/hero-md.avif 1200w,
          /images/hero-lg.avif 2000w"
        sizes="
          (max-width: 480px) 100vw,
          (max-width: 768px) 100vw,
          (max-width: 1200px) 100vw,
          1440px">
      
      <!-- WebP(主流格式,几乎全支持) -->
      <source 
        type="image/webp"
        srcset="
          /images/hero-xs.webp 480w,
          /images/hero-sm.webp 768w,
          /images/hero-md.webp 1200w,
          /images/hero-lg.webp 2000w"
        sizes="
          (max-width: 480px) 100vw,
          (max-width: 768px) 100vw,
          (max-width: 1200px) 100vw,
          1440px">
      
      <!-- JPEG回退(兼容所有浏览器) -->
      <img
        src="/images/hero-md.jpg"
        srcset="
          /images/hero-xs.jpg 480w,
          /images/hero-sm.jpg 768w,
          /images/hero-md.jpg 1200w,
          /images/hero-lg.jpg 2000w"
        sizes="
          (max-width: 480px) 100vw,
          (max-width: 768px) 100vw,
          (max-width: 1200px) 100vw,
          1440px"
        alt="春季大促 - 全场3折起"
        width="1200"
        height="600"
        decoding="async"
        fetchpriority="high">
    </picture>
  </div>
</body>
</html>

核心知识点拆解

1. 图片格式选择的决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// image-format-decision.js - 格式选择决策辅助
function decideImageFormat(image, usage) {
  const { hasTransparency, isPhoto, isAnimated, numColors } = image;
  const { requireLossless, maxFileSize } = usage;
  
  if (isAnimated) {
    // 动图:GIF或WebP动图
    if (numColors <= 256) {
      return { format: 'gif', note: '简单动图,GIF足够' };
    }
    return { format: 'webp', animated: true, note: '复杂动图,WebP更优' };
  }
  
  if (requireLossless) {
    // 需要无损:医疗影像、设计稿等
    return { format: 'png', note: '无损需求,保留PNG' };
  }
  
  if (!isPhoto) {
    // 非照片:图标、插画、UI元素
    if (hasTransparency && numColors <= 256) {
      return { format: 'png-8', note: '简单UI图标' };
    }
    if (hasTransparency) {
      return { format: 'webp', note: '需要透明度的复杂图形' };
    }
    return { format: 'webp', note: '非照片图形,WebP最佳' };
  }
  
  // 照片(有损压缩场景)
  if (browserSupportsAVIF()) {
    return { format: 'avif', note: '照片最优解,AVIF' };
  }
  
  return { format: 'webp', note: '照片次优解,WebP' };
}

决策要点

1
2
3
4
5
6
照片 → AVIF首选 → WebP降级 → JPEG兜底
UI图标/插画 → SVG > WebP/PNG-8
复杂图形(渐变色) → WebP > PNG
动图 → WebP动图 > GIF
需要绝对保真 → PNG
JPEG兼容性兜底 → MozJPEG编码的JPEG

2. 懒加载的四种实现方式

方式1:浏览器原生loading属性

1
2
3
4
5
6
7
8
9
<!-- 最简单、性能最好 -->
<img src="product.jpg" loading="lazy" width="300" height="200" alt="商品">
<iframe src="widget.html" loading="lazy"></iframe>

<!-- loading属性取值:
  lazy:  延迟加载(页面中非首屏内容推荐)
  eager: 立即加载(首屏内容用)
  auto:  浏览器自行决定(默认值)
-->

浏览器的懒加载实现细节

1
2
3
4
5
6
7
8
9
Chrome使用2种距离:
  4G/5G: 视口外1250px开始加载
  3G:    视口外2500px开始加载
  2G:    视口外3500px开始加载
  ​WiFi:  视口外1250px开始加载

Firefox距离:
  统一视口外300px
  (不同浏览器策略不同,这是个经验值)

方式2:IntersectionObserver实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// lazy-loader.js - 基于IntersectionObserver的懒加载
class LazyImageLoader {
  constructor(options = {}) {
    this.options = {
      rootMargin: '200px',  // 提前200px开始加载
      threshold: 0.01,      // 元素出现1%时触发
      placeholderClass: 'lazy-placeholder',
      ...options
    };
    
    this.observer = null;
    this.images = new Map(); // 存储图片加载状态
    this.init();
  }

  init() {
    if ('IntersectionObserver' in window === false) {
      // 回退:如果浏览器不支持,立即加载所有图片
      this.loadAllImmediately();
      return;
    }
    
    this.observer = new IntersectionObserver(
      (entries) => this.onIntersection(entries),
      {
        rootMargin: this.options.rootMargin,
        threshold: this.options.threshold
      }
    );
    
    // 观察所有懒加载图片
    document.querySelectorAll('[data-src]').forEach(img => {
      this.observer.observe(img);
    });
  }

  onIntersection(entries) {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        const img = entry.target;
        this.loadImage(img);
        this.observer.unobserve(img);
      }
    }
  }

  loadImage(img) {
    const src = img.getAttribute('data-src');
    const srcset = img.getAttribute('data-srcset');
    
    if (!src) return;
    
    // 使用Image对象预加载(保证加载完成后显示)
    const tempImg = new Image();
    
    tempImg.onload = () => {
      // 加载完成,切换到实际图片
      img.src = src;
      if (srcset) img.srcset = srcset;
      img.classList.remove(this.options.placeholderClass);
      img.classList.add('loaded');
      
      // 更新加载状态
      this.images.set(img, { loaded: true, time: performance.now() });
      
      // 触发自定义事件
      img.dispatchEvent(new CustomEvent('lazyloaded', {
        detail: { src, loadTime: performance.now() }
      }));
    };
    
    tempImg.onerror = () => {
      // 加载失败,使用占位图
      img.src = '/images/placeholder-error.svg';
      console.warn(`懒加载失败: ${src}`);
    };
    
    // 开始加载
    if (srcset) tempImg.srcset = srcset;
    tempImg.src = src;
    
    // 如果图片已经在浏览器缓存中,onload需要手动处理
    if (tempImg.complete && tempImg.naturalWidth > 0) {
      tempImg.onload(null); // 手动触发
    }
  }

  loadAllImmediately() {
    document.querySelectorAll('[data-src]').forEach(img => {
      this.loadImage(img);
    });
  }

  // 添加新图片(动态插入的DOM)
  observe(img) {
    if (this.observer) {
      this.observer.observe(img);
    }
  }

  // 销毁
  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

// 使用
const loader = new LazyImageLoader({
  rootMargin: '300px 0px',
  placeholderClass: 'skeleton'
});

// 动态添加图片后
function addNewProductCard(product) {
  const html = `
    <div class="product-card">
      <img class="skeleton"
           data-src="${product.image.webp}"
           data-srcset="${product.image.srcset}"
           width="${product.image.width}"
           height="${product.image.height}"
           alt="${product.name}">
      <h3>${product.name}</h3>
    </div>
  `;
  
  const container = document.getElementById('product-grid');
  container.insertAdjacentHTML('beforeend', html);
  
  // 让懒加载观察者检测新图片
  const newImg = container.lastElementChild.querySelector('img');
  loader.observe(newImg);
}

方式3:Canvas占位(LQIP)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 低质量图片占位符(LQIP):使用tiny JPEG作为占位 -->
<img 
  src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."  <!-- 极低质量的base64 JPEG (< 1KB) -->
  data-src="/images/product-full.webp"
  alt="商品"
  class="lqip-image"
  width="400"
  height="500">

<style>
  .lqip-image {
    filter: blur(10px); /* 模糊效果,过渡平滑 */
    transition: filter 0.3s ease-out;
  }
  .lqip-image.loaded {
    filter: blur(0);
  }
</style>

方式4:CSS背景图懒加载

1
2
3
4
5
6
7
8
9
10
11
/* 使用CSS background-image + on-screen detection */
.lazy-bg {
  background-image: none; /* 初始无背景 */
  background-size: cover;
  background-position: center;
  transition: opacity 0.3s;
}

.lazy-bg.loaded {
  opacity: 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
// 监听元素进入视口,设置CSS背景
const bgObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const el = entry.target;
      const bgUrl = el.getAttribute('data-bg');
      el.style.backgroundImage = `url(${bgUrl})`;
      el.classList.add('loaded');
      bgObserver.unobserve(el);
    }
  });
}, { rootMargin: '200px' });

3. CDN图片处理

使用支持动态图片处理的CDN(如Cloudinary、Imgix)可以避免本地生成所有尺寸版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// cdn-image-helper.js - CDN图片URL生成器
class CDNImageProcessor {
  constructor(baseURL, defaults = {}) {
    this.baseURL = baseURL;
    this.defaults = {
      quality: 75,
      format: 'auto',
      ...defaults
    };
  }

  // 生成优化的图片URL
  buildURL(originalPath, options = {}) {
    const params = new URLSearchParams();
    
    // 尺寸
    if (options.width) params.set('w', options.width);
    if (options.height) params.set('h', options.height);
    if (options.fit) params.set('fit', options.fit); // cover, contain, fill
    
    // 质量
    params.set('q', options.quality || this.defaults.quality);
    
    // 格式(auto表示CDN自动选择最佳格式)
    params.set('f', options.format || this.defaults.format);
    
    // 其他高级处理
    if (options.blur) params.set('blur', options.blur);
    if (options.grayscale) params.set('efx', 'grayscale');
    if (options.trim) params.set('trim', options.trim); // 自动裁切空白
    
    const paramStr = params.toString();
    return `${this.baseURL}/${originalPath}${paramStr ? '?' + paramStr : ''}`;
  }

  // 生成响应式srcset
  generateSrcSet(originalPath, sizes = [480, 768, 1024, 1440, 2000]) {
    return sizes
      .map(w => `${this.buildURL(originalPath, { width: w })} ${w}w`)
      .join(', ');
  }

  // 生成<picture>所需的所有source
  generatePictureSources(originalPath, width) {
    const base = this.buildURL(originalPath, { width });
    
    return {
      avif: this.buildURL(originalPath, { width, format: 'avif' }),
      webp: this.buildURL(originalPath, { width, format: 'webp' }),
      fallback: this.buildURL(originalPath, { width, format: 'jpg' }),
      srcset: this.generateSrcSet(originalPath)
    };
  }
}

// 使用
const cdn = new CDNImageProcessor('https://cdn.example.com', {
  quality: 80,
  format: 'auto'
});

// 生成商品列表缩略图
const thumb = cdn.buildURL('/products/phone-12.jpg', {
  width: 300,
  height: 300,
  fit: 'cover'
});

// 生成响应式srcset
const srcset = cdn.generateSrcSet('/products/phone-12.jpg');
// → "https://cdn.example.com/products/phone-12.jpg?w=480 480w, ..."

// 生成LQIP占位
const lqip = cdn.buildURL('/products/phone-12.jpg', {
  width: 20,
  quality: 20,
  blur: 100
});

实战案例:图片密集型应用的系统优化

场景:旅游预订平台的图片优化

一个在线旅游平台,每个页面包含:

  • 1张大Banner(首页/目的地页)
  • 15-30张酒店缩略图(列表页)
  • 50+张酒店/景点图(详情页画廊)
  • 需要支持多设备、多网络环境
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
// travel-image-system.js - 旅游平台的图片优化系统

// ===== 1. 构建时自动化管线 =====
const glob = require('glob');
const path = require('path');
const sharp = require('sharp');

class TravelImagePipeline {
  constructor(config) {
    this.inputDir = config.inputDir || './src/images';
    this.outputDir = config.outputDir || './dist/images';
    this.breakpoints = config.breakpoints || [480, 768, 1024, 1440, 1920, 2560];
  }

  async processAll() {
    const images = glob.sync(`${this.inputDir}/**/*.{jpg,jpeg,png}`);
    const results = [];
    
    for (const imgPath of images) {
      const relativePath = path.relative(this.inputDir, imgPath);
      const result = await this.processImage(imgPath, relativePath);
      results.push(result);
    }
    
    this.generateManifest(results);
    return results;
  }

  async processImage(inputPath, relativePath) {
    const meta = await sharp(inputPath).metadata();
    const name = relativePath.replace(/\.[^.]+$/, '');
    const outputs = [];
    
    // 根据用途选择尺寸(不生成所有断点)
    const isHero = name.startsWith('hero') || name.startsWith('banner');
    const isThumbnail = name.startsWith('thumb');
    const isGallery = name.startsWith('gallery');
    
    let sizes;
    if (isHero) sizes = [480, 1200, 2000];
    else if (isThumbnail) sizes = [200, 300, 400];
    else if (isGallery) sizes = [480, 800, 1200, 1920];
    else sizes = [480, 768, 1200];
    
    for (const width of sizes) {
      // WebP
      const webpPath = `${this.outputDir}/${name}_${width}w.webp`;
      await sharp(inputPath)
        .resize(width, undefined, { 
          fit: isThumbnail ? 'cover' : 'inside',
          position: 'center',
          withoutEnlargement: true 
        })
        .webp({ quality: isThumbnail ? 60 : 75, effort: 6 })
        .toFile(webpPath);
      
      // AVIF
      const avifPath = `${this.outputDir}/${name}_${width}w.avif`;
      await sharp(inputPath)
        .resize(width, undefined, {
          fit: isThumbnail ? 'cover' : 'inside',
          position: 'center',
          withoutEnlargement: true
        })
        .avif({ quality: isThumbnail ? 50 : 65, effort: 6 })
        .toFile(avifPath);
      
      // JPEG
      const jpgPath = `${this.outputDir}/${name}_${width}w.jpg`;
      await sharp(inputPath)
        .resize(width, undefined, {
          fit: isThumbnail ? 'cover' : 'inside',
          position: 'center',
          withoutEnlargement: true
        })
        .jpeg({ quality: isThumbnail ? 70 : 80, mozjpeg: true })
        .toFile(jpgPath);
      
      outputs.push({
        width,
        webp: webpPath,
        avif: avifPath,
        jpg: jpgPath,
        size: {
          webp: require('fs').statSync(webpPath).size,
          avif: require('fs').statSync(avifPath).size,
          jpg: require('fs').statSync(jpgPath).size
        }
      });
    }
    
    return {
      name,
      original: { path: inputPath, size: meta.size, width: meta.width, height: meta.height },
      outputs
    };
  }

  generateManifest(results) {
    const manifest = {};
    
    for (const result of results) {
      manifest[result.name] = result.outputs.reduce((acc, o) => {
        acc[o.width] = {
          webp: o.webp,
          avif: o.avif,
          jpg: o.jpg
        };
        return acc;
      }, {});
    }
    
    require('fs').writeFileSync(
      path.join(this.outputDir, 'image-manifest.json'),
      JSON.stringify(manifest, null, 2)
    );
    
    // 生成统计报告
    let totalOriginal = 0;
    let totalWebP = 0;
    let totalAVIF = 0;
    
    for (const result of results) {
      totalOriginal += result.original.size;
      const maxSize = Math.max(...result.outputs.map(o => o.size.webp));
      totalWebP += maxSize;
      totalAVIF += Math.max(...result.outputs.map(o => o.size.avif));
    }
    
    console.log(`
╔══════════════════════════════════════════╗
║       图片优化管线报告                     ║
╠══════════════════════════════════════════╣
║  处理图片: ${results.length} 张
║  原始大小: ${(totalOriginal / 1024 / 1024).toFixed(2)} MB
║  WebP:     ${(totalWebP / 1024 / 1024).toFixed(2)} MB (${((1 - totalWebP/totalOriginal) * 100).toFixed(1)}% 节省)
║  AVIF:     ${(totalAVIF / 1024 / 1024).toFixed(2)} MB (${((1 - totalAVIF/totalOriginal) * 100).toFixed(1)}% 节省)
╚══════════════════════════════════════════╝`);
  }
}

// ===== 2. 运行时图片加载组件 =====
class TravelImageLoader {
  constructor(manifestUrl = '/images/image-manifest.json') {
    this.manifest = null;
    this.loadManifest(manifestUrl);
  }

  async loadManifest(url) {
    try {
      const res = await fetch(url);
      this.manifest = await res.json();
    } catch (e) {
      console.warn('无法加载图片清单,使用默认尺寸');
    }
  }

  // 根据当前视口选择最佳图片
  getBestImage(imageName, viewportWidth) {
    if (!this.manifest || !this.manifest[imageName]) return null;
    
    const sizes = Object.keys(this.manifest[imageName])
      .map(Number)
      .sort((a, b) => a - b);
    
    // 选择大于视口宽度的最小尺寸
    const bestWidth = sizes.find(w => w >= viewportWidth) || sizes[sizes.length - 1];
    
    return this.manifest[imageName][bestWidth];
  }

  generatePictureHTML(imageName, alt, className = '') {
    const vpWidth = window.innerWidth;
    const images = this.getBestImage(imageName, vpWidth);
    if (!images) return '';
    
    return `
      <picture class="${className}">
        <source type="image/avif" srcset="${images.avif}">
        <source type="image/webp" srcset="${images.webp}">
        <img src="${images.jpg}" 
             alt="${alt}"
             loading="lazy"
             decoding="async"
             onload="this.classList.add('loaded')">
      </picture>
    `;
  }
}

// ===== 3. 渐进式图片加载(Progressive Loading) =====
class ProgressiveImageManager {
  constructor(options = {}) {
    this.lqipQuality = options.lqipQuality || 10;
    this.loadTriggerMargin = options.loadTriggerMargin || '300px';
    this.init();
  }

  init() {
    // 低分辨率占位图 → 高分辨率渐进加载
    this.processProgressiveImages();
    
    // 基于网络条件的自适应加载
    this.setupAdaptiveLoading();
  }

  processProgressiveImages() {
    const images = document.querySelectorAll('[data-progressive]');
    
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            const img = entry.target;
            this.loadProgressive(img);
            observer.unobserve(img);
          }
        });
      },
      { rootMargin: this.loadTriggerMargin }
    );
    
    images.forEach(img => observer.observe(img));
  }

  async loadProgressive(img) {
    const highResSrc = img.getAttribute('data-src');
    
    // 第一步:显示低分辨率占位(已经加载好的小型缩略图)
    // 使用模糊背景过渡
    
    // 第二步:加载高分辨率图片
    const highResImg = new Image();
    
    highResImg.onload = () => {
      // 将高分辨率图片作为背景,淡入过渡
      img.style.backgroundImage = `url(${highResSrc})`;
      img.style.transition = 'opacity 0.5s';
      img.style.opacity = '1';
      
      // 移除低分辨率占位
      const lqip = img.querySelector('.lqip');
      if (lqip) {
        lqip.style.opacity = '0';
        setTimeout(() => lqip.remove(), 500);
      }
    };
    
    highResImg.src = highResSrc;
  }

  setupAdaptiveLoading() {
    // 根据网络条件选择加载策略
    const connection = navigator.connection;
    if (!connection) return;
    
    // 监听网络变化
    connection.addEventListener('change', () => {
      const strategy = this.getNetworkStrategy(connection.effectiveType);
      this.applyStrategy(strategy);
    });
    
    // 初始策略
    const initialStrategy = this.getNetworkStrategy(connection.effectiveType);
    this.applyStrategy(initialStrategy);
  }

  getNetworkStrategy(effectiveType) {
    const strategies = {
      'slow-2g': { quality: 'low', maxWidth: 480, lazyMargin: '1000px', images: 'none' },
      '2g':      { quality: 'low', maxWidth: 480, lazyMargin: '800px', images: 'low' },
      '3g':      { quality: 'medium', maxWidth: 768, lazyMargin: '500px', images: 'medium' },
      '4g':      { quality: 'high', maxWidth: 1440, lazyMargin: '200px', images: 'all' },
      '5g':      { quality: 'high', maxWidth: 2000, lazyMargin: '100px', images: 'all' },
      'default': { quality: 'high', maxWidth: 1440, lazyMargin: '200px', images: 'all' }
    };
    
    return strategies[effectiveType] || strategies.default;
  }

  applyStrategy(strategy) {
    document.documentElement.setAttribute('data-image-quality', strategy.quality);
    document.documentElement.setAttribute('data-image-max-width', strategy.maxWidth);
    document.documentElement.style.setProperty('--lazy-margin', strategy.lazyMargin);
    
    // 根据策略隐藏/显示图片
    if (strategy.images === 'none') {
      document.querySelectorAll('[data-src]').forEach(img => {
        img.style.display = 'none';
      });
    } else if (strategy.images === 'low') {
      // 只显示关键图片
      document.querySelectorAll('[data-src]:not([data-critical])').forEach(img => {
        img.style.display = 'none';
      });
    }
  }
}

底层原理:浏览器图片解码与渲染管线

1. 浏览器图片解码流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
网络加载完成
    ↓
HTTP响应解析:检查Content-Type
    ↓
字节流解码:
    └─ JPEG → MozJPEG解码器(多线程)
    └─ PNG → libpng + 解压流
    └─ WebP → libwebp解码器
    └─ AVIF → dav1d(AV1解码器,多线程)
    ↓
像素缓冲区(ImageBuffer):
    └─ RGBA格式的像素矩阵
    ↓
上传到GPU:
    └─ 创建纹理对象(Texture)
    └─ 如果图片有透明通道 → RGBA纹理
    └─ 如果图片无透明度 → RGB纹理(节省GPU内存)
    ↓
合成:
    └─ GPU将纹理合成到页面中
    ↓
显示

解码性能对比

1
2
3
4
5
6
7
8
9
10
11
12
解码速度(相对比较):
JPEG:    100ms (基线,最快的)
WebP:    120ms (略慢于JPEG)
PNG:     250ms (解压缩开销大)
AVIF:    300ms (AV1解码器较重,但逐年优化)
JPEG XL: 150ms (设计为快速解码)

解码内存占用:
JPEG:   1x (基线)
WebP:   1.2x
PNG:    1.5x (需要解压缩缓冲区)
AVIF:   1.8x

2. loading=”lazy”的浏览器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Chromium中loading=lazy的实现流程:

1. 解析阶段
   └─ HTML解析器遇到 <img loading="lazy">
   └─ 标记该图片为"延迟加载"
   └─ 记录图片尺寸(使用width/height属性设置布局)

2. 布局阶段
   └─ 确定图片位置(相对于视口)
   └─ 由于width/height已指定,不会造成CLS

3. 滚动/加载决策
   └─ 监听滚动、resize、orientationchange事件
   └─ 使用IntersectionObserver2(如果可用)
   └─ 检查图片是否在"加载距离"内
   └─ 加载距离 = f(network_type, device_memory, data_saver)

4. 加载触发
   └─ 满足条件 → 设置<img>的src属性
   └─ 浏览器开始下载、解码、渲染
   └─ 不满足条件 → 延迟检查(300ms节流)

3. 响应式图片的sizes计算

1
2
3
4
5
6
7
8
<!-- sizes属性如何工作 -->
<img 
  srcset="img-480.jpg 480w, img-768.jpg 768w, img-1200.jpg 1200w"
  sizes="(max-width: 480px) 100vw,
         (max-width: 768px) 90vw,  
         (max-width: 1200px) 80vw,
         1200px"
  src="img-fallback.jpg">

浏览器决策过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
假设当前视口宽度为1000px:

1. 计算sizes中匹配的media条件
   (max-width: 480px) → false
   (max-width: 768px) → false
   (max-width: 1200px) → true
   匹配宽度:80vw = 800px

2. 从srcset中选择
   480w → 太小(480 < 800×2=1600 device pixel? 不,用像素比)
   假设2x DPR:
   所需宽度:800 × 2 = 1600px
   srcset中最接近且≥1600px的:1200w → 不够
   取最大:1200w → 浏览器可能会选择这个

   假设1x DPR:
   所需宽度:800px
   srcset中最接近且≥800px的:768w → 不够
   选1200w → 这个

4. 图片加载对LCP的影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
LCP计算中的图片优先级:
1. 视口内的 <img> 元素 → 作为LCP候选
2. 带有 fetchpriority="high" 的图片 → 优先级最高
3. 经过 <link rel="preload"> 预加载的 → 早期开始下载
4. 图片的响应式版本 → 影响下载时间和解码时间

LCP时间 = 
  图片请求开始时间 - HTML请求开始时间
  + 网络下载时间
  + 解码时间
  + 渲染时间

优化方向:
  压缩文件体积 → 减少 网络下载时间
  预加载       → 提前 图片请求开始时间
  响应式图片   → 减少不必要的解码开销

高频面试题解析

面试题1:WebP和AVIF的核心压缩原理有什么区别?在什么场景下AVIF反而会”更慢”?

答案

WebP的压缩原理

  • 基于VP8视频编解码器的帧内压缩
  • 有损模式:使用预测编码 + DCT变换(类似JPEG但更高效)
  • 无损模式:使用不同的压缩算法(类似PNG但更高效)
  • 支持动图

AVIF的压缩原理

  • 基于AV1视频编解码器的帧内压缩
  • 使用更先进的编码工具:
    • 更大的变换块尺寸(128×128 vs WebP的16×16)
    • 多参考帧预测
    • 自适应颜色变换(YCgCo)
    • 内容自适应的量化矩阵
  • 这些高级工具使AVIF在相同文件大小下能保持更高的质量

AVIF”更慢”的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. 解码性能
   AVIF解码需要AV1解码器(dav1d或libaom)
   在低端设备上,AVIF解码时间可能是WebP的2-3倍
   特别是对于大尺寸图片(如4000×3000px的全屏图)
   
2. 编码速度
   AVIF编码是WebP的5-10倍(使用libaom编码器)
   这对于构建时处理影响不大,但对于动态图片生成影响显著
   
3. 渐进渲染
   WebP支持渐进式加载
   AVIF在2025年底才添加了渐进式支持,生态还不成熟
   
4. 浏览器解码
   Chrome的AVIF解码器dav1d支持多线程
   Firefox的AVIF解码器也基于dav1d
   但Safari的AVIF解码性能较差(使用软件解码)

选择策略

1
2
3
4
照片 → AVIF(大小优先)或 WebP(解码速度优先)
缩略图 → WebP(解码快,质量要求不高)
低端设备 → WebP(优先保障渲染效率)
批量处理 → WebP(编码速度快10倍)

面试题2:如何在不使用JavaScript的情况下实现图片的懒加载和高低质量渐进式加载?

答案

纯HTML/CSS方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!-- 方案1:利用loading="lazy" + 背景颜色占位 -->
<img 
  src="product-high.jpg" 
  loading="lazy" 
  style="
    background: #f0f0f0;           /* 占位背景色 */
    background-image: url(data:image/jpeg;base64,...); /* LQIP base64 */
    background-size: cover;
  "
  width="400"
  height="300"
  alt="商品">

<style>
  /* 当图片加载完成后,占位背景消失 */
  img[loading="lazy"] {
    transition: opacity 0.3s;
    opacity: 1;
  }
  
  /* 利用img加载完成的样式变化 */
  img[loading="lazy"]:not([src=""]) {
    opacity: 1;
  }
</style>

方案2:使用SVG占位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 使用内联SVG作为占位 -->
<img 
  src="product.jpg" 
  loading="lazy"
  style="
    /* Base64编码的微型图片作为背景 */
    background: #f0f0f0;
    background-size: cover;
    /* 或者使用SVG占位 */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Crect fill='%23f0f0f0' width='400' height='300'/%3E%3Ctext fill='%23999' font-size='14' x='50%25' y='50%25' text-anchor='middle'%3E加载中...%3C/text%3E%3C/svg%3E");
  "
  width="400"
  height="300"
  alt="商品">

实际效果

1
2
3
4
1. 页面加载时:显示占位颜色 + LQIP(base64编码的小图被模糊/缩放展示)
2. 图片进入视口:浏览器触发loading="lazy"
3. 图片加载完成:覆盖占位背景,显示实际图片
4. 整个过程:无需JavaScript,零CLS(因为有width/height)

答案

1
2
3
4
5
<!-- fetchpriority="high":告诉浏览器这张图很重要 -->
<img src="hero.webp" fetchpriority="high" alt="主视觉">

<!-- <link rel="preload">:强制浏览器提前加载 -->
<link rel="preload" href="hero.webp" as="image">

区别

维度fetchpriority=”high”preload
作用机制提示(hint)→ 浏览器可选采纳指令(instruction)→ 浏览器必须执行
加载时机在HTML解析到该元素时在HTML解析到该时(更早)
资源类型仅限