我一直有个毛病看在线课程的时候总觉得讲师说话的节奏比我的思考慢半拍。最开始我只是把进度条拖来拖去后来发现平台自带的倍速功能并不好用要么档位太死要么每次打开新视频都要重新设置。于是我开始琢磨能不能写一个脚本直接接管所有 HTML5 视频的播放速率并且记住我的偏好这就是后来被朋友戏称为“倍速刷课脚本”的小项目。先说实话“刷课”这个词有点标题党。这套脚本真正做的事情只是在浏览器里把视频的播放速率精确调到我想要的速度比如 1.3、1.75、2.0它不会自动跳转到结尾也不会伪造学习时长。合适的用法是遇到已经熟悉的内容加快通过遇到需要理解的内容保持一个听得清又不拖沓的速度。学没学到东西还是自己心里最清楚。1. 从“手动点倍速”到“脚本控制”需求是这么长出来的1.1 平台自带倍速功能的尴尬大多数视频网站都内置了倍速播放看起来够用但真正每天看课时会发现几个问题。第一档位太固定。常见平台提供的倍速一般是 0.5、1.0、1.25、1.5、2.0。有些讲师本身节奏偏慢1.25 倍不够省时间1.5 倍又会听漏细节中间缺一个 1.4 或 1.35。你只能在一个勉强能接受的速度里来回妥协。第二个问题是设置不记忆。在 A 视频里手动调成 1.5切换到 B 视频又回到 1.0repeat 几轮以后人就烦了。第三个问题更隐蔽部分自定义播放器把倍速按钮藏在二级菜单里每次要开菜单再选比重新调一次还要崩溃。这些问题单独看都是小事叠在一起就很影响学习连续感。我最早只是在浏览器控制台里手动执行一句document.querySelector(video).playbackRate 1.5后来发现这样也不行页面一刷新就失效换一集又失效而且不是每次都能找到那个video标签。于是我想把它做成一个在每次打开页面时自动生效的小脚本。1.2 为什么不用现成浏览器插件搜索之后发现市面上的倍速插件确实不少但要么只支持某个特定视频网站要么交互很重还带着一堆和倍速无关的功能。浏览器扩展本身也要经过严格的权限审核如果只在内部学习平台或者几个固定的在线课程站点上用为它单独维护一个扩展有点大材小用。油猴脚本Tampermonkey / Violentmonkey是不同的思路。它本质是一段 JavaScript用一个带注释头的文件声明在哪些网站运行剩下的代码完全由自己控制。优点是没有编译、匹配规则一目了然、改一行代码刷新页面就能生效。对于这种“小工具”级别的需求比起单独做个浏览器插件油猴脚本的开发成本低得多出问题也更容易排查。所以我定下选型原则核心逻辑尽量短能少依赖扩展 API 就少依赖兼容性优先优先面向 HTML5video播放器因为它覆盖了绝大多数在线课程平台。1.3 拆成三个关键动作整个脚本的功能看起来花哨但拆开只有三步找到页面里的视频元素。把playbackRate播放速率设置成目标值。在视频切换、页面刷新后保持这个设置。后来在实测过程中又加了一堆细节动态监听、快捷键、按域名记忆倍速、处理 iframe 里的视频等等。后面几个章节我按踩坑的顺序来聊先说核心原理再展示代码最后讲那些文档里不会写的坑。2. video.playbackRate 的底层逻辑倍速播放不是玄学2.1 playbackRate 和 defaultPlaybackRate 的区别在浏览器里控制视频播放速度最核心的属性就是playbackRate。它表示当前视频的播放速率1.0是正常速度2.0是两倍速。这个属性几乎是所有现代浏览器的标准实现而且对video元素直接生效。但很多第一次写倍速脚本的人会忽略另一个属性defaultPlaybackRate。这两个名字看起来很像作用完全不同。属性作用触发时机video.playbackRate当前实时播放速率只要赋值马上生效video.defaultPlaybackRate媒体资源的默认播放速率切换视频、重新加载媒体时生效如果你只改了playbackRate在同一个视频播放过程里确实有效但一旦播放器切到下一集、调用load()或者重新获取媒体资源浏览器就会把播放速率重置回defaultPlaybackRate也就是1.0。很多脚本“看着写对了换一集就失效”就是栽在这里。所以正确做法是两个属性一起设video.defaultPlaybackRate 1.75; video.playbackRate 1.75;顺手也要处理视频的loadedmetadata事件因为有些播放器在元数据加载完成后会主动重置属性。2.2 音频为什么没有变成“花栗鼠”一个困扰很多人的问题是视频倍速之后声音为什么通常还是正常音调而不是变得尖细这在音频处理里叫“时间伸缩”简单说就是浏览器把音频切片通过加窗、重叠、变调补偿等算法在改变播放速度的同时尽量保持原始音调。HTML5 视频里有一个属性preservesPitch默认值就是true表示倍速播放时保持音调不变。不同浏览器对这个属性的支持程度略有差异但主流浏览器都已经做得不错。当然这有个前提视频播放器的音频输出也是走 HTML5 的默认路径。如果某个平台用 Web Audio API 自己做音频处理把声音单独抽离出来那preservesPitch可能就不起作用倍速后会出现音画不同步或者声音变调。这个问题到第 4 部分再展开。2.3 为什么控制台改了 playbackRate 却没有反应很多人刚开始试的时候在控制台里输入一句document.querySelector(video).playbackRate 2发现视频完全没有变化。这不是因为浏览器不支持而是因为“找到的根本不是真正在播放的那个视频”。在线课程平台普遍会用自定义播放器包装video标签。常见的几种情况播放器嵌在iframe里主文档的document.querySelector根本看不到 iframe 内部的元素。播放器用了Shadow DOM标签被封装在开放或闭合的 shadow root 里常规选择器无法直接穿透。播放器不是基于 HTML5video标签而是用 canvas 一点一点画出来的这种情况比较少见但遇到就基本只能找播放器实例。网站脚本有定时器每隔几秒就把播放速率重置回1.0你手动改完下一秒又被覆盖。所以写脚本时不能只写一句setPlaybackRate还得解决“找不到”“被重置”“每次刷新失效”这三个问题。下面这段代码就是在解决这些问题的过程中逐步长出来的。3. 核心脚本实现从零搭一个能上路的倍速控制脚本3.1 脚本骨架与匹配规则我用的运行环境是 Tampermonkey但同样适用于 Violentmonkey。脚本开头有一段注释头主要是声明脚本的元信息。// UserScript // name 自定义视频倍速助手 // namespace video-rate-helper // version 1.2.3 // description 给HTML5视频提供自定义倍速控制支持快捷键和站点记忆 // match *://*/* // grant none // run-at document-start // /UserScriptmatch *://*/*表示匹配所有网页。实际使用时我建议收敛一下比如只匹配你常看课的几个域名否则脚本会在所有页面里执行虽然大多数页面没有视频也不会报错但没必要。grant none表示不需要额外的 GM API这样脚本能拿到页面上下文里的变量。某些播放器会把自己的实例挂到window上少依赖几个权限反而更容易和页面交互。run-at document-start让脚本在 DOM 刚开始解析时就运行。不过只靠这个还不够视频元素往往是在页面脚本执行后异步创建的所以后面要用MutationObserver做动态监听。3.2 设置倍速、记忆倍速和快捷键这个脚本最核心的动作只有一段函数function applyRate(video) { const rate readRate(); if (!video) return; video.defaultPlaybackRate rate; video.playbackRate rate; video.preservesPitch true; }readRate负责从localStorage里读取用户上次设置的倍速默认给一个1.5。用localStorage的好处是不依赖 Tampermonkey 的存储接口也不弹权限代码更干净。function readRate() { const v parseFloat(localStorage.getItem(videoRateHelper:rate)); if (isNaN(v) || v 0.5 || v 3.0) return 1.5; return v; } function saveRate(rate) { localStorage.setItem(videoRateHelper:rate, String(rate)); }快捷键我用的是Alt 方向键上/下来增减倍速Alt 0恢复 1 倍速。为什么不直接用数字键因为视频网站里键盘事件很密集方向键还可能被用来控制进度加一个Alt能减少误触。document.addEventListener(keydown, (e) { const video getCurrentVideo(); if (!video) return; if (e.altKey e.key ArrowUp) { const rate Math.min(readRate() 0.25, 3.0); saveRate(rate); applyRate(video); e.preventDefault(); } else if (e.altKey e.key ArrowDown) { const rate Math.max(readRate() - 0.25, 0.5); saveRate(rate); applyRate(video); e.preventDefault(); } else if (e.altKey e.key 0) { saveRate(1.0); applyRate(video); e.preventDefault(); } });这里不会在每次按键时把所有视频都遍历一遍只取当前页面第一个正在播放的video。大多数在线课程页面同一时间只有一个视频在播放够用了。getCurrentVideo的最简实现是这样function getCurrentVideo() { return document.querySelector(video); }但它有个问题拿不到 iframe 里的视频。这个问题我在第 4 部分会详细说怎么处理。3.3 用 MutationObserver 兼容动态加载的播放器在线课程基本是单页应用SPA视频播放器会在点击“开始学习”之后才被插件渲染出来。就算脚本在document-start阶段注入等真正出现video标签时脚本已经跑完了自己的逻辑结果什么都没管到。解决方案是监听 DOM 变化。MutationObserver可以在节点添加或删除时收到通知我在回调里检查新增节点里有没有video有就自动绑定倍速逻辑。function observeVideoElements() { const observer new MutationObserver((mutations) { mutations.forEach((mutation) { mutation.addedNodes.forEach((node) { if (node.nodeName VIDEO) { setupVideo(node); } else if (node.querySelectorAll) { node.querySelectorAll(video).forEach(setupVideo); } }); }); }); observer.observe(document.documentElement, { childList: true, subtree: true }); }setupVideo需要防止同一个视频被监听多次。我用一个自定义属性做标记function setupVideo(video) { if (video.dataset.videoRateHelper ready) return; video.dataset.videoRateHelper ready; [loadedmetadata, durationchange, play].forEach((eventName) { video.addEventListener(eventName, () applyRate(video)); }); applyRate(video); }这样视频一出现、一加载、一开始播放都会执行一次applyRate确保倍速值不被播放器内部逻辑覆盖。3.4 完整代码清单把这些综合起来就是一个可以直接粘贴到 Tampermonkey 里的脚本。省掉了一些容错保留核心思路方便你自己改。// UserScript // name 自定义视频倍速助手 // namespace video-rate-helper // version 1.2.3 // description 给HTML5视频提供自定义倍速控制支持快捷键和站点记忆 // match *://*/* // grant none // run-at document-start // /UserScript (function () { use strict; const DEFAULT_RATE 1.5; const MIN_RATE 0.5; const MAX_RATE 3.0; const STEP_RATE 0.25; const STORAGE_KEY videoRateHelper:rate; function readRate() { const value parseFloat(localStorage.getItem(STORAGE_KEY)); if (isNaN(value) || value MIN_RATE || value MAX_RATE) { return DEFAULT_RATE; } return value; } function saveRate(rate) { localStorage.setItem(STORAGE_KEY, String(rate)); } function getCurrentVideo() { return document.querySelector(video); } function applyRate(video) { if (!video) return; const rate readRate(); video.defaultPlaybackRate rate; video.playbackRate rate; video.preservesPitch true; } function setupVideo(video) { if (video.dataset.videoRateHelper ready) return; video.dataset.videoRateHelper ready; [loadedmetadata, durationchange, play].forEach((eventName) { video.addEventListener(eventName, () applyRate(video)); }); applyRate(video); } function observeVideoElements() { const observer new MutationObserver((mutations) { mutations.forEach((mutation) { mutation.addedNodes.forEach((node) { if (node.nodeName VIDEO) { setupVideo(node); } else if (node.querySelectorAll) { node.querySelectorAll(video).forEach(setupVideo); } }); }); }); observer.observe(document.documentElement, { childList: true, subtree: true }); } function bindShortcuts() { document.addEventListener(keydown, (e) { const video getCurrentVideo(); if (!video) return; if (e.altKey e.key ArrowUp) { const rate Math.min(readRate() STEP_RATE, MAX_RATE); saveRate(rate); applyRate(video); e.preventDefault(); } else if (e.altKey e.key ArrowDown) { const rate Math.max(readRate() - STEP_RATE, MIN_RATE); saveRate(rate); applyRate(video); e.preventDefault(); } else if (e.altKey e.key 0) { saveRate(1.0); applyRate(video); e.preventDefault(); } }); } if (document.readyState loading) { document.addEventListener(DOMContentLoaded, () { observeVideoElements(); }); } else { observeVideoElements(); } bindShortcuts(); })();这个版本算是一个比较扎实的“通用倍速脚本”。但真拿到真实环境里跑几天就会发现一些让人头大的边界情况。下面这部分是我在实际使用中踩过的坑。4. 真实使用中踩过的坑从“改不了”到“被重置”4.1 播放器不在当前文档iframe 与 Shadow DOM我最早是在公司内部的在线学习平台测试的打开控制台一看页面里根本没有video标签但视频明明在播放。找了半天发现播放器被封装在一个 iframe 里。如果我们只监听当前文档的 DOM永远找不到 iframe 里的视频。此时有两个思路。第一个思路是给脚本加all-frames指令让脚本在 iframe 内部也执行一遍。代价是同一个脚本会在主文档和每个 iframe 里各跑一次需要自己在脚本里判断是否要处理当前 frame。// all-frames true然后在脚本开头加一句if (window.self window.top) { // 只在顶层页面处理快捷键 } else { // iframe 环境只负责找到 video 并应用倍速 }不过更省事的做法是写一个函数遍历当前页面里所有 iframe直接操作 iframe 内部元素。但跨 iframe 操作容易遇到同源策略非同一源的页面你拿不到内嵌文档的 DOM。对于同一个站点下的课程平台主站和 iframe 同源的情况居多可以直接拿到。Shadow DOM 情况类似但更麻烦。播放器组件把video塞在 shadow root 里常规querySelector扫不到。如果你的目标站点是这种情况可以尝试遍历所有元素检查element.shadowRoot是否存在再递归查找里面的video。function findVideoInShadowRoots(root) { if (!root) return null; const video root.querySelector(video); if (video) return video; const all root.querySelectorAll(*); for (const el of all) { if (el.shadowRoot) { const found findVideoInShadowRoots(el.shadowRoot); if (found) return found; } } return null; }开放类型的 Shadow DOM 可以通过这种方式找到闭合类型不行。好在大多数在线课程平台不会把播放器做成闭合 Shadow DOM毕竟还要支持用户脚本和浏览器原生调试。4.2 平台定时重置倍速怎么办脚本上线第一天很顺利第二天就收到同事反馈某个课程平台在看视频的前 10 秒倍速正常过一会儿就自动回到 1.0再按快捷键设置回去过几秒又被重置。这基本上可以断定平台播放器内部有定时监听。我遇到的几种重置触发时机视频调用play()时重置。播放器切清晰度时重新创建video元素。播放器内部有个requestAnimationFrame循环每帧检查playbackRate发现不是平台允许的值就改回来。对于前两种情况我们的事件监听已经能处理。对第三种最粗暴但有效的办法是加一个低压轮询兜底。setInterval(() { const video getCurrentVideo(); if (!video) return; const desired readRate(); if (Math.abs(video.playbackRate - desired) 0.01) { applyRate(video); } }, 5000);每 5 秒检查一次发现倍速不对就改回来。这个间隔兼顾了“及时恢复”和“不浪费资源”。不要再短了每秒一次会让页面有可感知的卡顿也不要太长否则平台重置后要等很久才能恢复。轮询只是兜底不是最优解。如果播放器暴露了实例方法比如player.setPlaybackRate()那就直接调用它同时监听播放器的自定义事件。但这类方法每个平台都不一样脚本没法通用我只能在自己的需求里做定制。4.3 声音变调和音画不同步问题我的脚本里特意写了video.preservesPitch true这是为了让 2 倍速时声音听起来还是一个正常人说话。现代浏览器默认就是 true但显式写出来更稳妥。真正让我头疼的是另一类问题有些平台把音频单独用 Web Audio API 处理了画面走video标签声音不走。这时候你改video.playbackRate画面是倍速了可声音还是原速音画直接错开观感很难受。想彻底解决得找到页面的AudioContext实例把playbackRate.value也改掉。不同平台的实现差异很大没有统一解法。我的建议是在遇到这类平台时先看有没有官方 API 或者播放器实例可用。如果都没有就不要强行用脚本硬调否则只会得到一段画面和声音对不上的视频。4.4 脚本对性能的影响和控制台报错通用版脚本里最耗性能的地方是MutationObserver和setInterval。MutationObserver监听整个document.documentElement在单页应用里 DOM 变化非常频繁如果回调逻辑里还不断执行querySelectorAll(video)会对页面产生额外负担。处理办法是给观察目标适当收敛。如果知道视频播放器挂在某个固定容器内可以把观察范围从document.documentElement缩小到该容器。不知道就不要乱猜保持全局监听但回调函数尽量轻量。控制台报错最常见的原因是getCurrentVideo()返回null我们又在null上调用.playbackRate。脚本里每次调用前做一次空值判断能省掉大量红色报错。另一个常见问题是localStorage在某些隐私模式下不可用读取时会抛异常可以用try...catch包住readRate和saveRate。5. 进阶玩法从“能用”到“好用”5.1 按域名设置不同的默认倍速不同平台的视频内容性质不同有的老师语速慢有的老师语速快。固定一个默认倍速不够合理。我后来在脚本里加了一张站点速查表根据location.hostname匹配不同的默认值。const SITE_RATES { bilibili.com: 1.75, coursera.org: 1.5, youtube.com: 1.5, talk.example.com: 1.25, }; function getDefaultRateForCurrentSite() { const host location.hostname; for (const [domain, rate] of Object.entries(SITE_RATES)) { if (host domain || host.endsWith(. domain)) { return rate; } } return DEFAULT_RATE; }然后把readRate()里的默认值改成getDefaultRateForCurrentSite()。这样即使localStorage里没有记录也能根据站点自动选一个合理的初始速度。5.2 加一个不遮挡画面的倍速浮标纯快捷键虽然方便但很容易忘记当前是多少倍速。我加了一个半透明的小浮标挂在页面右下角实时显示当前倍速。这个浮标不需要任何框架用原生 DOM 就能做。function createBadge() { const badge document.createElement(div); badge.id video-rate-helper-badge; badge.style.cssText position:fixed;right:20px;bottom:20px;padding:6px 10px; background:rgba(0,0,0,0.6);color:#fff;font-size:14px;border-radius:4px; z-index:99999;pointer-events:none;display:none;; document.documentElement.appendChild(badge); return badge; }然后在每次设置倍速时更新浮标内容。pointer-events: none很重要否则按钮会在页面最底层挡到点击事件。5.3 有哪些必要但我不建议做的“扩展”有一个经常被提起的扩展是“自动跳过片头”也就是自动点击播放器里的“跳过广告”或者“跳过片头”按钮。技术上就是监听 DOM找到对应按钮并触发click()实现起来不难。但我个人不建议在课程平台里这么干因为片头可能包含课程信息、讲师提醒、作业要求跳过之后容易漏东西。还有人在脚本里加了防挂机逻辑比如定时把playbackRate设为 16 倍速、自动暂停恢复、模拟用户操作。这种东西已经超出“倍速播放”的范畴接近刷课作弊了。我在脚本里明确不碰这些功能原因很简单工具帮我们提高效率但不该替我们欺骗自己。如果你是拿课程学习不是花钱买时长那就老老实实把该听的部分听进去。这个脚本迭代到现在从最初十行不到的控制台命令变成了一个带快捷键、带站点记忆、会处理动态播放器的完整工具。我觉得最有价值的不是“刷完课”的速度而是通过写它理解了一个网页播放器到底是怎么工作的。最后分享一个我自己的使用习惯绝大多数课程我固定在 1.75 倍速遇到重点段落会切回 1.25 倍听完再切回来。倍速只是提高效率的手段不是目的。把速度调到 3 倍以后大脑确实跟不上那省下来的时间也就没有意义了。