Skip to content

图片优化

图片优化系统提供全面的图片处理功能,通过自动格式转换、响应式图片生成、懒加载和智能优化工作流程来提升网站性能。

概览

系统处理图片优化的多个方面:

  • 格式转换: 自动生成 WebP 和 AVIF 格式
  • 响应式图片: 为不同设备生成多种尺寸
  • 懒加载: 性能优化的图片加载
  • 尺寸检测: 自动添加宽度/高度属性
  • CDN 集成: 无缝上传到 Cloudflare R2
  • 构建集成: 在网站构建过程中自动处理
graph TD
    A[源图片] --> B[图片处理管道]
    B --> C[格式转换]
    B --> D[响应式生成]
    B --> E[优化]

    C --> F[WebP]
    C --> G[AVIF]
    C --> H[JPEG 备用]

    D --> I[多种尺寸]
    I --> J[400w, 800w, 1200w, 1600w]

    E --> K[压缩]
    E --> L[元数据剥离]

    F --> M[CDN 上传]
    G --> M
    H --> M
    J --> M
    K --> M
    L --> M

    M --> N[优化交付]

核心功能

1. 自动格式转换

WebP 转换:

// 自动 WebP 生成
const generateWebP = async (imagePath) => {
  const image = sharp(imagePath);
  const metadata = await image.metadata();

  const webpBuffer = await image
    .webp({
      quality: 85,
      effort: 6,
      smartSubsample: true
    })
    .toBuffer();

  const outputPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '.webp');
  fs.writeFileSync(outputPath, webpBuffer);

  return {
    original: imagePath,
    webp: outputPath,
    savings: ((metadata.size - webpBuffer.length) / metadata.size) * 100
  };
};

注: 关于 upload-images 的默认检测策略(基于 Git 更改)与如何强制指定目录,请参见 docs/workflows/upload-images-git-default.md(推荐阅读)。

AVIF 支持:

// 下一代 AVIF 格式
const generateAVIF = async (imagePath) => {
  const image = sharp(imagePath);

  const avifBuffer = await image
    .avif({
      quality: 80,
      effort: 9,
      chromaSubsampling: '4:2:0'
    })
    .toBuffer();

  const outputPath = imagePath.replace(/\.(jpg|jpeg|png|webp)$/i, '.avif');
  fs.writeFileSync(outputPath, avifBuffer);

  return outputPath;
};

2. 响应式图片生成

多种尺寸生成:

const generateResponsiveImages = async (imagePath) => {
  const image = sharp(imagePath);
  const metadata = await image.metadata();
  const sizes = [400, 800, 1200, 1600];
  const formats = ['webp', 'avif', 'jpeg'];

  const variants = [];

  for (const format of formats) {
    for (const size of sizes) {
      if (size <= metadata.width) {
        const resized = await image
          .resize(size, null, {
            withoutEnlargement: true,
            fastShrinkOnLoad: false
          })
          .toFormat(format, getFormatOptions(format))
          .toBuffer();

        const filename = `${path.basename(imagePath, path.extname(imagePath))}-${size}w.${format}`;

        variants.push({
          format,
          size,
          buffer: resized,
          filename,
          url: await uploadToCDN(resized, filename)
        });
      }
    }
  }

  return variants;
};

const getFormatOptions = (format) => {
  const options = {
    webp: { quality: 85, effort: 6 },
    avif: { quality: 80, effort: 9 },
    jpeg: { quality: 85, progressive: true, mozjpeg: true }
  };

  return options[format] || {};
};

3. 智能尺寸检测

自动尺寸添加:

// scripts/add-image-dimensions.js
const addImageDimensions = async () => {
  const files = await glob('content/**/*.md');
  let processedFiles = 0;
  let addedDimensions = 0;

  for (const file of files) {
    const content = fs.readFileSync(file, 'utf8');
    const images = extractImages(content);
    let updatedContent = content;
    let fileChanged = false;

    for (const image of images) {
      if (!image.hasDimensions) {
        try {
          const dimensions = await getImageDimensions(image.src);
          const dimensionString = `{width=${dimensions.width} height=${dimensions.height}}`;

          // Add dimensions after the image
          updatedContent = updatedContent.replace(
            image.original,
            `${image.original}\n${dimensionString}`
          );

          fileChanged = true;
          addedDimensions++;
        } catch (error) {
          console.warn(`Could not get dimensions for ${image.src}: ${error.message}`);
        }
      }
    }

    if (fileChanged) {
      fs.writeFileSync(file, updatedContent);
      processedFiles++;
    }
  }

  console.log(`✅ Processed ${processedFiles} files, added dimensions to ${addedDimensions} images`);
};

const getImageDimensions = async (imagePath) => {
  if (imagePath.startsWith('http')) {
    // 远程图片
    const response = await fetch(imagePath, {
      headers: { 'User-Agent': 'Mozilla/5.0 Image Optimizer' }
    });
    const buffer = await response.arrayBuffer();
    return sizeOf(Buffer.from(buffer));
  } else {
    // 本地图片
    const fullPath = path.join('static', imagePath);
    if (fs.existsSync(fullPath)) {
      return sizeOf(fullPath);
    } else {
      // 尝试 assets 目录
      const assetsPath = path.join('assets', imagePath);
      if (fs.existsSync(assetsPath)) {
        return sizeOf(assetsPath);
      }
    }
    throw new Error(`Image not found: ${imagePath}`);
  }
};

4. 智能图片处理

智能优化:

const optimizeImage = async (imagePath, options = {}) => {
  const image = sharp(imagePath);
  const metadata = await image.metadata();

  // 根据图片特征确定最佳设置
  const settings = getOptimalSettings(metadata, options);

  let pipeline = image;

  // 根据 EXIF 数据自动旋转
  pipeline = pipeline.rotate();

  // 如果图片太大,则调整大小
  if (metadata.width > settings.maxWidth) {
    pipeline = pipeline.resize(settings.maxWidth, null, {
      withoutEnlargement: true,
      fastShrinkOnLoad: false
    });
  }

  // 应用格式特定的优化
  switch (settings.format) {
    case 'webp':
      pipeline = pipeline.webp({
        quality: settings.quality,
        effort: 6,
        smartSubsample: true
      });
      break;

    case 'avif':
      pipeline = pipeline.avif({
        quality: settings.quality - 5, // AVIF 可以使用较低质量
        effort: 9,
        chromaSubsampling: '4:2:0'
      });
      break;

    case 'jpeg':
      pipeline = pipeline.jpeg({
        quality: settings.quality,
        progressive: true,
        mozjpeg: true
      });
      break;
  }

  return await pipeline.toBuffer();
};

const getOptimalSettings = (metadata, options) => {
  const defaults = {
    maxWidth: 1600,
    quality: 85,
    format: 'webp'
  };

  // 根据图片类型调整质量
  if (metadata.channels === 1) {
    // 灰度图片可以使用更高压缩率
    defaults.quality = 90;
  } else if (metadata.density && metadata.density > 150) {
    // 高 DPI 图片可以使用较低质量
    defaults.quality = 80;
  }

  // 根据宽高比调整最大宽度
  const aspectRatio = metadata.width / metadata.height;
  if (aspectRatio > 2) {
    // 宽图 (横幅、横幅等)
    defaults.maxWidth = 1200;
  } else if (aspectRatio < 0.5) {
    // 高图 (信息图、图表等)
    defaults.maxWidth = 800;
  }

  return { ...defaults, ...options };
};

Hugo 集成

1. 图片渲染钩子

自定义图片渲染:

<!-- layouts/_default/_markup/render-image.html -->
{{ $src := .Destination | safeURL }}
{{ $alt := .Text }}
{{ $title := .Title }}

{{/* 从下一行解析图片属性 */}}
{{ $width := "" }}
{{ $height := "" }}
{{ $class := "" }}
{{ $loading := "lazy" }}

{{/* 检查维度属性 */}}
{{ if .Page.RawContent }}
  {{ $pattern := printf `!\[.*?\]\(%s\)\s*\n\{[^}]*width=(\d+)[^}]*height=(\d+)[^}]*\}` (regexp.QuoteMeta $src) }}
  {{ $matches := findRE $pattern .Page.RawContent }}
  {{ if $matches }}
    {{ $match := index $matches 0 }}
    {{ $dimensions := findRE `width=(\d+)[^}]*height=(\d+)` $match }}
    {{ if $dimensions }}
      {{ $width = replaceRE `.*width=(\d+).*` "$1" (index $dimensions 0) }}
      {{ $height = replaceRE `.*height=(\d+).*` "$1" (index $dimensions 0) }}
    {{ end }}
  {{ end }}
{{ end }}

{{/* 生成响应式图片 */}}
{{ if and $width $height }}
  <picture class="responsive-image {{ $class }}">
    {{/* AVIF 源 */}}
    {{ $avifSrc := replace $src ".webp" ".avif" }}
    {{ $avifSrc = replace $avifSrc ".jpg" ".avif" }}
    {{ $avifSrc = replace $avifSrc ".jpeg" ".avif" }}
    {{ $avifSrc = replace $avifSrc ".png" ".avif" }}
    <source srcset="{{ $avifSrc }}" type="image/avif">

    {{/* WebP 源 */}}
    {{ $webpSrc := replace $src ".jpg" ".webp" }}
    {{ $webpSrc = replace $webpSrc ".jpeg" ".webp" }}
    {{ $webpSrc = replace $webpSrc ".png" ".webp" }}
    <source srcset="{{ $webpSrc }}" type="image/webp">

    {{/* 备用图片 */}}
    <img src="{{ $src }}"
         alt="{{ $alt }}"
         {{ with $title }}title="{{ . }}"{{ end }}
         width="{{ $width }}"
         height="{{ $height }}"
         loading="{{ $loading }}"
         decoding="async"
         {{ with $class }}class="{{ . }}"{{ end }}>
  </picture>
{{ else }}
  {{/* 无维度图片的简单图片 */}}
  <img src="{{ $src }}"
       alt="{{ $alt }}"
       {{ with $title }}title="{{ . }}"{{ end }}
       loading="{{ $loading }}"
       decoding="async"
       {{ with $class }}class="{{ . }}"{{ end }}>
{{ end }}

2. Hugo Pipes 集成

资源处理:

<!-- layouts/partials/head.html -->
{{ range .Page.Resources.ByType "image" }}
  {{ $image := . }}
  {{ $webp := $image.Resize "800x webp q85" }}
  {{ $avif := $image.Resize "800x avif q80" }}

  <link rel="preload" as="image" href="{{ $webp.RelPermalink }}" type="image/webp">
{{ end }}

3. 短代码集成

图例短代码与优化:

<!-- layouts/shortcodes/figure.html -->
{{ $src := .Get "src" }}
{{ $alt := .Get "alt" | default (.Get "caption") }}
{{ $caption := .Get "caption" }}
{{ $class := .Get "class" }}
{{ $width := .Get "width" }}
{{ $height := .Get "height" }}

{{ if $src }}
  <figure class="figure {{ $class }}">
    {{ if and $width $height }}
      <picture class="figure-image">
        {{/* 生成多种格式 */}}
        {{ $avifSrc := replace $src ".webp" ".avif" | replace ".jpg" ".avif" | replace ".jpeg" ".avif" | replace ".png" ".avif" }}
        {{ $webpSrc := replace $src ".jpg" ".webp" | replace ".jpeg" ".webp" | replace ".png" ".webp" }}

        <source srcset="{{ $avifSrc }}" type="image/avif">
        <source srcset="{{ $webpSrc }}" type="image/webp">
        <img src="{{ $src }}"
             alt="{{ $alt }}"
             width="{{ $width }}"
             height="{{ $height }}"
             loading="lazy"
             decoding="async">
      </picture>
    {{ else }}
      <img src="{{ $src }}"
           alt="{{ $alt }}"
           loading="lazy"
           decoding="async"
           class="figure-image">
    {{ end }}

    {{ with $caption }}
      <figcaption class="figure-caption">{{ . | markdownify }}</figcaption>
    {{ end }}
  </figure>
{{ end }}

CDN 集成

1. Cloudflare R2 上传

自动上传流程:

// scripts/upload-images.js
const uploadToR2 = async () => {
  const recentImages = await findRecentImages();
  console.log(`Found ${recentImages.length} images to process`);

  for (const imagePath of recentImages) {
    try {
      // 生成优化变体
      const variants = await generateResponsiveImages(imagePath);

      // 上传每个变体
      for (const variant of variants) {
        const uploadResult = await uploadVariant(variant);
        console.log(`✅ Uploaded: ${variant.filename}${uploadResult.url}`);
      }

      // 更新内容引用
      await updateImageReferences(imagePath, variants);

    } catch (error) {
      console.error(`❌ Failed to process ${imagePath}:`, error.message);
    }
  }
};

const uploadVariant = async (variant) => {
  const command = [
    'rclone',
    'copy',
    '-',
    `r2:jimmysong-assets/images/${variant.filename}`,
    '--stdin-filename', variant.filename
  ];

  const process = spawn(command[0], command.slice(1), {
    stdio: ['pipe', 'pipe', 'pipe']
  });

  process.stdin.write(variant.buffer);
  process.stdin.end();

  return new Promise((resolve, reject) => {
    process.on('close', (code) => {
      if (code === 0) {
        resolve({
          url: `https://assets.jimmysong.io/images/${variant.filename}`,
          size: variant.buffer.length
        });
      } else {
        reject(new Error(`Upload failed with code ${code}`));
      }
    });
  });
};

2. 内容引用更新

自动 URL 更新:

const updateImageReferences = async (originalPath, variants) => {
  const contentFiles = await glob('content/**/*.md');
  const originalFilename = path.basename(originalPath);

  for (const contentFile of contentFiles) {
    let content = fs.readFileSync(contentFile, 'utf8');
    let updated = false;

    // 查找原始图片的引用
    const imageRegex = new RegExp(`!\\[([^\\]]*)\\]\\(([^)]*${originalFilename}[^)]*)\\)`, 'g');

    content = content.replace(imageRegex, (match, alt, src) => {
      // 替换为 CDN URL
      const webpVariant = variants.find(v => v.format === 'webp' && v.size === 800);
      if (webpVariant) {
        updated = true;
        return `![${alt}](${webpVariant.url})`;
      }
      return match;
    });

    if (updated) {
      fs.writeFileSync(contentFile, content);
      console.log(`📝 Updated references in ${contentFile}`);
    }
  }
};

性能特性

1. 懒加载实现

Intersection Observer:

// assets/js/enhanced-lazy-loading.js
class LazyImageLoader {
  constructor() {
    this.imageObserver = null;
    this.images = [];
    this.init();
  }

  init() {
    if ('IntersectionObserver' in window) {
      this.imageObserver = new IntersectionObserver(
        this.onIntersection.bind(this),
        {
          rootMargin: '50px 0px',
          threshold: 0.01
        }
      );

      this.observeImages();
    } else {
      // 旧版浏览器降级
      this.loadAllImages();
    }
  }

  observeImages() {
    const lazyImages = document.querySelectorAll('img[loading="lazy"]');

    lazyImages.forEach(img => {
      this.imageObserver.observe(img);
      this.images.push(img);
    });
  }

  onIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        this.loadImage(img);
        this.imageObserver.unobserve(img);
      }
    });
  }

  loadImage(img) {
    // 添加加载动画
    img.classList.add('loading');

    // 创建新图片预加载
    const imageLoader = new Image();

    imageLoader.onload = () => {
      img.classList.remove('loading');
      img.classList.add('loaded');
    };

    imageLoader.onerror = () => {
      img.classList.remove('loading');
      img.classList.add('error');
    };

    // 开始加载
    imageLoader.src = img.src;
  }

  loadAllImages() {
    // 降级:立即加载所有图片
    this.images.forEach(img => this.loadImage(img));
  }
}

// 初始化:DOM 加载完成
document.addEventListener('DOMContentLoaded', () => {
  new LazyImageLoader();
});

2. 渐进式增强

CSS 加载状态:

// assets/scss/components/_image-optimization.scss
.responsive-image {
  position: relative;
  display: block;
  overflow: hidden;

  img {
    width: 100%;
    height: auto;
    transition: opacity 0.3s ease;

    &[loading="lazy"] {
      opacity: 0;

      &.loading {
        opacity: 0.5;

        &::after {
          content: '';
          position: absolute;
          top: 50%;
          left: 50%;
          width: 20px;
          height: 20px;
          margin: -10px 0 0 -10px;
          border: 2px solid #f3f3f3;
          border-top: 2px solid #007bff;
          border-radius: 50%;
          animation: spin 1s linear infinite;
        }
      }

      &.loaded {
        opacity: 1;
      }

      &.error {
        opacity: 0.3;
        background: #f8f9fa;

        &::before {
          content: '⚠️ Image failed to load';
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          color: #6c757d;
          font-size: 0.875rem;
        }
      }
    }
  }
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

// 响应式图片行为
@media (max-width: 768px) {
  .responsive-image img {
    width: 100% !important;
    height: auto !important;
  }
}

自动化和流程

1. 构建集成

Package.json 脚本:

{
  "scripts": {
    "add-image-dimensions": "node scripts/add-image-dimensions.js",
    "check-image-dimensions": "node scripts/check-image-dimensions.js",
    "upload-images": "node scripts/upload-images.js",
    "optimize-images": "node scripts/optimize-images.js",
    "build": "npm run add-image-dimensions && hugo --environment production --minify && npm run compress-json"
  }
}

Makefile 集成:

## 优化和上传图片
upload-images:
 @echo "$(BLUE)Optimizing images and uploading to CDN...$(RESET)"
 @if ! command -v rclone >/dev/null 2>&1; then \
  echo "$(RED)Error: rclone not installed$(RESET)"; \
  exit 1; \
 fi
 @npm run upload-images
 @echo "$(GREEN)Image optimization complete$(RESET)"

## 为 markdown 文件添加图片维度
add-dimensions:
 @echo "$(BLUE)Adding image dimensions...$(RESET)"
 @npm run add-image-dimensions
 @echo "$(GREEN)Image dimensions added$(RESET)"

## 检查图片维度覆盖率
check-dimensions:
 @echo "$(BLUE)Checking image dimensions...$(RESET)"
 @npm run check-image-dimensions

2. CI/CD 集成

GitHub Actions 工作流:

# .github/workflows/optimize-images.yml
name: Optimize Images

on:
  push:
    paths:
      - 'static/images/**'
      - 'content/**/*.md'

jobs:
  optimize:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install rclone
        run: |
          curl https://rclone.org/install.sh | sudo bash
          echo "${{ secrets.RCLONE_CONFIG }}" > ~/.config/rclone/rclone.conf

      - name: Optimize and upload images
        run: npm run upload-images
        env:
          CLOUDFLARE_R2_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY }}
          CLOUDFLARE_R2_SECRET_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_KEY }}

      - name: Add image dimensions
        run: npm run add-image-dimensions

      - name: Commit changes
        run: |
          git config --local user.email "[email protected]"
          git config --local user.name "GitHub Action"
          git add -A
          git diff --staged --quiet || git commit -m "Auto-optimize images and add dimensions"
          git push

监控和分析

1. 性能指标

图片性能追踪:

const trackImagePerformance = () => {
  const images = document.querySelectorAll('img');
  const metrics = {
    totalImages: images.length,
    lazyLoaded: 0,
    loadTimes: [],
    formats: { webp: 0, avif: 0, jpeg: 0, png: 0 }
  };

  images.forEach(img => {
    // 跟踪 loading 属性
    if (img.getAttribute('loading') === 'lazy') {
      metrics.lazyLoaded++;
    }

    // 跟踪格式
    const src = img.src || img.currentSrc;
    if (src.includes('.webp')) metrics.formats.webp++;
    else if (src.includes('.avif')) metrics.formats.avif++;
    else if (src.includes('.jpg') || src.includes('.jpeg')) metrics.formats.jpeg++;
    else if (src.includes('.png')) metrics.formats.png++;

    // 跟踪加载时间
    const startTime = performance.now();
    img.addEventListener('load', () => {
      const loadTime = performance.now() - startTime;
      metrics.loadTimes.push(loadTime);
    });
  });

  // 发送指标到分析
  if (typeof gtag !== 'undefined') {
    gtag('event', 'image_performance', {
      custom_map: {
        total_images: metrics.totalImages,
        lazy_loaded_percentage: (metrics.lazyLoaded / metrics.totalImages) * 100,
        webp_usage: (metrics.formats.webp / metrics.totalImages) * 100
      }
    });
  }

  return metrics;
};

// 页面加载时追踪
window.addEventListener('load', trackImagePerformance);

2. 优化报告

构建时报告:

const generateOptimizationReport = (results) => {
  const report = {
    timestamp: new Date().toISOString(),
    summary: {
      totalImages: results.length,
      totalSavings: 0,
      averageSavings: 0,
      formatDistribution: {}
    },
    details: results
  };

  // 计算节省
  results.forEach(result => {
    if (result.savings) {
      report.summary.totalSavings += result.savings.bytes;
    }

    // 跟踪格式分布
    const format = result.format || 'unknown';
    report.summary.formatDistribution[format] =
      (report.summary.formatDistribution[format] || 0) + 1;
  });

  report.summary.averageSavings =
    report.summary.totalSavings / report.summary.totalImages;

  // 保存报告
  fs.writeFileSync(
    `reports/image-optimization-${Date.now()}.json`,
    JSON.stringify(report, null, 2)
  );

  console.log(`📊 Optimization Report:`);
  console.log(`   Total Images: ${report.summary.totalImages}`);
  console.log(`   Total Savings: ${formatBytes(report.summary.totalSavings)}`);
  console.log(`   Average Savings: ${report.summary.averageSavings.toFixed(1)}%`);

  return report;
};

故障排除

常见问题

1. 图片未优化

问题: 图片未被处理或转换

解决方案:

# 检查 Sharp 安装
npm list sharp

# 如果需要,重新安装 Sharp
npm uninstall sharp
npm install sharp

# 检查图片路径
ls -la static/images/
ls -la assets/images/

2. CDN 上传失败

问题: 图片无法上传到 Cloudflare R2

解决方案:

# 检查 rclone 配置
rclone config show

# 测试 rclone 连接
rclone lsd r2:

# 检查凭证
echo $CLOUDFLARE_R2_ACCESS_KEY
echo $CLOUDFLARE_R2_SECRET_KEY

3. 尺寸检测问题

问题: 脚本无法检测图片尺寸

解决方案:

# 检查图片文件存在
find static/ -name "*.jpg" -o -name "*.png" -o -name "*.webp"

# 手动测试维度检测
node -e "const sizeOf = require('image-size'); console.log(sizeOf('path/to/image.jpg'));"

# 检查网络问题 (远程图片)
curl -I https://example.com/image.jpg

下一步

设置图片优化后:

相关主题


需要图片优化帮助?请查看我们的 故障排除指南 或创建 GitHub 问题。