面试官:聊聊图片的懒加载和预加载

1,317 阅读10分钟

今天我们来聊一聊一个和性能优化方面相关的问题:图片的懒加载和预加载。那什么是图片的懒加载和预加载呢?其实就是怎么去加载图片,当面对大量的图片资源时,我们怎么去处理它们。

图片的懒加载指的就是当用户打开一个页面时,没有出现在用户屏幕可视区域内的图片我们先不加载,等用户去滑动屏幕时,就让原本能看见的图片加载出来。

图片的预加载指的就是一次性把图片资源全部加载出来,这样当用户去滑动屏幕时,不会出现空白、图片没有加载出来的情况,用户的体验非常好。

我们为什么要去做图片的懒加载和预加载呢?如果我们不去做图片的懒加载和预加载,浏览器的回流重绘是非常快的,而图片的加载是需要发送网络请求的,所以浏览器会面临一次性几十甚至是上百个加载图片资源的网络请求,这会导致网络的堵塞,导致原本能很快加载出来的图片资源速度都变得很慢,影响用户体验。
懒加载提高初始加载速度和节省带宽,适用于大规模内容,尤其是包含大量图片的页面。 预加载确保关键资源及时加载,提升交互时的流畅性,避免延迟加载影响用户体验。

1. 懒加载

我们先来聊聊图片的懒加载是怎么实现的。

我们说,图片懒加载的原理是当用户刚打开页面时,只去加载用户能看得见的图片,藏在屏幕之外的图片先不加载,等用户去滑动屏幕之后,原本看不见的图片需要被看见时,再去加载。

那怎么去知道一个图片是否出现在可视区域之内呢?那就要介绍一个很好用的方法了,getBoundingClientRect。它能获取到元素的几何属性。我来直接展示给你看。

<body>
    <img src="https://t7.baidu.com/it/u=1732966997,2981886582&fm=193&f=GIF" alt="">
    <script>
        let img = document.querySelector('img');
        let rect = img.getBoundingClientRect() // 获取元素的几何属性
        console.log(rect);
    </script>
</body>

我在页面上放了一张图片,然后利用js获取到这个img标签,直接让img去调用getBoundingClientRect方法,它的执行结果就是这个元素的几何属性。我们来看看有些什么内容:

image.png

得到的rect是一个对象,它里面存放着这样一些内容:图片的高、宽、top属性、bottom属性还有x、y坐标之类的。那获取到这些内容,我们就能知道一张图片是不是出现在可视区域内。我们知道,left指的是图片的左边框距离屏幕左边的距离;right指的是右边框距离屏幕左边的距离;top指的是上边框距离屏幕顶部的距离;bottom指的是下边框距离屏幕顶部的距离。

那一张出现在可视区域之内的图片,它的bottom一定是大于0的,并且top一定是小于屏幕高度的,没错吧。你想一想,bottom大于0是不是图片的下边框一定在屏幕顶部的下方啊,top小于图片的高度是不是图片的上边框一定在屏幕底部的上方啊。只要满足这两个条件,图片一定出现在可视区域内。

那既然能知道图片是否出现在可视区域内,这代码应该就非常好写了吧。

我们来实现一下图片的懒加载。我们准备10张要展示的图片,每张图片的src我们就不应该去放值了,否则一打开页面所有图片都在同一时间加载了,就不是我们要的效果了。

我们可以这么干,我们在img标签里自己定义一个属性,就叫“data-src”吧,这个属性是没有任何意义的,我们自己随便定义的。然后我们在“data-src”属性里去放图片的地址,这样浏览器是不会去加载图片的,然后当图片出现在可视区域内,我们就让图片的src赋值为“data-src”就行了。

<body>
    <img src="" data-src="https://t7.baidu.com/it/u=1732966997,2981886582&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=1785207335,3397162108&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=2581522032,2615939966&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=245883932,1750720125&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=3423293041,3900166648&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=3241434606,2550606435&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=1417505637,1247476664&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=3659156856,3928250034&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=1416385889,2308474651&fm=193&f=GIF" alt="">
    <img src="" data-src="https://t7.baidu.com/it/u=2469680087,3014121106&fm=193&f=GIF" alt="">

</body>

然后我们去写一份js。首先应该获取到屏幕的高度,我们可以通过window上的innerHeight属性获取到屏幕的高度。然后我们定义一个lazyLoad方法,去实现我们的懒加载。

<script>
        let height = window.innerHeight
        
        function lazyLoad() {
            
        }
        lazyLoad()
    </script>

首先去获取屏幕上所有的img标签,然后去判断每个标签是否出现在可视区域之内。我们写了一个属性选择器,去获取具有 data-src 属性的img标签,这些是我们需要做懒加载的图片,我们提前定义了一个data-src属性。并且这一步为我们之后删除data-src属性做准备,当我们的img标签src属性有值了之后,我们就可以移除data-src属性,更美观一点。但如果我们移除了data-src属性,这个lazyLoad函数会在之后滚动屏幕的时候反复执行,就又会执行将data-src属性赋值为src,但此时已经没有data-src属性了,就会报错。所有,我们在每次lazyLoad函数被调用时,重新去获取具有data-src属性的img标签,这样获取到的就是一个空数组,for循环就不会走,就不会把空的data-src属性又赋值给src。

<script>
        let height = window.innerHeight
        
        function lazyLoad() {
            let imgs = document.querySelectorAll('img[data-src]')

            for (let i = 0; i < imgs.length; i++) {
                let rect = imgs[i].getBoundingClientRect() // 获取元素的几何属性
                if (rect.bottom > 0 && rect.top < height) {
                    
                    }
                }
            }
        }
        lazyLoad()
    </script>

然后我们去获取每个img标签的几何属性,对它们的位置做判断,这里我们前面已经讲过,当bottom大于0且top小于height时,它就是出现在屏幕里的。我们就去给它的src属性赋值。

我们可以这样写:

<script>
        let height = window.innerHeight
        
        function lazyLoad() {
            let imgs = document.querySelectorAll('img[data-src]')

            for (let i = 0; i < imgs.length; i++) {
                let rect = imgs[i].getBoundingClientRect() // 获取元素的几何属性
                if (rect.bottom > 0 && rect.top < height) {
                    let newImg = new Image()
                    newImg.src = imgs[i].getAttribute('data-src')
                    newImg.onload = function () {  // 图片被浏览器加载完毕
                        imgs[i].src = newImg.getAttribute('src')
                    }
                    imgs[i].removeAttribute('data-src')
                }
            }
        }
        lazyLoad()
    </script>

我们使用Image构造函数再凭空创建一个newImg,然后让newImg的src属性赋值为data-src,此时读到这一步,js就会去加载这一图片资源,然后我们去监听,当这个newImg加载完成之后,再让imgs[i]的src赋值为newImg的src。为什么要这样做呢?

这是因为通过new Image()创建新的Image对象,图片的加载是异步进行的,不会直接影响页面上其他图片的渲染和排版。只有当图片成功加载后,才会更新DOM中<img>元素的src,从而实现懒加载效果。

赋完值之后data-src就不需要了,我们就可以移除它,图片资源已经被加载回来了。

现在我们可以来试一下,当我们刚打开页面时,只有映入眼帘的图片才会有src属性,其它几个看不见的src就没有值。

PixPin_2024-12-27_10-15-41.png

看,只有前3个图片的src有值。接下来,我们就可以去监听屏幕的滚动,让lazyLoad函数在屏幕滚动的时候不断去触发就行了。

<script>
        let height = window.innerHeight
        
        function lazyLoad() {
            let imgs = document.querySelectorAll('img[data-src]')

            for (let i = 0; i < imgs.length; i++) {
                let rect = imgs[i].getBoundingClientRect() // 获取元素的几何属性
                if (rect.bottom > 0 && rect.top < height) {
                    let newImg = new Image()
                    newImg.src = imgs[i].getAttribute('data-src')
                    newImg.onload = function () {  // 图片被浏览器加载完毕
                        imgs[i].src = newImg.getAttribute('src')
                    }
                    imgs[i].removeAttribute('data-src')
                }
            }
        }
        lazyLoad()
        window.addEventListener('scroll', lazyLoad)
    </script>

这样我们就实现了图片的懒加载效果。我们来试一下:

PixPin_2024-12-27_10-19-11.gif

看,当我们在滑动屏幕的时候src不断有值,再划回去时,因为我们获取的是具有data-src属性的img标签,虽然lazyLoad函数被调用了,但for循环语句不会执行,就不会将空的data-src属性赋值给src,就不会报错了。

懒加载我们就实现了,但它也有一个小缺点:当图片过大,图片出现在可视区域内的那一刻也会有短暂的白屏,影响用户体验,但也可以接受。

2. 预加载

我们再来看一看预加载是怎么写的。

我们说预加载就是一次性把图片资源提前加载好,这样,当用户不管去查看哪张图片时,都是已经加载好的,非常丝滑。

那我们怎么去干呢?我们知道。js是单线程的,但它允许我们人为地再去开一个线程,我们可以把这些加载图片资源的操作全部再开一个线程去干,让它异步进行。

<body>
    <div id="pic"></div>
    <script>
        let pic = document.getElementById("pic");
        let arr = [
            "https://t7.baidu.com/it/u=1732966997,2981886582&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1785207335,3397162108&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=2581522032,2615939966&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=245883932,1750720125&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3423293041,3900166648&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3241434606,2550606435&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1417505637,1247476664&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3659156856,3928250034&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1416385889,2308474651&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=2469680087,3014121106&fm=193&f=GIF",
        ]

        // 创建一个新的线程
        const worker = new Worker('worker.js')
    </script>
</body>

我们把图片地址先全部存放到一个数组arr里。Worker函数就是js自带的一个构造函数,它可以创建一个新的线程,它就会把'worker.js'中的代码再开一个线程执行。

两个线程之间还可以进行通信,我们可以使用postMessage方法把arr发送给'worker.js'。

<body>
    <div id="pic"></div>
    <script>
        let pic = document.getElementById("pic");
        let arr = [
            "https://t7.baidu.com/it/u=1732966997,2981886582&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1785207335,3397162108&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=2581522032,2615939966&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=245883932,1750720125&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3423293041,3900166648&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3241434606,2550606435&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1417505637,1247476664&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3659156856,3928250034&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1416385889,2308474651&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=2469680087,3014121106&fm=193&f=GIF",
        ]

        // 创建一个新的线程
        const worker = new Worker('worker.js')
        // 将数据发送给子线程
        worker.postMessage(arr)
    </script>
</body>

在'worker.js'中我们就可以用onmessage接收这个数组。self代表的就是worker,这个子线程。

self.onmessage = function (e) {
    console.log(e.data);
}

它会存放在事件参数里,我们可以输出e.data看一眼。

image.png

存放的就是我们的url地址数据。然后我们在这个线程中去进行加载图片的操作。但这个子线程也没有标签可以让我们去加载啊,我们怎么去加载呢?我们可以直接向这个url地址发送http请求,将图片资源加载回来。

self.onmessage = function (e) {
    // 将数组中的地址资源加载出来
    let arr = e.data
    for (let i = 0; i < arr.length; i++) {
        let xhr = new XMLHttpRequest()
        xhr.open("get", arr[i], true)
        xhr.responseType = "blob"  // 文件类型
        xhr.send();
        xhr.onload = function () {
            if (xhr.readyState == 4 && xhr.status == 200) {
                // console.log(xhr.response);
                self.postMessage(xhr.response)
            }
        }
    }
}

我们将请求回来的数据类型设为"blob",是一种文件类型,这是浏览器自带的一种类型,所有资源都可以被转换成"blob"类型,因为我们请求的是图片资源,所以我们转换成"blob"类型。

我们可以输出xhr.response看一看是否拿到了:

image.png

我们确实拿到了图片资源,它是一个Blob对象。那现在浏览器图片资源已经加载完毕了,我们把这份资源发送给主线程,用postMessage方法。

主线程就能接收子线程返回的数据。

<body>
    <div id="pic"></div>
    <script>
        let pic = document.getElementById("pic");
        let arr = [
            "https://t7.baidu.com/it/u=1732966997,2981886582&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1785207335,3397162108&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=2581522032,2615939966&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=245883932,1750720125&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3423293041,3900166648&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3241434606,2550606435&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1417505637,1247476664&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=3659156856,3928250034&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=1416385889,2308474651&fm=193&f=GIF",
            "https://t7.baidu.com/it/u=2469680087,3014121106&fm=193&f=GIF",
        ]

        // 创建一个新的线程
        const worker = new Worker('worker.js')
        // 将数据发送给子线程
        worker.postMessage(arr)
        // 接收子线程返回的数据
        worker.onmessage = function (e) {
            const img = new Image();
            img.src = window.URL.createObjectURL(e.data)
            pic.appendChild(img);
        }
    </script>
</body>

然后我们就去将它变成图片添加到屏幕上。因为我们得到的是blob类型的资源,我们可以使用window自带的一个方法window.URL.createObjectURL,它能将blob类型的图片资源转换成url。然后我们去创建一个img,添加到pic容器里,就完成了。

这样就实现了图片的预加载,我们开了一个子线程去帮我们完成图片的加载。

3. 总结

本次我们一起学习了一下图片的懒加载和预加载是什么,以及怎么实现它们。如果对你有帮助的话请点个赞吧!