简介:面向前端开发者的轻量级脚本工具,用于在页面滚动时自动开始或停止视频播放,能在用户上下滚动过程中有效切换视频的播放与暂停状态,提升交互体验。压缩包内共三个文件,包含核心控制脚本、Markdown说明文档以及许可证文件,整体仅14KB,体量轻巧便于快速集成。说明文档给出了标准的接入方式,需要先引入jQuery库,再加载控制器脚本,并通过浏览器滚动事件调用对应方法;同时提供了基础用法示例,适合具备jQuery基础、希望在滚动场景中精细控制视频状态的开发者。已有252人学习下载,可作为视频滚动交互的轻量参考实现。资源不仅给出可运行的脚本,还解释了事件监听与视频接口调用的配合思路,能帮助读者理解滚动触发与媒体控制的实现机制,减少从零调试的工作量。
1. video-scroll:用滚动开始/停止视频的简单 JavaScript,解决的是一类交互问题
做内容站和落地页的人应该都遇到过这个需求:用户往下滚动,页面里的视频进入可视区域就自动开始播放,滚动离开就自动暂停,全程不需要点播放按钮。这就是 video-scroll 这类简单的 JavaScript 方案在做的事。它和“点击播放”最大的区别在于,事件源从离散的点击变成了一段连续的滚动过程,判断逻辑从“有没有点中”变成“这个视频现在到底算不算可见”。
这套方案适合谁?适合做信息流、产品介绍页、教学长页面的前端开发者,也适合想在列表页里给一组视频做自动启停的人。一开始你会觉得这就是监听滚动、判断位置、调 play 和 pause 的事,写起来确实很短,可一旦涉及移动端触摸滚动、嵌套滚动容器、浏览器自动播放策略,代码量就会悄悄膨胀。后文我从原理、实现、参数到踩坑按一条线走完,你照着能落地。
2. 先拆原理:滚动到哪算“开始”,离开多少算“停止”
2.1 为什么是滚动事件而不是点击事件:交互模型从“点一下”变成“看一眼”
视频播放的触发方式,决定了后面所有代码的结构。点击播放时,事件源单一,逻辑链是“用户点了按钮 → 调用 video.play()”,不需要关心视频在屏幕上的位置。滚动播放则不同,用户在滚动过程中不产生任何明确指令,你只能通过位置判断“他看到了视频没有”。
常见做法是把“看到”拆成两个条件:视频元素进入了我们指定的可视区域,并且可见面积达到一个阈值。阈值的意义在于防止误触——比如视频顶部只露出两三个像素就开始播,滚动快一点就会在播放和暂停之间疯狂切换,体验很像劣质广告页。所以判断的核心不是“有没有出现在屏幕上”,而是“有没有足够多地出现在屏幕上”。
这里引出一个关键选择:用什么方式感知“进入可视区域”。有两条路线,一条是监听 scroll 事件后自己算位置,另一条是用 IntersectionObserver 让浏览器替你算。两条路线各有适用场景,下面这组对比能帮你做决定。
2.2 IntersectionObserver 与 scroll 监听:两套方案怎么选
| 对比项 | IntersectionObserver | scroll 监听 + getBoundingClientRect |
|---|---|---|
| 主线程消耗 | 浏览器原生计算,回调只通知结果 | 每次滚动都在主线程跑布局计算 |
| 代码维护成本 | 低,只需要配置 root 和 threshold | 中,要自己处理节流、方向、容器 |
| 已知滚动位置 | 回调里拿不到 scrollTop,只能拿相交状态 | 随时能拿到 scrollTop,方便做进度映射 |
| 兼容性 | 现代浏览器可用,老项目要 polyfill | 所有浏览器可用 |
| 适合场景 | 只做“播放/暂停”这种二态控制 | 需要按滚动进度精确控制视频进度 |
我自己在大多数滚动场景里优先用 IntersectionObserver。原因很朴素:滚动在移动端本来就容易掉帧,scroll 监听里如果再做 getBoundingClientRect,哪怕只跑一次,也等于把布局计算塞进了高频路径。而 IntersectionObserver 是浏览器异步计算的,回调只在状态变化时触发,天然省事。只有后文 6.1 那种“滚动进度直接映射播放进度”的需求,才必须回到 scroll 监听,因为它需要连续的 scrollTop 数据。
不管是哪条路线,二态控制的最小模型都一样:进入视口播放,离开视口暂停。下面给最小可运行代码。
2.3 最小可运行代码:用 IntersectionObserver 实现滚动播放与暂停
// 最小示例:进入视口自动播放,离开视口自动暂停 const video = document.querySelector('video'); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { video.play().catch((err) => { // 浏览器自动播放策略拦截时会走到这里 console.warn('autoplay blocked:', err); }); } else { video.pause(); } }); }, { root: null, // null 表示以浏览器视口为准 threshold: 0.5 // 视频一半进入视口才触发播放 }); observer.observe(video);这段代码的逻辑很简单:observer.observe(video) 之后,浏览器会在视频元素和视口的相交比例越过 threshold 时回调一次,并在离开时再回调一次。回调里通过 entry.isIntersecting 区分“进来了”和“出去了”,分别调 play() 和 pause()。
参数说明:threshold 是 0 到 1 的数组或单个数,表示可见面积占比到达多少就触发。0.5 表示整个元素露出一半才算进入,适合大多数视频卡片;如果视频高度很大或页面很长,可以放宽到 0.3,避免用户都快滚过去了才开播。root 指定滚动容器,null 是浏览器视口;当页面外侧有某个 div 在滚而视口没动时,必须把 root 改成那个容器,否则永远不触发。play() 返回的是 Promise,浏览器自动播放策略会拒绝它,所以一定要 catch,不然后端控制台会一直飘未处理的 Promise 异常。
3. 把“简单”做成产品级:节流、进度回退与状态同步
3.1 滚动监听的节流参数:rAF 与 passive 的配合
IntersectionObserver 够用的时候,不需要写滚动监听,但有些项目必须自己监听滚动:比如要判断滚动方向“向上滚回视频要不要重新播”,或者要记录用户滚过视频的速度。这时候 scroll 事件是最直接的,但必须做节流,否则移动端每滚动一像素就触发一次回调,页面主线程会被打满。
常见的节流手段是用 requestAnimationFrame 把回调压缩到每帧一次,再配合一个 ticking 标记防止一帧内重复执行:
let ticking = false; window.addEventListener('scroll', () => { if (ticking) { return; } ticking = true; requestAnimationFrame(() => { // 在这里做视频位置判断,例如调用后文的 isInViewport ticking = false; }); }, { passive: true });逻辑说明:第一次触发 scroll 时 ticking 还是 false,于是把 rAF 注册进去并把 ticking 置为 true;这一帧内再来的 scroll 事件直接 return,直到 rAF 回调跑完、ticking 重置。这个写法比 setTimeout 节流更适合滚动,因为 rAF 的触发时机跟着浏览器绘制节奏走,滚动动画会更顺。
参数说明里被动监听这个选项容易被忽略:{ passive: true } 是告诉浏览器“这个滚动监听器不会调用 preventDefault”,所以浏览器可以不等待监听器执行完再去滚动,能明显降低触摸滚动延迟。反过来,如果你在监听器里调了 preventDefault(比如禁止页面滚动、改成手势控制),就必须改成 passive: false,否则在 Chrome 里会被悄悄忽略甚至报警告。对 video-scroll 这个需求,几乎不需要阻止滚动,所以一律用 passive: true。
3.2 回到顶部自动暂停:按可见面积判定进入/离开
用了滚动监听就绕不开一个问题:凭什么判定“进入”和“离开”。很多初学者用元素顶部是否越过视口顶部来判断,比如rect.top < window.innerHeight就播放。结果是视频只露出一个边角就开播,快速滚动时画面闪个不停。
我一般用可见面积占比,而不是单一坐标。实现有两种:直接拿元素的高度和可视区域做重叠面积计算,或者偷懒用 IntersectionObserver 里的 intersectionRatio。手动实现时代码如下:
function isInViewport(el, ratio = 0.5) { const rect = el.getBoundingClientRect(); const viewHeight = window.innerHeight || document.documentElement.clientHeight; // 计算视频在视口内可见的高度 const visibleHeight = Math.min(rect.bottom, viewHeight) - Math.max(rect.top, 0); if (visibleHeight <= 0) { return false; } // 可见面积占比超过阈值才视为“进入” return visibleHeight / rect.height >= ratio; }逻辑说明:先算出元素底部和视口底部的较小值,再减去元素顶部和视口顶部的较大值,得到真正露在屏幕内的那一段高度。如果这个值大于 0 说明元素有一部分在屏幕里,再把它除以元素总高度得到可见比例,超过 ratio 才返回 true。这样处理有两个好处:一是避免顶部露出 1px 就触发的抖动,二是滚动很快时不会因为一次短暂越过就误播。
参数说明:ratio 建议设在 0.4 到 0.6 之间,0.5 是一个折中值。视频比较高时,用户需要滚更多才能触发,此时可以下调;视频是横屏小卡片时,露出过半很容易,0.5 就好。这段代码配合前面的 rAF 节流,就是完整的 scroll 方案主干。
3.3 ended 事件的兜底:dispatchEvent(new Event('ended')) 的正确用法
滚动视频还有一个容易漏的状态:视频播完了。视频 end 时自动停在最后一帧,用户这时滚出去再滚回来,很多实现会直接调 play() 让它从头开始。可如果没有先重置 currentTime,play() 在 ended 状态下调用是无效的,画面会一动不动,看起来像坏了。
这个场景里,dispatchEvent(new Event('ended')) 是一个常见的兜底手段。需要说明的是,它并不是“把视频真的播到结束”,而是手动触发监听 ended 的播放器组件做状态同步,比如让播放器 UI 从“播放中”切成“已结束”,显示重播按钮。对 video 元素本身,真正要做的操作是重置进度再播放:
function restartAndNotify(video) { // 先回到起点,再开始播放;顺序反了会无效 video.currentTime = 0; video.play().catch((err) => { console.warn('restart blocked:', err); }); // 通知播放器 UI:视频重新开始了 video.dispatchEvent(new Event('play')); }逻辑说明:视频处于 ended 状态时,currentTime 停留在 duration,直接 play() 不会重新播放,所以必须先清零。清零后 play() 会从头开始,再 dispatch 一个 play 事件,让自定义播放器组件重置“已结束”的状态。这个 dispatch 不是必需操作,原生 controls 会自动处理,但对接自绘播放器 UI 时很有用。
参数和边界:不要把 dispatchEvent 当成改变播放状态的手段。dispatchEvent(new Event('ended')) 只是向监听器广播“视频结束了”,它不会 pause,也不会改变 currentTime。如果业务代码在 ended 事件里做了弹窗、上报等逻辑,你需要保证只触发一次,这就涉及到后文 5.4 的状态锁。
4. 移动端与桌面端差异:触摸滚动、被动监听与手势冲突
4.1 passive: true 与滚动性能:为什么移动端不能忽略它
桌面端滚动靠滚轮事件,频率本来就比触摸滚动低;移动端触摸滚动时,浏览器每秒可以派发几十上百个 scroll 事件。如果每个事件都触发一次视频位置判断,iPhone 上很容易出现滚动时页面发白、视频卡顿的现象。我在上一节说过 passive: true,这里要单独强调一次:它不是一个可有可无的参数,而是移动端滚动监听的默认选择。
Chrome 和 Safari 在监听器是 passive: true 时,会认为监听器不会调用 preventDefault,于是浏览器可以先滚动、后执行 JS,触摸滚动的第一屏延迟会明显降低。反过来,如果你没有写 passive 标志,一些浏览器默认按 passive: false 处理,等于每次滚动都在等 JS 跑完,这是很多移动端页面“滚动不跟手”的根源。
如果你的项目只用 IntersectionObserver,不写滚动监听,那么 passive 完全不用管;但只要写了滚动监听,就无条件把 passive: true 写上。现代浏览器对 window 上的 scroll 事件已经默认 passive,但为了稳妥,显式写上是最保险的。只有当你确实要阻止滚动时才写 passive: false——video-scroll 场景基本用不到。
4.2 视频控件与页面手势冲突:点住进度条却滚动了页面
移动端给<video>加 controls 属性后,用户可以直接在视频上拖动进度条。这个拖动动作和页面上下滚动是冲突的:手指按下去往下滑,浏览器分不清是在拖进度条还是在滚页面,经常出现拖到一半页面跟着跑的情况。
处理这类冲突,业界没有统一的完美方案,只有取舍。常见做法是在视频元素上用 CSS 限制触摸行为:
video { /* 允许页面纵向滚动,但视频内部不拦截触摸 */ touch-action: pan-y; }解释一下:touch-action: pan-y 表示“只响应纵向滚动”,横向滑动和缩放手势不归浏览器处理,留给视频组件自己。这样用户横向滑的时候拖动视频进度条,纵向滑的时候页面滚动,各管各的。更激进的做法是 touch-action: manipulation,直接禁用双击缩放和横向滚动,适合完全自定义控件的场景,但原生控件效果会受限。
还有一个移动端特有细节:视频需要 playsinline 才能“内嵌在页面里播放”,否则 iOS Safari 会强制全屏播放,滚动到位置也没意义。加了 requires 和 muted 见后文 5.3。这些参数在桌面端无感,移动端缺一个就翻车。
4.3 同一套逻辑复用到歌词滚动、数据滚动提示场景
video-scroll 的核心思想——“滚动状态决定业务状态”——不只适合视频。前端常见的歌词滚动是另一个典型:歌词列表跟着音频播放进度滚动,如果列表滚动后要同步高亮当前句,完全可以复用 IntersectionObserver 反过来做:高亮那一句进入视口时,更新当前播放时间;或者在高亮句滚出视口时暂停滚动。
包括上下滚动的数据表格、信息流卡片、滚动提示条,都是同一个问题:用户滚动后,某个目标元素和视口的相对位置变了,需要更新一个业务状态。把这些场景抽象出来,无非是三种用法:目标进入视口时触发,目标离开视口时触发,目标在视口内的面积变化时触发。代码上,前面的最小示例稍作修改就是一个通用函数:把 video 换成任意 DOM 元素,把 play/pause 换成任意回调。
这就是为什么我推荐优先掌握 IntersectionObserver:它的心智模型和布局计算都足够通用,视频只是其中一个消费方。项目里已经有滚动播放逻辑,后面接歌词滚动、列表懒加载,都能复用同一份观察器配置,只是回调不同。
5. video-scroll 避坑:5 个最常见的翻车现场
5.1 现象:视频卡顿或黑屏
滚动播放的页面常见的问题是视频卡顿,尤其是快速滚动后立即播放,画面会黑屏一两秒甚至一直黑。
原因有两类。一类是回调里每次滚动都调 play()/pause(),导致视频频繁切换状态,解码器来回来去起停,性能跟不上;另一类是判断逻辑没做状态锁,同一帧内多个入口重复调 play(),浏览器排队处理,表现就是“没反应”。
解决方法是加状态锁:只有当前“应该播放但没在播”时才调 play,只有“应该暂停但仍在播”时才调 pause。最简单的方式是记一个变量,比如let shouldPlay = false;在滚动回调里只更新 shouldPlay,再单独写一个同步函数处理真正的播放动作。这样视频状态切换只发生在 shouldPlay 变化的一瞬间,而不是每次滚动都变化。
5.2 现象:离开又回来,进度条“回退”
用户滚到视频一半暂停,再滚回来,结果视频从头播了。用户会觉得“我看过的进度丢了”,这是一个实打实的交互 bug。
原因通常是把“进入视口播放”和“从头播放”混在一起了,很多人在滚动回调里写了video.currentTime = 0; video.play();,这样每次进入视口都重置进度。正确做法是保留暂停时的进度,进入视口时从暂停位置继续播放;只有视频处于 ended 状态才考虑重置。即:播放前先判断video.ended,为 true 才设置 currentTime 为 0。
我自己的经验是,这个坑特别隐蔽,因为桌面端不一定复现,移动端快速滚动时很容易看出来。想验证,先播到中段暂停,再快速向下滚走、滚动回来,看是否从头开始。
5.3 现象:iOS 上静音才能自动播放
安卓现代浏览器允许非静音视频在获得用户手势后播放,但 iOS Safari 更严格:自动播放场景下,非静音视频会被拦截,表现为 play() 的 Promise reject,没有任何画面。
这是因为 iOS 要求视频要么静音(muted + playsinline),要么发生在用户主动点击事件里。滚动本身不算用户手势,所以 video-scroll 在 iOS 上默认只能做静音自动播放。
解决有两条路。一是确实需要静音播放,给 video 加muted和playsinline属性;二是需要有声音,就只能退而求其次:首次进入视口时先静音自动播,用户点击画面后手动取消静音并继续播放。代码里要给一个“用户点击后恢复声音”的监听,并在视频进入视口时判断当前是否已获得用户交互授权。
5.4 现象:ended 被反复触发
视频播到结尾,ended 事件触发了一次,播放器正要弹“重播”按钮,用户手指一动,页面又滚动了,ended 又被触发一次,整个播放器 UI 在“已结束”和“播放中”之间反复横跳。
原因很典型:滚动回调里没有做“当前是否已处于目标状态”的判断,每次滚动差值过大时,多个入口(滚动监听、IntersectionObserver、play 的异步返回)都同时去 dispatch ended,或者都去调用重播逻辑。
解决方案是给播放器状态加锁:用一个变量记录当前状态,比如let endedNotified = false;。视频真正播放中时置回 false,每次触发 ended 处理前先判断这个锁;已经为 true 就 return。细节上,重播按钮应该手动把锁重置,避免用户点了重播后 ended 通知永远不再触发。
5.5 现象:滚动容器不是 window
页面结构是非标准布局时,坑会出现在意想不到的地方:视频嵌在一个overflow: auto的 div 里,页面本身不滚动,只有这个 div 里的内容滚动。此时 IntersectionObserver 默认以浏览器视口为 root,结果视频永远在视口内,滚动 div 也永远不触发播放,或反过来——div 滚出去了,页面视口里还是能看见一部分,所以一直没有暂停。
原因是默认的 root 是浏览器视口,不是实际滚动的容器。解决方法是把观察器的 root 显式设为那个滚动容器,并且在容器里设置了 border 或 padding 时配合 rootMargin 做修正:
const container = document.querySelector('.scroll-area'); const observer = new IntersectionObserver(callback, { root: container, threshold: 0.5 });这样浏览器会以容器内部为可视区计算相交面积。同时注意,如果使用 scroll 监听方案,滚动的读取要改成container.scrollTop而不是window.scrollY,否则永远读不到正确的滚动进度。定位这种问题最快的方法是在控制台执行document.elementsFromPoint看看目标元素的上层结构,确认到底是哪个层在承担滚动。
6. 进阶玩法:从“滚动触发播放”到“滚动驱动播放”
6.1 滚动进度直接映射视频进度:滚到哪播到哪
基础二态控制之外,更有意思的场景是“滚动驱动”:整个页面变成视频的时间轴,往下滚,视频像电影逐帧走一样跟着进到对应时间点。这种交互在叙事型长页面上很流行,实现也比想象中简单:
function mapScrollToVideo(container, video) { // 容器的总滚动范围 const maxScroll = container.scrollHeight - container.clientHeight; if (maxScroll <= 0) return; container.addEventListener('scroll', () => { const progress = container.scrollTop / maxScroll; // 映射到视频时长并强制 seek video.currentTime = progress * video.duration; }, { passive: true }); }逻辑说明:scrollTop 除以总的可滚动高度得到 0 到 1 的进度,再乘视频时长,就等于当前应处的播放位置。每次滚动都把 currentTime 设为这个值,视频画面就会跟手。需要提醒的是,这个写法适合短时间视频,比如几十秒内的展示片;长视频的 seek 开销很大,滚动会导致频繁解码跳帧。
参数上,建议配合 rAF 节流使用,让 seek 一帧最多执行一次;同时把 maxScroll 为 0 的情况提前 return,否则页面短、没得滚时会出现除零问题。
6.2 用自定义事件封装 video-scroll:业务层只看 start/stop
项目里滚动逻辑和播放逻辑常常要解耦。给 video-scroll 定一套自定义事件是个好习惯:观察器只负责判断“进入/离开”,然后把结果广播出去,业务组件各自监听并处理播放、上报、展示逻辑。
const videoScroll = new IntersectionObserver((entries) => { entries.forEach((entry) => { const dir = entry.isIntersecting ? 'enter' : 'leave'; // 广播自定义事件,payload 带上方向,业务层自行处理 entry.target.dispatchEvent( new CustomEvent('videoscroll', { detail: { direction: dir } }) ); }); }, { threshold: 0.5 }); // 业务组件只监听自已需要的事件 const video = document.querySelector('video'); video.addEventListener('videoscroll', (e) => { if (e.detail.direction === 'enter') { video.play(); } else { video.pause(); } }); videoScroll.observe(video);这样做的好处是,未来加“进入视口自动上报曝光”只需要再监听一个 videoscroll 事件,不用改动观察器代码;反过来换掉观察器实现,播放组件也不用动。自定义事件的名字、payload 结构约定好后,就是团队内部共同遵守的接口。
6.3 用 Playwright 验证滚动播放行为
滚动播放的坑大多出在“以为触发了,实际没触发”上。手工测试很累,我一般用 Playwright 自动滚动页面来做冒烟验证。所谓冒烟,就是脚本驱动一个浏览器实例滚动到视频位置,然后断言视频有没有进入播放状态。
import { test, expect } from '@playwright/test'; test('滚动到视频位置后自动播放', async ({ page }) => { await page.goto('/demo-page'); // 跳到视频元素,浏览器会自动滚动到可视区域 await page.locator('video').scrollIntoViewIfNeeded(); // 等待一小段缓冲后检查是否处于播放状态 await page.waitForTimeout(1000); const currentTime = await page.evaluate(() => { const v = document.querySelector('video'); return { paused: v.paused, time: v.currentTime }; }); // 断言没有暂停,并且播放时间在前进 expect(currentTime.paused).toBe(false); expect(currentTime.time).toBeGreaterThan(0); });说明一下断言为什么不用只看 paused:有些实现里 play() 被调了,但被浏览器拦截,paused 依然是 true;而 currentTime 能前进说明解码和播放都真的在跑。我习惯把 currentTime 也检查一遍,比单查 paused 更接近真实播放状态。这套冒烟脚本还可以配合滚动速度参数模拟用户快速滚动,用来验证 5.1 的状态锁是否生效。
这一块说到底是经验问题:做滚动驱动播放那阵子,我踩的最多的是 5.2——回来继续播的进度被重置,后来养成的习惯,是每个滚动播放需求在写之前先把“进入视口是否重置进度”写进方案里,别让前后端各自脑补。希望帮到你。
本文还有配套的精品资源,点击获取