简介:这是一份面向前端开发者的轻量级 JavaScript 工具,用于实现页面滚动时自动开始或停止视频播放的交互效果,适合需要制作滚动触发视频场景的网页项目,技能层级偏向入门到中级。资源包共 3 个文件,包含 1 个 js 脚本、1 个 md 说明文档和 1 个 copying 授权文件,压缩包整体约 14KB,体积小巧便于快速集成。其中 js 文件为核心控制器,md 文档提供使用说明,copying 文件明确授权方式。目前已有 254 人学习下载,说明该方案在滚动视频交互场景中具有一定参考价值。读者可获取完整的脚本源码与调用示例,了解如何借助 jQuery 监听滚动事件并控制视频播放状态,从而快速将滚动启停视频的能力接入自己的页面,减少从零实现交互逻辑的成本,也可作为学习滚动事件与媒体控制结合的实践参考。
1. 滚动即播、划走即停:video-scroll 到底解决了什么
信息流里塞视频,最怕两件事:用户还没看到就开始播,声音先出来吓人一跳;用户已经划走了还在后台跑,流量和电量一起烧。video-scroll就是冲着这两个痛点来的——一个用原生 JavaScript 写的轻量脚本,靠监听滚动位置来判断视频是否进入可视区域,进则play(),出则pause()。它不依赖任何框架,不挑 React 还是 Vue,直接丢进页面就能跑。
适合谁用?做内容流、商品流、课程列表的前端同学,尤其是那种「一屏一个视频卡片」的布局。它解决的不是播放器功能,而是「什么时候该播」这个时机问题。下面把它拆开,从原理到落地,再到我踩过的坑,一次讲清楚。
2. 原理拆解:IntersectionObserver 与 scroll 事件怎么选
2.1 两种监听方式的本质差别
video-scroll这类脚本,核心就一件事:知道视频元素当前在不在视口里。实现路径有两条,选错了后面全是麻烦。
第一条是传统的scroll事件 +getBoundingClientRect()。每次滚动都触发回调,在回调里算元素顶部和视口高度的关系。优点是兼容性极好,IE 都能跑;缺点是滚动事件触发频率极高,不做节流的话,一屏十几个视频能把主线程拖垮,滚动直接掉帧。
第二条是IntersectionObserver。浏览器原生提供,异步回调,不占用滚动主线程,性能好得多。它直接告诉你「目标元素和视口的交叉比例是多少」,判断逻辑从「自己算」变成「读结果」。现代浏览器支持度已经很稳,常见做法是优先用它,老环境再降级到 scroll 方案。
我一般会这样写一个带降级的判断:
// 优先用 IntersectionObserver,不支持时降级到 scroll + 节流 function createVisibilityWatcher(video, onEnter, onLeave) { if ('IntersectionObserver' in window) { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { // threshold 设为 0.6,意味着 60% 可见才算「进入」 if (entry.intersectionRatio >= 0.6) { onEnter(); } else { onLeave(); } }); }, { threshold: [0, 0.6, 1] } // 多个阈值,便于做渐进控制 ); observer.observe(video); return () => observer.disconnect(); } // 降级方案:scroll 事件 + requestAnimationFrame 节流 let ticking = false; const check = () => { const rect = video.getBoundingClientRect(); const visible = rect.top < window.innerHeight * 0.4 && rect.bottom > window.innerHeight * 0.6; visible ? onEnter() : onLeave(); ticking = false; }; const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(check); } }; window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }逻辑说明:IntersectionObserver的threshold数组是关键参数。设[0, 0.6, 1]是为了在「刚露头」「大半可见」「完全可见」三个节点都能拿到回调,方便你做更细的策略,比如 60% 才播、低于 60% 就停。降级方案里用requestAnimationFrame而不是setTimeout节流,是因为 rAF 跟着浏览器刷新节奏走,滚动时更顺滑。passive: true告诉浏览器这个监听不会阻止默认滚动,能再省一点性能。
2.2 播放阈值为什么不能设成 0.5 就完事
很多人第一次写,阈值随手设 0.5,觉得「一半可见就播」很合理。实际跑起来会发现两个问题。
一是快速滚动时,视频刚过 50% 就触发play(),但用户手指没停,视频可能只播了 0.3 秒又被划走,pause()紧接着触发。这一播一停,播放器要初始化解码器、要请求数据,白白浪费一次网络往返。二是不同视频卡片高度不一样,有的卡片带标题带按钮,视频本身只占卡片 60% 高度,用卡片做观察目标,阈值算的是卡片,不是视频,判断就偏了。
所以观察目标要选视频元素本身,不是外层卡片。阈值我一般给 0.6 到 0.7,让视频「大部分露出来」再播,减少误触发。如果布局是一屏刚好一个视频,0.6 足够;如果是小窗预览,可以降到 0.4,但别低于 0.3,否则划过去一路都在播。
提示:阈值不是越高越好。设成 1.0 意味着必须完全可见才播,用户稍微滚动一点就停,体验反而碎。
3. 落地实操:把 video-scroll 接进真实页面
3.1 最小可用接入流程
假设你有一个视频列表,每个卡片里一个<video>,结构大概是这样:
<div class="feed"> <div class="card"> <video src="clip-1.mp4" muted playsinline preload="metadata"></video> </div> <div class="card"> <video src="clip-2.mp4" muted playsinline preload="metadata"></video> </div> </div>接入脚本分三步:拿到所有视频元素、给每个建观察器、在进入和离开时控制播放。下面是一个可直接抄的初始化函数:
// 初始化整个信息流的视频滚动播放 function initVideoScroll(container, options = {}) { const { threshold = 0.6, // 可见比例阈值 autoPauseOthers = true, // 是否同时只允许一个视频播放 rootMargin = '0px' // 视口外扩,提前或延后触发 } = options; const videos = Array.from(container.querySelectorAll('video')); let currentPlaying = null; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const video = entry.target; if (entry.intersectionRatio >= threshold) { // 进入可视区:先暂停上一个,再播当前 if (autoPauseOthers && currentPlaying && currentPlaying !== video) { currentPlaying.pause(); } video.play().catch(() => { // 自动播放被拦截时静默失败,等用户交互 }); currentPlaying = video; } else { // 离开可视区:暂停并复位 if (!video.paused) { video.pause(); } if (currentPlaying === video) { currentPlaying = null; } } }); }, { threshold, rootMargin } ); videos.forEach((v) => observer.observe(v)); return () => observer.disconnect(); } // 调用 const feed = document.querySelector('.feed'); const destroy = initVideoScroll(feed, { threshold: 0.65 });逻辑说明:autoPauseOthers这个开关很实用。信息流里如果允许多个视频同时播,声音会叠在一起,体验灾难。默认开成true,保证同一时刻只有一个在跑。video.play()返回的是 Promise,现代浏览器对无声音视频(muted)一般放行,但有声音的会被自动播放策略拦截,所以必须.catch()兜住,不能让报错冒到控制台。rootMargin可以用来做「提前 100px 开始加载」,比如设成'100px 0px',视频还没完全进视口就开始缓冲,滚动到位时更跟手。
3.2 参数怎么调:一张对照表
不同布局对参数的要求不一样,下面这张表是我在几种常见场景里试出来的经验值,可以直接参考再微调。
| 场景 | threshold | rootMargin | autoPauseOthers | 说明 |
|---|---|---|---|---|
| 一屏一视频 | 0.6 ~ 0.7 | 0px | true | 最稳,误触发少 |
| 双列小卡片 | 0.4 ~ 0.5 | 0px | true | 卡片小,阈值降低 |
| 带预览的列表 | 0.3 ~ 0.4 | 100px 0px | true | 提前缓冲,滚动跟手 |
| 允许画中画 | 0.5 | 0px | false | 多视频并存时关掉互斥 |
调参时重点看两个现象:滚动时视频启动是否跟手、快速划过时是否频繁启停。跟手差就加rootMargin提前量;频繁启停就提高threshold。这两个参数是一对平衡,没有万能值。
3.3 和播放器库的配合方式
video-scroll只管「播不播」,不管「怎么播」。如果你用的是原生<video>,上面代码就够了。如果页面里用的是某个播放器封装(比如带自定义控制条的组件),思路一样,只是把video.play()换成播放器实例的play()方法,把观察目标换成播放器容器。常见做法是给播放器组件暴露一个getVideoEl()或者直接传 DOM 引用,别去猜内部结构。
注意:有些播放器在
pause()后会重置进度。如果你希望划回来接着播,得在暂停前记下currentTime,恢复时再写回去。这个逻辑video-scroll本身不管,要自己加。
4. 避坑排查:这五个问题我几乎每次都遇到
4.1 视频不播,控制台报 NotAllowedError
现象:脚本调了play(),但视频纹丝不动,控制台出现NotAllowedError: play() failed because the user didn't interact with the document first。
原因:浏览器的自动播放策略。带声音的视频必须等用户有过交互(点击、触摸)才允许播。你的视频如果没加muted,首次进入页面时必然被拦。
解决:给所有信息流视频加muted和playsinline属性。muted让浏览器放行自动播放,playsinline防止 iOS 上全屏劫持。如果业务必须带声音,那就等用户第一次点击后再解除静音,别在初始化时就指望能出声。
4.2 快速滚动时视频疯狂启停
现象:手指快速划过列表,能听到视频断断续续启动又停下的声音,或者看到播放按钮闪。
原因:阈值卡在临界点,滚动过程中元素反复跨越阈值,play()和pause()被高频调用。
解决:加一个「停留时间」判断。进入可视区后不立刻播,等 150 到 200 毫秒确认还停在那儿再播。用setTimeout配合清除即可:
let playTimer = null; // 进入时 clearTimeout(playTimer); playTimer = setTimeout(() => video.play().catch(() => {}), 180); // 离开时 clearTimeout(playTimer); video.pause();这个延迟能过滤掉大部分「一闪而过」的误触发,代价是启动慢了一点点,但体验稳得多。
4.3 划走之后视频还在后台跑
现象:明明已经划到下一个视频,上一个视频的声音还在,或者流量统计里视频请求一直没停。
原因:观察器只监听了「进入」,没处理「离开」;或者离开时只调了pause(),但视频的preload是auto,浏览器还在后台下载。
解决:离开时除了pause(),把preload动态改成none,等再次进入时改回metadata或auto。这样能明显省流量。另外确认观察器的disconnect()在页面卸载时被调用,别让监听器泄漏。
4.4 列表动态加载后新视频不生效
现象:首屏视频滚动播放正常,下拉加载出来的新卡片,划到了却不播。
原因:initVideoScroll只在初始化时querySelectorAll了一次,新插入的 DOM 没被观察。
解决:把观察逻辑抽成observeVideo(el)单独函数,每次新增卡片后对新视频元素调一次。或者在MutationObserver里监听列表容器的子节点变化,自动给新视频挂观察器。我一般用后者,省心。
4.5 阈值算的是卡片不是视频
现象:视频明明只露了一半,却已经开播;或者视频几乎全露了还不播。
原因:观察目标选成了外层卡片,而卡片里有标题、按钮、留白,视频实际占比和卡片占比对不上。
解决:observer.observe()的参数必须是<video>元素本身。如果视频被包在多层容器里,用querySelector('video')精确定位,别图省事观察父级。
5. 进阶技巧:用 rootMargin 做预加载与多视频节流
把基础跑通之后,真正拉开体验差距的是两个细节:预加载时机和多视频的资源调度。
先说预加载。默认情况下,视频进入视口才开始请求数据,滚动到位时往往要等一两秒才出画面。用rootMargin把观察区域向外扩一圈,就能让视频「还没到但快到了」时提前开始缓冲。比如列表向下滚动,给底部留 200px 提前量:
const observer = new IntersectionObserver(callback, { threshold: 0.6, rootMargin: '0px 0px 200px 0px' // 底部外扩 200px });rootMargin的顺序是上、右、下、左,和 CSS margin 一致。底部外扩意味着「元素距离视口底部还有 200px 时就算进入」,视频提前加载。这个值别设太大,200 到 300px 够用,设成 1000px 会导致大量视频同时请求,带宽被抢,反而更卡。
再说多视频节流。信息流里同时有多个视频在缓冲,网络和内存压力都不小。除了前面说的autoPauseOthers,还可以限制「同时处于预加载状态的视频数量」。常见做法是维护一个队列,最多允许 3 个视频处于preload="auto",其余保持none,进入视口时再提升优先级。这样首屏之外的视频不会一上来就抢带宽。
最后给一个验证方法:打开浏览器开发者工具的 Network 面板,筛选 Media 类型,滚动列表,观察视频请求的发起时机。理想状态是——视频在进入视口前 200px 左右开始请求,划走后请求停止或不再增长。如果发现划走很久请求还在跑,回去检查preload有没有动态改回none。
从那以后我每次接信息流视频,都强制走一遍「静音属性检查 → 阈值实测 → 快速滚动压测 → Network 面板看请求」这四步,少一步都可能在上线后收到「视频乱播」的反馈。希望帮到你。
本文还有配套的精品资源,点击获取