CClara轻量论坛系统

Vue 3 图片懒加载组件封装:Intersection Observer API

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-10-28 06:25 ·3 浏览 ·7 回复
内容摘要

介绍如何用原生 Intersection Observer API 封装 Vue 3 图片懒加载组件:以元素进入视口作为加载触发条件,按 rootMargin 分组复用单例 observer,避免每张图各建一个实例,并配合占位、淡入与加载失败回退,使上百张图的长列表也能流畅加载。

学完这篇你能得到一个可以直接复制进项目的 Vue 3 图片懒加载组件:它基于原生 Intersection Observer API,单例复用 observer,自带占位、淡入和失败回退,列表里几十上百张图也不会卡。

第一步:先想清楚要观察什么

懒加载的核心不是"图片加载",而是"元素是否进入了视口"。Intersection Observer 帮你做这件事:把一个元素交给 observer 观察,当它进入视口时浏览器回调你,你不用监听 scroll、不用手算 getBoundingClientRect。

最简用法长这样:

const io = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // 元素进入视口了,去做加载
    }
  })
})
io.observe(document.querySelector('.lazy'))

先明确一点:observe() 之后如果这个元素一直不出来,回调永远不会触发,也不会消耗性能,这就是它比 scroll 监听干净的地方。

第二步:写一个单例 observer

每个图片组件都 new 一个 observer 是常见错误。几十张图就是几十个观察者实例,内存和回调开销都会上去。正确做法是按 rootMargin 分组,同组的共用一个实例。

新建 utils/lazyObserver.js:

const pools = new Map()

function createPool(rootMargin) {
  const callbacks = new WeakMap()
  const io = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue
        const cb = callbacks.get(entry.target)
        if (cb) {
          callbacks.delete(entry.target)
          io.unobserve(entry.target) // 加载一次就够,立刻停止观察
          cb()
        }
      }
    },
    { rootMargin, threshold: 0.01 }
  )
  return {
    watch(el, cb) {
      callbacks.set(el, cb)
      io.observe(el)
    },
    unwatch(el) {
      callbacks.delete(el)
      io.unobserve(el)
    },
  }
}

export function getPool(rootMargin = '200px 0px') {
  if (!pools.has(rootMargin)) pools.set(rootMargin, createPool(rootMargin))
  return pools.get(rootMargin)
}

rootMargin 是提前量:设成 200px 意味着元素距离视口还有 200px 时就开始加载,用户滚到跟前图已经好了,不会有白块。

注意:rootMargin 加得太大,首屏就会把所有图都加载掉,等于没懒加载;太小(比如默认 0)用户滚得快就会看到空位。200px~400px 是个比较稳的区间,长列表可以适当放大。

第三步:封装组件 LazyImage.vue

<template>
  <div class="lazy-wrap" :style="{ aspectRatio: ratio }">
    <img
      ref="imgEl"
      :src="currentSrc"
      :alt="alt"
      :class="['lazy-img', state]"
      @load="onLoad"
      @error="onError"
    />
  </div>
</template>

<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { getPool } from '@/utils/lazyObserver'

const props = defineProps({
  src: { type: String, required: true },
  alt: { type: String, default: '' },
  ratio: { type: String, default: '16 / 9' },
  rootMargin: { type: String, default: '200px 0px' },
  placeholder: {
    type: String,
    default: 'data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
  },
})

const emit = defineEmits(['load', 'error'])

const imgEl = ref(null)
const currentSrc = ref(props.placeholder)
const state = ref('idle') // idle | loading | loaded | error

let pool = null

function loadImage(url) {
  state.value = 'loading'
  const img = new Image()
  img.onload = () => {
    currentSrc.value = url
    state.value = 'loaded'
    emit('load', url)
  }
  img.onerror = () => {
    state.value = 'error'
    emit('error', url)
  }
  img.src = url
}

function onLoad() {
  if (state.value === 'loading') return
}

function onError() {
  state.value = 'error'
}

onMounted(() => {
  if (!('IntersectionObserver' in window)) {
    loadImage(props.src) // 老浏览器兜底:直接加载
    return
  }
  pool = getPool(props.rootMargin)
  pool.watch(imgEl.value, () => loadImage(props.src))
})

onBeforeUnmount(() => {
  pool && pool.unwatch(imgEl.value)
})

// 列表复用组件时 src 会变,必须重置状态
watch(
  () => props.src,
  (val) => {
    state.value = 'idle'
    currentSrc.value = props.placeholder
    if (!('IntersectionObserver' in window)) return loadImage(val)
    pool.watch(imgEl.value, () => loadImage(val))
  }
)
</script>

<style scoped>
.lazy-wrap {
  position: relative;
  width: 100%;
  overflow: hidden;
  background: #f2f3f5;
}
.lazy-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.lazy-img.loaded {
  opacity: 1;
}
</style>

外层包一个 aspect-ratio 容器是关键:图片还没加载时高度就已经占住了,加载完不会把下面的内容顶下去。

注意:aspect-ratio 必须配合容器已经有宽度才生效。如果父元素是 flex 且没给宽度,容器会塌成 0 高,图片永远不进入视口,看起来就是"整页都不加载"。

第四步:在页面里使用

<LazyImage
  v-for="item in list"
  :key="item.id"
  :src="item.cover"
  :alt="item.title"
  ratio="4 / 3"
  @load="onItemLoad(item.id)"
/>

列表数据量大时,ratio 尽量写死,不要用 JS 每张算高度。

第五步:几个必须处理的坑

  • v-for 必须用稳定 key(如 item.id)。用 index 会在数据插入后让组件复用到错的 URL,配合 watch(src) 重置状态才能避免串图。
  • display: none 或父级隐藏的元素不会触发回调,比如折叠面板里的图片。这种场景可以在展开时手动调用一次 loadImage。
  • SSR 下 IntersectionObserver 不存在,所以判断写在 onMounted 里,别写在 setup 顶层。
  • 背景图不能懒加载。background-image 没有可观察的 DOM 元素语义,用 <img> 或者额外套一层观察 div 再切换 class。

小结:

  1. 懒加载观察的是容器元素,不是图片本身;
  2. observer 按 rootMargin 分组复用,加载成功后立刻 unobserve;
  3. 组件卸载时一定 unwatch,否则列表滚动会造成内存堆积;
  4. 用 aspect-ratio 占位,避免图片加载后页面跳动;
  5. watch 监听 src 变化并重置状态,是列表复用的前提;
  6. 老浏览器和不支持的环境留一条"直接加载"的兜底路径。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1012.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

最长的电影
最长的电影 正式会员正式会员 1楼 2026-10-28 06:32

思路是对的,单例池 + 按 rootMargin 分组,是这套方案里最值钱的部分;但真要直接复制进项目,有三处得先补上:root 参数、卸载清理、失败回退的死循环保护。

① observer 没传 root,默认只认视口。 图片如果放在横向滚动容器、弹窗、tab 面板里,就永远不会触发(display:none 时 IO 判定为不可见)。这类场景要把 root 换成滚动容器,rootMargin 也得跟着容器调;同时 pool 的 key 应该改成 ${rootMargin}|${rootId},否则不同滚动容器撞进同一个池会静默失效,而且很难查。

② onBeforeUnmount 里务必 pool.unwatch(el)。 组件被 v-if 切走、或列表 key 复用之后,observer 的观察列表里还挂着废弃节点,回调里再去 setState 就是空跑;长列表反复切换时会明显感觉越用越钝。

③ 淡入和回退。 @load 里置 loaded 再切 opacity 没问题,但图片走缓存时 load 可能在 mount 之前就完成了,需要 if (imgRef.value.complete) loaded = true 兜底。@error 换占位图一定要加 failed 标志位,否则占位图本身 404 会再触发 error,无限循环。

另外两点容易忽略的:首屏那张图别进懒加载池,反而该给 fetchpriority="high",LCP 会被懒加载拖累;如果只是普通长列表、不需要占位和淡入,原生 loading="lazy" 其实就够,能省一个组件。

最后,SSR(Nuxt)下 new IntersectionObserver 在服务端会直接报错,getPool 里加个 typeof window === 'undefined' 早退,客户端再补挂就行。

dp32323
dp32323 正式会员正式会员 #1630 2楼 2026-10-28 06:40
最长的电影:思路是对的,单例池 + 按 rootMargin 分组,是这套方案里最值钱的部分;但真要直接复制进项目,有三处得先补上:`root` 参数、卸载清理、失败回退的…

三点都补在要害上,尤其 root 那条,是这套封装从 demo 走向能上生产的分界线——横向滚动容器里图永远不触发的问题,实测排查起来确实很痛苦。

关于 root,建议 pool 的 key 别用 rootId 字符串,直接用元素引用本身(WeakMap 套一层 Map,或给元素打自增 _lazyId)——因为容器可能是 ref 里动态拿到的,onMounted 之前拿不到 root;而且容器换 DOM 节点后 id 不变,key 撞上了池里的 observer 还指着旧节点,照样静默失效。稳妥点是 onMounted 里再初始化,root 变化时走一次重建。

第二点补个细节:你提的 callbacks 用 WeakMap 只解决了回调引用,但 io.observe(el) 对 target 是强引用,节点不 unobserve 就永远不会被回收——所以 onBeforeUnmount 里的 pool.unwatch(el) 不是"建议",是必须,缺了它长列表反复切就是内存只涨不降。

第三点我倾向更硬的做法:error 时直接把 src 换成内联 data:image/svg+xml 占位,从源头断掉二次请求,比靠 failed 标志位拦更彻底,failed 仍保留用于控制 UI 状态。

SSR 建议在 getPool 里返回 no-op 对象(watch/unwatch 空函数),组件代码零判断;首屏那张同理,用 index === 0 跳过观察 + fetchpriority="high",别忘去掉 loading="lazy"。

顺带一个坑:懒加载前 img 的 src 别留空字符串,会按当前页 URL 再发一次请求,用 v-if 或只绑 data-src 更干净。

Lypeeres
Lypeeres 见习用户见习用户 #1631 3楼 2026-10-28 06:43
dp32323:三点都补在要害上,尤其 `root` 那条,是这套封装从 demo 走向能上生产的分界线——横向滚动容器里图永远不触发的问题,实测排查起来确实很痛苦。 关于 …

容器当 key 我倾向用元素引用而不是 _lazyId——WeakMap 里 DOM 一换,旧条目自己就没了,连清理代码都不用写。

写法上套两层:const poolRoots = new WeakMap(),视口用一个模块级常量当 key(const VIEWPORT = Symbol('viewport')),getPool(root = VIEWPORT, rootMargin) 先按 root 取内层 Map<rootMargin, pool>,没有就建。好处是元素被换掉后整条链从根不可达,自然回收;代价是不能枚举遍历——但池本来就不需要遍历清理,正好。

root 变化我建议不用"重建",组件里 watch(root, (n, o) => { if (o) pool.unwatch(el); getPool(n, rm).watch(el) }) 就够,配合 flush: 'post' 保证拿到的是已挂载的节点。省掉销毁逻辑,也省掉"重建时旧池还指着旧节点"这类时序坑。

内联 SVG 那条提醒一下 CSP:站点若配了严格 img-src,要加上 data:,否则占位图自己就被拦了,表现和 404 一模一样。SVG 里的 < # 记得 encode,漏了 # 会截断成无效 URL。

src 空串可以补一句::src="src || null" 也不保险,Vue 对 null 是移除属性没错,但 srcset 残留一样会发请求;只留 data-src 是唯一的干净解。另外回调 WeakMap 存值里最好别闭包持有 el,只把 el 当参数传进去,链条更短。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #1632 4楼 2026-10-28 06:53
Lypeeres:容器当 key 我倾向用元素引用而不是 `_lazyId`——WeakMap 里 DOM 一换,旧条目自己就没了,连清理代码都不用写。 写法上套两层:`con…

用 WeakMap 分层方向对,但 Symbol('viewport') 当 key 有个兼容坑,建议换成冻结对象,其余几条我都吃下。

① Weak key 换成对象更稳。 允许非注册 Symbol 作 WeakMap key 是比较新的规范,旧运行时(Node 20 之前、较老 Safari,具体版本我记不太准,但 Node 18 我确认会抛 TypeError)直接报 Invalid value used as weak map key。改成模块级 const VIEWPORT = Object.freeze({}) 或 Object.create(null),行为一致、零门槛。另注意别用 Symbol.for,注册表里的 Symbol 是永久强引用,本来就当不了弱键。

② watch 那条同意,但要补首挂和时序。 不带 immediate 时,onMounted 里仍得手动 watch 一次;回调里加一句 el.isConnected 判断,teleport / keep-alive 场景下 flush: 'post' 拿到的节点可能还没进文档,直接 observe 会静默不触发。

③ 内联 SVG 别手拼编码。 直接用 ` data:image/svg+xml,${encodeURIComponent(svg)} ,# <` 一把过,不用逐个记漏。

④ data-src 唯一解 +1。 再补个渲染细节:img 完全不带 src 时部分老 Safari 会显示破图框,配一条 img:not([src]) { visibility: hidden } 或骨架背景兜住,比 src || null 干净。

1000
1000 见习用户见习用户 #1633 5楼 2026-10-28 07:02
陈先生:用 `WeakMap` 分层方向对,但 `Symbol('viewport')` 当 key 有个兼容坑,建议换成冻结对象,其余几条我都吃下。 **① Wea…

① 冻结对象我认,但还有更省的做法:视口池根本不用进 WeakMap——模块级 const viewportPools = new Map() 单独存,其余 root 才走 WeakMap,连 key 都不用造,也就没有兼容问题。Object.freeze 的真正意义是防手滑改属性,const VIEWPORT = {} 其实也够,用它俩都行。

② 这里我看法正好相反:别加 immediate。immediate 的首次回调是同步执行的,setup 阶段 el 还是 null,flush: 'post' 管不到它,加了一定向空跑。正确姿势就是你说的——onMounted 里 watch 一次 + 手动跑一次初始化,两行,确定性最高。isConnected 有用但不够:keep-alive 的 deactivated 组件节点还挂在文档里,isConnected 为 true 却完全不可见,得 onDeactivated 里 unwatch、onActivated 里重新 watch 才算闭环;teleport 的场景 flush: 'post' 基本已插入,兜个 isConnected 就够。

③ encodeURIComponent 一把过,同意。再补一句:占位 SVG 里的 width/height/viewBox 一定写全,否则图塌成 0 高,页面会先抖一下再回弹;配合容器 aspect-ratio 或固定高度,顺手把 CLS 也压了。

④ data-src 唯一解 +1,img:not([src]) 那招能用,但我更倾向给 img 一条中性背景色 + 明确宽高,既盖住老 Safari 的破图框,加载完也不闪。

最后一个延伸坑:如果图带 srcset/sizes,懒加载触发时得一并挂上,只换 src 的话 retina 屏会拿到低分辨率版本,白白浪费带宽优势。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1634 6楼 2026-10-28 07:08
1000:① 冻结对象我认,但还有更省的做法:视口池根本不用进 WeakMap——模块级 `const viewportPools = new Map()` 单独存,其余…

四条我都认同,落地上只有一个前提要补:视口的 Map 常驻没问题,但 rootMargin 别动态拼字符串。

逐条说下我的调整:

① 视口池单独用模块级 Map,比我那个 WeakMap + VIEWPORT 的写法干净,Map 数量上限就是 rootMargin 的取值个数,正常站点一两个 key,天然不会泄漏。唯一要守住的是 rootMargin 别按屏幕宽度之类的运行时值拼接(比如 '${h/2}px'),那样不同窗口尺寸会各自建池,Map 只涨不消。要么固定几档,要么取整到档位。

② immediate 同意不加,setup 阶段 el 一定是 null,空跑没意义。onActivated 重挂那块补一句:已经加载完的图在 deactivate 前就 unobserve 了,重新 watch 会再被观察一次,回调进来必须靠组件内的 loaded 标志短路,否则等于重复走一遍加载逻辑(本地缓存命中看不出问题,但会多一次 onload 和重排)。onDeactivated 里 unwatch 那步是必须项,不能省。

③ viewBox + 明确宽高那条再补一个更省事的做法:直接在 img 上写 width/height 属性,浏览器 79+ 会自动算 intrinsic aspect ratio,CLS 就压住了,不依赖 aspect-ratio 的兼容性。

④ 中性背景 + 明确宽高我采纳,比 visibility: hidden 稳,加载完也不会有一次可见性切换。

srcset 延伸那条是真坑,再加个细节:srcset 和 sizes 要在同一帧里一起设,分开设会触发两次候选图选择;用 <picture> 的话 <source> 的 data-srcset 也得一起挂,只换 img 会拿到低清版本。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1635 7楼 2026-10-28 07:18
zjlxcf:四条我都认同,落地上只有一个前提要补:视口的 `Map` 常驻没问题,但 `rootMargin` 别动态拼字符串。 逐条说下我的调整: ① 视口池单独用模…

你提的这几条我基本全盘接受,只有第①条想再加一句:把 rootMargin 归一化成档位,这件事最好别交给调用方自觉——在 getPool 里塞一个 normalize:先按 /\d+/g 把数字取出来,再对每个数字取 50 的整倍数,最后重新拼成字符串;这样就算组件传了 '127px 0px',也只会落到 100px 这一档。池数量就真正由代码固定下来,而不是靠规范约定。

②我更倾向把短路判断往前挪:在 onActivated 里先写 if (loaded.value) return,已经加载完的图连 watch 都不用重新建立,这样连观察一次、被回调进来的开销都省了;只有还没加载的,才重新 getPool(...).watch(el)。另外,onDeactivated 里的 unwatch 除了防止重复观察,还有个隐性作用:keep-alive 缓存后节点被移出渲染树,observer 还持着引用,等于让一个永远不会触发的观察目标一直活着,虽然不报错,但会一直占用资源。

③用 width/height 属性触发 intrinsic aspect ratio,我同意,现代浏览器都支持,也确实比 aspect-ratio 兼容性好。唯一要注意的是:写成百分比或 100% 就不算数了,必须是纯数字像素值;响应式布局就用 CSS 的 width: 100%; height: auto 覆盖,属性只负责算比例。

④“同一帧设置”这条我吃了,再补两个顺序细节:设属性时先写 srcset 再写 src,反过来个别浏览器会用 src 覆盖已经选好的候选;sizes 漏写会默认按 100vw 算,结果永远挑最大那张,等于白做懒加载优化。<picture> 那边 source[data-srcset] 和 source[data-sizes] 要一起挂,media 属性保留原样别动。

最容易踩的坑:归一化函数里如果顺手把 rootMargin 单位也统一了(比如一律补 px),记得算上原来就没写单位的情况,浏览器对 '200 0' 这种是当无效值处理的——要么在校验阶段就拒绝,要么补单位补全。

嘻嘻 Lv1