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

前言

  • 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 属性,
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 = () => (
    <div>
        <h1>Image Component With Color Data URL</h1>
        <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'
            }}
        />
    </div>
);

export default Color;

方案 2:使用某个颜色

  • next.config.js 中配置 placeholdercolor,然后使用 backgroundColor 属性
// next.config.js
module.exports = {
    images: {
        placeholder: 'color',
        backgroundColor: '#121212'
    }
};
// 使用
<Image src="/path/to/image.jpg" alt="image title" width={500} height={500} placeholder="color" />

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

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>渐进式图片加载</title>
        <style>
            .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);
            }
        </style>
    </head>
    <body>
        <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"
            />
            <div style="padding-bottom: 66.6%"></div>
        </div>
    </body>
</html>
<script>
    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);
    };
</script>

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

// 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 (
        <div className={`${className}`} style={getContainerStyle()}>
            {currentSrc && <img src={currentSrc} alt={alt} style={getImageStyle()} />}
        </div>
    );
};
// 使用
<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 才会对图片进行渐进式加载的预处理
import Image from 'next/image';
import mountains from '/public/mountains.jpg';

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

export default PlaceholderBlur;

总结

  • 产品第一印象很重要,良好的用户体验对于产品来说是必需的。
  • 感谢阅读,我们下次再见!
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,904评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,581评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,527评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,463评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,546评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,572评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,582评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,330评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,776评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,087评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,257评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,923评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,571评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,192评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,436评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,145评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容