图片渐进式加载优化实践指南

前言

Hey, 我是 Immerse

文章首发于个人博客【https://yaolifeng.com】，更多内容请关注个人博客

转载说明：转载请在文章头部注明原文出处及版权声明！

起因

最近上线了个人博客，片段页面存在大量图片，在图片加载方面体验很差，可以说是断崖式，从 0-1 完全没有任何过渡（这很影响页面布局和用户体验，对于设定了图片宽高的图片还好，如果没设置，就会有一个图片撑高的过程）

巧合

在准备写这篇文章当天前端南玖大佬发表了一篇文章，我直呼大数据牛逼 👍🏻文章：点击查看

这篇文章我们将讨论其他几种方案，闲话少说，言归正传。

对于常规的图片优化这里不在赘述，大致如下：

压缩图片、使用 CSS sprites、懒加载、预加载、CDN 缓存、合适的图片格式、七牛 CDN 图片参数等等

探索

以下是这篇文章提到的几种方案（因为个人项目基于 Next，所以有些示例代码是 React）

（1）使用图片主色调

（2）使用某个颜色

（3）使用图片的缩略图

（4）使用模糊 + 压缩图片

（5）图片占位符

方案 1：使用图片主色调

在日常开发中，我们的图片 src 可能是动态的，也就是一个字符串 string url, 当我们指定了 placeholder="blur" 时，还必须添加 blurDataURL 属性，

Copy
import Image from 'next/image';

// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
const keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';

const triplet = (e1: number, e2: number, e3: number) =>
keyStr.charAt(e1 >> 2) +
keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
keyStr.charAt(e3 & 63);

const rgbDataURL = (r: number, g: number, b: number) =>
`data:image/gif;base64,R0lGODlhAQABAPAA${
triplet(0, r, g) + triplet(b, 255, 255)
}/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`;

const Color = () => (

Image Component With Color Data URL
<Image
alt="Dog"
src="/dog.jpg"
placeholder="blur"
blurDataURL={rgbDataURL(237, 181, 6)}
width={750}
height={1000}
style={{
maxWidth: '100%',
height: 'auto'
}}
/>
<Image
alt="Cat"
src="/cat.jpg"
placeholder="blur"
blurDataURL={rgbDataURL(2, 129, 210)}
width={750}
height={1000}
style={{
maxWidth: '100%',
height: 'auto'
}}
/>

);

export default Color;

方案 2：使用某个颜色

在 next.config.js 中配置 placeholder 为 color，然后使用 backgroundColor 属性

Copy
// next.config.js
module.exports = {
images: {
placeholder: 'color',
backgroundColor: '#121212'
}
};

Copy
// 使用
<Image src="/path/to/image.jpg" alt="image title" width={500} height={500} placeholder="color" />

方案 3: 使用图片的缩略图

Copy





渐进式图片加载

.placeholder {
background-color: #f6f6f6;
background-size: cover;
background-repeat: no-repeat;
position: relative;
overflow: hidden;
}

.placeholder img {
position: absolute;
opacity: 0;
top: 0;
left: 0;
width: 100%;
transition: opacity 1s linear;
}

.placeholder img.loaded {
opacity: 1;
}

.img-small {
filter: blur(50px);
transform: scale(1);
}



<div
class="placeholder"
data-large="https://qncdn.mopic.mozigu.net/work/143/24/42b204ae3ade4f38/1_sg-uLNm73whmdOgKlrQdZA.jpg"
>
<img
src="https://qncdn.mopic.mozigu.net/work/143/24/5307e9778a944f93/1_sg-uLNm73whmdOgKlrQdZA.jpg"
class="img-small"
/>





window.onload = function () {
var placeholder = document.querySelector('.placeholder'),
small = placeholder.querySelector('.img-small');

// 1. 显示小图并加载
var img = new Image();
img.src = small.src;
img.onload = function () {
small.classList.add('loaded');
};

// 2. 加载大图
var imgLarge = new Image();
imgLarge.src = placeholder.dataset.large;
imgLarge.onload = function () {
imgLarge.classList.add('loaded');
};
placeholder.appendChild(imgLarge);
};


方案 4：使用模糊 + 压缩图片

Copy
// progressive-image.tsx
'use client';

import React, { useState, useEffect } from 'react';
import imageCompression from 'browser-image-compression';

interface ProgressiveImageProps {
src: string;
alt?: string;
width?: number;
height?: number;
layout?: 'fixed' | 'responsive' | 'fill' | 'intrinsic';
className?: string;
style?: React.CSSProperties;
}

export const ProgressiveImage: React.FC<ProgressiveImageProps> = ({
src,
alt = '',
width,
height,
layout = 'responsive',
className = '',
style = {}
}) => {
const [currentSrc, setCurrentSrc] = useState<string>(src);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [blurLevel, setBlurLevel] = useState<number>(20);

useEffect(() => {
let isMounted = true;

const loadImage = async () => {
try {
// 加载并压缩原始图片
const response = await fetch(src);
const blob = await response.blob();

// 生成低质量预览图
const tinyOptions = {
maxSizeMB: 0.0002,
maxWidthOrHeight: 16,
useWebWorker: true,
initialQuality: 0.1
};

const tinyBlob = await imageCompression(blob, tinyOptions);
if (isMounted) {
const tinyUrl = URL.createObjectURL(tinyBlob);
setCurrentSrc(tinyUrl);
// 开始逐渐减小模糊度
startSmoothTransition();
}

// 加载原始图片
const highQualityImage = new Image();
highQualityImage.src = src;
highQualityImage.onload = () => {
if (isMounted) {
setCurrentSrc(src);
// 当高质量图片加载完成时，继续平滑过渡
setTimeout(() => {
setIsLoading(false);
}, 100);
}
};
} catch (error) {
console.error('Error loading image:', error);
if (isMounted) {
setCurrentSrc(src);
setIsLoading(false);
}
}
};

const startSmoothTransition = () => {
// 从20px的模糊逐渐过渡到10px
const startBlur = 20;
const endBlur = 10;
const duration = 1000; // 1秒
const steps = 20;
const stepDuration = duration / steps;
const blurStep = (startBlur - endBlur) / steps;

let currentStep = 0;

const interval = setInterval(() => {
if (currentStep < steps && isMounted) {
setBlurLevel(startBlur - blurStep * currentStep);
currentStep++;
} else {
clearInterval(interval);
}
}, stepDuration);
};

setIsLoading(true);
setBlurLevel(20);
loadImage();

return () => {
isMounted = false;
if (currentSrc && currentSrc.startsWith('blob:')) {
URL.revokeObjectURL(currentSrc);
}
};
}, [src]);

const getContainerStyle = (): React.CSSProperties => {
const baseStyle: React.CSSProperties = {
position: 'relative',
overflow: 'hidden'
};

switch (layout) {
case 'responsive':
return {
...baseStyle,
maxWidth: width || '100%',
width: '100%'
};
case 'fixed':
return {
...baseStyle,
width: width,
height: height
};
case 'fill':
return {
...baseStyle,
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
};
case 'intrinsic':
return {
...baseStyle,
maxWidth: width,
width: '100%'
};
default:
return baseStyle;
}
};

const getImageStyle = (): React.CSSProperties => {
const baseStyle: React.CSSProperties = {
filter: isLoading ? `blur(${blurLevel}px)` : 'none',
transition: 'filter 0.8s ease-in-out', // 增加过渡时间
transform: 'scale(1.1)', // 稍微放大防止模糊时出现边缘
...style
};

switch (layout) {
case 'responsive':
return {
...baseStyle,
width: '100%',
height: 'auto',
display: 'block'
};
case 'fixed':
return {
...baseStyle,
width: width,
height: height
};
case 'fill':
return {
...baseStyle,
width: '100%',
height: '100%',
objectFit: 'cover'
};
case 'intrinsic':
return {
...baseStyle,
width: '100%',
height: 'auto'
};
default:
return baseStyle;
}
};

return (

{currentSrc && }

);
};

Copy
// 使用
<ProgressiveImage
src={photo}
alt={short.title}
width={300}
height={250}
layout="responsive"
className="h-full min-h-[150px]"
/>

方案 5：图片占位符

Next.js 的 next/image 组件 placeholder 属性提供了个选项 blur，默认为 empty

blur 会生成一个模糊的预览图像 (但这个选项会增加初始加载实践，因为需要时间去生成模糊图片)

注意：如果 placeholder="blur" 时，必须使用 import 静态引入图片的方式，这样 Next.js 才会对图片进行渐进式加载的预处理

Copy
import Image from 'next/image';
import mountains from '/public/mountains.jpg';

const PlaceholderBlur = () => (

Image Component With Placeholder Blur
<Image
alt="Mountains"
src={mountains}
placeholder="blur"
width={700}
height={475}
style={{
maxWidth: '100%',
height: 'auto'
}}
/>

);

export default PlaceholderBlur;

总结

产品第一印象很重要，良好的用户体验对于产品来说是必需的。

感谢阅读，我们下次再见！