Vue图片懒加载实现方案:原生、自定义指令与VueUse实践
做 Vue Web 端项目尤其是后台管理、内容列表、商城首页这类页面图片往往是性能的第一短板。一个商品列表动辄上百张图全部一起加载下来首屏白屏时间、接口带宽、CDN 流量都会被拖累。图片懒加载的核心思想很简单图片进入视口之前不加载滚动到接近视口时才真正发起请求。本文从原生方案到 Vue 自定义指令再到 VueUse 封装和第三方库完整梳理 Web 端 Vue 项目里实现图片懒加载的几种可行做法同时结合我的实际项目经验把踩过的坑一起写出来适合正在做 Vue 生态项目的前端开发参考。1. 为什么要做图片懒加载先看清请求瀑布图很多同学写 demo 时页面就三张图看不出来什么问题。但真实项目里一个列表接口返回 200 条数据每条至少一张封面图如果把这些图片地址直接塞进img src浏览器会一次性把几十上百个图片请求全部并发发出去。打开 Network 面板看瀑布图那一长排浅蓝色的图片请求非常壮观首屏也肉眼可见地卡。1.1 浏览器加载机制与懒加载的本质先理清一个概念懒加载并不是让图片“消失”而是把加载时机延后。用户滚到哪就加载哪不滚到就不加载。首屏只发送视口内那几张图的请求其他请求被拆分到滚动过程中避免网络带宽在页面初始化阶段被瞬间打满。这背后是浏览器的 HTML 解析策略决定的。解析器遇到带src的img标签后会立刻认为这是一个需要加载的资源将其加入加载队列。大量图片同时进入队列会带来几个连锁反应图片解码占用 CPU 和内存滚动和交互出现明显掉帧。网络层出现队头阻塞重要接口的响应被图片请求拖慢。移动端更明显弱网环境下页面能卡好几秒。懒加载的本质是把这些“渲染阻塞”从页面打开瞬间往后挪让首屏快速可交互再逐步按需补齐剩余资源。最终用户如果把整个列表都滚完图片还是会全部加载流量并不会减少但关键渲染路径上的资源竞争会大幅缓解这对 LCP、FCP 等性能指标的影响是实打实的。1.2 哪些场景必须上懒加载我自己罗列了几个“必须上”的场景如果你的项目命中其中任意一条都值得认真考虑列表页多图商品列表、资讯卡片、瀑布流内容这是最常见的场景。弹窗或折叠面板中的图片面板未展开时图片完全不可见加载了也没人看得到。路由切换后长页面的底部图集比如详情页底部的关联推荐区、图集区。H5 页面内嵌入大量运营位图片一个页面七八个轮播位每个轮播位背后还有十几张图。反过来的情况也要注意Logo、头像、首屏 LCP 元素、骨架屏背景图这种用户打开页面就应该立刻看到的资源不需要懒加载。强行懒加载反而会造成首屏大面积占位图闪烁视觉上更难受。判断标准很简单——问自己一句这个图会不会出现在首屏的 LCP 区域内如果是就别懒加载如果不是懒加载就是划算的。2. 最简单的一条路原生 loadinglazy如果你项目简单、图片数量不算太多、兼容性要求也不苛刻可以先考虑直接用浏览器原生能力。这个方案零依赖Vue 模板里直接写属性就行也是目前性价比最高的一种做法。2.1 原生属性和 Vue 模板的配合用法相当直白img src/images/cover.jpg loadinglazy alt封面图浏览器在解析到这个标签后会根据loadinglazy决定是否延迟加载这张图片。原生方案的好处是浏览器底层做了很多优化包括滚动节流、可见性判断、解码时机调整我们完全不用管。在 Vue 项目里如果配合 v-for 渲染列表模板大概是这样的li v-foritem in list :keyitem.id img :srcitem.cover :altitem.title loadinglazy / /li这里注意一个细节如果你在v-loading或v-if控制的区域使用原生 lazy只有元素真正插入 DOM 后loadinglazy才会生效。Vue 的v-if切换很快影响不大但如果你把display: none的图片也加上了原生 lazy浏览器一般是不会加载这类不可见图的这反而符合懒加载的预期。2.2 原生方案的兼容性与可控性局限原生loadinglazy的兼容性整体不错但如果你想在国内 Web 端做精细控制还是得知道它的边界。我整理了一个简表浏览器环境对 loadinglazy 的支持情况Chrome / Edge76 支持Firefox75 支持Safari15.4 起支持往期版本不支持微信内置 X5 / 系统 WebView版本差异大需要实测IE完全不支持原生方案的局限也很明显可控性太弱。占位图、加载失败兜底、提前加载阈值、动态切换真实地址这些行为你都得自己写事件处理。比如要做失败兜底就得单独监听errorimg :srcitem.cover loadinglazy errorhandleImageError /一个两个还好一整个列表都要监听错误事件模板会非常啰嗦。再加上很多团队还需要兼容旧版 Safari原生方案就不太够用了。这时候就该自己写指令或者上组件库。3. 自己写 Vue 指令v-lazy 从 0 到 13.1 为什么优先选 IntersectionObserver早期的懒加载实现基本都是监听scroll事件然后在回调里用getBoundingClientRect()计算元素相对视口的位置。这个方案的缺点很明显滚动事件触发极其频繁虽然可以节流但页面上元素一多计算量仍然很大滚动时容易出现肉眼可见的卡顿。而且每个图片元素都要手动判断“进入视口没有”代码写起来很啰嗦。IntersectionObserver是浏览器提供的异步观察 API核心作用就是告诉开发者“你关心的那个元素进入了可视区 / 离开了可视区。”它不需要频繁读布局而是由浏览器底层高效调度性能好很多。目前主流浏览器对它的支持已经很成熟连移动端 WebView 的普及度也很高只有特别老的 webview 需要打 polyfill。3.2 从零实现一个 v-lazy 指令接下来直接上代码。Vue 3 项目里我先创建一个指令文件// src/directives/lazy.js let observer null function createObserver() { return new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const el entry.target const realSrc el.dataset.src if (realSrc) { // 真正加载图片 el.src realSrc el.removeAttribute(data-src) // 加载完成后解除观察避免无效回调 observer.unobserve(el) } } }) }, { rootMargin: 0px 0px 200px 0px }) } function getObserver() { if (!observer) { observer createObserver() } return observer } export default { mounted(el, binding) { // 把真实图片地址放在>// main.js import LazyDirective from ./directives/lazy const app createApp(App) app.directive(lazy, LazyDirective) app.mount(#app)模板里就可以直接这样写img v-lazyitem.cover :altitem.title /指令内部逻辑流是挂载时先给元素设置一个 1px 的透明占位图把真实地址存在>const defaultPlaceholder data:image/svgxml;charsetutf-8,%3Csvg xmlnshttp://www.w3.org/2000/svg width100 height100 viewBox0 0 100 100%3E%3Crect width100 height100 fill%23f5f5f5/%3E%3Ctext x50 y50 text-anchormiddle dy0.35em fill%23999 font-size14 font-familysans-serif%3E加载中…%3C/text%3E%3C/svg%3E也可以根据业务环境把占位图地址直接写到指令的 binding value 配置里。第二个是加载失败兜底。真实项目里 CDN 地址失效、图片被限制、网络断开都是可能发生的没有兜底就会出现破图图标。可以在指令里挂一个error监听export default { mounted(el, binding) { if (binding.value) { el.dataset.src binding.value el.addEventListener(error, () { el.src https://cdn.example.com/error.png }) } observer observer.observe(el) } }这里有个细节图片加载失败后如果直接改src还是要确保新地址没有无效请求同时为了避免循环触发 error兜底地址最好放在一个>// 在指令回调中增加 background 判断 if (el.dataset.bg) { el.style.backgroundImage url(${el.dataset.bg}) el.removeAttribute(data-bg) observer.unobserve(el) }3.4 为什么加载前先 new Image 会更顺滑一个很容易被忽略的体验问题是直接在回调里给el.src赋真实地址时图片开始下载到下载完成之间有一段时间的空白尤其是弱网环境下表现更明显视觉上会先看到占位图消失然后出现一块灰底最后才“啪”地一下显示图片。如果想避免这种闪烁可以在赋值前先用 JavaScript 预加载一次function loadImage(el, realSrc) { const image new Image() image.onload () { el.src realSrc observer.unobserve(el) } image.onerror () { el.src errorFallback observer.unobserve(el) } image.src realSrc }原理很简单new Image()触发一次图片下载浏览器会有 HTTP 缓存下载成功后再赋值给el.src页面直接从本机缓存读取图片几乎零延迟。所以并不会出现“下载两次”的带宽浪费这只是用内存缓存换取了更平滑的视觉过渡。3.5 Vue 2 升级 Vue 3 后指令钩子的变化如果你是从 Vue 2 的老项目升级过来要实现懒加载需要特别注意指令生命周期钩子的映射关系。Vue 2 里用的是bind、inserted、update、unbind但 Vue 3 把它们拆成了beforeMount、mounted、updated、beforeUnmount。最常见的坑是Vue 2 项目迁移到 Vue 3 后指令里的inserted没有报错但也没执行图片全都不懒加载了。排查顺序一般是先确认指令有没有真正注册成功再确认钩子名是否正确。4. 工程化方案用 VueUse 封装或直接引入 vue-lazyload自己写指令的优点是可控性强代码量也不大。但如果团队里已经有 VueUse或者项目本身更习惯组件化思维可以直接用现成方案。4.1 基于 VueUse 的 useIntersectionObserver 封装VueUse 的useIntersectionObserver把观察器的状态管理和生命周期都封装好了返回一个stop函数非常适合在组件内部使用。比如组合式 API 下封装一个 LazyImage 组件script setup import { ref, useTemplateRef } from vue import { useIntersectionObserver } from vueuse/core const props defineProps({ src: { type: String, required: true }, alt: { type: String, default: }, placeholder: { type: String, default: } }) const imgRef useTemplateRef(imgRef) const realSrc ref() const { stop } useIntersectionObserver( imgRef, ([entry]) { if (entry.isIntersecting) { realSrc.value props.src stop() // 加载一次后停止观察 } }, { rootMargin: 0px 0px 200px 0px } ) /script template img refimgRef :srcrealSrc || placeholder :altalt / /template这段代码里useIntersectionObserver的第一个参数可以直接传模板 ref组件挂载后会自动建立观察关系。观察回调里拿到isIntersecting为 true 时就把真实地址设置给realSrc同时stop()停止观察避免后续滚动反复触发回调。整个组件只有二三十行不需要全局注册指令也不污染组件作用域。配合 Vite 的按需导入只有用到懒加载的页面才会打进对应的代码这对包体积比较敏感的项目很友好。4.2 vue-lazyload 的配置与老坑提醒vue-lazyload 是 Vue 2 时代非常成熟的图片懒加载库不少老项目的代码里都长这样import VueLazyload from vue-lazyload Vue.use(VueLazyload, { preLoad: 1.3, attempt: 2, loading: require(/assets/loading.png), error: require(/assets/error.png) })Vue 3 项目再使用 vue-lazyload注意注册方式已经变了import { createApp } from vue import VueLazyload from vue-lazyload import App from ./App.vue const app createApp(App) app.use(VueLazyload, { preLoad: 1.3, attempt: 2, loading: https://cdn.example.com/loading.png, error: https://cdn.example.com/error.png })它暴露的指令也是v-lazyimg v-lazyitem.coverImage :altitem.title /vue-lazyload 在实际项目中比较常见的坑有两个。第一个是preLoad参数它表示提前加载区域的倍数1.3 表示在视口底部外 1.3 倍视口高度范围内就开始加载。这个值不要贪大调的过高等于把后半屏的图都提前加载了懒加载就名存实亡。第二个坑是动态列表场景比如搜索条件变化列表从 20 条变成 100 条或者从 100 条变成 5 条如果key不稳定旧的观察器状态可能残留导致新进入视口的图片不加载。处理方式很简单在列表根节点上加一个根据筛选条件变化的key强制重建。4.3 几种方案的选型对比方案代码复杂度可控性兼容性推荐场景原生 loadinglazy最低弱一般简单页面、头像等轻量图片自定义指令 v-lazy中强高项目已有规范需要背景图懒加载、错误兜底、预加载VueUse 封装组件低强高Vue 3 工程且已使用 VueUsevue-lazyload中中中老项目已有依赖或需要快速接入单条图片懒加载本身逻辑不复杂所以我的观点是能不引库就不引库。一个指令文件 50 行能解决的事引入一个第三方库会带来体积、版本、兼容性三重成本。当然如果项目里已经跑着 vue-lazyload为了改一点体验去重写也不值得。5. 实战给一个商品列表页完整加上懒加载理论讲完上点实战。这里用一个典型的商品列表场景后端返回的数据结构长这样[ { id: 1, name: 防晒霜, cover: https://cdn.example.com/p/1.jpg }, { id: 2, name: 保温杯, cover: https://cdn.example.com/p/2.jpg } ]模板部分我一般会给每张图片一个固定的卡片容器ul classgoods-list li v-foritem in goodsList :keyitem.id classgoods-card img v-lazyitem.cover :altitem.name classgoods-cover / div classgoods-name{{ item.name }}/div /li /ul这里有三个实操细节是我在真实项目里反复踩出来的。第一个是图片尺寸固定。懒加载最常见的布局事故就是图片加载完成后把容器撑开导致页面滚动位置跳动。因为懒加载本身依赖可视区判断尺寸突然变化后IntersectionObserver 的生效范围也会跟着变就可能出现“滚到位置了但图片还没触发加载”的错位。解决办法是用 CSS 提前占位最稳妥的方式是写死宽高或使用aspect-ratio.goods-card { aspect-ratio: 4 / 3; overflow: hidden; } .goods-cover { width: 100%; height: 100%; object-fit: cover; }第二个是列表的key必须稳定。如果列表因为排序、筛选而重新渲染但key用的是数组下标复用节点时指令状态会混乱严重的会出现图片地址错位A 商品的图加载到 B 商品的格子里。用后端返回的唯一 id 当 key是最基础也是最重要的习惯。第三个是配合骨架屏。列表数据请求时先渲染一批骨架占位图片懒加载触发后自然替换。比直接放占位图更高级一点体验也更完整。配合前面指令占位图的思路整个链路就是骨架屏 → 占位图 → 真实图层级清晰用户几乎感知不到图片加载过程。路由场景里也顺带提一句如果页面配合了keep-alive缓存路由切走再切回来时组件不会重新 mountedIntersectionObserver 也不会因为页面重新可见而自动重新判断。这时候需要在activated钩子里手动触发一次检查把当前可视区内的图片强制加载一下避免用户看到一片占位图空白。6. 常见问题与排查技巧实录这部分我从实际维护的项目里整理了一个问题速查表遇到类似症状可以对照着排查。症状可能原因解决建议懒加载图片全部白屏老版本浏览器不支持 IntersectionObserver接入 intersection-observer polyfill图片先显示占位图再闪烁后才出图直接赋值 src未预下载用 new Image 预加载后再回填滚动到图片位置时有明显加载延迟rootMargin 预加载阈值太小调整到 0px 0px 200px 0px动态新增列表项不触发加载新增元素没有被 observer 观察数据变更后重新 observe或用 key 重建keep-alive 页面切回来图片仍占位缓存组件不会重新触发观察回调activated 中手动检查可视区并强制加载某些图片加载失败显示破图CDN 地址失效、防盗链限制onerror 兜底 尝试重试一次背景图不生效background-image 不属于 img 懒加载范畴用>