
1. 从一次真实需求说起为什么要自己写一个 HLS 播放器前阵子帮一个做在线课程的朋友收拾他们那套老后台需求很朴素课程视频要能在网页里播跨桌面端和移动端最好带上清晰度切换、倍速、记忆播放位置。听上去是随便找个播放器库挂上去的事结果一上手就发现坑比想象的多。他们的视频源已经是 HLS 切片索引是 m3u8但分片走的不是标准后缀直接扔给 video 标签只有 Safari 认Chrome 和 Firefox 打开就是一片黑。折腾了两天我干脆按自己的思路从零撸了一个原生 JS 的 HLS 播放器也就是后来整理出来的tiancai-video-hls。先说清楚它是什么、能干什么。tiancai-video-hls是一个不依赖任何框架的原生 JS HLS 视频播放器核心能力是把 m3u8 索引解析出来、按需拉取分片、通过 MSEMedia Source Extensions喂给 video 元素播放同时支持多码率切换、AES-128 加密流的解密播放、自定义控制条、播放进度记忆这一整套东西。它解决的痛点很具体在非 Safari 内核的浏览器里让 m3u8 视频能正常播起来而且播放器的外观和交互完全由你自己说了算不用被第三方库的默认皮肤绑架。它适合谁看如果你正在做在线教育、企业内训、点播后台这类需要自建视频播放能力的项目或者你手头有一批自己分发的 HLS 内容、想在网页端做个轻量可控的播放器这篇文章应该能省下你不少试错时间。我会把架构设计的取舍、m3u8 的解析细节、MSE 管线的搭建、分片加载与缓冲队列的调度、加密分片的处理、以及我在调试过程中踩过的那些坑一条条摊开来讲。代码都是可以直接抄走的片段不是伪代码。有个前提我得先讲明白这里讨论的加密分片处理前提是你对所分发的视频内容拥有完整控制权密钥由你自己的服务端下发给已授权用户。这是 HLS 协议本身自带的能力用来自有内容的访问控制跟绕过别人内容保护是两码事请务必分清楚这个边界。2. 技术选型为什么不用现成的非要原生 JS 手写2.1 现成方案的三个现实问题一上来肯定有人问hls.js 那么成熟为什么不用我确实先试了。hls.js 能力很强但在这个项目里它有三个绕不开的别扭之处。第一是体积和可控性。hls.js 压缩后也有几百 KB而我们的播放场景其实很单一固定几种码率、固定分片时长、没有直播需求。为了这点功能引入一个大而全的库在后台首页同时加载好几个视频卡片的时候首屏压力很明显。第二是 UI 定制成本。hls.js 只负责解码和喂数据UI 得自己写那我既然 UI 都要自己写为什么不把播放核心也一起做了省掉一层库的抽象调试的时候堆栈能少翻好几层。第三是他们的索引文件有点非标准。分片链接的扩展名被改过有些字段的写法也不太规范。hls.js 在解析这种偏门写法时会直接拒绝日志里只给一句语焉不详的 error。而我自己写解析器遇到不认识的行可以选择跳过而不是整体失败容错率完全由我掌控。2.2 原生 JS 的边界与成本原生 JS这个说法容易被误解成什么都不能用。我的定义是不引入框架级别的运行时依赖但浏览器标准 API 该用就用。MediaSource、SourceBuffer、URL.createObjectURL、crypto.subtle、fetch、requestAnimationFrame这些都是平台能力用它们不算依赖库。注意判断一个东西算不算依赖看的是它是否需要额外的运行时体积和版本管理成本而不是看它是否由浏览器提供。用标准 API 是天经地义用第三方库才是需要论证的决策。成本也有而且不小。最直接的一点MSE 只吃 fMP4或者 WebM不吃 MPEG-TS。这意味着如果服务器切出来的是 .ts 分片你看似拿到了视频数据却没法直接 appendBuffer 进去。Safari 之所以能直接播 m3u8是因为它内建了 HLS 和一整套 TS 解复用能力Chrome、Firefox、Edge 都没有。这个事实决定了整个项目的架构走向我在第 3 章会专门展开。2.3 三条路线对比我把当时考虑的方案列成了一张表方便你对照自己的场景做决策。这三条路我都实际跑过或者小范围验证过。方案实现成本兼容性可控性适用场景video 标签直挂 m3u8极低仅 Safari 系低只面向 iOS/macOS 用户引入 hls.js低全平台中追求快速上线、功能要求标准自研原生 JS 播放器高全平台需 MSE高内容格式特殊、UI 深度定制、体积敏感选第三条路的前提很明确你的内容格式有特殊性或者你对播放器体积和 UI 有强控制欲。如果这两点都不成立说实话 hls.js 更省事我也不会为了造轮子而造轮子。tiancai-video-hls是在特定约束下长出来的东西不是要取代谁。3. m3u8 索引到底长什么样把协议读透再动手3.1 两种索引形态别搞混了很多人被 m3u8 绕晕是因为没分清它有两种完全不同的角色。第一种叫Master Playlist主清单它本身不含视频数据只列出多个不同码率的子清单地址第二种叫Media Playlist媒体清单它才是真正列出分片序列的文件。一个典型的主清单大概长这样#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH1200000,RESOLUTION1280x720,CODECSavc1.64001f,mp4a.40.2 720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH600000,RESOLUTION854x480,CODECSavc1.64001e,mp4a.40.2 480p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH300000,RESOLUTION640x360,CODECSavc1.42e01e,mp4a.40.2 360p/index.m3u8而媒体清单是这样的#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:8 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-MAP:URIinit.mp4 #EXTINF:8.000, seg-000.ts #EXTINF:8.000, seg-001.ts #EXTINF:5.280, seg-002.ts #EXT-X-ENDLIST播放器拿到一个 URL第一件事就是抓取它然后判断里面有没有#EXT-X-STREAM-INF有就是主清单得再往下抓一层没有#EXTINF之外的子清单引用那就是媒体清单可以直接开工。3.2 关键标签逐个拆真正影响播放逻辑的标签其实不多我把它们整理成了一张速查表。理解这些字段的含义比背协议文档有用得多。标签出现位置作用我的处理方式#EXT-X-TARGETDURATION媒体清单分片最大时长整数秒作为调度器的时间基准#EXTINF媒体清单下一分片的时长和标题累加得到总时长#EXT-X-MEDIA-SEQUENCE媒体清单第一个分片的序号用于生成默认 IV 和定位#EXT-X-MAP媒体清单fMP4 的初始化段地址播放前必须先 append#EXT-X-KEY媒体清单加密方式和密钥地址按需拉密钥并解密#EXT-X-ENDLIST媒体清单点播结束标志判断是否为点播VOD#EXT-X-DISCONTINUITY媒体清单编码参数断层触发新建 SourceBuffer#EXT-X-TARGETDURATION这个字段值得单独说。它是个整数表示所有分片时长的向上取整。用它来规划缓冲区大小非常方便比如你想缓存 5 个分片直接targetDuration * 5就行不用去遍历每个分片的真实时长。我在早期版本里忽视了它用固定 30 秒做缓冲上限结果遇到 10 秒分片的源缓冲区只剩 3 个分片的余量一卡就断特别难查。3.3 分片后缀不是 .ts 也能播热搜里提到分片链接全部是 .png这类情况其实在自建分发里挺常见。做法很简单把切片文件名从seg-000.ts改成seg-000.png索引里跟着改。这样做的动机通常有两个一是某些静态资源服务器或对象存储对特定后缀有独立的分发策略和缓存规则换个后缀能蹭上更合适的缓存头二是避免某些客户端或中间层对视频后缀做特殊拦截处理影响加载稳定性。对播放器来说后缀名本身没有任何意义。我用 fetch 拉回来的是 ArrayBuffer只要字节流是合法的 TS 或 fMP4就照样能解析。所以你在解析媒体清单的时候千万不要自作聪明去过滤非.ts的分片行那会直接把整个播放列表砍空。我第一版代码就犯过这个错写了句if (line.endsWith(.ts))结果所有的请求都石沉大海Network 面板里一条分片请求都看不到排查了半天才发现是解析器自己吞掉了。3.4 分片时长为什么不能是整数有个细节新手容易疑惑为什么#EXTINF里写的是8.000和5.280这种小数而不是整齐的 8 和 5因为视频编码器是按关键帧切片的而关键帧的出现位置取决于编码器的 GOP 设置和实际画面内容不可能刚好落在一秒的整数倍上。#EXTINF记录的是该分片的真实时长小数位非常重要——如果你把所有分片时长四舍五入成整数来算总时长进度条会越播越偏一个两小时的课程视频最后能差出十几秒。我的做法是全程用浮点数累加只在渲染进度条的时候才换算成mm:ss。总时长就是所有#EXTINF之和这个值在 VOD 场景下是绝对准确的比依赖video.duration更早拿到可以先把 UI 画出来体验会好一些。4. 架构设计一个播放器应该分成哪几层4.1 整体分层与数据流tiancai-video-hls的内部分成四层从下往上分别是索引解析层、分片加载层、媒体数据层含解复用和解密、UI 与控制层。这个分层不是为了好看是为了让每一层都能独立调试。数据流是这样的Loader抓取 m3u8 文本 →Parser解析成结构化的分片列表 →Scheduler根据当前播放位置决定要加载哪些分片 →Fetcher并发拉取分片二进制 →Decrypter可选解密 →TransmuxerTS 才需要转成 fMP4 →BufferManager塞进 SourceBuffer → video 元素自动播放。UI 层订阅播放器的事件更新进度条和状态。这样分层之后出问题的时候定位非常快。视频不出画面先看 Network 有没有分片请求有请求但没数据看解析器有没有漏分片有数据但黑屏看 appendBuffer 有没有报错。每一层都有明确的输入输出不用满世界打日志。4.2 事件驱动而不是回调地狱播放器要把内部状态变化告诉外部最忌讳的就是层层传回调。我采用的是一个极简的事件总线几十行代码搞定class EventBus { constructor() { this.handlers Object.create(null); } on(type, fn) { (this.handlers[type] || (this.handlers[type] [])).push(fn); return () this.off(type, fn); } off(type, fn) { const list this.handlers[type]; if (!list) return; const idx list.indexOf(fn); if (idx -1) list.splice(idx, 1); } emit(type, payload) { const list this.handlers[type]; if (!list) return; for (const fn of list.slice()) { try { fn(payload); } catch (e) { console.error([hls] handler error, e); } } } }对外暴露的事件就那么几个manifest拿到清单、level码率切换、frag分片加载完成、progress缓冲进度变化、error。外部代码通过player.on(frag, ...)订阅就行UI 层和数据层彻底解耦。提醒事件回调里一定要做异常捕获。用户订阅的回调抛异常不应该把播放器内部的状态机带崩。我在emit里包了 try/catch就是被这个问题教育过——有次某个业务代码在progress回调里访问了还没初始化的 DOM直接抛错结果整条updateend链路断掉视频再也不续播了。4.3 码率选择与自动切换策略多码率是 HLS 的核心优势之一但切换逻辑不复杂。我在Scheduler里维护一个目标码率选择策略分三档手动指定就锁死自动模式则根据播放期间的实际缓冲健康度来调。缓冲水位低于 3 秒就降一档高于 15 秒就升一档中间加个 5 秒的抖动窗口防止频繁切换。切换的动作其实很简单改掉当前使用哪个 level的指针然后把 SourceBuffer 里已有的数据清掉从当前位置对应的分片重新按新码率加载 append。这里有个必须注意的点——切换码率后要 append 新码率的初始化段也就是#EXT-X-MAP指的那个文件不然解码器会用旧参数去解新数据画面花屏或者直接报错。这个坑我在第一次做码率切换时踩得很结实切过去之后画面变成了一堆绿色马赛克。switchLevel(index) { if (index this.currentLevel) return; this.currentLevel index; const playlist this.playlists[index]; const pos this.video.currentTime; // 找到新码率下覆盖当前位置的分片 const fragIndex this.findFragAt(playlist, pos); this.scheduler.reset(playlist, fragIndex); this.bufferManager.clear(); // 清空 SourceBuffer this.emit(level, { index, playlist }); }5. 核心实现从解析到上屏的完整链路5.1 主清单解析解析主清单相对简单核心是处理#EXT-X-STREAM-INF行后面跟着的那行 URL。注意 URL 可能是相对路径必须用基准 URL 做拼接。function parseMasterPlaylist(text, baseUrl) { const lines text.split(/\r?\n/).map(s s.trim()).filter(Boolean); const variants []; let pending null; for (const line of lines) { if (line.startsWith(#EXT-X-STREAM-INF)) { pending { bandwidth: 0, resolution: , codecs: , url: }; const bw /BANDWIDTH(\d)/.exec(line); if (bw) pending.bandwidth Number(bw[1]); const res /RESOLUTION([\dx])/i.exec(line); if (res) pending.resolution res[1]; const co /CODECS([^])/.exec(line); if (co) pending.codecs co[1]; } else if (!line.startsWith(#) pending) { pending.url new URL(line, baseUrl).href; variants.push(pending); pending null; } } return variants.sort((a, b) a.bandwidth - b.bandwidth); }排序这一步是为了让低码率在前方便后面按序号做升降档。5.2 媒体清单解析与序号生成媒体清单的解析要复杂一些因为要处理状态切换比如中途变换加密方式和 URI 拼接。关键点是记录每个分片的全局序号这个序号在解密时会被用来生成默认 IV。function parseMediaPlaylist(text, baseUrl) { const lines text.split(/\r?\n/).map(s s.trim()); const result { version: 3, targetDuration: 6, mediaSequence: 0, segments: [], endList: false, currentKey: null, initSegment: null }; let dur 0, title ; for (const line of lines) { if (!line) continue; if (line.startsWith(#EXT-X-VERSION)) { result.version Number(line.split(:)[1]); } else if (line.startsWith(#EXT-X-TARGETDURATION)) { result.targetDuration Number(line.split(:)[1]); } else if (line.startsWith(#EXT-X-MEDIA-SEQUENCE)) { result.mediaSequence Number(line.split(:)[1]); } else if (line.startsWith(#EXT-X-MAP)) { const uri /URI([^])/.exec(line); if (uri) result.initSegment new URL(uri[1], baseUrl).href; } else if (line.startsWith(#EXT-X-KEY)) { const m /METHOD([^,])/.exec(line); const u /URI([^])/.exec(line); const iv /IV(0x[0-9A-Fa-f])/.exec(line); result.currentKey (m m[1] ! NONE) ? { method: m[1], uri: u ? new URL(u[1], baseUrl).href : null, iv: iv ? iv[1] : null } : null; } else if (line.startsWith(#EXTINF)) { const body line.slice(8); const comma body.indexOf(,); dur parseFloat(comma -1 ? body.slice(0, comma) : body); title comma -1 ? body.slice(comma 1) : ; } else if (line #EXT-X-ENDLIST) { result.endList true; } else if (!line.startsWith(#)) { result.segments.push({ url: new URL(line, baseUrl).href, duration: dur, title, key: result.currentKey, seq: result.mediaSequence result.segments.length }); dur 0; title ; } } result.totalDuration result.segments.reduce((s, x) s x.duration, 0); return result; }这里特别提醒一句#EXT-X-KEY是状态型标签它会一直生效到下一个#EXT-X-KEY出现。所以我在解析时用了currentKey变量每遇到新分片就把当前生效的 key 挂上去。如果你的源中途换了密钥这种写法也能正确处理比只读第一个 KEY 行的粗暴做法稳得多。5.3 MSE 初始化与 MIME 类型MSE 的初始化是这个项目里最容易被忽略、又最容易出问题的地方。核心就三行但每一行都有讲究。const ms new MediaSource(); video.src URL.createObjectURL(ms); ms.addEventListener(sourceopen, () { const mime video/mp4; codecsavc1.64001f,mp4a.40.2; if (!MediaSource.isTypeSupported(mime)) { player.fail(当前浏览器不支持该编码组合); return; } const sb ms.addSourceBuffer(mime); sb.mode segments; bufferManager.attach(sb, ms); });MediaSource.isTypeSupported这一步不能省。它的参数是 MIME 加 codecs 字符串必须和你实际分片的编码严格匹配。avc1.64001f里的4d、64这些数字代表 H.264 的 profile 和 level写错了就算浏览器支持 H.264也会返回 false。稳妥的做法是从#EXT-X-STREAM-INF的CODECS字段里读出来直接用而不是自己硬编码。注意addSourceBuffer必须在sourceopen事件之后调用。在 MediaSource 还没打开的时候就调用会直接抛InvalidStateError。我见过不少人把这段逻辑写在同步代码里然后抱怨为什么没反应。5.4 缓冲队列与追加调度SourceBuffer 有个硬性限制同一时刻只能有一个 append 操作。如果你拿到三个分片就一口气 append 三次后两次会直接抛错。所以必须用队列串行化。const queue []; let appending false; function enqueue(buffer) { queue.push(buffer); pump(); } function pump() { const sb bufferManager.sb; if (!sb || sb.updating || appending || queue.length 0) return; if (bufferManager.ms.readyState ! open) return; const buf queue.shift(); appending true; try { sb.appendBuffer(buf); } catch (e) { if (e.name QuotaExceededError) { // 缓冲区满了先清理再重试 queue.unshift(buf); bufferManager.trim(true); } else { player.emit(error, e); } appending false; } }注意QuotaExceededError的处理。当 SourceBuffer 里堆积的数据超过浏览器给的内存配额时就会抛这个错。我的做法是把它放回队首然后立刻触发一次激进清理清完再重试。这个场景在移动端尤其常见因为手机给单个页面的内存配额比桌面端小得多。5.5 缓冲区回收长视频不崩的关键一个两小时的 720p 点播如果全程不回收SourceBuffer 能吃掉好几百 MB 内存移动端直接就崩了。回收逻辑要兼顾不能清掉正在播的部分和不能清得太狠导致回退时又要重新下载。const KEEP_BEHIND 30; // 保留当前时间点之前 30 秒 const TRIM_THRESHOLD 60; // 落后超过 60 秒才触发回收 function trim(force) { const sb bufferManager.sb; if (!sb || sb.updating || sb.buffered.length 0) return; const current video.currentTime; const start sb.buffered.start(0); const behind current - start; if (!force behind TRIM_THRESHOLD) return; const removeEnd current - KEEP_BEHIND; if (removeEnd start 1) { try { sb.remove(start, removeEnd); } catch (e) { /* 忽略瞬态错误 */ } } }KEEP_BEHIND 30是我实测下来比较舒服的值。保留半分钟回头缓冲用户想往回拖一点不用重新下真拖得远了重新拉分片也就几百毫秒的事。force参数专门给前面那种配额超限的场景用那时候顾不上体验了先保命。5.6 自定义控制条与交互细节既然不用原生 controls控制条就得自己撸。结构上就是一个进度条轨道加分片进度、缓冲进度、播放进度三层叠加再加一排按钮。div classhls-controls div classhls-progress>progressEl.addEventListener(pointerdown, (e) { const rect progressEl.getBoundingClientRect(); const seekTo (e) { const ratio Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); updateThumbVisual(ratio); return ratio; }; const onMove (ev) seekTo(ev); const onUp (ev) { const ratio seekTo(ev); video.currentTime ratio * player.totalDuration; window.removeEventListener(pointermove, onMove); window.removeEventListener(pointerup, onUp); }; window.addEventListener(pointermove, onMove); window.addEventListener(pointerup, onUp); });6. 加密分片的处理AES-128 在浏览器里怎么解6.1#EXT-X-KEY的三种 METHOD#EXT-X-KEY的METHOD字段有三个合法值NONE、AES-128、SAMPLE-AES。NONE表示从这里开始不加密AES-128是整片加密实现起来最直接SAMPLE-AES是样本级加密只在音视频帧的部分数据上加密浏览器端手动解基本不现实得靠原生能力或者复杂的解复用配合。tiancai-video-hls支持的AES-128属于 CBC 模式密钥长度 16 字节是 HLS 里最常见的自有内容保护方式。再次强调这里的应用前提是密钥服务由你自己控制、只发给通过鉴权的用户。6.2 密钥获取与 IV 生成规则解密流程分三步拿密钥、算 IV、解密数据。密钥通过URI字段指向的地址用 fetch 拉返回 16 字节的原始二进制。这里有个优化点——密钥要缓存同一个密钥地址在整条播放链路上只会拉到一份我用一个 Map 存起来key 是密钥地址。IV 的规则稍微绕一点如果#EXT-X-KEY里显式写了IV就用它如果没写就用该分片的序号#EXT-X-MEDIA-SEQUENCE加上分片在列表中的偏移作为 IV以大端 32 位整数的形式填充到 16 字节的最后 4 个字节。这个规则不写清楚解出来的就是一堆乱码。const keyCache new Map(); function seqToIv(seq) { const iv new Uint8Array(16); new DataView(iv.buffer).setUint32(12, seq 0, false); return iv; } function hexToBytes(hex) { const s hex.startsWith(0x) ? hex.slice(2) : hex; const out new Uint8Array(s.length / 2); for (let i 0; i out.length; i) { out[i] parseInt(s.substr(i * 2, 2), 16); } return out; } async function getKey(uri) { if (keyCache.has(uri)) return keyCache.get(uri); const res await fetch(uri, { credentials: include }); if (!res.ok) throw new Error(密钥获取失败 res.status); const raw await res.arrayBuffer(); const key await crypto.subtle.importKey( raw, raw, { name: AES-CBC }, false, [decrypt] ); keyCache.set(uri, key); return key; } async function loadSegment(seg) { const res await fetch(seg.url, { credentials: include }); const data await res.arrayBuffer(); if (!seg.key || seg.key.method ! AES-128) return data; const key await getKey(seg.key.uri); const iv seg.key.iv ? hexToBytes(seg.key.iv) : seqToIv(seg.seq); return await crypto.subtle.decrypt({ name: AES-CBC, iv }, key, data); }提醒crypto.subtle只在安全上下文HTTPS 或 localhost下可用。如果你的测试环境是 HTTP 的内网地址这个方法会是 undefined解密逻辑整个失效。要么上 HTTPS要么在开发阶段用 localhost 访问。6.3 浏览器里做解密的几个坑第一个坑是密钥接口的跨域。密钥地址通常跟分片地址不同域服务端必须给密钥接口配上允许跨域的响应头同时如果是带 Cookie 的鉴权fetch要带credentials服务端的 CORS 配置里也不能用*通配。这两边配置不匹配浏览器控制台只会给一句含糊的 CORS 报错。第二个坑是密钥接口的鉴权时效。有些后端会给密钥 URL 加签名参数签名有效期很短。如果用户暂停视频很久再点播放签名早就过期了密钥拉不到播放直接中断。我的处理是在密钥获取失败时触发一次重新鉴权拿到新的清单再继续而不是直接报错终止。第三个坑也是最容易忽略的解密得到的是明文分片体积往往比密文大。AES-CBC 是分块加密明文会补齐到 16 字节整数倍所以解密结果通常会比加密数据多出最多 15 个字节。这个尾部填充在 TS 解析时会被当成无效数据一般无害但如果你做严格的字节校验得允许这一点误差。7. 多码率、进度记忆与老项目集成7.1 播放位置的本地记忆课程类场景里上次看到哪儿了是个刚需。实现很简单但有几个细节值得说。我监听timeupdate别太频繁节流到 5 秒写一次 localStorage用户主动 seek 和暂停时立即写一次播放结束时把记录清掉。存的值除了时间点最好连视频标识和码率一起存下次进来先按上次的码率加载能省掉一次切换。function remember(id, time, level) { const data { time, level, at: Date.now() }; localStorage.setItem(hls-pos: id, JSON.stringify(data)); } function recall(id, maxAge 30 * 24 * 3600 * 1000) { const raw localStorage.getItem(hls-pos: id); if (!raw) return null; try { const data JSON.parse(raw); if (Date.now() - data.at maxAge) return null; return data; } catch { return null; } }恢复播放位置有个时机问题必须在loadedmetadata之后再设置currentTime否则会被重置。而且如果是基于 MSE 的播放此时缓冲区是空的设置currentTime后调度器要从那个位置开始拉分片所以记得把Scheduler的起始指针也同步过去。我一开始只改了video.currentTime结果播放头跳过去了但加载的还是开头那几个分片视频就卡在那边转圈。7.2 塞进 jQuery 老后台的做法热搜里出现原生 JS、jQuery、ajax、echarts 结合这个组合其实很典型——很多存量后台就是 jQuery 时代的产物不可能为了一个播放器重构。好消息是tiancai-video-hls 不依赖任何框架天然就是一个全局构造函数jQuery 代码里直接 new 一个就行。$(function () { const player new TiancaiHls({ el: #player-box, src: /api/course/123/manifest.m3u8, autoplay: false, remember: course-123 }); // AJAX 拉课程章节点击切换 $(.chapter-item).on(click, function () { const mid $(this).data(mid); $.getJSON(/api/media/ mid, function (res) { player.load(res.m3u8, res.title); }); }); // 记录播放行为交给后端统计 const events []; player.on(progress, (p) { events.push({ t: p.currentTime, at: Date.now() }); if (events.length 20) { $.ajax({ url: /api/report, type: POST, contentType: application/json, data: JSON.stringify({ mid: player.id, events: events.splice(0) }) }); } }); window.player player; });至于 echarts我一般拿它做后台的播放数据看板比如每节课的完播率曲线、拖动行为热力图。播放器只管往前端上报事件图表怎么画是另一回事两者不要耦合在一起。别为了画个图把 echarts 打进播放器包里那样每次更新图表库版本都要重新发一遍播放器。7.3 多码率切换按钮的生成控制条里的清晰度下拉菜单选项直接从主清单解析结果里生成用分辨率和带宽组合成可读的标签function buildLevelOptions(variants, onChange) { const sel document.querySelector([data-actlevel]); sel.innerHTML ; variants.forEach((v, i) { const opt document.createElement(option); opt.value String(i); const res v.resolution || auto; const mbps (v.bandwidth / 1000000).toFixed(2); opt.textContent res · mbps Mbps; sel.appendChild(opt); }); sel.addEventListener(change, () onChange(Number(sel.value))); }8. 调试实录那些让我抓头的排查经历8.1 Network 面板里看不到 m3u8 请求这是最常见的困惑之一。你打开 DevTools切到 Network滤镜填 m3u8结果空空如也但视频明明在播。原因通常有几种其一播放走的是原生 HLS。如果你在 Safari 里调试而代码又回退到了video.src m3u8这条路径分片是浏览器内核自己发起的某些情况下在 Network 面板里的呈现方式跟 XHR 请求不一样容易被过滤条件筛掉。把过滤器清空或者勾选 All 再看看。其二请求被 Service Worker 接管了。如果有 PWA 或者缓存策略在拦截实际请求发生在 SW 里面板需要打开 Service Worker 相关的显示选项才能看到。其三播放器的加载逻辑在 Web Worker 里跑。为了不阻塞主线程有些实现会把 fetch 和解析放到 Worker而 Network 面板默认不聚合 Worker 的请求得去对应的 worker 上下文里看。其四也是最隐蔽的——解析器压根没发出请求。前面提到的后缀过滤问题就属于这种解析器把 URL 都过滤没了自然一条请求也没有。遇到没请求的时候先别怀疑网络先在解析结果上打个console.log看分片数量对不对。8.2 常见问题速查表我把这段时间遇到并解决的问题整理成了一张表遇到类似现象可以直接对号入座。现象可能原因排查动作解决方式一直转圈无画面解析器返回空分片列表打印解析结果检查后缀过滤逻辑播几秒就卡住缓冲队列串行化失效看sb.updating状态用队列串行 append移动端播一会儿崩SourceBuffer 内存超限看报错是否QuotaExceededError开启主动缓冲回收画面绿色马赛克切码率没换初始化段检查切换前后 MAP切换时重 append init解密后是乱码IV 计算错误比对 IV 字节用序号生成默认 IV拖动进度后黑屏调度器指针未同步看currentTime与加载位置拖动后重置调度起点控制台 CORS 报错密钥或分片域未放行看响应头配 Access-Control-Allow-OriginSafari 能播 Chrome 不能走的是原生 HLS 回退看实际播放路径强制走 MSE 路径8.3 TS 分片的处理思路如果你的源给的是.ts分片MSE 是不能直接吃的这是绕不过去的坎。处理方式有三条一是服务端改用 fMP4/CMAF 切片前端就能直接 append这是最干净的路二是在前端做重新封装也就是把 TS 里的 H.264 和 AAC 数据解出来再按 fMP4 的格式重新打包工作量不小三是让服务端在拉取时实时转封装。如果走第二条路核心步骤是这样的TS 是 188 字节定长包每个包有 PID得先解析 PAT 和 PMT 表找到视频流和音频流的 PID再从对应 PID 的包里把 PES 负载拼起来然后在视频 PES 里按起始码扫描 NALU音频则按 ADTS 头拆帧。扫描 NALU 的基础逻辑大致是下面这样function splitNALUs(es) { const units []; let start -1; for (let i 0; i 2 es.length; i) { if (es[i] 0 es[i 1] 0 es[i 2] 1) { if (start 0) units.push(es.subarray(start, i)); start i 3; i 2; } } if (start 0) units.push(es.subarray(start)); return units; }拿到 NALU 之后还要组装 avcC、mp4a 的配置盒再加 moof 和 mdat才能变成 MSE 认的格式。这套逻辑我实现了一版能跑通但客观说维护成本不低编码器稍有变化就可能要跟着调。所以除非你的服务端完全无法改切片方式我强烈建议优先选 fMP4 路线。这是个架构决策不是实现技巧越早定越好。9. 几个我认为最值钱的实操经验先说缓冲参数这件事。很多人喜欢把缓冲往大了调觉得缓冲越多越不卡。但实测下来在移动端把缓冲上限从 30 秒提到 90 秒卡顿率没有明显下降反而崩溃率上去了。原因是移动端的内存配额本来就紧张缓冲越大越容易触发配额超限。后来我把策略改成桌面 45 秒、移动 20 秒并且根据navigator.hardwareConcurrency做个简单分级效果反而更稳。参数这种东西一定要在真机上测模拟器给你的是假象。再说一个关于错误处理的教训。播放器内部任何一次 append 失败都不应该让整个播放终止。我现在的做法是单个分片失败先重试两次带一点退避连续失败就把这个分片的加载标记跳过继续往后拉。视频可能会有一个小卡顿但不会整个停死。用户能接受偶尔卡一下不能接受播放器彻底死掉。这个心态的转变是从追求完美播放到追求优雅降级做了两年播放器才真正转过弯来。第三个经验是关于码率切换的抖动。早期我按瞬时缓冲水位来切码率结果网络稍有波动就在两个档位之间来回横跳用户看着清晰度一会儿糊一会儿清体验非常差。后来加了两个约束切换之间有最小间隔我用 10 秒并且降档比升档更激进——缓冲掉到 5 秒立刻降但要涨到 20 秒才考虑升。因为卡顿比模糊难受得多宁可多糊一会儿也别让用户看到转圈。最后一个关于调试方式的建议给播放器加一个可视化的状态浮层把当前缓冲水位、已加载分片序号、当前码率、队列长度这些数字实时显示在角落里用个?debug1参数控制开关。这个东西在真机调试的时候价值极高因为你没法在手机上开 DevTools但你能看到这些数字。我很多问题都是靠这个浮层一眼看出来的比翻日志快十倍。这套东西后来我还想继续往下做的方向是把主清单的解析结果做成一个可选的预加载信号——在用户点开视频之前先用一次轻量的 HEAD 请求探测各码率的可用性和首片响应时间提前选一个最合适的起始档位而不是永远从中间档开始试探。这个思路在弱网环境下应该能明显改善起播速度等我验证完再单独写一篇。