Vue3 组件中的图片懒加载与渐进式加载

13 阅读8分钟

前言

在当今的 Web 应用中,图片资源已成为用户体验的关键因素。一个页面动辄几十张高清图片,如果全部同时加载,不仅浪费用户流量,还会导致页面卡顿甚至崩溃。据统计,图片懒加载可以减少 40-60% 的首屏加载时间

本文将深入探讨 Vue3 中图片懒加载的实现原理,从底层的 IntersectionObserver 到高级的 LQIP 渐进式加载,最终手写一个完整的图片懒加载指令和预加载组件。

懒加载原理 - 怎么知道图片该加载了?

传统懒加载:监听滚动

传统懒加载通常通过监听滚动事件 + 计算元素位置实现:

window.addEventListener('scroll', () => {
  // 获取所有图片
  const images = document.querySelectorAll('img[data-src]')
  
  images.forEach(img => {
    // 计算图片位置
    const rect = img.getBoundingClientRect()
    
    // 如果图片进入可视区
    if (rect.top < window.innerHeight) {
      // 加载图片
      img.src = img.dataset.src
      img.removeAttribute('data-src')
    }
  })
})

问题

  • 滚动事件频繁触发,影响性能(需配合节流)
  • 需要手动计算位置,代码复杂
  • 无法处理元素在可视区内但不滚动的情况

现代方案:IntersectionObserver

IntersectionObserver 是现代浏览器提供的 API,用于异步观察元素与其祖先或视口的交叉状态。

// 创建观察器
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // 如果图片进入可视区
    if (entry.isIntersecting) {
      const img = entry.target
      const src = img.dataset.src
      
      // 加载图片
      img.src = src
      
      // 加载完就不再观察了
      observer.unobserve(img)
    }
  })
})

// 观察所有图片
const images = document.querySelectorAll('img[data-src]')
images.forEach(img => observer.observe(img))

优势

  • 无需监听滚动,性能更好
  • 自动处理元素在可视区的判断
  • 提供精细的阈值配置

阈值设置的艺术

阈值(threshold)决定元素进入可视区多少时触发回调:

const observer = new IntersectionObserver(callback, {
  // 阈值数组:触发回调的交叉比例
  threshold: [0, 0.25, 0.5, 0.75, 1]
  
  // 或者单个值
  // threshold: 0.5 // 50% 可见时触发
})

不同阈值的适用场景

阈值触发时机适用场景
0元素刚进入可视区普通图片懒加载
0.1-0.3元素部分可见预加载、提前加载
0.5元素半可见视频自动播放
1元素完全可见广告曝光统计

自定义指令 v-lazy:让任何图片都能懒加载

指令的生命周期

在 Vue3 中,自定义指令的生命周期钩子包括:

const vLazy = {
  // 在绑定元素的 attribute 或事件监听器被应用之前调用
  created(el, binding, vnode) {},
  
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {},
  
  // 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {
    // 通常在这里初始化懒加载
  },
  
  // 在包含组件的 VNode 更新之前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  
  // 在包含组件的 VNode 及其子组件的 VNode 更新之后调用
  updated(el, binding, vnode, prevVnode) {},
  
  // 在绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {},
  
  // 在绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {
    // 清理工作
  }
}

基础 v-lazy 指令实现

// directives/vLazy.ts
import { Directive } from 'vue'

interface LazyOptions {
  loading?: string      // 加载中占位图
  error?: string        // 加载失败占位图
  threshold?: number    // 阈值
  rootMargin?: string   // 扩展区域
}

class LazyManager {
  private observer: IntersectionObserver | null = null
  private cache = new Set<string>() // 已加载图片缓存
  
  constructor(options: LazyOptions = {}) {
    this.observer = new IntersectionObserver(
      this.onIntersection.bind(this),
      {
        root: null,
        rootMargin: options.rootMargin || '50px',
        threshold: options.threshold || 0
      }
    )
  }
  
  private onIntersection(entries: IntersectionObserverEntry[]) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target as HTMLImageElement
        const src = img.dataset.src
        if (src && !this.cache.has(src)) {
          this.loadImage(img, src)
        }
      }
    })
  }
  
  private loadImage(img: HTMLImageElement, src: string) {
    const tempImg = new Image()
    
    tempImg.onload = () => {
      img.src = src
      img.classList.add('loaded')
      this.cache.add(src)
      this.observer?.unobserve(img)
    }
    
    tempImg.onerror = () => {
      img.src = img.dataset.error || ''
      img.classList.add('error')
      this.observer?.unobserve(img)
    }
    
    tempImg.src = src
  }
  
  add(img: HTMLImageElement, src: string, errorSrc?: string) {
    if (this.cache.has(src)) {
      // 已缓存,直接显示
      img.src = src
    } else {
      // 设置占位图并开始观察
      img.src = img.dataset.loading || ''
      img.dataset.src = src
      if (errorSrc) {
        img.dataset.error = errorSrc
      }
      this.observer?.observe(img)
    }
  }
  
  remove(img: HTMLImageElement) {
    this.observer?.unobserve(img)
  }
}

const lazyManager = new LazyManager()

export const vLazy: Directive<HTMLImageElement, string> = {
  mounted(el, binding) {
    const { value, modifiers } = binding
    
    // 处理修饰符
    const options = {
      loading: modifiers.loading ? binding.instance?.loadingSrc : undefined,
      error: modifiers.error ? binding.instance?.errorSrc : undefined
    }
    
    lazyManager.add(el, value, options.error)
  },
  
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      lazyManager.add(el, binding.value)
    }
  },
  
  unmounted(el) {
    lazyManager.remove(el)
  }
}

使用 v-lazy 指令

<template>
  <div>
    <!-- 直接使用,图片会自动懒加载 -->
    <img v-lazy="imageUrl" alt="图片">
    
    <!-- 也可以自定义占位图 -->
    <img 
      v-lazy="imageUrl" 
      :loading-src="'/images/my-loading.gif'"
      :error-src="'/images/my-error.png'"
      alt="图片"
    >
  </div>
</template>

<script setup>
import { vLazy } from './directives/vLazy'
import { ref } from 'vue'

const imageUrl = ref('https://example.com/large-image.jpg')
</script>

渐进式加载(LQIP):先模糊后清晰

什么是渐进式加载?

先看到模糊的占位图(很小)
    ↓
等高清图加载完成
    ↓
平滑过渡到高清图

LQIP 原理

LQIP (Low Quality Image Placeholders) 的核心思想:先展示一个极低质量的模糊图,等原图加载完成后,平滑过渡到高清图

graph LR
    A[原始图片] --> B[生成缩略图]
    B --> C[Base64/内联]
    C --> D[展示模糊占位]
    D --> E[加载原图]
    E --> F[平滑过渡]

如何生成缩略图?

方案一:构建时生成(推荐)

// vite.config.js 配合 imagemin 生成缩略图
import imagemin from 'imagemin'
import imageminJpegtran from 'imagemin-jpegtran'

// 构建时自动生成缩略图
await imagemin(['src/assets/**/*.{jpg,png}'], {
  destination: 'dist/thumbnails',
  plugins: [
    imageminJpegtran({ progressive: true })
  ]
})

方案二:运行时动态生成(性能差,不适合大量图片)

function generateThumbnail(file, maxSize = 20) {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.onload = (e) => {
      const img = new Image()
      img.onload = () => {
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')
        
        // 缩小图片
        canvas.width = maxSize
        canvas.height = (img.height / img.width) * maxSize
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
        
        resolve(canvas.toDataURL('image/jpeg', 0.5))
      }
      img.src = e.target.result
    }
    reader.readAsDataURL(file)
  })
}

方案三:使用现成的 CDN 服务

const thumbnailUrl = `https://images.example.com/w=20,q=30/${originalPath}`

使用渐进式图片组件

<template>
  <div class="gallery">
    <ProgressiveImage
      v-for="item in images"
      :key="item.id"
      :src="item.src"
      :thumbnail="item.thumbnail"
      :alt="item.alt"
    />
  </div>
</template>

<script setup>
import ProgressiveImage from './ProgressiveImage.vue'

const images = ref([
  {
    id: 1,
    src: 'https://example.com/high-quality.jpg',
    thumbnail: 'https://example.com/low-quality.jpg',
    alt: '风景图'
  }
])
</script>

加载失败兜底:完善的错误处理机制

基础错误处理

<template>
  <div class="image-wrapper">
    <img
      ref="imgRef"
      :src="currentSrc"
      :alt="alt"
      @error="handleError"
      @load="handleLoad"
    />
    <div v-if="loadingFailed" class="error-overlay">
      <span>⚠️ 图片加载失败</span>
      <button @click="retry">重试</button>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  src: String,
  alt: String,
  fallbackSrc: { type: String, default: '/images/fallback.png' },
  retryCount: { type: Number, default: 3 }
})

const imgRef = ref()
const currentSrc = ref(props.src)
const loadingFailed = ref(false)
const retryAttempts = ref(0)

const handleError = () => {
  if (retryAttempts.value < props.retryCount) {
    // 重试
    retryAttempts.value++
    setTimeout(() => {
      currentSrc.value = props.src + `?retry=${retryAttempts.value}`
    }, 1000 * retryAttempts.value) // 指数退避
  } else {
    // 使用兜底图
    currentSrc.value = props.fallbackSrc
    loadingFailed.value = true
  }
}

const handleLoad = () => {
  loadingFailed.value = false
  retryAttempts.value = 0
}

// 监听 src 变化,重置状态
watch(() => props.src, (newSrc) => {
  currentSrc.value = newSrc
  loadingFailed.value = false
  retryAttempts.value = 0
})
</script>

指数退避重试算法

class RetryStrategy {
  private attempts = 0
  private maxAttempts: number
  private baseDelay: number
  
  constructor(maxAttempts = 3, baseDelay = 1000) {
    this.maxAttempts = maxAttempts
    this.baseDelay = baseDelay
  }
  
  getDelay(): number {
    this.attempts++
    if (this.attempts > this.maxAttempts) {
      return -1 // 不再重试
    }
    // 指数退避:baseDelay * 2^(attempts-1)
    return this.baseDelay * Math.pow(2, this.attempts - 1)
  }
  
  reset(): void {
    this.attempts = 0
  }
}

// 使用
const strategy = new RetryStrategy(5, 500)
const delay = strategy.getDelay()
if (delay > 0) {
  setTimeout(() => retry(), delay)
}

多种占位图策略

const placeholders = {
  // 纯色背景 + 文字
  color: (color = '#f0f2f5', text = '暂无图片') => {
    const canvas = document.createElement('canvas')
    canvas.width = 200
    canvas.height = 200
    const ctx = canvas.getContext('2d')
    ctx.fillStyle = color
    ctx.fillRect(0, 0, 200, 200)
    ctx.fillStyle = '#999'
    ctx.font = '14px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText(text, 100, 100)
    return canvas.toDataURL()
  },
  
  // 内置图标
  icon: (type = 'image') => {
    // 返回内置图标 URL
    return `/icons/placeholder-${type}.svg`
  },
  
  // 纯 Base64 透明图
  transparent: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
}

虚拟列表 + 懒加载:处理大量图片

问题分析

在虚拟列表中,大量图片同时存在,比如 1000 张。如果全部进行 IntersectionObserver 观察,仍会造成性能问题。最佳策略是:只观察可视区附近的元素

虚拟列表 + 懒加载的实现

<template>
  <div
    ref="containerRef"
    class="virtual-list"
    @scroll="onScroll"
  >
    <div
      class="list-phantom"
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <div
      class="list-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        <!-- 只在可视区内的图片才加载 -->
        <ProgressiveImage
          v-if="shouldLoadImage(item)"
          :src="item.src"
          :thumbnail="item.thumbnail"
        />
        <div v-else class="placeholder"></div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import ProgressiveImage from './ProgressiveImage.vue'

const props = defineProps({
  items: Array,
  itemHeight: { type: Number, default: 200 },
  buffer: { type: Number, default: 5 } // 缓冲区大小
})

const containerRef = ref()
const scrollTop = ref(0)

// 计算可视区域
const visibleCount = computed(() => 
  Math.ceil(containerRef.value?.clientHeight / props.itemHeight)
)

// 计算起始索引(带上缓冲区)
const startIndex = computed(() => {
  let index = Math.floor(scrollTop.value / props.itemHeight)
  return Math.max(0, index - props.buffer)
})

// 计算结束索引
const endIndex = computed(() => {
  let index = startIndex.value + visibleCount.value + props.buffer * 2
  return Math.min(index, props.items.length)
})

// 可视区域内的项目
const visibleItems = computed(() => 
  props.items.slice(startIndex.value, endIndex.value)
)

// 总高度
const totalHeight = computed(() => 
  props.items.length * props.itemHeight
)

// 偏移量
const offsetY = computed(() => 
  startIndex.value * props.itemHeight
)

// 判断图片是否应该加载
const shouldLoadImage = (item) => {
  const index = props.items.indexOf(item)
  // 只加载可视区及前后 buffer 范围内的图片
  return index >= startIndex.value && index < endIndex.value
}

const onScroll = () => {
  scrollTop.value = containerRef.value.scrollTop
}
</script>

性能对比

方案DOM 节点数内存占用滚动帧率
直接渲染1000080MB5-10fps
懒加载1000080MB15-20fps
虚拟列表 + 懒加载205MB60fps

预加载 - 提前加载重要图片

什么情况需要预加载?

用户即将看到的图片,都需要进行预加载:

  • 轮播图的下一张
  • 鼠标悬停的图片
  • 首屏的关键图片
  • 预计用户会看的图片

带进度条的预加载组件

<template>
  <div class="preload-container">
    <!-- 预加载进度条 -->
    <div v-if="loading" class="progress-wrapper">
      <div class="progress-bar">
        <div class="progress-fill" :style="{ width: progress + '%' }"></div>
      </div>
      <div class="progress-text">{{ Math.round(progress) }}%</div>
    </div>
    
    <!-- 加载完成后显示图片 -->
    <img v-else :src="currentSrc" :alt="alt" />
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  src: String,
  alt: String,
  priority: { type: Boolean, default: false }  // 是否高优先级
})

const currentSrc = ref('')
const loading = ref(true)
const progress = ref(0)

// 使用 XMLHttpRequest 实现进度跟踪
const loadImage = () => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('GET', props.src, true)
    xhr.responseType = 'blob'
    
    xhr.onload = () => {
      if (xhr.status === 200) {
        const blob = xhr.response
        const url = URL.createObjectURL(blob)
        resolve(url)
      } else {
        reject(new Error('加载失败'))
      }
    }
    
    xhr.onerror = reject
    
    xhr.onprogress = (e) => {
      if (e.lengthComputable) {
        progress.value = (e.loaded / e.total) * 100
      }
    }
    
    xhr.send()
  })
}

onMounted(async () => {
  try {
    const url = await loadImage()
    currentSrc.value = url
    loading.value = false
  } catch (error) {
    console.error('图片加载失败', error)
    loading.value = false
  }
})

onUnmounted(() => {
  if (currentSrc.value) {
    URL.revokeObjectURL(currentSrc.value)
  }
})
</script>

<style scoped>
.progress-wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

.progress-bar {
  width: 200px;
  height: 4px;
  background: #f0f0f0;
  border-radius: 2px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: #1890ff;
  transition: width 0.1s ease;
}

.progress-text {
  font-size: 12px;
  color: #666;
}
</style>

最佳实践清单

配置清单

  • 使用 IntersectionObserver 实现懒加载
  • 封装 v-lazy 指令,方便复用
  • 大图使用渐进式加载(先模糊后清晰)
  • 配置错误重试机制(指数退避)
  • 大量图片使用虚拟列表
  • 重要图片使用预加载
  • 添加 loading 动画或骨架屏

不同场景的选择

场景技术方案性能收益
普通图片v-lazy 指令 + IntersectionObserver减少 80% 首屏请求
高质量图片LQIP + 平滑过渡提升 60% 感知性能
图片墙/画廊虚拟列表 + 按需加载内存占用减少 90%
关键图片预加载 + 进度条用户体验提升
轮播图预加载下一张流畅切换

实施清单

  1. 懒加载基础:使用 IntersectionObserver 实现 v-lazy 指令
  2. 渐进式增强:LQIP 模糊占位 + 高清图过渡
  3. 错误处理:重试机制 + 兜底图
  4. 性能优化:虚拟列表 + 缓冲区控制
  5. 用户体验:进度反馈 + 平滑动画

最后的建议

图片加载优化不是单一技术,而是一个系统工程:

  • 网络层面:使用 HTTP/2、CDN 加速
  • 构建层面:压缩、格式转换、雪碧图
  • 运行时层面:懒加载、预加载、缓存
  • 体验层面:进度反馈、平滑过渡

结语

好的图片加载策略应该是无感知的。用户不会注意到图片是懒加载的,不会注意到有进度条,他们只会感觉页面"很快很流畅"。这才是优化的最高境界。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!