圖片漸進(jìn)式加載優(yōu)化實(shí)踐指南

前言

  • Hey, 我是 Immerse
  • 文章首發(fā)于個(gè)人博客【https://yaolifeng.com】范抓,更多內(nèi)容請(qǐng)關(guān)注個(gè)人博客
  • 轉(zhuǎn)載說(shuō)明:轉(zhuǎn)載請(qǐng)?jiān)谖恼骂^部注明原文出處及版權(quán)聲明衡载!

起因

  • 最近上線了個(gè)人博客抄淑,片段頁(yè)面存在大量圖片球切,在圖片加載方面體驗(yàn)很差制妄,可以說(shuō)是斷崖式涌萤,從 0-1 完全沒(méi)有任何過(guò)渡(這很影響頁(yè)面布局和用戶體驗(yàn)坠狡,對(duì)于設(shè)定了圖片寬高的圖片還好继找,如果沒(méi)設(shè)置,就會(huì)有一個(gè)圖片撐高的過(guò)程)

巧合

  • 在準(zhǔn)備寫這篇文章當(dāng)天前端南玖大佬發(fā)表了一篇文章逃沿,我直呼大數(shù)據(jù)牛逼 ????文章: 點(diǎn)擊查看
  • 這篇文章我們將討論其他幾種方案婴渡,閑話少說(shuō),言歸正傳凯亮。
    • 對(duì)于常規(guī)的圖片優(yōu)化這里不在贅述边臼,大致如下:
      • 壓縮圖片、使用 CSS sprites假消、懶加載硼瓣、預(yù)加載、CDN 緩存置谦、合適的圖片格式堂鲤、七牛 CDN 圖片參數(shù)等等

探索

  • 以下是這篇文章提到的幾種方案(因?yàn)閭€(gè)人項(xiàng)目基于 Next,所以有些示例代碼是 React)
    • (1)使用圖片主色調(diào)
    • (2)使用某個(gè)顏色
    • (3)使用圖片的縮略圖
    • (4)使用模糊 + 壓縮圖片
    • (5)圖片占位符

方案 1:使用圖片主色調(diào)

  • 在日常開發(fā)中媒峡,我們的圖片 src 可能是動(dòng)態(tài)的瘟栖,也就是一個(gè)字符串 string url, 當(dāng)我們指定了 placeholder="blur" 時(shí),還必須添加 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:使用某個(gè)顏色

  • 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>漸進(jìn)式圖片加載</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();

                // 生成低質(zhì)量預(yù)覽圖
                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);
                        // 當(dāng)高質(zhì)量圖片加載完成時(shí),繼續(xù)平滑過(guò)渡
                        setTimeout(() => {
                            setIsLoading(false);
                        }, 100);
                    }
                };
            } catch (error) {
                console.error('Error loading image:', error);
                if (isMounted) {
                    setCurrentSrc(src);
                    setIsLoading(false);
                }
            }
        };

        const startSmoothTransition = () => {
            // 從20px的模糊逐漸過(guò)渡到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', // 增加過(guò)渡時(shí)間
            transform: 'scale(1.1)', // 稍微放大防止模糊時(shí)出現(xiàn)邊緣
            ...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 屬性提供了個(gè)選項(xiàng) blur签餐,默認(rèn)為 empty
    • blur 會(huì)生成一個(gè)模糊的預(yù)覽圖像(但這個(gè)選項(xiàng)會(huì)增加初始加載實(shí)踐寓涨,因?yàn)樾枰獣r(shí)間去生成模糊圖片)
    • 注意:如果 placeholder="blur" 時(shí),必須使用 import 靜態(tài)引入圖片的方式氯檐,這樣 Next.js 才會(huì)對(duì)圖片進(jìn)行漸進(jìn)式加載的預(yù)處理
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;

總結(jié)

  • 產(chǎn)品第一印象很重要戒良,良好的用戶體驗(yàn)對(duì)于產(chǎn)品來(lái)說(shuō)是必需的。
  • 感謝閱讀冠摄,我們下次再見糯崎!
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市河泳,隨后出現(xiàn)的幾起案子沃呢,更是在濱河造成了極大的恐慌,老刑警劉巖拆挥,帶你破解...
    沈念sama閱讀 218,858評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件薄霜,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)惰瓜,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,372評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門否副,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人鸵熟,你說(shuō)我怎么就攤上這事副编「旱椋” “怎么了流强?”我有些...
    開封第一講書人閱讀 165,282評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)呻待。 經(jīng)常有香客問(wèn)我打月,道長(zhǎng),這世上最難降的妖魔是什么蚕捉? 我笑而不...
    開封第一講書人閱讀 58,842評(píng)論 1 295
  • 正文 為了忘掉前任奏篙,我火速辦了婚禮,結(jié)果婚禮上迫淹,老公的妹妹穿的比我還像新娘秘通。我一直安慰自己,他們只是感情好敛熬,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,857評(píng)論 6 392
  • 文/花漫 我一把揭開白布肺稀。 她就那樣靜靜地躺著,像睡著了一般应民。 火紅的嫁衣襯著肌膚如雪话原。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,679評(píng)論 1 305
  • 那天诲锹,我揣著相機(jī)與錄音繁仁,去河邊找鬼。 笑死归园,一個(gè)胖子當(dāng)著我的面吹牛黄虱,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播庸诱,決...
    沈念sama閱讀 40,406評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼悬钳,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了偶翅?” 一聲冷哼從身側(cè)響起默勾,我...
    開封第一講書人閱讀 39,311評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎聚谁,沒(méi)想到半個(gè)月后母剥,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,767評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年环疼,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了习霹。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,090評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡炫隶,死狀恐怖淋叶,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情伪阶,我是刑警寧澤煞檩,帶...
    沈念sama閱讀 35,785評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站栅贴,受9級(jí)特大地震影響斟湃,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜檐薯,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,420評(píng)論 3 331
  • 文/蒙蒙 一凝赛、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧坛缕,春花似錦墓猎、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,988評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至直晨,卻和暖如春搀军,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背勇皇。 一陣腳步聲響...
    開封第一講書人閱讀 33,101評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工罩句, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人敛摘。 一個(gè)月前我還...
    沈念sama閱讀 48,298評(píng)論 3 372
  • 正文 我出身青樓门烂,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親兄淫。 傳聞我的和親對(duì)象是個(gè)殘疾皇子屯远,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,033評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容