1. 从一个让人抓狂的小问题说起如果你最近在浏览器里看网课、看录播、看在线培训视频大概率遇到过这种体验视频看着看着鼠标一挪到别的地方或者切到另一个标签页回个消息回来一看——视频暂停了。进度条停在原地你得手动点一下播放有时候还得重新定位。一节课下来光点播放就点了十几次思路被打断得七零八落。这个问题的根源是很多在线学习平台为了防止“挂机刷课”在网页里埋了监听逻辑只要检测到页面失去焦点、鼠标离开视频区域、或者标签页被切到后台就自动触发暂停。从平台的角度看这是防作弊从学习者的角度看这就是纯粹的折磨。尤其是你一边看课一边记笔记、查资料、对着文档操作的时候鼠标根本不可能一直停在视频上。我前后试过好几种办法。最早是手动改页面元素用开发者工具把视频的暂停事件监听器删掉但每次刷新页面都得重来一遍而且有些平台把逻辑混淆得很深找起来费劲。后来想写个油猴脚本但不同平台的 DOM 结构、事件绑定方式都不一样维护成本太高。直到我把目光转向浏览器扩展用visibilitychange和blur这两个事件的拦截思路做了一个通用方案才算真正把这个问题按住了。这篇内容就是把这个方案的来龙去脉讲清楚为什么平台能检测到你的鼠标移动、浏览器扩展是怎么绕过这些检测的、NoPause Playback这类插件背后的核心原理是什么、以及如果你想自己动手做一个具体该怎么写。不管你是被网课暂停折磨的学习者还是想了解浏览器扩展开发的技术人都能从这里拿到能直接用的东西。2. 问题拆解平台到底是怎么知道你“没在看”的2.1 三个关键事件blur、visibilitychange 和鼠标移出要解决问题先得搞清楚敌人是谁。网页能检测到用户“离开”视频主要靠三类信号。第一类是blur事件。当页面上的某个元素失去焦点时浏览器会触发blur。比如你正在看视频鼠标点了一下地址栏或者点了页面里另一个输入框视频所在的窗口或元素就“失焦”了。很多平台会在window或document上监听blur一旦触发就暂停视频。第二类是visibilitychange事件。这是 HTML5 规范里定义的当页面的可见性状态发生变化时触发。具体来说document.visibilityState会在visible和hidden之间切换。你切到别的标签页、最小化浏览器窗口、甚至在某些系统上锁屏都会让当前页面变成hidden平台收到这个信号就暂停。第三类是鼠标移出视频区域。这个不是浏览器原生事件而是平台自己在视频容器上监听mouseleave或mouseout一旦鼠标离开视频的矩形范围就认为你没在看触发暂停。有些做得更细的还会结合mousemove判断鼠标是否在视频区域内活动静止几秒也算“离开”。注意这三类信号的触发时机和优先级在不同平台上不一样。有的平台只用visibilitychange有的三个全上还有的会做组合判断。所以一个通用的解决方案必须把这三条路都堵住。2.2 为什么直接删监听器不靠谱最直觉的做法是打开开发者工具找到视频元素看看它绑了哪些事件然后手动移除。这个思路本身没错但实操起来有几个坑。第一个坑是事件绑定的位置不固定。有的平台把监听器绑在document上有的绑在window上还有的绑在视频外层的一个div上。你得一个个排查费时费力。第二个坑是监听器可能是匿名函数。removeEventListener要求传入和添加时完全相同的函数引用。如果平台用的是匿名函数或者箭头函数你根本拿不到那个引用也就删不掉。第三个坑是页面会重新绑定。很多单页应用在路由切换、组件重新渲染的时候会重新执行绑定逻辑。你这次删掉了下次切个章节回来监听器又回来了。第四个坑是平台可能用了捕获阶段。addEventListener的第三个参数如果是true监听器在捕获阶段执行你在冒泡阶段做的拦截可能根本拦不住。所以靠手动删监听器只能应急不能作为长期方案。真正稳的做法是在事件传播的早期阶段就把它截住让平台的监听器根本收不到信号。2.3 扩展插件的介入时机比页面脚本更早浏览器扩展有一个天然优势它可以注入脚本到页面的最早执行时机。通过manifest.json里的content_scripts配置run_at: document_start扩展的脚本会在页面 DOM 构建之前、页面自己的脚本执行之前就运行。这意味着你可以在平台绑定监听器之前先把addEventListener这个原生方法“劫持”掉对所有涉及blur、visibilitychange、mouseleave的注册做过滤。这个思路的核心是方法劫持monkey patching把原生的EventTarget.prototype.addEventListener保存一份然后用自定义函数替换它。当页面调用addEventListener时先检查事件类型如果是我们要拦截的那几种就直接忽略不往下传如果是其他事件就调用原始方法正常注册。这样做的好处是不管平台在哪个元素上绑、用匿名函数还是具名函数、在捕获阶段还是冒泡阶段只要它走的是标准的addEventListener就都会被拦下来。而且因为我们在document_start就执行了劫持发生在平台脚本之前平台完全没有察觉。3. 核心原理NoPause Playback 这类插件是怎么工作的3.1 方法劫持的具体实现先看最核心的一段代码。这是整个方案的基石理解了它后面的一切都好说。// 保存原生方法 const originalAddEventListener EventTarget.prototype.addEventListener; // 替换为自定义方法 EventTarget.prototype.addEventListener function(type, listener, options) { // 需要拦截的事件类型 const blockedEvents [visibilitychange, blur, mouseleave, mouseout]; if (blockedEvents.includes(type)) { // 直接返回不注册 return; } // 其他事件正常注册 return originalAddEventListener.call(this, type, listener, options); };这段代码看起来简单但有几个细节值得展开。首先是EventTarget.prototype。addEventListener定义在EventTarget上window、document、Element都继承自它。所以改这一处全局生效。其次是blockedEvents列表。visibilitychange和blur是必须拦的。mouseleave和mouseout要不要拦取决于平台。有些平台用mouseleave判断鼠标离开视频拦掉它就能解决。但要注意mouseout在正常的 UI 交互里也会频繁触发如果无差别拦截可能会影响页面其他功能。所以更稳妥的做法是只拦mouseleave或者对mouseout做更细的判断。第三是options参数。原生的addEventListener第三个参数可以是布尔值表示是否捕获或者对象包含capture、once、passive等。我们的替换函数原样透传不做修改保证其他事件的注册行为完全一致。实操心得劫持addEventListener的时候一定要用call或apply把this正确传递过去。否则在某些调用场景下this会指向错误的对象导致事件注册到错误的元素上。3.2 为什么只拦 addEventListener 就够了有人可能会问如果平台不用addEventListener而是用onblur function(){}或者onvisibilitychange这种属性赋值的方式呢确实属性赋值是另一条路。element.onblur fn本质上也是注册事件但它不走addEventListener。不过在实际的在线学习平台里属性赋值的方式很少见原因有几个一是属性赋值只能绑一个处理函数多个模块要监听同一个事件时会互相覆盖二是属性赋值不方便做捕获阶段和once控制三是现代前端框架React、Vue 等底层都是走addEventListener。所以拦截addEventListener能覆盖绝大多数场景。如果你遇到特别顽固的平台可以再加一层保险用Object.defineProperty把onblur、onvisibilitychange这些属性定义成不可写的或者定义成 getter/setter 做拦截。但这属于进阶操作一般用不上。3.3 visibilitychange 的特殊处理visibilitychange有一个特殊之处它监听的是document而且document.visibilityState是一个只读属性。平台除了监听事件还可能直接读这个属性来判断页面是否可见。// 平台可能的判断逻辑 if (document.visibilityState hidden) { video.pause(); }如果平台是轮询读取visibilityState那光拦事件就不够了。这时候需要把visibilityState也劫持掉让它永远返回visible。Object.defineProperty(document, visibilityState, { get: function() { return visible; }, configurable: true }); Object.defineProperty(document, hidden, { get: function() { return false; }, configurable: true });这样双管齐下事件收不到属性读出来也是“可见”平台就彻底没辙了。注意劫持visibilityState要谨慎。有些页面用这个属性做性能优化比如页面不可见时暂停动画、降低轮询频率。如果你把它永远改成visible这些优化会失效可能增加 CPU 占用。所以这个操作建议只在视频播放相关的场景下启用或者做成可开关的选项。4. 动手做一个从零搭建 NoPause 扩展4.1 项目结构和 manifest 配置一个最小可用的扩展目录结构是这样的nopause-extension/ ├── manifest.json ├── content.js └── icon.pngmanifest.json是扩展的配置文件用 Manifest V3 规范{ manifest_version: 3, name: NoPause Playback, version: 1.0.0, description: 阻止网课视频因鼠标移动或标签切换而暂停, content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_start, all_frames: true } ], icons: { 48: icon.png } }几个关键配置说明run_at: document_start是最重要的。它保证脚本在页面任何其他脚本之前执行劫持才能生效。all_frames: true让脚本注入到所有 iframe 里。很多网课平台把视频播放器放在 iframe 中不开这个选项就拦不到。matches: [all_urls]匹配所有网址。如果你想更克制一点可以只匹配你常用的学习平台域名减少对其他网站的影响。4.2 content.js 的完整实现把前面讲的原理整合起来content.js的完整版本如下(function() { use strict; // 需要拦截的事件类型 const BLOCKED_EVENTS [ visibilitychange, blur, mouseleave ]; // 保存原生方法 const originalAddEventListener EventTarget.prototype.addEventListener; // 劫持 addEventListener EventTarget.prototype.addEventListener function(type, listener, options) { if (BLOCKED_EVENTS.includes(type)) { return; } return originalAddEventListener.call(this, type, listener, options); }; // 劫持 visibilityState try { Object.defineProperty(document, visibilityState, { get: function() { return visible; }, configurable: true }); Object.defineProperty(document, hidden, { get: function() { return false; }, configurable: true }); } catch (e) { // 某些环境下可能不允许修改忽略 } // 兜底定期检查视频是否被暂停如果是则恢复播放 setInterval(function() { const videos document.querySelectorAll(video); videos.forEach(function(video) { if (video.paused !video.ended video.readyState 2) { // 只有在用户没有主动暂停的情况下才恢复 // 这里用一个标记来区分 if (!video.dataset.userPaused) { video.play().catch(function() {}); } } }); }, 1000); // 监听用户的主动暂停操作 document.addEventListener(click, function(e) { const target e.target; if (target.tagName VIDEO) { const video target; if (video.paused) { video.dataset.userPaused true; } else { delete video.dataset.userPaused; } } }, true); })();这段代码分四层防护第一层是事件拦截把visibilitychange、blur、mouseleave全部挡掉。第二层是属性劫持让visibilityState永远返回visible。第三层是定时兜底每秒检查一次视频状态如果发现被暂停了就恢复播放。第四层是用户意图识别通过监听点击事件区分“平台暂停”和“用户主动暂停”避免用户想暂停的时候被强行恢复。实操心得第三层的定时检查间隔不要设得太短。1 秒是比较合适的值既能及时恢复又不会太耗性能。设成 100 毫秒的话页面视频多的时候会有明显的 CPU 占用。4.3 处理 iframe 和动态加载的视频有些平台的视频不是一开始就在 DOM 里的而是用户点击某个按钮后才动态创建。这种情况下document_start时执行的脚本可能找不到视频元素。解决办法有两个。一是用MutationObserver监听 DOM 变化一旦有新的video元素插入就给它加上保护。const observer new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { if (node.nodeName VIDEO) { protectVideo(node); } else if (node.querySelectorAll) { node.querySelectorAll(video).forEach(protectVideo); } }); }); }); observer.observe(document.documentElement, { childList: true, subtree: true }); function protectVideo(video) { // 给视频元素单独做保护 video.addEventListener(pause, function(e) { if (!video.dataset.userPaused) { // 延迟一点再恢复避免和平台的逻辑打架 setTimeout(function() { video.play().catch(function() {}); }, 100); } }); }二是针对 iframe。如果视频在 iframe 里主页面的脚本访问不到 iframe 内部的内容跨域限制。这时候需要靠all_frames: true让扩展脚本注入到 iframe 里每个 iframe 独立运行一份保护逻辑。注意MutationObserver的subtree: true会监听整个文档树的变化页面复杂的时候回调会很频繁。建议在回调里做节流或者只监听特定容器。5. 常见问题与排查技巧实录5.1 装了扩展还是暂停怎么排查这是最常见的问题。按下面的顺序一步步查。第一步确认扩展是否真的注入了。打开开发者工具在 Console 里输入EventTarget.prototype.addEventListener.toString()看看输出的是不是原生代码。如果显示的是function(type, listener, options) { ... }这种自定义的说明劫持生效了。如果显示function addEventListener() { [native code] }说明扩展没注入或者被页面覆盖了。第二步确认视频在不在 iframe 里。在 Elements 面板里看看video标签的上下文如果它在一个iframe里检查扩展的manifest.json有没有开all_frames: true。第三步确认平台的暂停逻辑走的是哪条路。在 Sources 面板里给HTMLMediaElement.prototype.pause打个断点然后复现暂停。看调用栈就能知道是哪个函数触发的暂停。如果调用栈里出现了visibilitychange相关的处理函数说明事件拦截没生效如果出现的是setTimeout或者setInterval里的轮询逻辑说明平台用的是属性读取而不是事件监听需要靠属性劫持或者定时兜底来解决。第四步检查是否有多个扩展冲突。有些广告拦截、隐私保护类的扩展也会修改addEventListener。如果两个扩展都做了劫持后执行的会覆盖先执行的导致其中一个失效。排查方法是临时禁用其他扩展只留 NoPause看问题是否解决。5.2 常见问题速查表现象可能原因解决方法扩展装了没反应脚本注入时机太晚确认run_at为document_start主页面有效iframe 里无效未开启 all_framesmanifest 里加all_frames: true视频恢复后又被暂停平台用了轮询检测增加定时兜底逻辑缩短检查间隔用户主动暂停也被恢复未区分用户意图监听点击事件标记userPaused页面其他功能异常拦截了不该拦的事件缩小BLOCKED_EVENTS范围切换标签页后视频卡顿属性劫持影响了性能优化把visibilityState劫持做成可选项5.3 几个容易踩的坑坑一在document_start时document.body还不存在。如果你的脚本里有document.body.appendChild(...)这类操作会报错。解决办法是用DOMContentLoaded事件包起来或者直接操作document.documentElement。坑二video.play()返回的是 Promise。如果自动播放被浏览器策略阻止play()会 reject。一定要加.catch()否则控制台会刷一堆未捕获的 Promise 错误。而且有些平台会在play()被拒绝后做额外处理所以 catch 里最好什么都不做静默失败。坑三dataset在旧版本浏览器上兼容性。video.dataset.userPaused在 IE 上不支持但现代浏览器都没问题。如果你需要兼容很老的浏览器用getAttribute和setAttribute代替。坑四扩展更新后需要刷新页面。修改content.js后已经打开的页面不会自动加载新代码。需要在chrome://extensions/里点一下扩展的刷新按钮然后刷新目标页面。坑五某些平台会检测扩展。极少数平台会检查EventTarget.prototype.addEventListener是否被修改如果发现异常就报错或者阻止播放。这种情况比较棘手可以考虑用Proxy做更隐蔽的劫持或者把劫持逻辑放在一个闭包里让toString()返回原生代码的样子。6. 进阶玩法让方案更稳、更通用6.1 用 Proxy 做更隐蔽的劫持前面用的直接替换方法addEventListener.toString()会暴露自定义代码。用Proxy可以做得更隐蔽const handler { apply: function(target, thisArg, argumentsList) { const type argumentsList[0]; if (BLOCKED_EVENTS.includes(type)) { return; } return Reflect.apply(target, thisArg, argumentsList); } }; EventTarget.prototype.addEventListener new Proxy( EventTarget.prototype.addEventListener, handler );Proxy的apply拦截函数调用toString()返回的还是原生函数的字符串形式平台检测不出来。这个方案在对抗性更强的场景下更可靠。6.2 做成可配置的开关一个通用的扩展不应该无差别地拦截所有网站。更好的做法是提供一个 popup 界面让用户自己决定在哪些网站上启用。manifest.json里加action: { default_popup: popup.html }, permissions: [storage, activeTab]popup.html里放一个开关用户点击后把当前域名存到chrome.storage.local里。content.js启动时先读配置只有在白名单里的域名才执行拦截逻辑。chrome.storage.local.get([enabledDomains], function(result) { const domains result.enabledDomains || []; if (domains.includes(location.hostname)) { initProtection(); } });这样既解决了网课暂停问题又不会影响其他网站的正常行为。6.3 处理音频和字幕的同步问题有些平台在暂停恢复后音频和字幕会不同步。这是因为视频的currentTime在暂停期间可能被平台修改了。解决办法是在恢复播放前记录currentTime恢复后对比一下如果偏差超过阈值就手动校正。function resumeVideo(video) { const savedTime video.currentTime; video.play().then(function() { if (Math.abs(video.currentTime - savedTime) 0.5) { video.currentTime savedTime; } }).catch(function() {}); }这个细节在长视频上尤其重要偏差累积起来会导致字幕完全对不上。6.4 移动端浏览器的适配手机浏览器上visibilitychange的触发更频繁比如来电、通知栏下拉而且移动端浏览器对自动播放的限制更严。在移动端video.play()通常需要用户手势触发脚本自动调用会被拒绝。针对移动端建议的策略是不强行恢复播放而是在视频上方显示一个浮层提示“视频已暂停点击继续”让用户一键恢复。这样既尊重了浏览器的自动播放策略又减少了用户的操作成本。7. 一些个人体会和后续可扩展的方向这个方案我从最初的手动删监听器到写油猴脚本再到做成正式的浏览器扩展前后迭代了七八个版本。最大的体会是对抗性的问题越早介入越省事。document_start这个时机点是整个方案的命门错过它后面要花十倍的力气去补救。另一个体会是不要追求 100% 的拦截。有些平台的检测逻辑非常复杂可能结合了鼠标轨迹、键盘活动、页面滚动等多种信号。与其把所有信号都堵死那样容易误伤正常功能不如把重点放在“兜底恢复”上你暂停你的我恢复我的只要恢复得够快用户体验就是连贯的。后续可以扩展的方向有几个。一是做成规则订阅制社区维护一个平台适配列表不同平台用不同的拦截策略。二是结合IntersectionObserver只在视频真正进入视口时才启用保护减少对后台标签页的影响。三是加一个统计面板记录每个平台触发了多少次暂停、恢复成功率是多少方便持续优化。如果你也在被网课暂停折磨建议先按第 4 节的代码搭一个最小版本跑起来确认核心逻辑通了再根据自己的使用场景慢慢加功能。大部分情况下光是事件拦截加定时兜底这两层就能解决九成以上的问题。
