图片优化
图片优化系统提供全面的图片处理功能,通过自动格式转换、响应式图片生成、懒加载和智能优化工作流程来提升网站性能。
概览
系统处理图片优化的多个方面:
- 格式转换: 自动生成 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 ``;
}
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 问题。