深入浅出:图片懒加载的底层原理与实践

108 阅读12分钟

引言

在当今富媒体的Web世界中,图片是构建用户体验不可或缺的一部分。然而,随着图片数量和质量的不断提升,网页的加载速度也面临着严峻的挑战。当一个页面包含大量图片时,一次性加载所有图片不仅会消耗大量的网络带宽,还会显著增加页面的初始加载时间,导致用户体验下降。为了解决这一问题,图片懒加载(Lazy Loading) 技术应运而生。它是一种优化策略,旨在延迟加载页面中的非关键资源(如图片),直到它们进入用户的视口(viewport)时才进行加载。本文将从底层原理出发,结合实际代码示例,深入探讨图片懒加载的实现机制、浏览器渲染过程中的图片加载行为,以及如何通过原生JavaScript和现代API实现高效的图片懒加载方案。

为什么需要图片懒加载?

想象一下,一个电商网站的商品列表页,可能包含几十甚至上百张商品图片。如果这些图片在页面加载时全部请求并渲染,将会带来以下问题:

  1. 网络带宽的浪费:用户可能只浏览了页面顶部的内容,但浏览器却已经下载了页面底部甚至永远不会被看到的所有图片,造成了不必要的流量消耗。
  2. 页面加载速度慢:大量的图片请求会阻塞页面的渲染,导致白屏时间过长,用户需要等待很长时间才能看到完整的页面内容。
  3. 服务器压力增大:短时间内的大量图片请求会给服务器带来巨大的压力,影响服务器的响应速度和稳定性。
  4. 浏览器性能下降:图片解码和渲染会占用大量的CPU和内存资源,导致页面卡顿,影响用户交互的流畅性。

图片懒加载正是为了解决这些问题而设计的。通过按需加载图片,我们可以显著提升页面的初始加载速度,减少带宽消耗,降低服务器压力,并改善整体的用户体验。

浏览器如何加载图片?底层机制解析

要理解图片懒加载,首先需要了解浏览器是如何加载和渲染图片的。这涉及到浏览器的工作原理、网络请求以及渲染流程。

浏览器渲染机制概述

当浏览器获取到HTML、CSS和JavaScript文件后,会经历以下主要阶段来构建和渲染页面:

  1. 解析(Parsing) :浏览器解析HTML文件,构建DOM树(Document Object Model);同时解析CSS文件,构建CSSOM树(CSS Object Model)。
  2. 样式计算(Style Calculation) :将DOM树和CSSOM树结合,计算出每个DOM节点的最终样式。
  3. 布局(Layout/Reflow) :根据计算出的样式,浏览器计算每个元素的几何尺寸和位置,生成布局树(Render Tree)。布局树只包含可见元素,不包含display: none的元素。
  4. 绘制(Paint) :将布局树中的每个元素绘制到屏幕上,这个过程会生成一系列的绘制指令。
  5. 合成(Compositing) :将绘制好的图层合成为最终的图像,并显示在屏幕上。

图片的加载时机与HTTP请求

在上述渲染过程中,图片的加载是一个关键环节。当浏览器解析HTML遇到<img>标签时,会根据其src属性发起HTTP请求去下载图片资源。值得注意的是:

  • <img>标签的src属性:这是浏览器发起图片请求的直接依据。只要src属性有值,浏览器就会立即尝试下载对应的图片。这也是为什么传统的图片加载方式会导致页面初始加载慢的原因。
  • background-image属性:CSS中的background-image属性引用的图片,其加载时机与<img>标签有所不同。浏览器会在构建CSSOM树并计算样式时发现这些图片,但通常只有当元素在布局树中存在且可见时,才会发起对应的HTTP请求。这意味着,如果一个元素的display属性为none,即使它有background-image,浏览器也不会立即加载该图片。
  • 并发请求:现代浏览器为了提高页面加载速度,会对同一域名下的资源进行并发下载。然而,并发连接数是有限的(通常为6-8个),当图片数量过多时,仍然可能导致请求队列阻塞,影响其他资源的加载。
  • TCP/IP与HTTP协议:图片加载本质上是基于HTTP协议的网络请求,底层依赖于TCP/IP协议进行数据传输。每次图片请求都会经历DNS解析、TCP连接建立、发送HTTP请求、接收HTTP响应等过程。这些过程都会产生一定的延迟,尤其是在网络状况不佳时。

为什么src属性不能直接给?

在懒加载的场景中,我们不能直接将图片的真实URL赋值给<img>标签的src属性。原因在于,一旦src属性被赋值,浏览器就会立即发起图片请求。如果页面中有几十上百张图片,即使它们不在当前视口内,浏览器也会一股脑地去下载,这与懒加载的初衷背道而驰。因此,懒加载的核心思想是:在图片未进入可视区域时,不给<img>标签的src属性赋值真实的图片URL,而是使用一个占位图或者空值。 当图片进入可视区域时,再将真实的URL从其他地方(例如自定义属性data-original)取出,赋值给src属性,从而触发图片的加载。

图片懒加载的实现方式

图片懒加载的实现方式多种多样,从传统的监听滚动事件到现代的Intersection Observer API,每种方式都有其优缺点。这里我们将重点介绍两种常见的实现方式,并结合例子进行分析。

1. 基于scroll事件和getBoundingClientRect的实现

以下展示了一种经典的图片懒加载实现方式,它通过监听windowscroll事件,并结合getBoundingClientRect方法来判断图片是否进入了可视区域。让我们来详细分析这段代码:

<img class="image-item" lazyload="true" src="https://static.360buyimg.com/item/main/1.0.12/css/i/loading.gif"
    data-original="https://img.36krcdn.com/hsossms/20250313/v2_15ad8ef9eca34830b4a2e081bbc7f57a@000000_oswg172644oswg1536oswg722_img_000?x-oss-process=image/resize,m_mfit,w_960,h_400,limit_0/crop,w_960,h_400,g_center" />
<!-- ... 更多图片标签 ... -->
<script>
    const viewHeight = document.documentElement.clientHeight;
    const eles = document.querySelectorAll('img[data-original][lazyload]');
​
    const lazyload = function () {
        Array.prototype.forEach.call(eles, function (item, index) {
            if (item.dataset.original === "") return;
​
            rect = item.getBoundingClientRect();
            if (rect.bottom >= 0 && rect.top < viewHeight) {
                (function () {
                    var img = new Image();
                    img.src = item.dataset.original;
                    img.onload = function () {
                        item.src = item.dataset.original;
                        item.removeAttribute('data-original');
                        item.removeAttribute('lazyload');
                    }
                })();
            }
        });
    };
​
    window.addEventListener('scroll', lazyload);
    document.addEventListener('DOMContentLoaded', lazyload);
</script>

代码解析:

  1. HTML结构

    • <img>标签的src属性被设置为一个占位图(loading.gif),这样在图片未加载前会显示一个默认的图片,避免页面空白。
    • 真实的图片URL存储在自定义属性data-original中。data-*属性是HTML5中新增的,用于存储自定义数据,并且不会对页面布局或样式产生影响。
    • `lazyload=

true`属性作为一个标记,方便JavaScript选择器进行筛选。

  1. JavaScript逻辑

    • viewHeight:获取当前视口的高度,用于判断图片是否进入可视区域。

    • eles:通过document.querySelectorAll('img[data-original][lazyload]')选择所有带有data-originallazyload属性的<img>标签。这些是需要进行懒加载的图片元素。

    • lazyload函数:这是核心的懒加载逻辑。

      • 它遍历所有待懒加载的图片元素。

      • item.dataset.original === "":检查data-original属性是否为空,如果为空则跳过,避免不必要的处理。

      • item.getBoundingClientRect():这个方法返回一个DOMRect对象,包含了元素的大小及其相对于视口的位置。rect.bottom表示元素底部相对于视口顶部的距离,rect.top表示元素顶部相对于视口顶部的距离。

      • if (rect.bottom >= 0 && rect.top < viewHeight):这是判断图片是否进入可视区域的关键逻辑。它表示:

        • rect.bottom >= 0:图片的底部在视口上方或与视口顶部齐平(即图片至少有一部分在视口内或即将进入视口)。
        • rect.top < viewHeight:图片的顶部在视口下方或与视口底部齐平(即图片至少有一部分在视口内或即将离开视口)。
        • 这两个条件结合起来,确保了图片元素在当前视口范围内。
      • 图片加载:当图片进入可视区域后,会创建一个新的Image对象(var img = new Image();)。将item.dataset.original赋值给这个新Image对象的src属性。这样做的好处是,可以利用Image对象的onload事件来确保图片完全加载成功后再将其赋值给实际的<img>标签的src,避免了图片加载失败或加载过程中出现空白。

      • img.onload = function () { ... }:当新Image对象加载完成后,将其src赋值给实际的<img>标签的src,从而显示图片。同时,移除data-originallazyload属性,避免重复加载和提高性能(一旦加载完成,就不再需要懒加载处理)。

    • 事件监听

      • window.addEventListener('scroll', lazyload):监听windowscroll事件。当用户滚动页面时,会触发lazyload函数,检查图片是否进入可视区域。
      • document.addEventListener('DOMContentLoaded', lazyload):监听DOMContentLoaded事件。这个事件在初始HTML文档完全加载和解析完成后触发,此时DOM树已经构建完成,但外部资源(如图片、样式表)可能还在加载。在这里调用lazyload函数,是为了在页面首次加载时,立即加载首屏可见的图片。

这种实现方式的优缺点:

  • 优点:兼容性好,几乎支持所有浏览器,实现简单直观。

  • 缺点

    • 性能问题scroll事件触发频率非常高,每次滚动都会执行lazyload函数,频繁地进行DOM操作和计算getBoundingClientRect会带来较大的性能开销,尤其是在图片数量多、页面复杂的情况下。虽然可以通过节流(throttle)防抖(debounce) 来优化,但仍然无法根本解决问题。
    • 计算复杂:需要手动计算元素与视口的位置关系,逻辑相对复杂。

2. Intersection Observer API的现代解决方案

为了解决scroll事件带来的性能问题,现代浏览器提供了更高效、更优雅的解决方案——Intersection Observer API。这个API提供了一种异步检测目标元素与祖先元素或顶级文档的视口相交情况变化的方法。它不会在主线程中执行,而是由浏览器自行优化,当目标元素进入或离开视口时,异步触发回调函数,大大提高了性能。

Intersection Observer API的核心概念:

  • IntersectionObserver:构造函数,用于创建观察器实例。

  • callback:当目标元素与根元素(或视口)的交叉状态发生变化时,会执行的回调函数。回调函数会接收一个entries数组,每个entry代表一个被观察元素的交叉状态变化。

  • options:一个可选的对象,用于配置观察器。

    • root:指定目标元素的根元素,默认为浏览器视口(null)。
    • rootMargin:一个CSS margin属性的字符串,定义了根元素的边距。这可以用来扩大或缩小根元素的判定区域,例如,可以提前加载即将进入视口的图片。
    • threshold:一个数字或数字数组,表示目标元素可见性变化的阈值。例如,0表示目标元素刚进入或离开根元素时触发回调,1表示目标元素完全进入或离开根元素时触发回调。[0, 0.25, 0.5, 0.75, 1]表示在目标元素可见性达到0%、25%、50%、75%、100%时都触发回调。

使用Intersection Observer API实现懒加载的基本步骤:

  1. 创建IntersectionObserver实例

    const observer = new IntersectionObserver(callback, options);
    
  2. 指定观察目标

    observer.observe(targetElement);
    
  3. 在回调函数中处理交叉状态变化

    const callback = (entries, observer) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                // 目标元素进入视口
                const img = entry.target;
                const originalSrc = img.dataset.original;
                if (originalSrc) {
                    img.src = originalSrc;
                    img.removeAttribute('data-original');
                    img.removeAttribute('lazyload');
                    observer.unobserve(img); // 停止观察已加载的图片
                }
            }
        });
    };
    

Intersection Observer API的优缺点:

  • 优点

    • 高性能:异步执行,不阻塞主线程,性能开销极低。
    • 易用性:API设计简洁,无需手动计算元素位置。
    • 精确控制:可以通过rootMarginthreshold精确控制触发时机。
  • 缺点

    • 兼容性:IE浏览器不支持,对于需要兼容旧版浏览器的项目可能需要Polyfill或回退方案。

性能优化与最佳实践

除了选择合适的懒加载实现方式,还有一些通用的性能优化和最佳实践可以进一步提升用户体验:

  1. 设置占位图:在图片加载前显示一个低质量的占位图(如您代码中的loading.gif),可以避免页面布局跳动,提升用户感知。
  2. 预设图片宽高:在HTML中为<img>标签设置widthheight属性,或者通过CSS设置固定宽高。这可以避免图片加载完成后引起的页面重排(reflow)和重绘(repaint),减少布局抖动。
  3. 图片压缩与格式优化:使用WebP等现代图片格式,并对图片进行适当的压缩,可以显著减小图片文件大小,加快下载速度。
  4. CDN加速:将图片资源部署到CDN(内容分发网络)上,可以利用CDN的全球节点优势,使用户从最近的服务器获取图片,减少网络延迟。
  5. 服务端渲染(SSR) :对于首屏图片,可以考虑使用SSR,将图片直接渲染到HTML中,减少客户端JS执行和图片加载的等待时间。
  6. 错误处理:为懒加载图片添加错误处理机制,例如当图片加载失败时显示一个默认的错误图片。
  7. 节流与防抖:对于基于scroll事件的懒加载,务必使用节流或防抖函数来限制回调函数的执行频率,避免性能问题。

总结

图片懒加载是前端性能优化中一个非常重要的技术。通过延迟加载非可视区域的图片,我们可以有效减少页面初始加载时间、节省带宽、降低服务器压力,并显著提升用户体验。从传统的scroll事件监听结合getBoundingClientRect到现代的Intersection Observer API,懒加载的实现方式不断演进,变得更加高效和易用。在实际项目中,我们应该根据项目需求和浏览器兼容性要求,选择最合适的懒加载方案,并结合其他性能优化手段,共同打造高性能、高用户体验的Web应用。