
简介video-scroll 是一个基于 jQuery 的轻量级前端工具专门解决视频随页面滚动自动开始与停止的问题。其面向需要优化浏览体验的网页开发者尤其适合产品介绍页、故事化长页面、图文视频混排等滚动交互场景同时也可作为学习案例帮助前端初学者理解 scroll 事件与 HTML5 video 元素的配合方式。压缩包共 3 个文件包含核心控制脚本 videocontroller.js、说明文档 README.md 及开源许可 COPYING整体仅 14KB结构清晰、无冗余依赖方便直接嵌入项目或二次修改。目前已有 252 人学习该资源。文件中给出了明确的引入方法和调用示例拿到后即可根据项目路径完成配置阅读源码还能掌握通过 scroll 事件判断视频播放时机的实现思路并扩展到其他滚动动效中对提升前端交互开发效率有实际帮助。1. 滚动到哪视频放到哪video-scroll 解决的不只是「少点一次播放键」打开一篇带视频的长文最扫兴的交互是视频躺在页面中间用户滚到跟前还要停手去找播放键更扫兴的是页面一加载所有视频一起下载页面卡顿声音串台。video-scroll 这套方案要做的就是把「滚到可视区就播、滚出就暂停」做成一组简单、可复用的 JavaScript 逻辑让视频像配图一样跟随阅读节奏而不是一块需要用户单独操作的媒体组件。它适合产品介绍页、图文详情、课程章节页、甚至滚动歌词这类「内容即进度」的场景。不需要改服务端给 video 元素打一个标记剩下交给 IntersectionObserver 判断。下面把最小实现、参数调整、移动端坑点和进阶玩法一次讲完。2. 用 IntersectionObserver 实现 video-scroll 最小命令从标记到播放 30 行跑通我在给视频类页面做滚动播放时第一版方案通常是拿 scroll 事件硬算。做完之后会立刻遇到一轮性能和边界问题然后才换成 IntersectionObserver。下面把这条路线讲清楚你照着走一遍就能绕开我踩过的那些坑。2.1 为什么绕开 scroll 事件高频回调、强制重排与合成器判定很多人一开始都会写 window.addEventListener(scroll)然后在回调里判断每个视频的 offsetTop 和 scrollY 的关系。这个写法最直接但有两个硬伤。第一个是频率。scroll 事件在滚动过程中一帧可以触发多次而且回调跑在主线程上。只要回调里有读 scrollY、getBoundingClientRect、offsetTop 这类布局信息浏览器就可能被迫提前执行重排。页面上有多个视频时这个消耗会翻倍移动端上表现为滚动掉帧和视频卡顿。第二个是判据精度。用 scroll 事件做可见性判断需要自己算元素顶部、底部和视口的三段关系代码一多容易出边界 bug。比如「元素只露出一个像素」也被算成可见或者竖屏页面里横版大视频永远算不可见。有人会想用「滚动停止后再判断」来处理但滚动播放的核心是「滚到哪播到哪」停止后再判断就意味着视频永远是迟到的这个方向本身就错了。IntersectionObserver 把「元素与根的交集比例」交给浏览器合成器去计算回调只在状态变化时触发不会在滚动期间高频重复执行。开箱即用地给出 isIntersecting 和 intersectionRatio省掉手工几何计算。它不依赖 scroll 事件堆栈页面里其他滚动动画、懒加载脚本不会跟播放逻辑互相干扰这是选型上最大的优势。2.2 最小可运行版本data-video-scroll 标记加一个观察器先给视频元素打标记。我用 data 属性而不是 classdata-video-scroll 是行为配置项class 通常还兼职样式两者混在一起后端模板想在某些端不启用滚动播放时会很别扭。video>// 找到页面里所有标记了>// 自动播放兜底先保证静音播放首次交互后解锁音量 let soundEnabled false; document.addEventListener(pointerdown, () { soundEnabled true; }, { once: true }); function safePlay(video) { // 用户还没交互过强制静音后再播 if (!video.muted !soundEnabled) { video.muted true; } const p video.play(); if (p ! undefined) { p.catch(() { // 播放被拦截在视频上叠一个播放按钮交给用户手势触发 showPlayButton(video); }); } }逻辑说明{ once: true }让监听器在第一次触发后自动移除不会每点一次页面都重复赋值。safePlay 在调用play()前判断 soundEnabled保证用户没交互过时永远静音播放。如果连静音播放都被浏览器拒绝才在视频上方显示一个播放按钮把选择权交还给用户。还有一类情况是 iframe 内嵌视频比如第三方视频平台的播放器。大多数情况下你拿不到 iframe 里的 video 元素IntersectionObserver 也只能观察 iframe 本身这时候的做法是观察 iframe 容器进入可视区时通过 postMessage 通知播放器这个属于平台私有方案不在原生 video 的讨论范围内。桌面浏览器同样受 autoplay 策略约束不要以为只有手机才有这个问题上线前必须在无痕模式里验证一次。3.2 多个视频抢播怎么定优先级单一播放实例的互斥方案多 video 页面最典型的问题两个视频同时进入可视区声音串在一起。用户听到的不是两段声音的简单叠加而是两个解码器同时输出的混杂非常刺耳。移动端的解码资源也在同时被两份占用发热和耗电都会上来。最简单的互斥策略是「全局只有一个播放实例」。在 IntersectionObserver 回调里维护一个 playingVideo 全局变量新视频开播前先暂停上一个。// 全局当前播放实例新视频开播前先暂停旧的 let playingVideo null; const observer new IntersectionObserver((entries) { entries.forEach((entry) { const video entry.target; if (entry.isIntersecting video.paused) { // 先暂停上一个在播的视频再播新的 if (playingVideo playingVideo ! video) { playingVideo.pause(); } video.play(); playingVideo video; } else if (!entry.isIntersecting video playingVideo) { // 当前视频离开可视区暂停并清空引用 video.pause(); playingVideo null; } }); }, { threshold: 0.2 });逻辑说明entry.isIntersecting video.paused时才进入播放分支避免同一个视频在连续回调里重复 play()。离开分支只处理 playingVideo 指向的元素防止把其他视频的暂停状态搞乱。如果两个视频几乎同时进入可视区后进入的那个会先暂停前一个再播自己播放权是唯一的。如果视频在瀑布流里位置不固定可以再加「离视口中心最近优先」的排序规则// 从交叉状态里挑一个最接近视口中心的视频 function pickCenterVideo(entries) { const visible entries.filter((e) e.isIntersecting); if (visible.length 0) return null; return visible.sort((a, b) { const aRect a.target.getBoundingClientRect(); const bRect b.target.getBoundingClientRect(); const aDist Math.abs(aRect.top aRect.height / 2 - window.innerHeight / 2); const bDist Math.abs(bRect.top bRect.height / 2 - window.innerHeight / 2); return aDist - bDist; })[0].target; }这个函数只在回调触发时执行一次不用在滚动事件里高频调用所以用 getBoundingClientRect 是安全的。排序后只对中心距离最近的视频执行 play()其他视频即使在可视区也保持暂停。这个方案适合信息流、商品卡等视频元素位置动态变化的页面。还要处理一个边界用户切走标签页时页面的播放不应该继续消耗资源。可以在 document 的 visibilitychange 事件里暂停 playingVideo等用户切回来再恢复它进入可视区时的状态。3.3 视频首帧黑屏与封面抖动preload 与 poster 的兜底组合最小版本跑通后最常见的视觉缺陷是黑屏。视频元素默认是一块透明区域在资源没有下载完成前它显示的是背景色。很多页面给 video 的默认背景是黑色于是滚动到位后会先看到一块黑零点几秒后画面才出来。根源是 preload 属性。preloadnone 表示浏览器不主动下载任何视频数据只有在调用 play() 后才会发起网络请求。为了缩短空白期我一般对首屏附近的视频用 preloadauto对后面才出现的视频用 metadata等视频滚到离视口还差一段距离时再升级为 auto。video>// 预加载观察器视频进入视口周边 200px 时提前加载资源 const preloadObserver new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const video entry.target; if (video.readyState 0) { video.preload auto; video.load(); } preloadObserver.unobserve(video); } }); }, { rootMargin: 200px 0px 200px 0px, threshold: 0 }); videos.forEach((video) preloadObserver.observe(video));逻辑说明readyState 为 0 表示还没有任何数据可用。把 preload 升级为 auto 并调用load()后浏览器开始下载资源。unobserve 确保这个过程只做一次不会重复加载。rootMargin 设置上下各 200px比播放观察器的判定区更大视频还在接近视口时就开始拉数据播放时首帧已经就绪。poster 是封面的兜底。给 video 加一张与首帧内容接近的封面图等待期用户看到的是封面而不是黑底。注意 poster 只在未播放时展示所以它解决的是「等待感」不能替代真正的数据加载。还要给 video 设置 CSS object-fit: cover避免封面和首帧比例不一致播放瞬间画面跳一下。预加载的边界是不要把页面所有视频全设成 auto否则页面加载流量爆炸图片和视频抢带宽反而让首屏更慢。4. video-scroll 踩坑排查iOS 黑屏、快速滚动串台与判据早退这章写的是我在不同项目里真正遇到过的线上问题。每条都按现象、原因、解决的顺序写你可以按图索骥。4.1 现象进视口只出声不出画或者直接被系统播放器接管iPhone 上测试 video-scroll最常见的是两种结果视频开始播了但屏幕全黑只有声音或者页面突然被系统播放器全屏接管滚动和页面里的播放按钮全部失效。原因是 iOS Safari 对没有 playsinline 属性的 video 默认走全屏播放路径。视频一进全屏IntersectionObserver 对它的判断就失效了离开视口时的暂停逻辑也接不住用户只能手动关闭全屏。解决方法是同时做两件事HTML 里加 playsinlineJS 里也设置一次。video>// 兼容 WebKit 大小写差异 video.setAttribute(playsinline, ); video.playsInline true;说明setAttribute 写入字符串空值确保老版本 WebKit 能识别属性camelCase 的属性赋值覆盖新版本 Safari 的 DOM 属性检查。两者都做覆盖率最高。这里还容易漏一个场景视频嵌套在某个滚动容器里而不是页面主滚动全屏接管后内联播放状态依然会丢失所以建议在初始化时统一给所有被观察视频设置 playsinline而不是依赖模板里有人记得写。4.2 现象视频滚得快一点就反复暂停像在闪断用户快速上下滚动时两个视频交界处的播放状态会像触电一样反复横跳。这不是代码计算错误而是判据设置得太敏感。视频刚从视口底边露出一个角时intersectionRatio 在 0 到 0.1 之间摆动很容易跨越 threshold 的临界点于是 play、pause、play 连环触发。解决思路是给播放和暂停各留一个缓冲带。最简单的做法是用负值 rootMargin 收缩底部判定边界配合一个较低的 threshold让视频必须滚到视口内部更深处才触发播放退到边界外才暂停。// 给播放条件加缓冲底部收缩 10%阈值降低到 0.15 const observer new IntersectionObserver(callback, { rootMargin: 0px 0px -10% 0px, threshold: [0, 0.15, 0.5] });说明threshold 传数组后回调会在交叉比例跨越 0、0.15、0.5 等刻度时触发。负值 rootMargin 先把判定区域下边界往上移视频必须越过收缩后的边界并达到 15% 可见才会播放。退出时同样要退过这个边界才暂停中间的区域就是缓冲带快速滚动不容易闪断。如果页面同时存在横向滚动要把 rootMargin 的左右值也考虑进去比如 0px 0px -10% 0px 只收缩底部左右不需要动。4.3 现象两个视频同时满足条件声音串台多视频页面最常见的声音问题是两个视频同时出声。外放时特别刺耳戴耳机时更明显用户会一瞬间以为页面坏了。原理上还是播放权没有收拢到一个地方每个视频各自在回调里 play()谁都没有义务让路。解决方式在前一章已经给了但线上版本我还会加一个「待播」逻辑。如果页面里有一个视频在播其他视频即使进入可视区也先不动等当前视频离开后再接棒let playingVideo null; let pendingVideo null; function onIntersect(entry) { const video entry.target; if (entry.isIntersecting) { if (playingVideo playingVideo ! video) { // 已有视频在播当前视频先挂起 pendingVideo video; return; } video.play(); playingVideo video; } else if (video playingVideo) { video.pause(); playingVideo pendingVideo || null; if (pendingVideo) { pendingVideo.play(); pendingVideo null; } } }逻辑说明pendingVideo 记录被让路的视频。当前播放器离开视口后播放权交给 pendingVideo而不是让所有视频各自竞争。这个方案适合短视频流快速浏览的场景声音始终只有一个来源。需要注意 pendingVideo 在用户反向滚动时要能清掉否则会出现视频早就离远了播放权还在排队等的残留状态可以在 onIntersect 的非相交分支里把对应的 pendingVideo 置空。还有一个视线盲区video 标签加载失败时也应该释放播放权可以给 video 监听 error 事件把 playingVideo 置空否则页面会一直保持一个假播放状态。4.4 现象视频位置出现黑底或封面闪跳滚动到视频位置时黑底一闪而过或者封面和正片之间的画面比例跳变这两个问题经常一起出现。黑底是 preload 没做好封面跳变是 poster 和首帧的视觉呈现不一致。preload 的处理在前一章已经给过这里补一个容易被忽略的细节poster 是「未播放时显示的图片」当视频开始播放时poster 会被立刻撤掉如果此时视频首帧还没渲染出来video 区域就会重新变回黑底看起来就是「黑一下再出画面」。解决方法是保证视频在触发播放前已经 preload 到至少能解码首帧这个时机由预加载观察器控制。另外poster 尺寸和视频原始分辨率不一致时切换瞬间画面会跳。CSS 上加一个固定比例video[data-video-scroll] { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #000; }说明aspect-ratio 把视频区域占位固定成 16:9避免视频加载前后容器高度变化造成布局跳动object-fit: cover 让 poster 和正片画面都以裁切方式填满容器切换时观感一致background 留黑是兜底万一资源加载失败至少是一块干净的黑而不是透底的页面底色。aspect-ratio 是较新的 CSS 属性需要确认项目兼容性不支持的浏览器可以用 padding-top 百分比方案替代。5. 进阶用法滚动进度驱动视频 seek再做一套滚动歌词高亮video-scroll 的思路一旦形成能扩展的地方很多。播放/暂停只是最基本的判定进阶层是把滚动位置和视频时间对应起来或者把「谁在可视区谁激活」这个模式套到文字内容上。5.1 把滚动百分比映射到 currentTime视频当进度条约 20 行「滚动驱动视频进度」这种交互常被用在叙事型长页上。用户往下滚动视频画面跟随进度变化像在拖一根隐形的进度条。核心思路是页面可滚动总量代表视频完整时长当前滚动位置映射到对应的视频时间点。const video document.getElementById(story-video); const maxScroll document.documentElement.scrollHeight - window.innerHeight; function syncVideoToScroll() { const progress window.scrollY / maxScroll; // 0 ~ 1 const targetTime progress * (video.duration || 0); // 目标时间点 // 差值超过 0.2 秒才 seek避免小幅度滚动频繁跳帧 if (Math.abs(video.currentTime - targetTime) 0.2 video.duration 0) { video.currentTime targetTime; } } // scroll 事件配合 rAF 节流一帧最多执行一次 let ticking false; window.addEventListener(scroll, () { if (!ticking) { requestAnimationFrame(() { syncVideoToScroll(); ticking false; }); ticking true; } }, { passive: true });逻辑说明window.scrollY 除以 maxScroll 得到 0 到 1 的进度。video.duration 在元数据加载前是 NaN所以先用|| 0兜底并加 loadedmetadata 监听再开启同步。0.2 秒的差值阈值是防抖关键它过滤掉滚动过程中的微小位移避免 currentTime 被高频赋值导致视频解码卡顿。这里有几个参数要按场景调。视频时长和滚动距离的比例如果视频 60 秒而页面只有两屏进度会被压缩得很敏感手指动几像素视频就跳几秒这时应该把 maxScroll 换算成「视频时长对应的滚动区间」比如让视频在页面滚动前 80% 的行程里播完后面 20% 页面留白放结语比例用 scrollY / (maxScroll * 0.8) 计算。还要保证视频在同步前处于播放状态否则界面看起来是一张静止图用户会以为滚动没生效。5.2 滚动歌词场景把「播放/暂停视频」换成「高亮一句歌词」滚动歌词的核心需求是歌词面板内部滚动时当前应该播放的那一句要高亮。传统做法是计算每行歌词的 offsetTop 和滚动容器 scrollTop 的差值更新频繁还容易受行高和字体影响。用 IntersectionObserver 的思路完全不一样歌词行进入面板可视区且占比足够高它就是激活行。// 歌词面板内滚动高亮当前行 const lyricPanel document.querySelector(.lyric-panel); const lines document.querySelectorAll(.lyric-line); const lyricObserver new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { // 这一行高亮其余行取消高亮 entry.target.classList.add(active); lines.forEach((line) { if (line ! entry.target) line.classList.remove(active); }); } }); }, { root: lyricPanel, // 以歌词面板而不是视口为判定根 threshold: 0.8 // 一行至少 80% 可见才高亮 }); lines.forEach((line) lyricObserver.observe(line));逻辑说明root 从视口换成歌词面板容器observer 判定的是面板内部的可见关系而不是浏览器视口这是和视频方案最大的差异。threshold 0.8 保证歌词行几乎完整显示时才高亮避免一行歌词只露出 10% 就被误判为当前行。这个方案天然忽略每行的高度差异即使某一行句子短、换行少判定逻辑也一致。同样的思路可以直接复用到「当前章节高亮」「侧边栏菜单激活」「图片懒加载标记当前图集」等场景。核心就是谁在根容器里可见度最高谁就是激活态不需要维护任何坐标列表。歌词面板容器如果本身是隐藏状态observer 不会触发任何判定需要在面板 display 切换后重新观察这是滚动歌词里比较隐蔽的坑。5.3 老浏览器降级scroll requestAnimationFrame 的兜底实现IntersectionObserver 的兼容底线是 IE 全系不支持、部分老 WebView 缺失。如果项目必须兼容这些环境需要一个降级实现。老方案是 scroll 事件加 rAF 节流用 getBoundingClientRect 手算可见性。// 降级方案scroll rAF getBoundingClientRect function fallbackVideoScroll(videos) { let ticking false; function check() { const winHeight window.innerHeight; videos.forEach((video) { const rect video.getBoundingClientRect(); const top rect.top; const bottom rect.bottom; // 判定视频底部进入视口下沿且顶部未超过视口上沿 const visible top winHeight * 0.9 bottom 0; if (visible video.paused) { video.play(); } else if (!visible !video.paused) { video.pause(); } }); ticking false; } window.addEventListener(scroll, () { if (!ticking) { requestAnimationFrame(check); ticking true; } }, { passive: true }); check(); }逻辑说明getBoundingClientRect 返回元素相对视口的几何位置。top 小于视口高度的 90% 且 bottom 大于 0表示视频主体已经进入可视区。rAF 把检查频率限制到一帧一次避免滚动事件密集触发。降级的运行性能不如 IntersectionObserver但兼容面广老页面里够用。初始化时先做特性检测再决定启用哪条路径if (IntersectionObserver in window) { initVideoScrollObserver(); } else { fallbackVideoScroll(Array.from(videos)); }降级方案里有一个容易踩的坑如果视频元素放在隐藏容器里getBoundingClientRect 会返回全 0 坐标visible 会误判为 true导致隐藏页面的视频被播放。降级代码里要先检查元素的 offsetParent 是否为 null或者用 getClientRects().length 判断元素是否真实渲染。另外降级方案的 rootMargin 和 threshold 概念不直接存在只能在 check() 里自己写死阈值切换方案时注意两边的判定口径要一致否则线上会出现同一页面不同浏览器行为不一致的诡异问题。6. 三个手测场景与 Performance 面板验证 video-scroll 不卡、不早放、不串台写完代码不能只看界面正常我每次都会跑三个手测场景。慢速向下滚动确认视频越过 rootMargin 缓冲带才播放、离开视口才暂停而不是刚露出 1px 就响声音。快速从页首滚到页尾确认没有两个视频串台、滚动结束后播放状态是唯一的。再向上回滚确认视频能正常恢复播放封面和首帧之间没有明显跳变。有自动化回归需求时可以把这三个场景用 Playwright 的 mouse.wheel 模拟上下滚动再断言 video.paused 和 currentTime。慢滚和快滚要分开两组 wheel 参数否则覆盖不到快速滚动的边界。6.1 手测用例慢滚、快滚、弹回慢速滚动重点看 rootMargin 的缓冲带是否生效。视频应该提前或准时进入播放而不是露出一个边就触发也不是滚动到完全可见才开始。快滚重点看串台。如果互斥逻辑有 bug快速滚过三个视频时会出现短暂的重叠声音或者在最后一个视频离开后仍然有视频在播。回滚重点看状态恢复视频从离开状态回到可视区时要能从上次暂停的位置继续而不是重新加载重头播。6.2 Performance 面板看什么打开 Performance 面板录制一段滚动重点看三处。一是 Long Task 是否超过 50ms出现长任务说明主线程被阻塞。二是紫色 Rendering 块是否集中出现在滚动帧里如果是多半是手动读写布局触发了强制重排。三是 IntersectionObserver 回调里不能有网络请求或同步解码操作这些应该提前放到预加载阶段。再看一眼 Console有没有 Unhandled promise rejection。video.play() 被自动播放策略拦截时会返回 rejected Promise没接 catch 的话控制台会刷红也会污染监控告警上线前必须清掉。6.3 我踩过的一次教训有一版我把 threshold 设成 0.5 去适配竖屏长视频结果测试同学反馈在 iPhone 上「怎么都播不了」。查了半天才发现16:9 的视频在竖屏页面上根本达不到 50% 可见比例IntersectionObserver 永远等不到触发。改成 0.2 加负值 rootMargin 后问题消失。另一回是 preloadnone 导致视频滚动到位后一片黑用户以为上传失败了。从那以后我给自己定了个习惯所有 video-scroll 项目里video 默认加 muted、playsinline、preloadmetadata交互里永远保留静音播放降级。这套默认值帮我避掉了绝大多数移动端问题。希望帮到你。本文还有配套的精品资源点击获取