
视频背景这东西做品牌官网、营销落地页、产品介绍页的时候几乎躲不开。我自己的项目里踩过一次特别深的坑设计稿里放了一排沉浸式全屏视频背景当时图省事直接给 video 标签写死了 src结果上线后首屏加载被拖到 8 秒开外一个 6MB 的 MP4 硬生生把图片资源的带宽全抢了用户在滚动到第二个视频区域时还明显掉帧。后来我把视频背景的懒加载方案彻底重构了一遍从原生 JS 写到 Vue 3 自定义指令这篇文章就把我完整落地的思路、代码和踩坑过程都整理出来给同样被视频背景折磨的同学一个可以直接抄作业的参考。这套方案适合的场景很明确页面里存在一个或多个视频背景视频不在首屏或者虽然部分在首屏但你不希望它们阻塞页面整体加载。核心解决三件事——省流量、缩短首屏时间、避免滚动卡顿。无论你用的是原生开发还是 Vue 3下文都有对应实现。1. 视频背景为什么必须做懒加载先算清这笔账1.1 video 标签默认的加载行为根本不懒很多同学以为 video 标签放在页面底部浏览器就会等用户滚动到那个位置才开始下载。这是最大的误解。浏览器的实际处理逻辑是只要 video 元素出现在 DOM 树里、并且有 src 或 source 子标签它就会根据 preload 属性的取值去决定加载策略。而 preload 的默认值在不同浏览器里并不同——Chrome 默认 metadataSafari 在部分版本里默认 autoFirefox 也各有差异。也就是说你啥都不写的时候浏览器可能已经在后台把整个视频资源都拉下来了。preload 三个取值的实际效果区别非常大preload 值浏览器行为适用场景auto浏览器自行决定通常尽量多地缓冲甚至会下载完整个视频视频就是页面核心内容且就在首屏时metadata只加载视频元数据时长、尺寸和首帧画面需要提前知道视频尺寸或显示 poster 时none不主动加载任何视频数据直到用户或 JS 触发播放懒加载场景的首选值这里要额外说一个细节即使你设置了 preloadnone一旦给 video 赋值了 src浏览器为了获取第一帧画面通常也会发起至少一部分资源的请求。所以懒加载的核心操作是——在用户真正需要之前压根不把视频地址交给 video 标签让标签只保留一个 poster 占位图。1.2 不懒加载的视频背景会带来三个典型问题第一个是带宽竞争。视频文件动不动就是几 MB 到几十 MB放在 HTML 里加载时它和首屏的图片、CSS、JS 一起抢带宽。尤其移动端网络环境下视频下载会明显拖慢首屏关键图片的加载LCP 直接崩掉。第二个是滚动帧率问题。这个很多人没意识到浏览器在解析视频数据、解码视频帧时是有 CPU/GPU 开销的。页面上一个视频在后台缓冲你滚动页面时不会立刻卡但如果你同时有多个视频在缓冲再加上页面里的动画、懒加载图片解码主线程很容易被挤爆滚动的掉帧感就来了。第三个是流量消耗。我自己测过一个 10 秒的循环背景视频压缩后普遍也有 1.5MB 到 3MB。一个页面五六个视频背景等于让用户无端下载了十几 MB 根本还没看到的内容。移动端用户对流量敏感而且 Chrome 在数据消耗大的页面上会直接弹出系统级提示非常劝退。2. 懒加载的核心原理与方案选型从>// 选择所有带>video classbg-video >// directives/v-lazy-video.js // 模块级共享的 observer 配置 const observerOptions { root: null, rootMargin: 150px 0px, threshold: 0 } const lazyVideoObserver new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return const video entry.target const src video.dataset.src if (!src) { // 没有>template section classhero-section video v-lazy-videoheroVideoUrl classhero-video poster/images/hero-poster.jpg muted loop playsinline preloadnone /video div classhero-content h1品牌口号/h1 /div /section /template script setup import { ref } from vue import vLazyVideo from /directives/v-lazy-video const heroVideoUrl ref(/videos/hero.mp4) /script记住在 script setup 里引入的指令会自动以 vLazyVideo 的名称注册为 v-lazy-video不需要在 directives 选项里再声明。3.3 列表多视频场景一个 Observer 搞定所有条目营销页面里最常见的场景是列表——每个产品卡片或者 banner 区域都带一个视频背景。Vue 3 里配合 v-for 使用这个指令非常自然template section v-foritem in bannerList :keyitem.id classbanner-section video v-lazy-videoitem.videoUrl classbanner-video :posteritem.poster muted loop playsinline preloadnone /video h2{{ item.title }}/h2 /section /template因为 observer 实例是模块级的所以 v-for 渲染出来 10 个视频也只会使用同一个 IntersectionObserver。每个视频进入视口时回调里的 entry.target 会精确指向对应的 video DOM互不干扰。这种共享观察者的模式配合列表使用性能开销可以忽略不计。有一个排坑经验v-for 的 key 一定要稳定且唯一。我遇到过 key 用 index 导致的问题——列表重新排序后DOM 被复用data-src 残留的旧地址会和新视频地址产生冲突。用 item.id 做 key 就不会有这个烦恼。3.4 组合式函数 useLazyVideo给不想用指令的团队一个替代方案有些项目组风格偏好组合式函数而不是自定义指令。我也提供一套基于 Composition API 的写法// composables/useLazyVideo.js import { ref, onMounted, onUnmounted } from vue export function useLazyVideo(options {}) { const videoRef ref(null) const loaded ref(false) let observer null const { rootMargin 150px 0px, root null, threshold 0 } options function loadVideo() { const video videoRef.value if (!video || loaded.value) return const src video.dataset.src if (src) { video.src src video.load() video.play().catch(() {}) video.removeAttribute(data-src) loaded.value true } } onMounted(() { const video videoRef.value if (!video) return observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { loadVideo() observer.unobserve(entry.target) } }) }, { root, rootMargin, threshold }) observer.observe(video) }) onUnmounted(() { if (observer) observer.disconnect() }) return { videoRef, loaded, loadVideo } }使用的时候template video refvideoRef >// 判断是否应该用静态图替代视频 function shouldUsePosterOnly() { const saveData navigator.connection?.saveData || false const reducedMotion window.matchMedia((prefers-reduced-motion: reduce)).matches return saveData || reducedMotion }在进入懒加载回调里加上这个判断条件满足就跳过视频加载。这一招对用户体验的提升比想象中大特别是海外用户常在 2G/3G 网络上访问时。5. 进阶优化预加载时机、离开视口暂停、内存释放5.1 rootMargin 预热与预加载策略的平衡懒加载并不代表绝对不加载而是在合适的时机加载。rootMargin 实际上就是在定义这个合适的时机。我把 rootMargin 设为 150px 0px 的初衷是用户手指滑动 100 到 150 像素距离后视频就能立即播放避免进入视口才开始下载导致的明显等待。但如果视频文件特别大超过 3MB150px 的预热在高速滚动的场景下不够用。我试过把 rootMargin 放大到 500px 0px效果是视频提前加载的概率大幅提升但随之而来的问题是用户快速滑过页面根本没有停留视频白白下载了。在流量敏感的项目里这个度要把握好。我的经验值是普通落地页 150px视频大且需要流畅体验的核心页面可以放宽到 300px不要超过 500px否则懒加载的意义就没了。5.2 离开视口自动暂停与再次进入恢复视频背景如果一直循环播放即使离开视口也在消耗解码器资源。我在优化企业官网时加过一版视口感知播放控制视频进入视口就播放离开视口就暂停再次进入再播放。实现很简单在原有 observer 回调里扩展const observer new IntersectionObserver((entries) { entries.forEach((entry) { const video entry.target if (entry.isIntersecting) { // 首次进入执行懒加载 if (video.dataset.src) { video.src video.dataset.src video.load() video.removeAttribute(data-src) } video.play().catch(() {}) } else { // 离开视口暂停节省解码资源 video.pause() } }) }, { root: null, rootMargin: 0px 0px, threshold: 0.1 })这里 observer 不需要再 unobserve因为它要长期承担播放/暂停的职责。注意 threshold 从 0 调成了 0.1避免元素只有 1 像素进入视口就触发播放。这种做法的收益在 Chrome DevTools 的 Performance 面板里能清楚看到——滚动期间主线程的占用明显下降帧率更稳。5.3 blob URL 与内存释放的细节如果视频地址来自对象存储并且经过了 URL.createObjectURL 处理比如从 Blob 数据创建播放地址懒加载完成后要记得在合适时机调用 URL.revokeObjectURL 释放内存。这个场景比较进阶但很多视频处理方案比如前端压缩、转码后的临时视频都会用到。function loadVideo(video, src) { video.src src video.load() video.play().catch(() {}) // 如果是 blob URL在视频播放完一帧后释放 if (src.startsWith(blob:)) { video.addEventListener(loadeddata, () { URL.revokeObjectURL(src) }, { once: true }) } }如果视频地址是普通的 http 地址不需要手动释放。还有一个容易忽略的点监听 video 的 error 事件。视频文件如果被删除或地址写错会触发 error 并停留在 poster 状态这种情况要给外层容器上一个降级样式比如保留暗色背景别让用户看到一块白屏。6. 实测体验与踩坑记录线上数据不会骗人6.1 一次完整的性能对比我用同样的页面做了两组对比一组是不做任何处理直接加载视频背景一组是完整的懒加载方案。核心数据如下指标未懒加载懒加载后变化首屏 LCP4.2s1.8s下降 57%页面初始下载量18.6MB4.1MB减少 78%滚动掉帧次数30s14 次3 次减少 79%Data Saver 检测流量全部下载按需下载显著降低这个结果并不意外视频占了大头。真正让我惊讶的是首屏 LCP 的改善——视频不再抢占带宽后首屏的 hero 图片加载速度几乎翻倍用户的体感差异非常明显。6.2 几个值得记录的排查过程第一个坑是 Safari 上的视频加载了但一直黑屏。排查了半天发现是 poster 属性写在了>