使图片优雅地懒加载

dev 2022-06-26 19:21:24 #前端开发 632

懒加载是一项常用的网页优化技术,以达到节省算力、资源占用、网络 IO 等目的。例如其中最容易遇到的图片懒加载需求:只加载用户浏览器中可视区域里的图片。若非如此,所有图片在用户打开网页时一并加载,当图片资源较多时,就会产生巨大的流量消耗及带宽占用。

本文系之前群里有人在问懒加载怎么做而顺手写的,不是炒冷饭!

实现原理

在网页中,图片展现依靠 <img> 标签元素的 src 属性,当标签不具备该属性时,浏览器则不会发起资源请求;而若要实现其懒加载,只需在图片进入浏览器可视区域时,通过 JavaScript 动态赋予其 src 值即可,这便是懒加载最核心的原理。

为了实现这一需求,我们先主要考虑如何判断图片是否进入了浏览器的可视区域。比较传统的是借助 Element.onScroll 及 Element.getBoundingClientRect,前者用于监测用户滚动页面而造成的浏览器可视区域变化,后者用于获取图片元素的定位信息,例如:

u_1_62b8412a1cba2_1466x1099.png

代码逻辑如下:

<style>
    img.lazy {
        display: block;width: 400px;height: 300px;
        margin: 10px auto;border: 0;
        background-color: #F1F1FA;
    }
</style>

<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image1.jpeg?tr=w-400,h-300" />
<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image2.jpeg?tr=w-400,h-300" />
<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image3.jpeg?tr=w-400,h-300" />
<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image4.jpeg?tr=w-400,h-300" />
<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image5.jpeg?tr=w-400,h-300" />
<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image6.jpeg?tr=w-400,h-300" />
<img class="lazy" data-src="https://ik.imagekit.io/demo/img/image7.jpeg?tr=w-400,h-300" />
// 当初始的 HTML 文档被完全加载和解析完成之后,运行函数
document.addEventListener('DOMContentLoaded', function(){
    lazyloader();
});
// 当页面滚动时,运行函数
document.addEventListener('scroll', lazyloader, {passive:true});

function lazyloader(){
    document.querySelectorAll('img.lazy[data-src]').forEach(function(img){
        let rect = img.getBoundingClientRect();
        let visible = rect.top<=window.innerHeight && rect.bottom>=0;
        if(!visible){return;}
        // 如果元素可见,则替换其 src 的值
        img.src = img.dataset.src;
        img.classList.remove('lazy');
    });
}

同时,为了处理一些特殊情况,还需要监测以下几个事件:

// 当浏览器窗口大小改变时,运行函数
window.addEventListener('resize', lazyloader);
// 当设备的纵横方向改变时,运行函数
window.addEventListener('orientationChange', lazyloader);

较新的方法

在 2016 年的时候,推出了一个新的 Web API 来观察指定元素是否在浏览器的可视窗口内:IntersectionObserver.observe(),相比自行用 onScroll 实现,它的性能更好,且更优雅:

document.addEventListener('DOMContentLoaded', function(){
    // 观察器
    let io = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            // 当元素出现在浏览器可视窗口内
            if(entry.intersectionRatio > 0){
                let img = entry.target;
                img.src = img.dataset.src;
                img.classList.remove('lazy');
                // 移除观察元素
                io.unobserve(img);
            }
        });
    });
    
    document.querySelectorAll('img.lazy[data-src]').forEach(function(img){
        // 添加观察元素
        io.observe(img);
    });
});

进一步优化

至此,我们实现了最基础的图片懒加载功能,但它仍有以下缺点:

  • 图片加载很突兀
  • 对非固定尺寸图片不友好
  • 需封装,开放接口处理 AJAX 动态加载的图片

首先,为了解决图片加载突兀,我们可通过合适的 GIF 动图或图片蒙版锚点,以及提前在 JS 中加载好后再替换 src 值来解决:

<img class="lazy" src="https://gxzv.com/api/uploads/markCover/400x300.png" data-src="https://ik.imagekit.io/demo/img/image1.jpeg?tr=w-400,h-300" />

同时修改 JS,在替换之前提前加载好图片:

// 图片加载器 Promise
function iloader(src){
    return new Promise(function(resolve, reject){
        const image = new Image();

        image.onload = function(){resolve(image);};
        image.onerror = function(err){reject(err);};

        image.src = src;
        if(image.complete){resolve(image);}
    });
}

document.addEventListener('DOMContentLoaded', function(){
    let io = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            if(entry.intersectionRatio > 0){
                let img = entry.target;
                let src = img.dataset.src;
                iloader(src).then(function(){
                    img.src = src;
                    img.classList.remove('lazy');
                });
                io.unobserve(img);
            }
        });
    });
    
    document.querySelectorAll('img.lazy[data-src]').forEach(function(img){
        io.observe(img);
    });
});

另外,除了通过图片锚点,还可通过 HTML+CSS 的方式来自定义加载动画,后者无疑给了更多的发挥空间。但这类蒙版在遇到非固定尺寸的图片时,往往会遇到几个蛋疼且蛋疼的情况。

但不得不说我个人很喜欢用 HTML+CSS 来随心所欲的为各类图片创建不同的加载动画,实现这一点也仅需要对上面的例子稍作修改:

img, .lazyImg-wrap {
	display: block;width: 400px;height: 300px;
	margin: 10px auto;border: 0;
	background-color: #F1F1FA;
}
img.show {
	opacity: 0;animation: img-show 1s ease forwards;
}
@keyframes img-show {to {opacity: 1}}

.lazyImg-wrap {
	display: flex;align-items: center;justify-content: center;
}
.lazyImg-loader {
	position:relative;
	width: 42px;height: 42px;
	border: 6px solid #333;
	border-radius: 50%;
	animation: loader-spin 1.5s ease infinite;
}
.lazyImg-loader::after {
	content: '';position: absolute;
	left: calc(50% - 6px);top: -7px;
	display: block;width: 12px;height: 12px;
	background-color: #F1F1FA;
}
@keyframes loader-spin {
    to {transform: rotate(359deg);}
}
document.addEventListener('DOMContentLoaded', function(){
    let io = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            if(entry.intersectionRatio > 0){
                let wrap = entry.target;
                let src = wrap.dataset.src;
                iloader(src).then(function(){
                    wrap.outerHTML = `<img src='${src}' class='show'>`;
                });
                io.unobserve(wrap);
            }
        });
    });
    
    document.querySelectorAll('img.lazy[data-src]').forEach(function(img){
        let src = img.dataset.src;
        img.outerHTML = `<div class='lazyImg-wrap' data-src='${src}'><div class='lazyImg-loader'></div></div>`;
    });
	document.querySelectorAll('.lazyImg-wrap').forEach(function(wrap){
		io.observe(wrap);
	});
});

完整的例子在这里:https://codepen.io/ganxiaozhe/pen/jOzOxXJ

非固定尺寸

对于这类需求,我曾试过以下几种办法:

  • 想办法让它固定
  • 通过 JS 获取图片元数据以获取尺寸信息
  • 通过图片文件名获取尺寸信息(photo_1226x766.jpeg)

其中不难看出,最后一种方法最为优雅,无需再进行二次请求以得到尺寸信息。同时,若要继续使用上方的蒙版类型,则需通过 style 控制其长宽对应指定图片的长宽,但这也会导致其尺寸自适应难以适应所有情况,特别是在子父元素都是相对值的时候。如果你不明白我在说什么,欢迎亲自去踩踩这个坑。

总之,目前我认为最理想的自适应解决办法还是通过生成尺寸相同的图片直接充当蒙版,例如通过 API 在后端生成:

https://gxzv.com/api/api/image/getBySize?w=400&h=300
https://gxzv.com/api/api/image/getBySize?w=1550&h=1080
<?php
namespace App\Controller\Api;
use Base\Response;

class Image {
    public static function getBySize(): string|Response
    {
        $w = $_GET['w']??$_POST['w']??null;
        $h = $_GET['h']??$_POST['h']??null;
        if(!is_numeric($w)||!is_numeric($h)){return new Response(403);}

        $_dir = '/uploads/markCover/';
        $dir = BASE_DIR.$_dir;
        if( !is_dir($dir) ){mkdir($dir);}
        $_path = $_dir."{$w}x{$h}.png";
        $path = $dir."{$w}x{$h}.png";
        if( file_exists($path) ){
            header('location: /api'.$_path);
            exit;
        }

        $img = imagecreate($w, $h);
        imagecolorallocate($img, 188, 188, 188);
        imagepng($img, $path);
        imagedestroy($img);
        $data = file_get_contents($path);

        header('Content-Type: image/png');
        return $data;
    }
}

不过,有时为了追求美观,在能控制图片固定尺寸或比例时,HTML+CSS 图片蒙版仍是我的首选。目前我博客中的图片懒加载也是用的这一技术,但这一部分代码是蛮久以前写的了,不太雅观...