图片懒加载

懒加载 是一种延迟加载的技术,当图片进入视图可视区域范围时才进行加载显示。这种技术能显著减少初始加载时间、降低用户带宽消耗、提高用户体验。

核心优势

  • 减少页面初始加载时间
  • 减少用户流量消耗
  • 提升页面流畅度
  • 适用于移动端和低网速环境

实现方案

imgsrc 暂存起来,当检测到图片进入视图可视窗口后,赋值给 imgsrc,加载显示图片。

1、原生 loading="lazy" 属性(极简)

实现原理

  • HTML5 原生提供的 loading 属性,属性值:lazy(懒加载),eager(立即加载,默认值)。
  • 无需编写 JavaScript 代码,由浏览器原生控制懒加载图片逻辑。
  • 浏览器会自动判断图片是否进入可视视口,随后加载图片。

代码示例

<!-- 直接添加 loading="lazy" 即可 -->
<img src="https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg" loading="lazy" alt="原生懒加载图片" width="300" height="200">

兼容性

  • 桌面端:Chrome 77+、Firefox 75+、Edge 79+、Safari 15.4+。
  • 移动端:Chrome for Android 77+、Firefox for Android 75+、Safari on iOS 15.4+。
  • 不支持 IE 浏览器、旧版 Safari(15.4 以下)。

优势

  • 优势:零代码实现、性能最优(浏览器原生底层优化)、无需依赖第三方库。

注意事项

  • 必须指定图片 widthheight,否则可能导致布局偏移(CLS 问题)。
  • 仅适用于静态图片,无法自定义预加载距离等高级配置,且各浏览器的预加载距离不一,无法确定起预加载距离。
  • 若图片在初始视口内,即使设置 loading="lazy",浏览器也会立即加载。

2、基于 Intersection Observer API 实现(更多个性化设置时推荐使用)

实现原理

  • Intersection Observer(交叉观察器)是浏览器原生提供的 API,用于监听「目标元素」与「视口 / 根容器」的交叉状态变化。
  • 无需手动监听 scroll 事件,浏览器内部优化了性能,避免高频触发问题。
  • 当图片与视口交叉(进入 / 即将进入)时,自动触发回调函数,此时加载真实图片。

代码示例

组件实现

<!-- LazyImage.vue 定义组件实现 -->

<script setup>
/**
 * 图片懒加载组件(基于 IntersectionObserver 实现,不兼容直接加载)
 * @description 使用时图片key不要使用index,建议使用唯一值,避免销毁 IntersectionObserver 无法正确找到当前元素
 * @property {String} src - 目标图片地址
 * @property {String} mode - 裁剪模式,见官网说明 (默认 'aspectFill' )
 * @property {String} alt - 图片描述
 * @property {String|Number} width - 图片宽度
 * @property {String|Number} height - 图片高度
 * @property {Boolean} lazy - 是否启用懒加载(默认 true )
 * @property {Number} threshold - 懒加载阈值(提前加载距离,单位px)
 * @slot placeholder - 自定义占位符内容
 * @slot error - 自定义加载失败内容
 * @event load - 图片加载成功时触发
 * @event error - 图片加载失败时触发
 * @example <LazyImage width="300" height="200" src="image.jpg" />
 */
import {
  ref,
  watch,
  onMounted,
  onUnmounted,
  onBeforeUnmount,
  computed,
} from "vue";

// 定义 props
const props = defineProps({
  // 目标图片地址
  src: {
    type: String,
    required: true,
  },
  mode: {
    type: String,
    default: "aspectFill",
  },
  // 图片描述
  alt: {
    type: String,
    default: "懒加载图片",
  },
  lazy: {
    type: Boolean,
    default: true,
  },
  // 图片宽度
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 图片高度
  height: {
    type: [String, Number],
    default: "auto",
  },
  // 懒加载阈值(提前加载距离,单位px)
  threshold: {
    type: Number,
    default: 100,
  },
  // 是否启用淡入动画
  fade: {
    type: Boolean,
    default: true,
  },
});

const emit = defineEmits(["load", "error"]);

const imageRef = ref(null); // 图片元素引用
const isLoaded = ref(false); // 图片是否已加载
const isError = ref(false); // 图片是否加载失败
let observer = null; // IntersectionObserver 实例

// 容器样式
const containerStyle = computed(() => ({
  width: typeof props.width === "number" ? `${props.width}px` : props.width,
  height: typeof props.height === "number" ? `${props.height}px` : props.height,
  overflow: "hidden",
  position: "relative",
  backgroundColor: "#f0f0f0",
}));

// 图片样式
const imageStyle = computed(() => ({
  width: "100%",
  height: "100%",
  objectFit: "cover",
  opacity: isLoaded.value ? 1 : 0,
  transition: props.fade ? "opacity .3s ease-in-out" : "none",
  position: "absolute",
  top: 0,
  left: 0,
}));

// 卸载 observer
const fnUnobserve = () => {
  if (observer && imageRef.value) {
    observer.unobserve(imageRef.value);
  }
};

// 加载图片
const loadImage = () => {
  if (!props.src) return;

  const img = new Image();
  img.src = props.src;

  img.onload = () => {
    isLoaded.value = true;
    isError.value = false;
    fnUnobserve();
    imageRef.value.src = props.src;
    // 触发 load 事件
    emit("load");
  };

  img.onerror = () => {
    isError.value = true;
    isLoaded.value = false;
    fnUnobserve();
    // 触发 error 事件
    emit("error");
  };
};

// 初始化 IntersectionObserver
const initObserver = () => {
  // 兼容处理(不支持IntersectionObserver的浏览器直接加载)
  if (!window.IntersectionObserver || !props.lazy) {
    loadImage();
    return;
  }

  observer = new IntersectionObserver(
    (entries) => {
      if (entries && entries[0]?.isIntersecting) {
        loadImage();
      }
    },
    {
      rootMargin: `0px 0px ${props.threshold}px 0px`,
      threshold: 0,
    }
  );

  // 观察img
  if (imageRef.value) {
    observer.observe(imageRef.value);
  }
};

// 监听路径更换
watch(
  () => props.src,
  () => {
    // 重置状态
    isLoaded.value = false;
    isError.value = false;
    // 重新观察
    if (imageRef.value) {
      observer.observe(imageRef.value);
    }
  }
);

onMounted(() => {
  initObserver();
});

// 组件卸载停止观察
onBeforeUnmount(() => {
  if (observer && imageRef.value) {
    observer.unobserve(imageRef.value);
    observer.disconnect();
    observer = null;
  }
});
</script>

<template>
  <div class="lazy-image-container" :style="containerStyle">
    <img
      ref="imageRef"
      :alt="alt"
      :mode="mode"
      class="lazy-image-target"
      :style="imageStyle"
    />
    <slot name="placeholder" v-if="!isLoaded && !isError">
      <div class="lazy-image-placeholder">加载中</div>
    </slot>
    <slot name="error" v-if="isError">
      <div class="lazy-image-error">加载失败</div>
    </slot>
  </div>
</template>

<style scoped>
.lazy-image-container {
  display: inline-block;
}

.lazy-image-placeholder,
.lazy-image-error {
  width: 100%;
  height: 100%;
  object-fit: cover;
  background-color: #f5f5f5;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 14px;
  color: rgb(192, 196, 204);
  vertical-align: middle;
}

.lazy-image-target {
  position: absolute;
  top: 0;
  left: 0;
}
</style>

组件使用

<!-- 组件使用 -->

<script setup lang="ts">
import { reactive } from "vue";
import LazyImage from "./components/LazyImage.vue";

// 模拟图片列表
const imageList = reactive([
  {
    url: "https://images.dog.ceo/breeds/ovcharka-caucasian/IMG_20190628_144817.jpg",
    alt: "图片1",
  },
  {
    url: "https://images.dog.ceo/breeds/beagle/n02088364_17294.jpg",
    alt: "图片2",
  },
  {
    url: "https://images.dog.ceo/breeds/whippet/n02091134_10690.jpg",
    alt: "图片3",
  }
]);
</script>

<template>
  <div class="image-list">
    <!-- 基础使用 -->
    <LazyImage
      v-for="(item, index) in imageList"
      :key="item.alt"
      :src="item.url"
      :alt="item.alt"
      width="300px"
      height="200px"
      :threshold="200"
    />
  </div>
</template>

<style scoped>
.image-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
  padding: 20px;
}
</style>

指令实现

// lazyDirective.js 指令代码实现

// 定义懒加载指令配置
const lazyOptions = {
  loading: "", // 加载中的占位图
  error: "", // 加载失败的占位图
  threshold: 0.2, // 触发懒加载的可见比例(可覆盖配置)
  rootMargin: "0px", // 预加载距离(可覆盖配置)
};

let observer = null;

// 加载图片函数
const loadImage = (el, realSrc) => {
  const img = new Image();
  img.src = realSrc;

  img.onload = () => {
    el.src = realSrc;
    observer && observer.unobserve(el);
  };

  img.onerror = () => {
    el.src = lazyOptions.error || "";
    observer && observer.unobserve(el);
  };
};

// 自定义 v-lazy 指令
const lazyDirective = {
  mounted(el, binding) {
    // 真实图片地址
    const realSrc = binding.value;
    if (!realSrc) return;

    // 设置占位图
    el.src = lazyOptions.loading || "";

    observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadImage(el, realSrc);
        }
      },
      {
        rootMargin: lazyOptions.rootMargin,
        threshold: lazyOptions.threshold,
      }
    );

    observer.observe(el);
  },
  unmounted(el) {
    if (observer) {
      observer.unobserve(el);
      observer.disconnect();
      observer = null;
    }
  },
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      // 真实图片地址
      const realSrc = binding.value;
      if (!realSrc) return;

      // 设置占位图
      el.src = realSrc;
      observer.observe(el);
    }
  },
};

// 导出指令
export default lazyDirective;

main 文件指令定义

// main 文件指令定义

import { createApp } from "vue";
import App from "./App.vue";
import lazyDirective from "./utils/lazyDirective";

const app = createApp(App);
app.directive("lazy", lazyDirective);
app.mount("#app");

使用指令

<img v-lazy="imageSrc" alt="图片懒加载指令使用" />

兼容性

  • 现代浏览器全支持:Chrome 51+、Firefox 55+、Safari 12.1+、Edge 16+。
  • 不支持 IE 浏览器。
  • 兼容方案:可引入 Intersection Observer Polyfill,兼容到 IE9+。

优势

  • 性能优异:浏览器原生优化,无需手动节流 / 防抖。
  • 代码简洁:无需处理滚动事件和位置计算。
  • 灵活配置:可自定义根容器、预加载距离等。

3、基于 scroll 事件 + 位置计算(不推荐使用,低版本兼容时使用)

实现原理

  • 初始时,图片 src 指向占位图(或不设置 src,用 data-* 存储真实地址,如 data-src)。
  • 监听 window 或滚动容器的 scroll 事件。
  • 每次滚动时,通过 getBoundingClientRect() 获取图片的位置信息,判断是否进入视口。
  • 若进入视口,将 data-src 赋值给 src,完成图片加载。

代码示例

兼容性

  • 支持所有现代浏览器及 IE8+(IE8 需兼容 querySelectorAll,可替换为 getElementsByClassName)。
  • getBoundingClientRect() 兼容性:IE4+ 全支持。
  • 缺点:scroll 事件触发频率极高,会造成「性能抖动」,需配合节流优化。

各方案兼容性对比

实现方式 浏览器支持版本 性能表现 场景
原生 loading="lazy" Chrome77+、Firefox75+、Safari15.4+ 最优 当前项目、无需复杂配置
Intersection Observer Chrome51+、Firefox55+、Safari12.1+、可 polyfill 到 IE9+ 优秀 当前项目、追求自定义配置
scroll + getBoundingClientRect IE8+、所有现代浏览器 一般(需节流) 兼容旧项目、低版本浏览器场景

总结

  • 原生 loading="lazy":极简、性能最优,优先用于现代浏览器项目。
  • Intersection Observer:灵活、高性能,推荐用于需要自定义配置的现代项目(需兼容旧浏览器时加 polyfill)。
  • scroll + getBoundingClientRect:兼容性最强(支持 IE8+),仅用于老旧项目维护。
  • 兼容性优先级:原生懒加载 > Intersection Observer(带 polyfill) > 传统 scroll 方案。
  • 核心原则:保证图片正常加载的同时,优化性能和用户体验,避免布局偏移。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐