做前端这些年我接手过不少“打开慢得想砸电脑”的页面归根结底大多是一个原因资源加载太贪心。图片、脚本、样式、接口数据全部挤在首屏尤其是一张张高清大图用户还没看到内容带宽已经被吃干净了。懒加载lazy loading就是为解决这种问题而生的经典手段——它把资源的加载时机从“页面初始化”延后到“用户即将看到它”的时刻让图片学会“等你看到再出场”。这篇文章我会从为什么需要懒加载、三种主流实现方案讲起重点聊聊最近社区里讨论很多的 el-table 懒加载和 toggleRowExpansion 手动展开控制最后总结我踩过的坑和量化性能的方法。不管是刚入门的前端、写业务组件的老手还是正在优化线上项目的同学都能在中间找到可以直接复用的方案和代码。1. 先搞清楚懒加载到底在解决什么问题1.1 从“一张大图卡死页面”说起有一次我接手一个活动页设计师很兴奋地安排了 60 多张 1080P 的活动海报当时没做任何懒加载页面首屏加载了一遍所有图片结果测试手机直接卡到爆Network 面板里全是 pending 请求带宽被吃满连接口都返回得很慢。那次之后我彻底意识到懒加载不是“加分项”而是大图页面的刚需。从浏览器角度看页面加载时解析 HTML 遇到img标签就会立刻发起图片请求不管图片在不在视口内。一个页面有几十张高清图就等于首屏同时发起几十个请求消耗大量带宽和连接数。尤其在移动端网络环境不稳定图片下载会直接影响首屏文字和样式的呈现用户等 3 秒没看到内容就会走人。懒加载的思路很简单只加载当前视口内需要的图片其他图片先用占位符占住位置等用户滚动到附近再加载这样首屏请求数大幅下降加载速度自然就上来了。我习惯用一个比喻跟同事解释如果把页面比作一家餐厅普通的加载方式就是客人一进门就把所有菜都做好端出来哪怕客人根本吃不完懒加载则是等客人坐到某个位置、开口点菜了后厨再开火。这个“点菜”的时机就是图片进入视口边缘的那一刻。1.2 懒加载的核心原理交叉观察器、滚动监听、占位图懒加载的本质很简单需要判断“元素是否进入了可视区域”然后决定要不要把真实的资源地址替换到标签上。常见的判断方式有三种分别是浏览器原生的 loadinglazy、基于 IntersectionObserver 的交叉观察、以及老项目里用的 scroll 监听加 getBoundingClientRect 计算。三者各有千秋后面一章我会展开讲这里先理解它们的共同点。在实现上我们通常会把图片的真实地址先存在>img src./poster.jpg loadinglazy alt活动海报 /也可以给 iframe 用比如视频嵌入场景。优点是零成本浏览器自己调度资源不需要写任何 JavaScript也天然支持无 JavaScript 环境的降级只是退化为不懒加载而已。如果你的页面是静态页、小宝典、或者团队里没人愿意维护前端代码原生方案已经是首选。但别迷信它。原生 loadinglazy 的可控性很差做不了“曝光埋点”你很难知道图片什么时候真正被加载了也就无法统计用户滚动行为同时它不提供加载中的占位样式图片加载前那个空白区域需要你自己用 CSS 背景色去兜底还有一点动态插入的图片和某些异步渲染场景浏览器对 loadinglazy 的处理并不完全一致实测在部分 WebView 里会失效。所以但凡涉及业务数据上报、自定义加载动画、复杂布局我还是建议用下一节的方式。另外原生懒加载还有一个让人迷惑的点图片设置了 loadinglazy 之后它的加载时机是由浏览器内部策略决定开发者没法精确控制“提前到多少像素开始加载”。如果你希望用户快到之前就开始加载原生方案基本满足不了。所以大型项目里我更倾向于把控制权握在自己手里。2.2 Intersection Observer API现代浏览器最佳实践IntersectionObserver 是浏览器提供的一个异步观察接口专门用来监听元素与视口或其他容器的交叉状态。它最舒服的一点是不需要自己绑定 scroll 事件也不会因为频繁滚动触发大量计算浏览器内核会在合适的时机回调通知你性能非常好。核心代码如下const images document.querySelectorAll(img[data-src]); const observer new IntersectionObserver((entries, ob) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; img.removeAttribute(data-src); ob.unobserve(img); } }); }, { rootMargin: 200px 0px, threshold: 0.01 }); images.forEach(img observer.observe(img));代码逻辑很直白收集所有带>function isInViewport(el) { const rect el.getBoundingClientRect(); const viewHeight window.innerHeight || document.documentElement.clientHeight; return rect.top viewHeight rect.bottom 0; } const lazyImages [...document.querySelectorAll(img[data-src])]; function checkAllImages() { lazyImages.forEach(img { if (isInViewport(img)) { img.src img.dataset.src; img.removeAttribute(data-src); } }); } window.addEventListener(scroll, throttle(checkAllImages, 100), { passive: true }); window.addEventListener(resize, throttle(checkAllImages, 100)); checkAllImages();注意这里你必须做节流throttle否则滚动一次触发几十次计算性能反而比不懒加载还差。节流时间建议 100~200ms既能保证图片快到视口时及时加载又不会让主线程太忙。passive: true也很重要它告诉浏览器这个监听器不会调用 preventDefault滚动不会被强制同步阻塞。滚动方案的另一个问题是如果页面布局是异步加载内容图片插入后需要手动加入检测列表并且之前已经加载的图片要移除否则每次滚动都遍历整个数组浪费性能。做法是维护一个剩余图片的 Set成功加载后从 Set 里删除等 Set 为空时直接移除 scroll 监听。我在老项目里用这套方案顶了两年直到更新了浏览器内核才换成 IO稳定程度还是值得信赖的。下面把三种方案的优缺点放在一起方便你选型时快速对比方案实现成本性能可控性兼容性适用场景原生 loadinglazy极低高低现代浏览器静态页、标准图片列表IntersectionObserver中高高现代浏览器 polyfill中后台、移动端 H5、动态渲染scroll getBoundingClientRect中中需节流高IE 可用老项目、特殊 WebView2.4 在 Vue 项目里封装一个可复用的懒加载指令日常业务里图片懒加载很少只在一个页面用我更推荐在 Vue 项目里封装成全局自定义指令。这样业务代码里只需要写v-lazy不用关心内部实现团队成员使用成本极低。下面是我常用的一套封装思路基于 IntersectionObserver// directives/lazy.js const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; img.removeAttribute(data-src); observer.unobserve(img); } }); }, { rootMargin: 200px 0px }); export default { mounted(el, binding) { el.dataset.src binding.value; observer.observe(el); }, unmounted(el) { observer.unobserve(el); } };在 main.js 里注册之后模板里直接写img v-lazyimageUrl :alttitle /这套封装有几个好处第一观察者在模块加载时只创建一次不会每个图片都新建一个实例第二通过 unmounted 钩子保证组件销毁时观察器被解除避免内存泄漏第三指令的 value 可以是字符串也可以是响应式对象灵活性很高。唯一要注意的是如果绑定的值在图片加载前发生了变化你需要在updated钩子里重新设置>template el-table :datatableData row-keyid border lazy :loadloadChildren :tree-props{ children: children, hasChildren: hasChildren } el-table-column propname label节点名称 / el-table-column proptype label类型 / /el-table /template script export default { data() { return { tableData: [] }; }, methods: { async loadChildren(row, treeNode, resolve) { const { data } await fetch(/api/node/${row.id}/children); resolve(data); } } }; /script这里有几个官方文档没写透的细节。首先lazy属性不是单独的 true/false它是配合load方法生效的当行数据有hasChildren: true且子数组为空时el-table 就会在展开时调用 load如果你的数据里没有hasChildren字段又默认给了空数组children: []它就不会触发懒加载而是认为这个节点没有子级。这是个特别容易踩的坑我见过不少同学在这里卡一下午。其次tree-props里children和hasChildren都可以自定义字段名不一定非要用官方默认的children。比如后端返回的是childList你完全可以把 children 配成{ children: childList, hasChildren: hasChild }不改后端字段就能直接对接。大部分人在联调阶段被后端字段恶心过通过 tree-props 能缓和很多。3.2 toggleRowExpansion 手动控制展开/收起el-table 提供了toggleRowExpansion(row, expanded)方法用来手动切换某一行展开或收起。很多人已经习惯点箭头去展开节点但在复杂业务里我们常常需要程序化控制展开状态。最典型的需求是默认展开所有一级节点或者用户搜索某个节点后自动展开它的父级链。用法很简单通过 ref 拿到表格实例后调用this.$refs.table.toggleRowExpansion(row, true); // 强制展开 this.$refs.table.toggleRowExpansion(row, false); // 强制收起第二个参数不传时会在当前展开状态之间切换。需要注意的是调用时机非常讲究。如果你在组件 mounted 里直接调用表格可能还没渲染完此时 row 对象还没有绑定到内部状态调用会静默失败。稳妥做法是this.$nextTick(() { this.$refs.table.toggleRowExpansion(row, true); })。更麻烦的是配合懒加载的场景。懒加载子节点是异步的你在展开一个节点后立刻想拿到子节点的 DOM 做高亮很可能子节点还没加载完成。这时候需要用setTimeout或者监听 load 完成后的回调确保展开动画和后续操作不打架。我自己更倾向在 load 方法里把 resolve 后的数据缓存一份再通过界面上“展开/折叠”按钮去精确控制避免在生命周期早期过早操作。3.3 懒加载表格的性能与体验优化表格懒加载解决了数据体积问题但如果不做额外优化用户疯狂展开几十个节点时照样卡顿。一个常见问题是重复请求用户展开一个节点收起再展开load 会被再次触发接口又被打一次。解决办法是在前端维护一个 Map以 row.id 为 key 缓存子节点数据命中缓存后直接用resolve(cache)返回不再发请求。另一个优化点是配合 virtual scroll虚拟滚动。如果树的顶层节点就有几百个即使不展开子节点一次性渲染几百行也很难受。Element UI 官方没有内置虚拟滚动但社区里有一些基于 el-table 二次封装的方案或者你可以考虑切换到 Element Plus 的官方虚拟化表格。如果短期内不想换库折中方案是后端做分页每次只返回一两百个顶层节点配合“展开更多”交互。还有体验层面的细节懒加载请求期间行右侧应该有一个 loading 图标el-table 默认会给一个节点加载动画但样式不明显尤其在深色主题下基本看不清。你可以用 CSS 覆盖或者通过tree-props的checkStrictly之类配置去调整。核心目标是让用户明确知道“内容正在加载不是页面卡了”。另外展开动画不要设得太慢el-table 默认的 300ms 其实已经够了再长就会觉得粘手。3.4 表格里的图片懒加载不要重复造轮子第三部分虽然重点聊 el-table但实际业务里表格经常和图片同时出现——比如商品列表里每个商品一张缩略图列表可能几百行。如果每一行都包含一个图片地址el-table 渲染时又会一次性把图片全部请求一遍这时候你在外部做的图片懒加载失效了因为 DOM 是 el-table 内部动态渲染的。处理办法有两个思路。一个是用 el-table 的自定义列模板在模板里同样使用前面封装好的v-lazy指令el-table-column propcover label封面 template slot-scope{ row } img v-lazyrow.cover classtable-thumb :altrow.name / /template /el-table-column另一个思路是干脆不要用传统缩略图改成 CSS 渐变占位或按需展开大图。如果你的表格数据量大到需要虚拟滚动每行图片都懒加载反而会造成快速滚动时请求风暴。真正好的方案是“表格虚拟滚动 图片懒加载 图片请求池控制”但这套复杂度较高我通常在核心表格页面才会做。普通后台页面用 v-lazy 已经能解决 90% 的问题。4. 懒加载踩坑实录我栽过的几个跟头4.1 懒加载与SEO/爬虫的博弈懒加载最大的“反噬”来自 SEO。搜索引擎爬虫通常不执行 JavaScript你把真实图片埋在>img classlazy>img.addEventListener(error, () { if (!img.dataset.retried) { img.dataset.retried 1; img.src img.dataset.src; // 重试一次 } else { img.src ./error-placeholder.png; } });表格场景同样如此load 方法里如果接口报错一定要 try/catch并用 resolve([]) 或者直接给行数据标记一个错误状态避免用户点击展开时 load 被反复触发造成请求风暴。我还在线上环境加过请求失败自动重试一次的兜底对偶发的网络抖动很管用。4.4 内存泄漏最大的隐性坑懒加载监听类功能最容易出的问题不是不加载而是内存泄漏。你可能会在全局创建了一个 IntersectionObserver却忘记在页面销毁时执行 disconnect也可能在滚动方案里绑定了一个永远不会移除的 scroll 监听器。一次两次没事但用户在一个单页应用里反复切换路由内存就会一点一点涨上去最后整个页面变得迟钝。我的习惯是凡是自己创建了 observer 或监听了全局事件都必须在组件 beforeUnmount/unmounted 里收干净。上面 Vue 指令封装里已经演示过 unmounted 时observer.unobserve(el)如果你是在组件内部使用 observer别忘了onBeforeUnmount(() observer.disconnect())。滚动方案则要记得把已经处理完的图片从待加载数组里移除数组为空时直接移除 scroll 和 resize 监听。这些细节看起来不起眼线上稳定运行一个月后你就能感觉到差别。5. 懒加载的进阶玩法与性能评估5.1 虚拟列表能不能替代懒加载很多人会把懒加载和虚拟列表混为一谈其实它们是两个不同维度的手段。懒加载解决的是“资源加载时机”虚拟列表解决的是“DOM 渲染数量”。比如一个聊天窗口有上万条消息用懒加载只解决图片问题但每一条消息的 DOM 节点还是会在页面上依然卡。这时候需要虚拟列表只渲染可视区域的几十条。两者不是替代关系而是互补关系。一个典型的图片瀑布流外层用虚拟列表只渲染当前视口附近的两三百个卡片卡片内部的缩略图再用懒加载去延迟拉取。这样既控制了 DOM 数量又控制了网络请求数量体验是最好的。前端做虚拟列表可以优先考虑 vue-virtual-scroller、react-window 这类成熟库别自己造轮子边界情况太多。5.2 懒加载性能指标怎么量化做优化最忌讳凭感觉。我习惯用 Lighthouse 在改版前后各跑一次重点看三个指标LCP最大内容绘制、FCP首次内容绘制和传输字节数。懒加载生效后LCP 通常会变好因为首屏不再被大量图片请求阻塞但如果懒加载的提前量设置过大导致用户还在首屏时页面已经把下面几屏的图片也加载了LCP 虽然没变差但总流量反而上去了这种“过度预加载”也是需要警惕的。更精确的验证方法是看 Network 面板的时间线打开页面后前 3 秒内的图片请求应该只集中在首屏区域用户滚动后新的图片请求再陆续出现。如果你看到页面加载完立刻有几十个请求说明懒加载策略没有生效。也可以用 Performance API 写一个小脚本统计页面 load 之后 60 秒内图片请求的分布时间段用来分析用户滚动行为与资源加载的匹配程度。5.3 低代码/富媒体场景下的懒加载策略除了普通图片和表格懒加载在富文本编辑器、低代码搭建平台、移动端小程序里也有很强的应用场景。富文本内容里插入的图片通常都在文章中部甚至底部如果不处理用户打开文章就先下载所有插图体验非常差。我一般的做法是在富文本渲染容器里写一个扫描器等 DOM 就绪后用 IntersectionObserver 观察所有 img再把>