视频播放器自实现
# 案例 03 · jsplayer 视频播放器
案例定位:⭐⭐⭐⭐⭐ 难度 / 12 小时学习预算 / ~2000 行 JS / 多模块工程
本案例的灵魂:Web 平台 API 大全——这是 JS 区别于 C++/Go 的根本。前端工程师 80% 的时间在"驯服浏览器",而视频播放器是把 MSE / Canvas / Fullscreen / PiP / WebVTT / Media Session / Range / Web Component / EventTarget / requestVideoFrameCallback 十大宿主 API 一次性集中演练的最佳载体。
前置依赖:必须先完成 02.异步订阅流式解析 (opens new window) ——本案例直接复用它的
http.js/pLimit.js/AbortController取消链;如果跳过 02,会卡在 §03 视频分片下载。
# 目录快速导航
点击以下条目即可跳转。
- 01. 项目简介
- 02. 项目启动
- 03. Web Component 封装
- 04. Player 核心:状态机 + EventTarget
- 05. MSE 流水线 ⭐核心
- 06. 迷你 HLS:m3u8 + ts 分片
- 07. 控制层:进度条 + rVFC + 24 个 video 事件
- 08. Canvas 弹幕:对象池 + 时间索引
- 09. Web 平台 API 大全:PiP / Media Session / WebVTT
- 10. 项目总结
- 11. 项目技术思考
- 12. 衔接与延伸
# 01.项目简介
# 1.1 学完做出什么
写一个带自定义皮肤的 H5 视频播放器,不引入 video.js / hls.js / shaka-player。最终形态:
┌──────────────────────────────────────────────────┐
│ ╔════════════════════════════════════╗ 弹幕滚动→ │
│ ║ ║ │
│ ║ <video> 视频区 ║ │
│ ║ (Canvas 弹幕层叠加于上) ║ │
│ ║ ║ │
│ ╚════════════════════════════════════╝ │
│ ─────●────────────────────── 01:23 / 12:34 ⛶ 🎯 │
│ ▶ 🔊──○─ 1.0× 字幕▼ 画质▼ 画中画 全屏 │
└──────────────────────────────────────────────────┘
真实可用的能力:
- 加载 m3u8 / fmp4,自己实现迷你 HLS(解析 m3u8 → 顺序拉 ts → 喂 MSE)
- 进度条三色:已播 / 已缓冲 / 可 seek 区间(区分
played/buffered/seekable) - Canvas 弹幕层(单画布 RAF 调度,而不是 N 个 div + CSS 动画)
- 画中画(PiP)/ 全屏(多浏览器前缀)/ 系统媒体控件(Media Session)
- WebVTT 字幕(手写解析器,不依赖
<track>) - 切换清晰度:链式
AbortController取消旧请求——切换瞬时响应 - 媒体错误降级链:MSE → 原生
<video src>→ 提示用户
# 1.2 为什么是这套技术
❓ 为什么不直接用
<video src="x.mp4">一行解决?
可以解决"播放",但解决不了:
- m3u8 / DASH 分片协议——浏览器原生只支持完整 mp4/webm,不会自己解析 m3u8
- 自适应码率——根据网速切 480p / 1080p
- 首帧秒开——边下边播 vs 等下完才播
- 弹幕——
<video>上叠 1000 个 div 必卡,必须 Canvas
而这一切的钥匙是 MSE(Media Source Extensions)——它让 JS 可以像写文件一样把视频字节流喂给 <video>。MSE 是 video.js / hls.js / DASH.js / Shaka 这些库的共同地基,学完 MSE 你就能秒读这些库的源码。
❓ 为什么 ⭐⭐⭐⭐⭐ 比 jsfeed 还难?
| 维度 | jsfeed | jsplayer |
|---|---|---|
| 数据形态 | 文本(字符串友好) | 二进制(ArrayBuffer / Uint8Array) |
| 异步粒度 | Promise(整体) | 流式 + 帧级(rVFC 每秒 60 次) |
| 状态空间 | "已加载/未加载" 二态 | <video> 24 个事件 × 4 个 readyState |
| 错误恢复 | 重试就行 | 媒体管线一坏全坏,需要 reset MediaSource |
| 跨平台 | 桌面浏览器 | iOS Safari 完全不支持 MSE,必须降级 |
# 1.3 八阶段渐进路线
阶段 ① 启动 最小骨架 + 原生 <video> 跑通 30 min
阶段 ② Web Component 封装 <jsplayer-app> 自定义元素 + Shadow DOM 60 min
阶段 ③ Player 核心 class Player extends EventTarget 60 min
阶段 ④ MSE 流水线 ⭐ MediaSource + SourceBuffer + 队列化 120 min
阶段 ⑤ 迷你 HLS ⭐ m3u8 解析 + ts 分片下载 + 错误降级 120 min
阶段 ⑥ 控制层 24 个 video 事件 + 进度条三色 + rVFC 90 min
阶段 ⑦ Canvas 弹幕 ⭐⭐ 对象池 + 时间索引 + RAF 120 min
阶段 ⑧ 平台 API 大全 PiP + Fullscreen + Media Session + WebVTT 60 min
合计 ≈ 12 h
每阶段保留前两篇案例的统一骨架:🎯 目标卡片 / Step 拆分 / 🧪 测试 / 故意造 bug → 修复 / 📌 阶段小结。
# 1.4 卷一锚点速查
| 本案例段落 | 对应卷一章节 | 核心知识点 |
|---|---|---|
| §02 自定义元素 | 第 14.7 Web Component | customElements.define |
| §03 EventTarget | 第 05 面向对象 + 第 08 事件 | class extends EventTarget |
| §03 私有字段 | 第 05.4 私有字段 | #state |
| §04 MSE | 第 15 网络 + 第 02 数据类型 | ArrayBuffer / Blob |
| §05 m3u8 解析 | 第 11 字符串处理 | split + URL |
| §06 视频事件 | 第 08 事件设计 | 节流 / once / passive |
| §06 rVFC | 第 07 异步操作 | 帧回调替代 timeupdate |
| §07 Canvas | 第 14 DOM + 第 04 闭包 | RAF + 对象池 |
| §07 弹幕索引 | 第 02 数据类型 | 桶排序 + Map |
| §08 PiP / 全屏 | 第 14.10 浏览器 API | 多前缀兼容 |
# 02.项目启动
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:起一个静态服务器,<video> 标签播一个 mp4 跑通 │
│ 不做什么:先不用 MSE / 不写自定义元素 │
│ 验收标准:访问 http://localhost:5173 → 视频自动播放 │
│ 预计耗时:30 min │
└──────────────────────────────────────────────────┘
# 2.1 目录结构搭建
jsplayer/
├── index.html
├── style.css
├── public/
│ ├── demo.mp4 # 找一段任意 mp4(< 10MB 即可)
│ └── hls/ # §05 用,先空着
│ ├── index.m3u8
│ └── seg-001.ts ...
└── src/
└── main.js
📁 index.html(极简):
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>jsplayer</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<script type="module" src="./src/main.js"></script>
</body>
</html>
📁 src/main.js:
const app = document.querySelector('#app');
const video = document.createElement('video');
video.src = '/demo.mp4';
video.controls = true; // 暂时用浏览器原生控制条
video.playsInline = true; // ⭐ iOS 必需,否则全屏劫持
video.muted = true; // ⭐ Chrome 自动播放策略:必须静音
video.autoplay = true;
app.appendChild(video);
📁 style.css:
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #0a0a0a;
display: grid; place-items: center; font: 14px/1.5 system-ui; color: #eee; }
video { width: min(960px, 90vw); aspect-ratio: 16/9; background: #000; }
# 2.2 起静态服务器
cd jsplayer
npx --yes serve -p 5173 .
访问 http://localhost:5173 → 看到视频自动播放。
# 2.3 灵魂三问 · 自
❓ 为什么必须
muted = true才能 autoplay?
Chrome 66+ / Safari 11+ 的"用户优先"政策:带声音的视频不允许自动播放——避免广告打扰用户。muted 让你绕过这条规则。点击播放按钮后才能 video.muted = false 解除静音。
❓
playsInline是干嘛的?
iOS Safari 默认行为是视频开始播放就强制全屏(用 QuickTime 接管)。加 playsInline 才能让视频在页面内播。这一行能救命——很多新手在 PC 上一切正常,到了 iPhone 上视频一播就强制全屏,原因就是漏写它。
❓
controls不是已经够用了吗?为什么还要自己写?
原生 controls 不能定制——
- UI 跟着浏览器走,每个浏览器长得不一样
- 没有弹幕、字幕样式、画质切换
- 移动端原生 controls 会遮住自定义按钮
所以本案例的 §06-§08 会关掉 controls自己写。
┌─ 📌 阶段 ① 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • 视频 3 件套属性:muted / playsInline / autoplay │
│ • 浏览器自动播放策略 │
│ • 原生 controls 的局限 │
│ ⏸ 还没碰的: │
│ • MSE 流式喂数据(§04) │
│ • 自定义控制条(§06) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage1: native video" │
└──────────────────────────────────────────────────┘
# 03.Web Component 封装
┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:把整个播放器封装成 <jsplayer-app> 自定义元素 │
│ 不做什么:不写 MSE、不写控制条逻辑(先放占位) │
│ 验收标准:HTML 写一行 <jsplayer-app src="..."> 就能播 │
│ 预计耗时:60 min │
│ 关键思路:浏览器原生 Web Component 4 件套—— │
│ customElements / Shadow DOM / template / 生命周期回调 │
└──────────────────────────────────────────────────┘
# 3.1 为什么要做自定义
❓ 直接
app.appendChild(video)不就够了吗,为什么要绕一层?
来看反例——如果把所有 DOM 散在 main.js 里:
const root = document.querySelector('#app');
root.innerHTML = `<video></video><div class="bar">...</div><canvas></canvas>...`;
const video = root.querySelector('video');
const bar = root.querySelector('.bar');
const canvas = root.querySelector('canvas');
// 然后是 200 行事件绑定
问题暴露:
- 样式污染 ——页面其它
.bar也会被命中(没有作用域隔离) - 不可复用 ——别人想嵌入你的播放器,得复制一大段 HTML+JS
- 依赖耦合 ——
main.js既管业务又管 DOM,改一处怕牵动十处
✅ 正确做法:自定义元素 + Shadow DOM——就像浏览器内置的 <video> 一样,对外只暴露标签和属性。
❓ Shadow DOM 是什么,跟 iframe 有什么区别?
| 维度 | iframe | Shadow DOM |
|---|---|---|
| 进程 | 独立进程,重 | 同进程,轻 |
| 样式 | 完全隔离 | 完全隔离(CSS 不会泄漏进出) |
| JS | 跨域受限 | 直接访问 |
| 用途 | 嵌别人的页面 | 封装组件内部结构 |
Shadow DOM 是给组件用的"低成本样式沙箱"——这是 jsplayer 必须用它的根本原因(<video> 上叠 canvas/字幕轨/弹幕层,样式一乱整个播放器就花了)。
❓ 为什么不用 React/Vue?
可以,但自定义元素是浏览器原生标准——不需要任何框架就能在 React/Vue/Angular/原生 HTML 里复用。video.js 6+ / shaka-player UI / Vime.js 都是自定义元素。学完本节,你写的播放器能直接被任何框架引用。
# 3.2 目录结构搭建
src/
├── main.js # 仅一行:import './ui/JsPlayerApp.js'
└── ui/
└── JsPlayerApp.js # 自定义元素本体
# 3.3 最小自定义元素骨架
📁 src/ui/JsPlayerApp.js:
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = /*html*/`
<style>
:host { display: block; position: relative; background: #000;
width: 100%; aspect-ratio: 16/9; color: #eee;
font: 14px/1.5 system-ui; user-select: none; }
:host([hidden]) { display: none; }
video { width: 100%; height: 100%; display: block; background: #000; }
.placeholder { position: absolute; inset: 0; display: grid;
place-items: center; opacity: .5; pointer-events: none; }
</style>
<video playsinline></video>
<div class="placeholder">jsplayer · 阶段 ② 占位</div>
`;
export class JsPlayerApp extends HTMLElement {
// ⭐ 声明哪些属性变化要触发回调
static get observedAttributes() { return ['src', 'poster', 'autoplay', 'muted']; }
#video; // 私有字段(卷一第 05.4)
#shadow;
constructor() {
super();
// ⭐ Shadow DOM——样式沙箱
this.#shadow = this.attachShadow({ mode: 'open' });
this.#shadow.appendChild(TEMPLATE.content.cloneNode(true));
this.#video = this.#shadow.querySelector('video');
}
// ⭐ 生命周期 1:插入文档时调用
connectedCallback() {
console.log('[JsPlayerApp] connected');
this.#syncAttrs();
}
// ⭐ 生命周期 2:从文档移除时调用
disconnectedCallback() {
console.log('[JsPlayerApp] disconnected');
}
// ⭐ 生命周期 3:observedAttributes 里的属性变化时调用
attributeChangedCallback(name, _old, _new) {
if (!this.#video) return;
this.#syncAttrs();
}
#syncAttrs() {
const v = this.#video;
v.src = this.getAttribute('src') ?? '';
v.poster = this.getAttribute('poster') ?? '';
v.autoplay = this.hasAttribute('autoplay');
v.muted = this.hasAttribute('muted');
v.controls = true; // 阶段 ⑥ 自定义控制条之前先用原生
}
}
// ⭐ 注册自定义元素——必须含连字符(HTML 规范)
customElements.define('jsplayer-app', JsPlayerApp);
📁 src/main.js:
import './ui/JsPlayerApp.js';
console.log('[main] jsplayer 启动');
📁 index.html 里把 <main id="app"></main> 替换为:
<jsplayer-app src="/demo.mp4" muted autoplay></jsplayer-app>
🧪 立刻编译运行(阶段 ② 第 1 次验收):
npx --yes serve -p 5173 .
预期行为:
- 视频自动播放
- 控制台输出
[JsPlayerApp] connected - F12 → Elements → 展开
<jsplayer-app>→ 看到#shadow-root (open)节点
✅ 看到 shadow-root 节点 = 自定义元素工作了——这是浏览器原生支持,不依赖任何框架。
❌ 常见错误:
| 现象 | 原因 |
|---|---|
Failed to construct 'CustomElement' | 忘记写 super() |
| 标签没生效,原样渲染 | customElements.define 名字没含 - |
| 属性改了不响应 | 没加进 observedAttributes |
# 3.4 暴露属性 / 方
自定义元素要"用起来像 <video>"——必须把 attribute(HTML 属性) 和 property(JS 属性) 都接上。
在 JsPlayerApp 里补两段:
// ⭐ JS property 镜像 attribute(让 jsPlayer.src = '...' 也能工作)
get src() { return this.getAttribute('src'); }
set src(v) { v == null ? this.removeAttribute('src') : this.setAttribute('src', v); }
get muted() { return this.hasAttribute('muted'); }
set muted(v) { v ? this.setAttribute('muted', '') : this.removeAttribute('muted'); }
// ⭐ 公开方法(自定义元素就是普通的 class)
play() { return this.#video.play(); }
pause() { return this.#video.pause(); }
// ⭐ 透传 readyState / currentTime 这种只读属性
get currentTime() { return this.#video.currentTime; }
set currentTime(t) { this.#video.currentTime = t; }
get duration() { return this.#video.duration; }
get paused() { return this.#video.paused; }
🧪 立刻验证(在浏览器控制台敲):
const p = document.querySelector('jsplayer-app');
p.pause(); // 视频暂停
p.currentTime = 5; // 跳到 5 秒
p.muted = false; // 解除静音(注意会被 autoplay 策略拦)
p.src = '/demo.mp4'; // 重新加载
✅ 5 行控制台命令全部生效 = 你的自定义元素 API 已经"像原生 <video>"——这是后续案例 04(小程序 / Hippy)能直接复用同一份逻辑的关键。
# 3.5 自定义事件——`
播放器要"被外部监听"——比如埋点:开始播放上报一次。这就要用 CustomEvent。
继续在 connectedCallback 里加:
connectedCallback() {
this.#syncAttrs();
// ⭐ 把内层 video 的关键事件转发到自定义元素
const forward = (type, detail = null) => {
this.#video.addEventListener(type, () => {
// bubbles + composed 才能穿透 Shadow DOM 边界
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
});
};
['play', 'pause', 'ended', 'error', 'loadedmetadata'].forEach(t => forward(t));
}
📚
composed: true的意义:默认事件不会穿透 Shadow DOM(这正是封装性的体现)。要让外部document.addEventListener('play', ...)能收到,必须加composed: true——这是自定义元素必踩坑之一。
🧪 验证(控制台):
document.querySelector('jsplayer-app')
.addEventListener('loadedmetadata', e => console.log('收到事件', e.detail));
location.reload();
刷新页面 → 控制台应打印 收到事件 null ——表示事件成功冒泡出 Shadow DOM。
# 3.6 故意造 bug
很多新手不信"Shadow DOM 真的隔离了"。让你亲眼看一次。
实验 A——在外层 style.css 写:
video { border: 5px solid red !important; }
刷新 → 没有红框!外层 CSS 选不到 Shadow DOM 内的 <video>。
实验 B——在 JsPlayerApp 的 <style> 里写:
video { border: 5px solid red; }
看到红框了——只有 Shadow DOM 内的样式生效。
💡 这就是组件库为什么爱用 Shadow DOM:你做的 jsplayer 嵌进任何项目都不会被对方的
* { margin: 0 }弄花。
# 3.7 Light DO
| 维度 | Light DOM(普通 div) | Shadow DOM |
|---|---|---|
document.querySelector('video') 能找到吗 | ✅ | ❌(要先穿 shadow-root) |
| 外部 CSS 影响 | ✅ | ❌ |
:host / ::part 选择器 | ❌ | ✅ |
| DevTools 调试 | 直接看 | 要展开 #shadow-root |
| 适合场景 | 业务页面 | 通用组件 |
┌─ 📌 阶段 ② 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • customElements.define + class extends HTMLElement│
│ • Shadow DOM 样式沙箱(实验亲眼验证) │
│ • 三大生命周期:connected/disconnected/attrChanged │
│ • observedAttributes + attribute/property 双向 │
│ • CustomEvent + composed:true 穿透 Shadow │
│ ⏸ 还没碰的: │
│ • Player 业务核心(§04) │
│ • MSE 流水线(§05) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage2: web component"│
│ 💡 本阶段最大领悟: │
│ "自定义元素 = 浏览器内置的、零依赖的 React 组件" │
└──────────────────────────────────────────────────┘
# 04.Player 核心:状态机 + EventTarget
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:把"控制视频"的逻辑从 UI 抽出来 → class Player │
│ 不做什么:仍然不接 MSE,先用原生 <video src=...> │
│ 验收标准:UI 调 player.load() / player.play() 能跑 │
│ 预计耗时:60 min │
│ 关键思路:分层—— │
│ UI 层(JsPlayerApp):只管 DOM 和按钮 │
│ 核心层(Player) :状态机 + 事件中枢 │
│ 传输层(loaders) :§05 才做(MSE / m3u8) │
└──────────────────────────────────────────────────┘
# 4.1 为什么 UI 和
❓ 直接把
play() / pause()写在JsPlayerApp里不行吗?
短期可以,到了 §05 加入 MSE 就崩——因为:
- MSE 状态(
MediaSource.readyState)/ 网络状态(fetch进度)/ UI 状态(按钮 loading)三股状态会互相影响 - 单元测试时你不可能 new 一个 JsDOM 来测播放器 ——必须把核心逻辑剥离出来跑在纯 JS 环境
- 将来要做"无 UI 后台预加载"(比如 feed 流自动预播 5 秒),UI 层根本不存在
✅ 正确分层:
┌─────────────────────────────────────────┐
│ JsPlayerApp(自定义元素 / DOM / 事件) │ ← UI 层
└─────────────────────────────────────────┘
│ 调方法 / 监听事件
▼
┌─────────────────────────────────────────┐
│ Player(状态机 / 事件中枢) │ ← 核心层
└─────────────────────────────────────────┘
│ 调 SourceLoader
▼
┌─────────────────────────────────────────┐
│ HlsLoader / Mp4Loader (§05) │ ← 传输层
└─────────────────────────────────────────┘
❓ Player 该继承谁? 答:
EventTarget——它是addEventListener / dispatchEvent的祖先类,浏览器原生暴露给业务代码用。
class Player extends EventTarget {
// 自带 addEventListener / removeEventListener / dispatchEvent
}
❓ 状态用什么表达? 答:显式状态机(idle / loading / ready / playing / paused / error / ended)——比
isLoading / isPlaying / hasError一堆 bool 清晰 10 倍。
# 4.2 Step 1
📁 src/core/Player.js:
// ⭐ 显式状态枚举(虽然 JS 没有 enum,但用 const 对象语义等价)
export const PlayerState = Object.freeze({
IDLE: 'idle', // 还没 load
LOADING: 'loading', // 元数据加载中
READY: 'ready', // 可以播但还没播
PLAYING: 'playing',
PAUSED: 'paused',
ENDED: 'ended',
ERROR: 'error',
});
// ⭐ 合法状态转移表(状态机的灵魂)
const TRANSITIONS = {
idle: ['loading', 'error'],
loading: ['ready', 'error'],
ready: ['playing', 'error', 'idle'],
playing: ['paused', 'ended', 'error', 'idle'],
paused: ['playing', 'ended', 'error', 'idle'],
ended: ['playing', 'idle'],
error: ['idle'],
};
export class Player extends EventTarget {
// 私有字段
#video;
#state = PlayerState.IDLE;
#lastError = null;
constructor(videoEl) {
super();
if (!(videoEl instanceof HTMLVideoElement)) {
throw new TypeError('Player 需要一个 <video> 元素');
}
this.#video = videoEl;
this.#bindMediaEvents();
}
get state() { return this.#state; }
get video() { return this.#video; }
get lastError() { return this.#lastError; }
// ⭐ 状态转移——所有内部状态变更必须走这里
#setState(next, payload = null) {
const cur = this.#state;
if (cur === next) return;
const allowed = TRANSITIONS[cur] ?? [];
if (!allowed.includes(next)) {
console.warn(`[Player] 非法转移 ${cur} → ${next}`);
return;
}
this.#state = next;
this.dispatchEvent(new CustomEvent('statechange',
{ detail: { from: cur, to: next, payload } }));
}
// ⭐ 把 <video> 的 24 个原生事件折叠成"业务有意义"的状态转移
#bindMediaEvents() {
const v = this.#video;
v.addEventListener('loadedmetadata', () => this.#setState(PlayerState.READY));
v.addEventListener('playing', () => this.#setState(PlayerState.PLAYING));
v.addEventListener('pause', () => {
if (this.#state === PlayerState.PLAYING) this.#setState(PlayerState.PAUSED);
});
v.addEventListener('ended', () => this.#setState(PlayerState.ENDED));
v.addEventListener('error', () => {
this.#lastError = v.error;
this.#setState(PlayerState.ERROR, v.error);
});
}
// ⭐ 公开 API(方法名故意和 <video> 一致——降低使用心智成本)
async load(src) {
this.#setState(PlayerState.LOADING);
this.#video.src = src; // 阶段 ⑤ 会换成 MSE
this.#video.load();
}
async play() { return this.#video.play(); }
pause() { this.#video.pause(); }
seek(t) { this.#video.currentTime = t; }
destroy() {
this.#video.removeAttribute('src');
this.#video.load();
this.#setState(PlayerState.IDLE);
}
}
设计要点:
| 要点 | 写法 | 作用 |
|---|---|---|
extends EventTarget | 继承浏览器原生 | 白嫖 add/dispatch/removeEventListener |
#state | 私有字段(卷一第 05.4) | 外部不能 player.#state = ... 绕过校验 |
Object.freeze(枚举) | 不可变常量 | 防止业务代码误改 PlayerState.PLAYING = '...' |
TRANSITIONS 白名单 | 状态机 | "playing → loading" 这种非法跳转直接拦截 |
dispatchEvent('statechange') | 单一事件总线 | UI 层只听一个事件就能感知所有状态 |
# 4.3 把 UI 接到
回到 JsPlayerApp,把所有控制视频的代码都换成调 player.xxx:
import { Player, PlayerState } from '../core/Player.js';
export class JsPlayerApp extends HTMLElement {
#shadow; #video; #player;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
this.#shadow.appendChild(TEMPLATE.content.cloneNode(true));
this.#video = this.#shadow.querySelector('video');
this.#player = new Player(this.#video); // ⭐ 核心层
}
connectedCallback() {
// ⭐ 监听核心层的状态变化 → 更新 UI / 转发给外部
this.#player.addEventListener('statechange', (e) => {
const { from, to } = e.detail;
console.log(`[JsPlayerApp] ${from} → ${to}`);
this.#updatePlaceholder(to);
// 透传出去(Shadow 边界要 composed)
this.dispatchEvent(new CustomEvent('player-state',
{ detail: e.detail, bubbles: true, composed: true }));
});
if (this.hasAttribute('src')) this.#player.load(this.getAttribute('src'));
}
attributeChangedCallback(name, _old, val) {
if (name === 'src' && val) this.#player.load(val);
if (name === 'muted') this.#video.muted = this.hasAttribute('muted');
if (name === 'autoplay') this.#video.autoplay = this.hasAttribute('autoplay');
}
#updatePlaceholder(state) {
const ph = this.#shadow.querySelector('.placeholder');
const map = { idle: '待加载', loading: '加载中…', error: '播放失败',
ready: '', playing: '', paused: '', ended: '播放完毕' };
ph.textContent = map[state] ?? '';
ph.style.opacity = ph.textContent ? '0.7' : '0';
}
// ⭐ 公开方法直接转发到 player
play() { return this.#player.play(); }
pause() { this.#player.pause(); }
get state() { return this.#player.state; }
get player() { return this.#player; } // 进阶用户可拿到核心层
}
🧪 立刻编译运行(阶段 ③ 第 1 次验收)
刷新 → 控制台依次打印:
[JsPlayerApp] idle → loading
[JsPlayerApp] loading → ready
[JsPlayerApp] ready → playing
✅ 看到三段状态转移 = 状态机工作了。
# 4.4 故意造 bug
为了让你真正信任状态机机制,我们故意触发一次非法转移。在控制台敲:
const p = document.querySelector('jsplayer-app').player;
// 当前 state 是 playing,强行让它跳到 loading
p.dispatchEvent(new CustomEvent('statechange', { detail: { from:'playing', to:'loading' }}));
// ↑ 这种是外部伪造事件,不会改 #state
console.log(p.state); // 仍然是 playing —— 私有字段保护住了
再做一次"内部非法转移"测试。临时在 Player 里加一个调试方法:
// 临时调试用,验证完删掉
__debugForce(state) { this.#setState(state); }
控制台:
p.__debugForce('loading'); // 当前 playing → loading
// 控制台警告: [Player] 非法转移 playing → loading
console.log(p.state); // 还是 playing
💡 这就是状态机的价值——任何企图破坏状态完整性的调用都会被白名单拦下,让 bug 在最早的位置暴露。
验证后删掉 __debugForce。
# 4.5 EventTar
JS 跨端工程师常遇到的混淆:
| 维度 | EventTarget(浏览器) | EventEmitter(Node.js) |
|---|---|---|
| 事件对象 | Event / CustomEvent 实例 | 直接 (arg1, arg2, ...) |
| 监听 | addEventListener('x', fn) | on('x', fn) |
| 派发 | dispatchEvent(new Event('x')) | emit('x', a, b) |
| 一次性 | { once: true } 选项 | once('x', fn) |
| 跨边界 | composed: true 穿 Shadow | 不存在边界 |
| 现代化 | ✅ Node.js 14+ 也内置了 | 老 API |
本案例用 EventTarget——因为它是浏览器和 Node.js 的最大公约数,将来同一份 Player 移植到 Workers / Node 端测试都不用改。
┌─ 📌 阶段 ③ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • class extends EventTarget │
│ • 私有字段 #state(强封装) │
│ • Object.freeze 常量枚举 │
│ • 显式状态机 + TRANSITIONS 白名单 │
│ • UI / 核心 / 传输 三层分离 │
│ ⏸ 还没碰的: │
│ • MSE 喂数据(§05) │
│ • 自定义控制条(§07) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage3: player core" │
│ 💡 本阶段最大领悟: │
│ "前端的 OOP 不是抄 Java,而是用 EventTarget 让组件 │
│ 能像浏览器内置 API 一样被 addEventListener" │
└──────────────────────────────────────────────────┘
# 05.MSE 流水线 ⭐核心
┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────┐
│ 完成什么:MediaSource + SourceBuffer 把字节流喂给 video │
│ 不做什么:m3u8 解析放 §06;自定义控制条放 §07 │
│ 验收标准:用 fetch 把一个 fmp4 分两段下来 → 拼起来能播 │
│ 预计耗时:120 min(最长一节,要逐 Step 做) │
│ 关键思路:MSE 状态机 ≠ Player 状态机——MSE 自己有 │
│ open/ended/closed + SourceBuffer.updating 双重状态 │
└──────────────────────────────────────────────────┘
# 5.1 MSE 到底是什么
❓
<video src="x.mp4">不香吗?
香,但只解决"完整文件能播"。MSE(Media Source Extensions)解决的是 "边下边播 + 自适应码率 + 自定义协议"——这三件事是直播 / B 站 / Youtube / Twitch 的全部基础。
❓ 为什么 MSE 能做到?它的工作原理?
传统: network ──[完整 mp4 文件]──> <video>
↑
浏览器自己解码
MSE: network ──[任意分片字节]──> JS ──appendBuffer──> SourceBuffer
│
[组装成 mp4]
│
MediaSource
│
URL.createObjectURL
↓
<video src="blob:...">
核心翻译:MSE 给 JS **"伪造一个本地媒体文件"**的能力——你随时往里追加字节,浏览器随时拿去解码播放。
❓ iOS Safari 真的不支持 MSE 吗?
iPhone 上的 Safari 完全不支持 MSE(iPad Safari 13+ 支持)。这意味着 m3u8 必须走两条路:
- 桌面 + Android Chrome → MSE + JS 自己解析
- iOS iPhone → 直接
<video src="x.m3u8">(Safari 原生支持 HLS)
本节最后会写降级链。
# 5.2 MSE 4 件套
| 名词 | 类型 | 作用 |
|---|---|---|
MediaSource | 接口 | 整个流的容器,相当于"虚拟文件" |
SourceBuffer | 接口 | 一个轨道(视频 / 音频),喂字节用 |
mime codec | 字符串 | video/mp4; codecs="avc1.42E01E,mp4a.40.2" |
URL.createObjectURL(ms) | API | 把 MediaSource 包成 video 能用的 URL |
# 5.3 准备测试素材
MSE 只接受 fragmented mp4 / WebM / TS,普通 mp4 不行。最简单:用 ffmpeg 切两段。
# 在 jsplayer/public 下
brew install ffmpeg # macOS(其他系统类似)
# 切 fmp4:前 5 秒 + 后续——并强制 fragmented
ffmpeg -i demo.mp4 -t 5 -c copy -movflags frag_keyframe+empty_moov+default_base_moof seg-1.mp4
ffmpeg -i demo.mp4 -ss 5 -c copy -movflags frag_keyframe+empty_moov+default_base_moof seg-2.mp4
💡 没装 ffmpeg 也可以,文末挑战 D 给了备选——直接拆 demo.mp4 二进制成两段(虽然不是真 fmp4,但能跑通流程)。
# 5.4 最小 MSE 跑通
📁 src/core/MseEngine.js:
// MSE 引擎——只关心"喂字节",不关心"字节从哪来"
export class MseEngine extends EventTarget {
#video;
#ms = null; // MediaSource
#sb = null; // SourceBuffer
#queue = []; // 待 append 的字节队列
#mime;
#ended = false;
constructor(videoEl, mime) {
super();
this.#video = videoEl;
this.#mime = mime;
}
// 浏览器是否支持当前 mime
static isSupported(mime) {
return 'MediaSource' in window && MediaSource.isTypeSupported(mime);
}
// ⭐ Step A:开始一个新的 MediaSource
open() {
return new Promise((resolve, reject) => {
if (!MseEngine.isSupported(this.#mime)) {
return reject(new Error(`MSE 不支持 ${this.#mime}`));
}
const ms = new MediaSource();
this.#ms = ms;
this.#video.src = URL.createObjectURL(ms);
// 注意:sourceopen 事件**只触发一次**——必须 once
ms.addEventListener('sourceopen', () => {
URL.revokeObjectURL(this.#video.src); // 立刻回收 blob URL
try {
this.#sb = ms.addSourceBuffer(this.#mime);
// ⭐ updateend ≈ "上一段字节消化完了"
this.#sb.addEventListener('updateend', () => this.#drain());
this.#sb.addEventListener('error', e => this.#fail(e));
resolve();
} catch (err) { reject(err); }
}, { once: true });
});
}
// ⭐ Step B:业务层喂数据进来
appendChunk(arrayBuffer) {
if (!this.#sb) throw new Error('MSE 未 open');
if (this.#ended) return;
this.#queue.push(arrayBuffer);
this.#drain();
}
// ⭐ Step C:声明"我喂完了"
endOfStream() {
this.#ended = true;
if (!this.#sb || this.#sb.updating || this.#queue.length) return;
if (this.#ms.readyState === 'open') this.#ms.endOfStream();
}
// 排队消费 —— 因为 SourceBuffer.updating=true 时不能 append
#drain() {
if (!this.#sb || this.#sb.updating) return;
if (this.#queue.length) {
const buf = this.#queue.shift();
try { this.#sb.appendBuffer(buf); }
catch (err) { this.#fail(err); }
return;
}
// 队列空且业务声明结束 → 关流
if (this.#ended && this.#ms.readyState === 'open') {
this.#ms.endOfStream();
}
}
#fail(err) {
this.dispatchEvent(new CustomEvent('mse-error', { detail: err }));
}
destroy() {
if (this.#ms?.readyState === 'open') {
try { this.#ms.endOfStream(); } catch {}
}
this.#queue.length = 0;
this.#sb = null;
this.#ms = null;
}
}
这里有 4 个新手必踩坑,必须逐个解释。
# 5.5 MSE 的 4
🪤 陷阱 1:SourceBuffer.updating 是异步状态
sb.appendBuffer(chunk1);
sb.appendBuffer(chunk2); // ❌ 直接抛 InvalidStateError
原因:appendBuffer 是异步的——浏览器要先解析这段字节、写入解码器内部缓冲。第一段还没写完就喂第二段,浏览器直接拒绝。必须等 updateend 事件再喂下一段——这就是 #queue + #drain 的存在意义。
🪤 陷阱 2:MIME 必须精确到 codec
new MediaSource().addSourceBuffer('video/mp4'); // ❌ 报错
new MediaSource().addSourceBuffer('video/mp4; codecs="avc1.42E01E,mp4a.40.2"'); // ✅
怎么知道 codec? 用 MediaSource.isTypeSupported(...) 探测,或用 ffprobe 看:
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,profile -of csv seg-1.mp4
# 输出:h264,Constrained Baseline → avc1.42E01E
🪤 陷阱 3:MediaSource 只能用一次
endOfStream() 之后这个 MediaSource 就永远关闭了——切清晰度(§06 §08)必须 new 一个新的 MediaSource,把 video.src 重新指过去。
🪤 陷阱 4:blob URL 必须 revokeObjectURL
URL.createObjectURL(ms) 创建的 URL 会永久占用内存,直到页面卸载。前面代码里在 sourceopen 之后立刻 revokeObjectURL——不影响已绑定的播放,但能让浏览器及早回收。
# 5.6 用 fetch
回到 Player.js,加一个新方法 loadMse(url, mime):
import { MseEngine } from './MseEngine.js';
export class Player extends EventTarget {
// ... 已有字段 ...
#mse = null;
#abort = null; // AbortController,§06 切清晰度时用
async loadMse(url, mime = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"') {
this.#setState(PlayerState.LOADING);
// 取消上一次加载(如果有)
this.#abort?.abort('switch source');
this.#abort = new AbortController();
try {
// 1) 打开 MSE
this.#mse = new MseEngine(this.#video, mime);
this.#mse.addEventListener('mse-error', e => {
this.#lastError = e.detail;
this.#setState(PlayerState.ERROR, e.detail);
});
await this.#mse.open();
// 2) 流式拉取
const resp = await fetch(url, { signal: this.#abort.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader(); // ⭐ ReadableStream 流式 reader
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value 是 Uint8Array → 直接喂 MSE
this.#mse.appendChunk(value.buffer);
}
this.#mse.endOfStream();
} catch (err) {
if (err.name === 'AbortError') return; // 主动取消,不算错
console.error('[Player] loadMse 失败', err);
this.#lastError = err;
this.#setState(PlayerState.ERROR, err);
}
}
}
📚 response.body.getReader():fetch 返回的 Response.body 是 ReadableStream——getReader() 让你一段一段取字节,而不是 await resp.arrayBuffer() 等所有字节下完。这是流媒体的关键——下来多少就喂多少给 MSE,浏览器立刻开始播。
# 5.7 UI 接入 +
修改 JsPlayerApp.attributeChangedCallback,让它检测扩展名走不同路径:
attributeChangedCallback(name, _old, val) {
if (name === 'src' && val) {
if (val.endsWith('.mp4') && val.includes('seg')) {
// 测试用 fmp4 → 走 MSE
this.#player.loadMse(val);
} else {
this.#player.load(val); // 普通 mp4 走原生
}
}
}
🧪 立刻编译运行(阶段 ④ 第 1 次验收)
把 index.html 改成:
<jsplayer-app src="/seg-1.mp4" muted autoplay></jsplayer-app>
刷新 → 控制台依次看到:
[JsPlayerApp] idle → loading
[JsPlayerApp] loading → ready
[JsPlayerApp] ready → playing
视频前 5 秒能播 → 播完一会 video 应该自然停在 5 秒(因为 endOfStream)。
✅ 看到字节流通过 fetch + MSE 进入 video 元素 = MSE 闭环成功——这是 hls.js / shaka 的最小内核。
# 5.8 故意造 bug
为了让你感受 updating 异步,把 MseEngine.#drain 临时改成"不排队,直接喂":
appendChunk(arrayBuffer) {
// 故意 bug:直接喂,不入队
this.#sb.appendBuffer(arrayBuffer);
}
刷新 → 控制台立刻报:
DOMException: Failed to execute 'appendBuffer' on 'SourceBuffer':
This SourceBuffer is still processing an 'appendBuffer' operation.
✅ 看到这个错就对了——这是初学者第一次接 MSE 必踩的坑。改回 #queue + #drain 后 bug 消失。
💡 这就是 hls.js 源码里
class MSEController整整 600 行的来历——队列、updating 检测、回压、错误恢复,每一行都对应一个真实的浏览器陷阱。
# 5.9 拼接两段 fmp4
把 loadMse 升级——接受 URL 数组,串行拉多段:
async loadMseSegments(urls, mime = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"') {
this.#setState(PlayerState.LOADING);
this.#abort?.abort('switch');
this.#abort = new AbortController();
try {
this.#mse = new MseEngine(this.#video, mime);
await this.#mse.open();
for (const url of urls) {
const resp = await fetch(url, { signal: this.#abort.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status} @ ${url}`);
const buf = await resp.arrayBuffer();
this.#mse.appendChunk(buf);
// 等 MSE 把这段消化完再拉下一段(回压)
await new Promise((res, rej) => {
const sb = this.#mse.video.querySelector?.('source') ?? null;
// 简化:等一个 microtask 让队列消费一次
queueMicrotask(res);
});
}
this.#mse.endOfStream();
} catch (err) {
if (err.name !== 'AbortError') {
this.#lastError = err;
this.#setState(PlayerState.ERROR, err);
}
}
}
⚠️ 这里的"回压"是简化版(只等了一个微任务)。真实工程里要监听 SourceBuffer 的 buffered ranges,超过 30 秒就停止下载——§06 m3u8 加载器会做正式版。
🧪 第 2 次验收:
<jsplayer-app src="seg" muted autoplay></jsplayer-app>
<script type="module">
document.querySelector('jsplayer-app').addEventListener('player-state', e => {
if (e.detail.to === 'ready') console.log('元数据 OK,duration =', e.target.player.video.duration);
});
// 直接调 player API
customElements.whenDefined('jsplayer-app').then(() => {
document.querySelector('jsplayer-app').player
.loadMseSegments(['/seg-1.mp4', '/seg-2.mp4']);
});
</script>
刷新 → 视频应该能完整播完两段(≈ demo.mp4 全长)。
┌─ 📌 阶段 ④ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的(这些是 80% 前端工程师从未碰过的): │
│ • MediaSource / SourceBuffer / endOfStream │
│ • MIME codecs 必须精确到字符串(avc1.42E01E) │
│ • SourceBuffer.updating 异步状态 → 必须排队 │
│ • URL.createObjectURL + revokeObjectURL 内存回收 │
│ • response.body.getReader() 流式读字节 │
│ • AbortController 中断 fetch │
│ ⏸ 还没碰的: │
│ • m3u8 解析 + ts 分片(§06) │
│ • 自适应码率 ABR(挑战) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage4: MSE pipeline" │
│ 💡 本阶段最大领悟: │
│ "MSE 把 <video> 从'文件播放器'升级成'字节流播放器' │
│ 这就是 B 站 / Youtube / Twitch 整个直播栈的根基" │
└──────────────────────────────────────────────────┘
# 06.迷你 HLS:m3u8 + ts 分片
┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────┐
│ 完成什么:手写 m3u8 解析器 + 分片下载器,串起 §05 MSE │
│ 不做什么:不做加密 m3u8(AES-128 解密) │
│ 不做 LL-HLS 低延迟(part 字段) │
│ 验收标准:丢一个 index.m3u8 进去能边下边播 │
│ 预计耗时:120 min │
│ 关键思路:m3u8 是"播放清单"——纯文本协议 │
│ 解析它 = 字符串处理(卷一第 11 章) │
└──────────────────────────────────────────────────┘
# 6.1 m3u8 长什么样
❓ m3u8 协议是什么?
它是 Apple 设计的"视频清单格式"——一份纯文本文件,描述:"这部电影由哪些小片段组成、每片多长、在哪个 URL"。看一眼:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.967,
seg-001.ts
#EXTINF:9.967,
seg-002.ts
#EXTINF:5.123,
seg-003.ts
#EXT-X-ENDLIST
翻译:
#EXTM3U:固定文件头(必须)#EXT-X-TARGETDURATION:10:每个分片最大 10 秒#EXTINF:9.967,:下一行 ts 文件实际 9.967 秒seg-001.ts:分片 URL(相对当前 m3u8)#EXT-X-ENDLIST:点播(VOD)。直播没有这一行——拉的时候要不停轮询新清单
❓ HLS vs DASH 的区别?
| 维度 | HLS | DASH |
|---|---|---|
| 出身 | Apple | MPEG 国际标准 |
| 清单 | m3u8(文本) | MPD(XML) |
| 分片 | ts / fmp4 | fmp4 |
| iOS 原生 | ✅ | ❌ |
| 中国直播 | 主流 | 少数 |
国内绝大多数视频网站走 HLS——本节学完你就能拆 B 站 / 优酷 / 爱奇艺的 m3u8 了。
❓ 为什么手写而不用 hls.js?
hls.js 25000 行——学透要 3 个月。本节实现最小可用版(200 行)——理解了再读 hls.js 一周搞定。
# 6.2 m3u8 解析器
📁 src/loaders/parseM3u8.js:
/**
* 解析 m3u8 文本为结构化对象
* @param {string} text m3u8 原文
* @param {string} baseUrl m3u8 自身的 URL(用于把相对路径转成绝对)
* @returns {{ live: boolean, targetDuration: number, segments: Array<{url:string, duration:number, seq:number}> }}
*/
export function parseM3u8(text, baseUrl) {
const lines = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines[0] !== '#EXTM3U') throw new Error('非合法 m3u8:缺 #EXTM3U');
let live = true; // 默认按直播处理,看到 ENDLIST 再改
let targetDuration = 10;
let mediaSequence = 0;
const segments = [];
let pendingDuration = null; // EXTINF 是"下一行的属性"
for (const line of lines) {
if (line.startsWith('#EXT-X-TARGETDURATION:')) {
targetDuration = parseFloat(line.split(':')[1]);
} else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
mediaSequence = parseInt(line.split(':')[1], 10);
} else if (line === '#EXT-X-ENDLIST') {
live = false;
} else if (line.startsWith('#EXTINF:')) {
// #EXTINF:9.967, → 9.967
pendingDuration = parseFloat(line.slice(8).split(',')[0]);
} else if (!line.startsWith('#')) {
// 不是注释 = URL
if (pendingDuration == null) {
console.warn('[m3u8] 跳过 URL(缺 EXTINF):', line);
continue;
}
// ⭐ 用 URL 构造器把相对路径变绝对——不要自己拼字符串
const abs = new URL(line, baseUrl).toString();
segments.push({
url: abs,
duration: pendingDuration,
seq: mediaSequence + segments.length,
});
pendingDuration = null;
}
}
return { live, targetDuration, segments };
}
设计要点:
| 要点 | 写法 | 作用 |
|---|---|---|
split(/\r?\n/) | 容错 | Windows / Mac 行尾差异 |
URL(rel, base) | Web API | 永远不要自己拼路径——baseUrl + rel 在带 ? 时会出错 |
pendingDuration 状态 | 暂存上一行 | EXTINF 是 ts 行的"上一行" |
| 直播 / 点播判断 | EXT-X-ENDLIST | 直播要轮询,点播不要 |
🧪 立刻验证(控制台)
import('/src/loaders/parseM3u8.js').then(({parseM3u8}) => {
const sample = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:9.967,
seg-001.ts
#EXTINF:5.0,
seg-002.ts
#EXT-X-ENDLIST`;
console.log(parseM3u8(sample, 'http://localhost:5173/hls/index.m3u8'));
});
预期输出:
{
live: false,
targetDuration: 10,
segments: [
{ url: 'http://localhost:5173/hls/seg-001.ts', duration: 9.967, seq: 0 },
{ url: 'http://localhost:5173/hls/seg-002.ts', duration: 5.0, seq: 1 },
]
}
✅ 解析器工作了——这是 200 行 hls.js 的核心 30 行。
# 6.3 HlsLoader
📁 src/loaders/HlsLoader.js:
import { parseM3u8 } from './parseM3u8.js';
import { MseEngine } from '../core/MseEngine.js';
// 注:真实 ts 分片需要 mux.js 之类的库做 ts→fmp4 转封装
// 本案例为简化教学,直接用 fmp4 分段 + 自定义清单(详见 §6.5 备注)
export class HlsLoader extends EventTarget {
#video;
#abort = null;
#mime;
constructor(videoEl, mime = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"') {
super();
this.#video = videoEl;
this.#mime = mime;
}
cancel() { this.#abort?.abort('hls cancel'); }
async load(playlistUrl) {
this.cancel();
this.#abort = new AbortController();
const signal = this.#abort.signal;
try {
// 1) 拉清单
const text = await fetch(playlistUrl, { signal }).then(r => {
if (!r.ok) throw new Error('清单 HTTP ' + r.status);
return r.text();
});
const playlist = parseM3u8(text, playlistUrl);
this.dispatchEvent(new CustomEvent('manifest', { detail: playlist }));
// 2) 打开 MSE
const mse = new MseEngine(this.#video, this.#mime);
await mse.open();
// 3) 串行拉分片(带回压:buffered 超过 30s 就等)
for (const seg of playlist.segments) {
if (signal.aborted) return;
await this.#waitBufferRoom(30);
const buf = await fetch(seg.url, { signal }).then(r => r.arrayBuffer());
mse.appendChunk(buf);
this.dispatchEvent(new CustomEvent('segment', { detail: seg }));
}
mse.endOfStream();
this.dispatchEvent(new CustomEvent('done'));
} catch (err) {
if (err.name === 'AbortError') return;
this.dispatchEvent(new CustomEvent('hls-error', { detail: err }));
}
}
// ⭐ 回压:让 buffered 末端最多领先当前播放时间 maxAhead 秒
async #waitBufferRoom(maxAhead) {
while (true) {
const v = this.#video;
if (!v.buffered.length) return; // 还没开始播
const end = v.buffered.end(v.buffered.length - 1);
const ahead = end - v.currentTime;
if (ahead < maxAhead) return;
await new Promise(r => setTimeout(r, 500)); // 500ms 后再问
}
}
}
为什么要回压? 不加的话,10 分钟视频会一口气全下完到内存——手机用户骂街。hls.js 默认 buffer 不超过 60 秒——这里我们用 30 秒。
# 6.4 错误降级链(iO
Player.js 加一个统一入口 loadAuto:
import { HlsLoader } from '../loaders/HlsLoader.js';
async loadAuto(url) {
this.#setState(PlayerState.LOADING);
// 1) m3u8 + 浏览器原生支持(iOS Safari)→ 直接走原生
const isHls = url.includes('.m3u8');
if (isHls && this.#video.canPlayType('application/vnd.apple.mpegurl')) {
console.log('[Player] iOS 原生 HLS 路径');
this.#video.src = url;
return;
}
// 2) m3u8 + MSE → 走自定义 HlsLoader
if (isHls && 'MediaSource' in window) {
console.log('[Player] MSE + 自定义 HLS 路径');
this.#hls?.cancel();
this.#hls = new HlsLoader(this.#video);
this.#hls.addEventListener('hls-error', e => {
this.#lastError = e.detail;
this.#setState(PlayerState.ERROR, e.detail);
});
return this.#hls.load(url);
}
// 3) 兜底——普通 mp4 / webm
console.log('[Player] 原生 src 路径');
this.#video.src = url;
}
📚 canPlayType() 返回值:'probably' / 'maybe' / ''——只有空字符串算"不支持"。这是判断浏览器格式支持的金标准。
# 6.5 备一份测试 m3
如果你不想真切 ts,最简办法:用 §05 切的两个 fmp4 + 自己写一份"伪 m3u8"。
📁 public/hls/index.m3u8:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="../seg-1.mp4"
#EXTINF:5.0,
../seg-1.mp4
#EXTINF:5.0,
../seg-2.mp4
#EXT-X-ENDLIST
⚠️ 这只是教学用法。真生产环境的 m3u8 一定是 ts 分片——ts 喂 MSE 需要先用 mux.js (opens new window)(4KB gzip)把 ts 转 fmp4。挑战 B 让你接入 mux.js。
# 6.6 测试 + 故意触
index.html:
<jsplayer-app id="p" muted autoplay></jsplayer-app>
<script type="module">
customElements.whenDefined('jsplayer-app').then(() => {
const app = document.getElementById('p');
app.player.loadAuto('/hls/index.m3u8');
// 3 秒后切源——验证 AbortController 取消旧请求
setTimeout(() => {
console.log('[demo] 切到普通 mp4,旧 HLS 应被取消');
app.player.loadAuto('/demo.mp4');
}, 3000);
});
</script>
刷新 → 控制台:
[Player] MSE + 自定义 HLS 路径
[demo] 切到普通 mp4,旧 HLS 应被取消
[Player] 原生 src 路径
✅ 没有任何 "appendBuffer on closed MediaSource" 报错 = AbortController 取消链工作正常。
# 6.7 故意造 bug
把 loadAuto 里的 this.#hls?.cancel(); 临时注释掉,重新触发切源——控制台立刻报:
InvalidStateError: SourceBuffer is removed from MediaSource
原因:旧 HlsLoader 仍在 fetch 分片 → 拿到字节去 appendChunk → MSE 已经被新 loadAuto 替换 → 报错。
💡 这是前端"切源 / 切清晰度"的经典 bug——你随便打开 B 站切清晰度,F12 都能看到 hls.js 的
[hls.js] level 3 loaded日志,正是同一套机制。
恢复后继续。
┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • m3u8 协议(手写解析器 30 行) │
│ • new URL(rel, base) 路径处理 │
│ • HlsLoader:清单 → 分片串行 → MSE 喂数据 │
│ • buffered 回压(防内存爆炸) │
│ • canPlayType 探测原生 HLS(iOS 降级) │
│ • AbortController 取消链(切源不报错) │
│ ⏸ 还没碰的: │
│ • 自定义控制条 UI(§07) │
│ • Canvas 弹幕(§07) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage5: mini HLS" │
│ 💡 本阶段最大领悟: │
│ "hls.js 不是黑魔法——核心 = m3u8 解析 + fetch + │
│ MSE 排队。你已经写出了一个可以打实战的微缩版" │
└──────────────────────────────────────────────────┘
# 07.控制层:进度条 + rVFC + 24 个 video 事件
┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────┐
│ 完成什么:自定义控制条——播放/进度条/音量/倍速 │
│ 不做什么:不写弹幕(§08)、不写 PiP(§08) │
│ 验收标准:关掉原生 controls,自己 UI 全功能可用 │
│ 预计耗时:90 min │
│ 关键思路:进度条三色(played / buffered / seekable) │
│ 时间更新用 requestVideoFrameCallback 替代 timeupdate│
└──────────────────────────────────────────────────┘
# 7.1 HTMLVide
❓ 为什么这一节叫"24 个事件"?
W3C 规范规定 <video> 派发 24 个事件,初学者最少要分清这 8 个:
| 事件 | 触发时机 | 用途 |
|---|---|---|
loadstart | 开始下载 | 显示 loading |
loadedmetadata | duration 等元数据可用 | 初始化进度条 |
canplay | 当前帧可播 | 隐藏 loading |
playing | 真正开始播 | 切到播放图标 |
pause | 暂停 | 切到暂停图标 |
waiting | 缓冲不足卡顿 | 显示 loading(再现) |
timeupdate | 时间更新(约 4 次/秒) | 更新进度条 |
ended | 播完 | 显示重播按钮 |
❓
timeupdate不是更新进度条的最佳事件——为什么?
timeupdate 频率不稳——浏览器规范允许 4~66 ms 之间随便派发,60Hz 显示器上有时一帧不更新,导致进度条卡顿。正确做法:用 requestVideoFrameCallback(rVFC)—— 每帧解码完后精准回调一次。
❓
played / buffered / seekable三个 TimeRanges 是什么?
0────●────●────●────●────●────●────●────●────●──── duration
[ played ] ← 用户已观看的范围(红)
[ buffered ] ← 已下载到内存的范围(灰)
[ seekable ] ← 允许 seek 的范围(蓝/隐藏)
直播的 seekable 通常等于 buffered——你不能 seek 到"未来"。点播的 seekable 是 [0, duration]。
# 7.2 Step 1
把 JsPlayerApp 的 TEMPLATE.innerHTML 升级——加一整套控制条:
TEMPLATE.innerHTML = /*html*/`
<style>
:host { display: block; position: relative; background:#000; aspect-ratio:16/9;
color:#eee; font:14px/1.5 system-ui; user-select:none; }
video { width:100%; height:100%; display:block; background:#000; }
.placeholder { position:absolute; inset:0; display:grid; place-items:center;
opacity:.5; pointer-events:none; }
/* ─── 控制条 ─── */
.ctrl { position:absolute; left:0; right:0; bottom:0; padding:8px 12px;
background:linear-gradient(to top, rgba(0,0,0,.7), transparent);
display:flex; align-items:center; gap:10px; opacity:0;
transition:opacity .2s; }
:host(:hover) .ctrl, :host([data-paused]) .ctrl { opacity:1; }
button { background:transparent; border:0; color:#fff; font-size:16px;
cursor:pointer; padding:4px 8px; }
button:hover { color:#3ea6ff; }
/* ─── 进度条三色 ─── */
.bar { position:relative; flex:1; height:4px; background:rgba(255,255,255,.2);
cursor:pointer; border-radius:2px; }
.bar:hover { height:6px; }
.seekable, .buffered, .played {
position:absolute; left:0; top:0; bottom:0; border-radius:2px;
}
.seekable { background:rgba(255,255,255,.2); width:100%; }
.buffered { background:rgba(255,255,255,.5); }
.played { background:#3ea6ff; }
.thumb { position:absolute; top:50%; width:12px; height:12px;
margin:-6px 0 0 -6px; background:#3ea6ff; border-radius:50%;
opacity:0; transition:opacity .15s; }
.bar:hover .thumb { opacity:1; }
.time { font-variant-numeric: tabular-nums; opacity:.85; min-width:96px; }
</style>
<video playsinline></video>
<div class="placeholder"></div>
<div class="ctrl" part="controls">
<button class="play" title="播放/暂停">▶</button>
<div class="bar">
<div class="seekable"></div>
<div class="buffered"></div>
<div class="played"></div>
<div class="thumb"></div>
</div>
<span class="time">00:00 / 00:00</span>
<button class="speed" title="倍速">1.0×</button>
<button class="mute" title="静音">🔊</button>
<button class="full" title="全屏">⛶</button>
</div>
`;
📚 part="controls":让外部 CSS 可以用 jsplayer-app::part(controls) { ... } 调样式——这是 Shadow DOM 唯一允许的样式开放点,比全部塞 CSS 变量优雅。
# 7.3 进度条三色绘制
新建 Controls 类专门管控制条 UI——保持单一职责:
📁 src/ui/Controls.js:
export class Controls {
#host; // 自定义元素本体(Shadow root 的宿主)
#player;
#els;
#raf = null;
#rvfcId = null;
constructor(hostEl, shadowRoot, player) {
this.#host = hostEl;
this.#player = player;
this.#els = {
play: shadowRoot.querySelector('.play'),
mute: shadowRoot.querySelector('.mute'),
speed: shadowRoot.querySelector('.speed'),
full: shadowRoot.querySelector('.full'),
bar: shadowRoot.querySelector('.bar'),
played: shadowRoot.querySelector('.played'),
buffered: shadowRoot.querySelector('.buffered'),
thumb: shadowRoot.querySelector('.thumb'),
time: shadowRoot.querySelector('.time'),
};
this.#bind();
}
#bind() {
const v = this.#player.video;
const e = this.#els;
// ⭐ 按钮
e.play.addEventListener('click', () =>
v.paused ? this.#player.play() : this.#player.pause());
e.mute.addEventListener('click', () => {
v.muted = !v.muted;
e.mute.textContent = v.muted ? '🔇' : '🔊';
});
e.speed.addEventListener('click', () => {
const seq = [1.0, 1.25, 1.5, 2.0, 0.75];
const idx = (seq.indexOf(v.playbackRate) + 1) % seq.length;
v.playbackRate = seq[idx];
e.speed.textContent = seq[idx].toFixed(2) + '×';
});
e.full.addEventListener('click', () => this.#toggleFullscreen());
// ⭐ 进度条 seek
e.bar.addEventListener('click', (ev) => {
const rect = e.bar.getBoundingClientRect();
const pct = (ev.clientX - rect.left) / rect.width;
v.currentTime = pct * (v.duration || 0);
});
// ⭐ 状态镜像
v.addEventListener('play', () => { e.play.textContent = '⏸'; this.#host.removeAttribute('data-paused'); });
v.addEventListener('pause', () => { e.play.textContent = '▶'; this.#host.setAttribute('data-paused', ''); });
// ⭐ 关键:buffered 用 progress 事件(轻量)
v.addEventListener('progress', () => this.#renderBuffered());
v.addEventListener('loadedmetadata', () => this.#renderTime());
// ⭐ played + currentTime 用 rVFC(高频且与帧同步)
this.#startFrameLoop();
}
// ⭐ requestVideoFrameCallback——卷一第 14.10 浏览器 API
#startFrameLoop() {
const v = this.#player.video;
if ('requestVideoFrameCallback' in v) {
const tick = () => {
this.#renderPlayed();
this.#renderTime();
this.#rvfcId = v.requestVideoFrameCallback(tick);
};
this.#rvfcId = v.requestVideoFrameCallback(tick);
} else {
// 降级:rAF
const tick = () => {
this.#renderPlayed();
this.#renderTime();
this.#raf = requestAnimationFrame(tick);
};
this.#raf = requestAnimationFrame(tick);
}
}
#renderPlayed() {
const v = this.#player.video;
const pct = v.duration ? (v.currentTime / v.duration) * 100 : 0;
this.#els.played.style.width = pct + '%';
this.#els.thumb.style.left = pct + '%';
}
#renderBuffered() {
const v = this.#player.video;
if (!v.duration || !v.buffered.length) return;
// 拿"包含当前播放点"的那段 buffered
let end = 0;
for (let i = 0; i < v.buffered.length; i++) {
if (v.buffered.start(i) <= v.currentTime && v.currentTime <= v.buffered.end(i)) {
end = v.buffered.end(i); break;
}
}
if (!end) end = v.buffered.end(v.buffered.length - 1);
this.#els.buffered.style.width = (end / v.duration * 100) + '%';
}
#renderTime() {
const v = this.#player.video;
this.#els.time.textContent = `${fmt(v.currentTime)} / ${fmt(v.duration || 0)}`;
}
#toggleFullscreen() {
// 多前缀兼容(Safari iOS 还有更特殊的 webkitEnterFullscreen)
const enter = this.#host.requestFullscreen ?? this.#host.webkitRequestFullscreen;
const exit = document.exitFullscreen ?? document.webkitExitFullscreen;
const fsEl = document.fullscreenElement ?? document.webkitFullscreenElement;
if (fsEl) exit.call(document); else enter.call(this.#host);
}
destroy() {
if (this.#raf) cancelAnimationFrame(this.#raf);
if (this.#rvfcId && this.#player.video.cancelVideoFrameCallback) {
this.#player.video.cancelVideoFrameCallback(this.#rvfcId);
}
}
}
function fmt(sec) {
if (!isFinite(sec)) return '00:00';
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
# 7.4 把 Contro
import { Controls } from './Controls.js';
connectedCallback() {
super.connectedCallback?.();
this.#controls = new Controls(this, this.#shadow, this.#player);
this.#player.addEventListener('statechange', e => {
this.#updatePlaceholder(e.detail.to);
});
if (this.hasAttribute('src')) this.#player.loadAuto(this.getAttribute('src'));
}
disconnectedCallback() {
this.#controls?.destroy();
}
并把 <video> 的 controls = true 改回 false——现在我们自己渲染控制条。
🧪 立刻编译运行(阶段 ⑥ 验收)
刷新 → 应看到自定义控制条(鼠标悬停时浮现):
- 进度条三色:蓝(已播)/ 浅白(已缓冲)/ 暗白(可 seek)
- 时间显示
01:23 / 12:34实时更新 - 倍速按钮点一下:
1.00× → 1.25× → 1.50× → 2.00× → 0.75×循环 - 全屏按钮:进 / 出全屏
# 7.5 rVFC vs
| 事件源 | 频率 | 与视频帧同步? | 用途 |
|---|---|---|---|
timeupdate | 4~66 ms 不固定 | ❌ | 老代码兼容,不推荐 |
requestAnimationFrame | 60Hz(屏幕) | ❌(屏幕帧≠视频帧) | UI 动画 |
requestVideoFrameCallback | 每解码一帧调一次 | ✅ | 弹幕同步 / 字幕 / 进度条 |
最强证据:B 站新版播放器 / Youtube 新版播放器 / shaka-player 全部用 rVFC——老逻辑用 timeupdate 的播放器在 4K HDR 视频上肉眼可见的卡顿。
# 7.6 故意造 bug
把 Controls.destroy 里的 cancelVideoFrameCallback 临时删掉。然后在控制台:
// 频繁创建销毁元素
for (let i = 0; i < 10; i++) {
const el = document.createElement('jsplayer-app');
el.src = '/demo.mp4';
document.body.appendChild(el);
setTimeout(() => el.remove(), 500);
}
打开 Performance 面板录制 5 秒 → 会看到 rVFC 回调一直在触发——内存涨、电量耗。这就是前端最经典的事件泄漏。
恢复 destroy 后再测 → 回调归零。
┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • 24 个 video 事件中最重要的 8 个 │
│ • TimeRanges API(played / buffered / seekable) │
│ • requestVideoFrameCallback(与帧同步) │
│ • Controls 单一职责拆分 │
│ • Fullscreen API 多前缀兼容 │
│ • disconnectedCallback 释放资源 │
│ ⏸ 还没碰的: │
│ • Canvas 弹幕(§08) │
│ • PiP / Media Session(§08) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage6: controls" │
│ 💡 本阶段最大领悟: │
│ "高级前端 = 知道 timeupdate 不能用、必须用 rVFC" │
└──────────────────────────────────────────────────┘
# 08.Canvas 弹幕:对象池 + 时间索引
┌─ 🎯 阶段 ⑦ 目标 ────────────────────────────────────┐
│ 完成什么:单 Canvas 渲染 1000+ 条弹幕滚动不卡 │
│ 不做什么:不写"高级弹幕"——抽屉特效、礼物动画都不做 │
│ 验收标准:1000 条弹幕,首屏 60fps、内存稳定 │
│ 预计耗时:120 min │
│ 关键思路:弹幕 ≠ 1000 个 div + CSS 动画——必须 Canvas │
│ 核心:对象池 + 桶排序时间索引 + RAF │
└──────────────────────────────────────────────────┘
# 8.1 弹幕为什么不能用
❓ B 站为什么不直接 1000 个
<div>+transform: translateX?
试一下你就知道:
for (let i = 0; i < 1000; i++) {
const d = document.createElement('div');
d.style.cssText = 'position:absolute; transform: translateX(0)';
d.textContent = '弹幕 ' + i;
document.body.appendChild(d);
}
性能炸点:
- DOM 节点 = 内存大户——1000 个 div 平均 1MB+ 内存
- 每帧 1000 次 layout / composite——主线程帧率瞬间从 60→ 20
- GPU 纹理上传——transform 触发 layer,每条弹幕一个 layer = 显存爆炸
❓ Canvas 凭什么能扛?
| 维度 | 1000 div | 1 Canvas |
|---|---|---|
| DOM 节点 | 1000 个 | 1 个 |
| layout / paint | 1000 次/帧 | 0 次/帧 |
| 显存 | N 个 layer | 1 个纹理 |
| 文本渲染 | 浏览器原生 | 自己 fillText |
| 命中事件 | 每个 div 自动 | 自己算 |
结论:Canvas 把"1000 条独立 DOM"压成"1 张图"——CPU 算 1000 个坐标 vs 浏览器排版 1000 个元素,差 50 倍。
❓ 现在第一步该做哪个?
回到三问"先做被依赖项"——先做"加载弹幕数据",再做"按时间索引",最后才是 Canvas 绘制。
# 8.2 弹幕数据 + 时
📁 测试数据 public/danmaku.json:
[
{ "t": 1.5, "text": "前方高能", "color": "#fff", "type": "scroll" },
{ "t": 2.0, "text": "23333", "color": "#3ea6ff", "type": "scroll" },
{ "t": 2.0, "text": "笑死", "color": "#ff5c5c", "type": "scroll" },
{ "t": 5.5, "text": "稳", "color": "#fff", "type": "scroll" }
]
📁 src/danmaku/Index.js:
/**
* 时间索引:按整数秒分桶
* 查询"当前秒新出现的弹幕" 从 O(n) 变成 O(1)
*/
export class DanmakuIndex {
#buckets = new Map(); // second(int) → DanmakuItem[]
#total = 0;
load(items) {
this.#buckets.clear();
this.#total = 0;
for (const it of items) {
const sec = Math.floor(it.t);
let bucket = this.#buckets.get(sec);
if (!bucket) { bucket = []; this.#buckets.set(sec, bucket); }
bucket.push(it);
this.#total++;
}
}
// 取 [from, to) 区间内出现的所有弹幕
range(from, to) {
const out = [];
for (let s = Math.floor(from); s < Math.ceil(to); s++) {
const b = this.#buckets.get(s);
if (b) for (const it of b) {
if (it.t >= from && it.t < to) out.push(it);
}
}
return out;
}
get total() { return this.#total; }
}
📚 桶排序思路:弹幕的 t 是有限范围浮点数,按 Math.floor(t) 分组——查询当前播放秒时只看 1~2 个桶,O(1) 命中。这种"按时间维度建索引"的思路在卷一第 02.4 提过,第一次在工程里看到它的威力。
# 8.3 对象池——Tra
弹幕轨道(Track)= 横向条带
┌──────────────────────────────────┐
│ Track 0: ──→ [前方高能]──────→ │
│ Track 1: ────→ [23333]──────→ │
│ Track 2: ────────→ [笑死]──→ │
│ Track 3: ────────────→ [稳]─→ │
└──────────────────────────────────┘
📁 src/danmaku/Engine.js:
const FONT_SIZE = 20;
const TRACK_HEIGHT = 28;
const SCROLL_DURATION = 8; // 弹幕从右走到左 8 秒
class Bullet {
text = ''; color = '#fff';
x = 0; y = 0; w = 0;
speed = 0; // 像素/秒
alive = false;
reset(item, canvasW, ctx, trackY) {
this.text = item.text;
this.color = item.color || '#fff';
ctx.font = `${FONT_SIZE}px system-ui`;
this.w = ctx.measureText(item.text).width;
this.x = canvasW; // 从右边出场
this.y = trackY;
this.speed = (canvasW + this.w) / SCROLL_DURATION;
this.alive = true;
}
}
export class DanmakuEngine {
#canvas; #ctx;
#pool = []; // 对象池
#active = []; // 正在显示的 bullets
#tracks; // 每条 track 的"最右侧已占用 x"
#lastRenderSec = -1;
#index;
constructor(canvas, index) {
this.#canvas = canvas;
this.#ctx = canvas.getContext('2d');
this.#index = index;
this.#initTracks();
// 预分配 200 个对象
for (let i = 0; i < 200; i++) this.#pool.push(new Bullet());
}
#initTracks() {
const n = Math.floor(this.#canvas.height / TRACK_HEIGHT);
this.#tracks = new Array(n).fill(0);
}
// ⭐ 核心:从池里取,没有就 new
#acquire() {
return this.#pool.pop() ?? new Bullet();
}
#release(b) {
b.alive = false;
this.#pool.push(b);
}
// ⭐ 找一条"右端不会撞车"的 track
#pickTrack(bulletWidth) {
const W = this.#canvas.width;
for (let i = 0; i < this.#tracks.length; i++) {
// 这条 track 上"最右弹幕的左端"已经离开屏幕右边一定距离
if (this.#tracks[i] < W - 50) return i;
}
return -1; // 全满,丢弃这条弹幕
}
// 视频时间推进 → 拉取新出现的弹幕
spawnFor(currentTime) {
const sec = Math.floor(currentTime);
if (sec === this.#lastRenderSec) return;
const items = this.#index.range(this.#lastRenderSec + 1, sec + 1);
this.#lastRenderSec = sec;
for (const item of items) {
const tmpW = this.#ctx.measureText(item.text).width;
const trackIdx = this.#pickTrack(tmpW);
if (trackIdx < 0) continue; // 满轨:丢弃
const b = this.#acquire();
b.reset(item, this.#canvas.width, this.#ctx, trackIdx * TRACK_HEIGHT + FONT_SIZE);
this.#tracks[trackIdx] = this.#canvas.width + tmpW; // 占轨
this.#active.push(b);
}
}
// 每帧调一次:移动 + 绘制 + 回收
render(dt) {
const ctx = this.#ctx;
ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
ctx.font = `${FONT_SIZE}px system-ui`;
ctx.textBaseline = 'alphabetic';
for (let i = this.#active.length - 1; i >= 0; i--) {
const b = this.#active[i];
b.x -= b.speed * dt;
// 出屏 → 回收
if (b.x + b.w < 0) {
this.#active.splice(i, 1);
this.#release(b);
continue;
}
ctx.fillStyle = b.color;
ctx.fillText(b.text, b.x, b.y);
}
// 更新 track 占用(用 active 重新算)
this.#tracks.fill(0);
for (const b of this.#active) {
const tIdx = Math.floor((b.y - FONT_SIZE) / TRACK_HEIGHT);
this.#tracks[tIdx] = Math.max(this.#tracks[tIdx], b.x + b.w);
}
}
resize(w, h) {
this.#canvas.width = w; this.#canvas.height = h;
this.#initTracks();
}
}
对象池 4 大要点:
| 要点 | 写法 | 目的 |
|---|---|---|
#pool / #active 双数组 | 复用 vs 在播 | 避免 new/GC |
| 预分配 200 个 | 启动时 | 跳过冷启动抖动 |
#release 不是 splice | pop/push | O(1) 操作 |
| 满轨直接丢 | 不挤 | 用户体验:宁缺毋滥 |
# 8.4 把 Engine
TEMPLATE 里 video 上叠 canvas:
<video playsinline></video>
<canvas class="danmu" style="position:absolute; inset:0; pointer-events:none;"></canvas>
<div class="placeholder"></div>
<div class="ctrl"> ...原内容... </div>
Controls 加一段:
import { DanmakuIndex } from '../danmaku/Index.js';
import { DanmakuEngine } from '../danmaku/Engine.js';
// 在 constructor 末尾
const canvas = shadowRoot.querySelector('.danmu');
this.#index = new DanmakuIndex();
this.#engine = new DanmakuEngine(canvas, this.#index);
this.#syncCanvasSize();
new ResizeObserver(() => this.#syncCanvasSize()).observe(this.#host);
// 加载弹幕
fetch('/danmaku.json').then(r => r.json()).then(arr => {
this.#index.load(arr);
console.log('[Danmaku] loaded', arr.length);
});
// 把弹幕 render 接到 §07 的 frame loop
// 在原 #startFrameLoop 的 tick 里追加:
const v = this.#player.video;
const dt = (performance.now() - (this.#lastTs ?? performance.now())) / 1000;
this.#lastTs = performance.now();
this.#engine.spawnFor(v.currentTime);
this.#engine.render(Math.min(dt, 0.1)); // dt clamp 防止切后台后大跳
// #syncCanvasSize 实现
#syncCanvasSize() {
const r = this.#host.getBoundingClientRect();
this.#engine.resize(r.width, r.height);
}
🧪 立刻编译运行(阶段 ⑦ 第 1 次验收)
刷新 → 视频上叠了横向滚动的弹幕。打开 Performance:
- FPS 稳定 60
- Memory 不爆涨
- Main 线程 idle > 50%
✅ 看到弹幕滚动且不卡 = 对象池工作了。
# 8.5 故意造 bug
把 #acquire 改成永远 new Bullet()、#release 改成 // noop——再生成 1 万条弹幕:
const fake = []; for (let i=0;i<10000;i++) fake.push({t:i*0.05, text:'X'+i, color:'#fff'});
controls.index.load(fake);
DevTools Memory 录制 → 看到锯齿状 GC——每秒一次掉到 100MB 又回 200MB。这就是对象池的诞生原因:游戏 / 弹幕 / 动画 / WebGL 粒子全靠它压 GC。
恢复后再测 → 内存平稳。
# 8.6 切后台 dt 跳
❓ dt clamp 0.1 是干嘛的?
视频切到后台 tab 时浏览器把 rVFC 限到 1 秒/次——回到前台时一帧 dt 突然变 1 秒,所有弹幕瞬间飞出屏幕!Math.min(dt, 0.1) 把单帧最大跳变锁在 100ms 以内——这是真实播放器必须做的细节。
┌─ 📌 阶段 ⑦ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • Canvas 2D:fillText / measureText / clearRect │
│ • 对象池模式(acquire/release) │
│ • 桶索引按整数秒分组 │
│ • Track 防撞算法 │
│ • dt clamp 防后台切换抖动 │
│ • ResizeObserver 响应尺寸变化 │
│ ⏸ 还没碰的: │
│ • PiP / Media Session(§09) │
│ • WebVTT 字幕(§09) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage7: danmaku" │
│ 💡 本阶段最大领悟: │
│ "前端高性能渲染的本质:单 Canvas + 对象池 + 索引 │
│ 这三板斧打遍弹幕 / 编辑器 / 游戏 / 数据可视化" │
└──────────────────────────────────────────────────┘
# 09.Web 平台 API 大全:PiP / Media Session / WebVTT
┌─ 🎯 阶段 ⑧ 目标 ────────────────────────────────────┐
│ 完成什么:把 4 个"提升档次"的浏览器 API 一次性接全 │
│ 不做什么:不做剧场模式 / 不做 DRM(EME) │
│ 验收标准:PiP / 锁屏控制 / WebVTT 字幕 / 全屏全部生效 │
│ 预计耗时:60 min │
│ 关键思路:浏览器宿主 API 都长得很像—— │
│ 特性检测 → 多前缀 → 优雅降级 │
└──────────────────────────────────────────────────┘
# 9.1 宿主 API 凭
❓ PiP / Fullscreen / Media Session / WebVTT 不是 4 个完全不同的东西吗?
它们都遵循同一套套路:
if ('FeatureName' in window || 'methodName' in element) {
// 用法
element.methodName(...);
} else {
// 降级:要么不做,要么 polyfill
}
这就是宿主 API 的统一气质——MDN 上每个 API 都有一栏 "Browser compatibility",写代码时永远先看这栏,再看用法。
❓ 学完本节最大收获是什么?
学会"读 MDN 的方法论"——掌握 3 个 API,剩下 100 个 API 都是同一套(navigator.share / navigator.clipboard / Geolocation / Notification / WakeLock / Web Speech ……)。
# 9.2 画中画(PiP)
// 在 Controls 里加按钮
e.pip = shadow.querySelector('.pip'); // 模板里加 <button class="pip" title="画中画">🎯</button>
e.pip.addEventListener('click', async () => {
const v = this.#player.video;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else if (document.pictureInPictureEnabled) {
await v.requestPictureInPicture();
} else {
alert('当前浏览器不支持画中画');
}
} catch (err) {
console.warn('[PiP] 失败', err);
}
});
// 监听 PiP 进入 / 退出(用户手动关 PiP 窗口要同步 UI)
this.#player.video.addEventListener('enterpictureinpicture', () => e.pip.classList.add('active'));
this.#player.video.addEventListener('leavepictureinpicture', () => e.pip.classList.remove('active'));
特性检测三层:
document.pictureInPictureElement—— 当前是否已在 PiP(用来切换文案)document.pictureInPictureEnabled—— 浏览器是否允许 PiP(Firefox / iOS Safari = false)video.disablePictureInPicture—— 这个具体 video 是否禁用了
# 9.3 Media Se
🚀 效果:手机锁屏 / 蓝牙耳机 / Mac 控制中心 上能看到你的视频在播,封面、进度都对——一行
addEventListener都不用,浏览器自动接管。
function setupMediaSession(player, meta) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: meta.title || 'jsplayer 演示',
artist: meta.artist || '杨充',
album: meta.album || 'YCBookBlog',
artwork: [
{ src: meta.cover || '/cover-512.png', sizes: '512x512', type: 'image/png' },
],
});
// 锁屏按钮回调
navigator.mediaSession.setActionHandler('play', () => player.play());
navigator.mediaSession.setActionHandler('pause', () => player.pause());
navigator.mediaSession.setActionHandler('seekbackward', d => player.seek(player.video.currentTime - (d.seekOffset || 10)));
navigator.mediaSession.setActionHandler('seekforward', d => player.seek(player.video.currentTime + (d.seekOffset || 10)));
// ⭐ 给系统报"当前时间"——让锁屏进度条能实时滚动
player.video.addEventListener('timeupdate', () => {
if (!isFinite(player.video.duration)) return;
navigator.mediaSession.setPositionState({
duration: player.video.duration,
playbackRate: player.video.playbackRate,
position: player.video.currentTime,
});
});
}
在 JsPlayerApp.connectedCallback 里调一次 setupMediaSession(this.#player, { title: this.getAttribute('title') })。
🧪 验证:手机访问 → 播放 → 锁屏 / 拉下通知中心 → 看到正在播放卡片 → 点暂停按钮能控制视频。
# 9.4 WebVTT 字
WebVTT 是 W3C 标准字幕格式,长得像 m3u8——纯文本协议:
WEBVTT
00:00:01.000 --> 00:00:03.500
你好,世界
00:00:04.000 --> 00:00:06.000
这是字幕第二行
📁 src/subtitle/parseVtt.js:
export function parseVtt(text) {
const lines = text.split(/\r?\n/);
if (!lines[0].startsWith('WEBVTT')) throw new Error('非合法 WebVTT');
const cues = [];
let i = 1;
while (i < lines.length) {
const line = lines[i].trim();
if (!line || line.startsWith('NOTE')) { i++; continue; }
// 可能有 cue id(一整行非时间码),跳过
const arrow = line.includes('-->') ? line : (i++, lines[i]?.trim() ?? '');
if (!arrow.includes('-->')) { i++; continue; }
const [a, b] = arrow.split('-->').map(s => parseTime(s.trim()));
i++;
const buf = [];
while (i < lines.length && lines[i].trim()) {
buf.push(lines[i]); i++;
}
cues.push({ start: a, end: b, text: buf.join('\n') });
}
return cues;
}
function parseTime(s) {
// hh:mm:ss.ms 或 mm:ss.ms
const parts = s.split(':');
let h = 0, m = 0, sec = 0;
if (parts.length === 3) [h, m, sec] = parts.map(Number);
else [m, sec] = parts.map(Number);
return h * 3600 + m * 60 + sec;
}
📁 src/subtitle/SubtitleLayer.js——简化版,复用 §08 的 DanmakuIndex 思路:
export class SubtitleLayer {
#host; #cues = []; #cur = null;
constructor(hostEl) {
this.#host = document.createElement('div');
Object.assign(this.#host.style, {
position: 'absolute', left: '0', right: '0', bottom: '60px',
textAlign: 'center', color: '#fff', fontSize: '20px',
textShadow: '0 0 4px #000', pointerEvents: 'none', whiteSpace: 'pre-line',
});
hostEl.appendChild(this.#host);
}
load(cues) { this.#cues = cues; }
update(t) {
const c = this.#cues.find(x => t >= x.start && t < x.end);
if (c === this.#cur) return;
this.#cur = c;
this.#host.textContent = c?.text ?? '';
}
}
在 Controls frame loop 里追加 this.#subs?.update(v.currentTime)。
🧪 验证:放一份 demo.vtt 进 public,加载 → 关键时间点看到字幕浮现。
# 9.5 宿主 API 全
| 名字 | 入口 | 一句话 | 兼容 |
|---|---|---|---|
| Fullscreen | el.requestFullscreen() | 全屏 | iOS Safari 要 webkitEnterFullscreen 在 video 上 |
| PiP | video.requestPictureInPicture() | 画中画 | Firefox / iOS Safari 不支持 |
| Media Session | navigator.mediaSession | 系统控制 | iOS 14+ |
| WebVTT | 手写解析 + <track> | 字幕 | 全平台 |
| Wake Lock | navigator.wakeLock.request('screen') | 阻止息屏 | 移动端 87% 覆盖 |
| Screen Orientation | screen.orientation.lock('landscape') | 强制横屏 | Android 全屏后才生效 |
| Battery | navigator.getBattery() | 看电量 | 大部分浏览器已废弃 |
| Web Share | navigator.share() | 调起系统分享 | 移动端佳 |
💡 学习方法:每个新 API 第一时间查 caniuse.com——没 80% 覆盖就别上生产。
┌─ 📌 阶段 ⑧ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • Picture-in-Picture(多浏览器特性检测) │
│ • Media Session(手机锁屏 / Mac 控制中心) │
│ • WebVTT 手写解析器 │
│ • SubtitleLayer DOM 文本叠加 │
│ • caniuse 兼容性查表的方法论 │
│ ⏸ 还没碰的(留作挑战 / 下一案例): │
│ • EME(DRM 加密视频) │
│ • WebRTC(直播推流) │
│ • WebCodecs(自定义解码器) │
│ 📌 进入下阶段前: │
│ git add . && git commit -m "stage8: platform APIs"│
│ 💡 本阶段最大领悟: │
│ "Web 平台 API 都长一个样:特性检测→多前缀→降级" │
└──────────────────────────────────────────────────┘
# 10.项目总结
# 10.1 你写下的 ~20
jsplayer/
├── index.html
├── style.css
├── public/
│ ├── seg-1.mp4 / seg-2.mp4 # fmp4 测试分段
│ ├── hls/index.m3u8 # 测试 HLS 清单
│ ├── danmaku.json # 弹幕数据
│ ├── demo.vtt # 字幕
│ └── cover-512.png
└── src/
├── main.js # 入口
├── ui/
│ ├── JsPlayerApp.js # ⭐ 自定义元素 + Shadow DOM
│ └── Controls.js # 控制条 + rVFC 帧循环
├── core/
│ ├── Player.js # ⭐ 状态机 + EventTarget
│ └── MseEngine.js # ⭐ MediaSource + 队列
├── loaders/
│ ├── parseM3u8.js # ⭐ m3u8 字符串解析
│ └── HlsLoader.js # ⭐ 清单 → 分片 → MSE
├── danmaku/
│ ├── Index.js # 桶索引
│ └── Engine.js # ⭐ Canvas + 对象池
└── subtitle/
├── parseVtt.js
└── SubtitleLayer.js
# 10.2 一图看完八阶段成长
阶段 ① <video src=...> ← 30 行
阶段 ② + JsPlayerApp + Shadow DOM ← 100 行
阶段 ③ + Player(状态机) ← +180 行
阶段 ④ + MseEngine(队列) ← +250 行
阶段 ⑤ + parseM3u8 + HlsLoader ← +250 行
阶段 ⑥ + Controls + rVFC 进度条三色 ← +400 行
阶段 ⑦ + Canvas 弹幕(对象池+索引) ← +400 行
阶段 ⑧ + PiP + Media Session + WebVTT ← +200 行
合计 ≈ 1810 行
# 10.3 与上一案例 js
| 维度 | jsfeed | jsplayer |
|---|---|---|
| 数据 | 文本(XML/JSON) | 二进制(Uint8Array) |
| 异步 | Promise | Promise + ReadableStream + rVFC |
| 状态 | 已加载/未加载 | 7 态状态机 + MSE 双重 readyState |
| 错误 | 网络重试 | 媒体管线 reset + 多浏览器降级 |
| UI | 简单列表 | 自定义元素 + Shadow DOM + Canvas |
| 平台 API | fetch / localStorage | MSE / PiP / MediaSession / WebVTT / Fullscreen / rVFC |
# 10.4 这个 demo
| 缺失能力 | 生产怎么补 | 难度 |
|---|---|---|
| ts → fmp4 转封装 | 引入 mux.js | ⭐⭐ |
| 自适应码率(ABR) | 监听 buffered + 网速 → 切 level | ⭐⭐⭐ |
| 加密视频 DRM | EME API + Widevine/FairPlay | ⭐⭐⭐⭐⭐ |
| LL-HLS 低延迟 | partial segment / blocking playlist | ⭐⭐⭐⭐ |
| 弹幕特效 | 礼物/SVGA 动效 | ⭐⭐⭐ |
# 11.项目技术思考
# 11.1 EventTar
很多人初学时纠结"用事件还是状态机"——答案是叠加:
- 状态机 = 描述"我现在在哪"(idle / playing / error)
- EventTarget = 描述"刚刚发生了什么"(statechange / segment / hls-error)
Player 同时是两者:内部用状态机自我约束,对外用事件广播。这正是 jQuery → React → Web Component 三代演进留下的最佳实践。
# 11.2 为什么 MSE
MSE 的难不在接口数量(就 5 个)——而在 3 重异步嵌套:
Promise(fetch) ← ReadableStream(getReader) ← SourceBuffer.updating(事件)
主任务级 协程级 帧级
任意一层错位就出 InvalidStateError / QuotaExceededError。写过 jsplayer 的人,再看 hls.js / shaka 源码会觉得"原来不过如此"。
# 11.3 跨端思考:同一份
这是本案例最深的一层——你写的 Player 类几乎能 1:1 移植到:
| 平台 | 替换什么 | 留下什么 |
|---|---|---|
| 浏览器 | <video> | Player / MseEngine / HlsLoader |
| React Native | <Video> from react-native-video | Player(状态机不变)/ HlsLoader |
| 微信小程序 | <video> 组件 | Player(状态机)/ parseM3u8 |
| Hippy / Kuikly | <video> 跨端组件 | Player + 时间索引 / 弹幕引擎 |
| Electron | 同浏览器 | 整套 |
| Node.js(无头) | ffmpeg-stream | parseM3u8 / DanmakuIndex |
核心层(Player + 状态机 + 解析器)和宿主层(DOM / MSE)严格隔离——这是真跨端工程师的设计直觉。
# 12.衔接与延伸
# 12.1 与下一案例的关系
📚 下一案例:本卷综合案例完结,衔接到第三卷《JS 进阶》——TypeScript 类型系统 / 工程化 / 测试 / 现代工具链。
本案例打下的基础:
| 本案例能力 | 下一阶段如何升级 |
|---|---|
| 自定义元素 + Shadow DOM | TS 给 attribute / property 加类型 |
| Player 状态机 | 上 XState / discriminated union |
| MSE 队列 | RxJS Observable 重写 |
| 对象池弹幕 | 转 OffscreenCanvas + Worker |
# 12.2 三个延伸挑战
⭐ 挑战 A · 基础:把 m3u8 解析做成可解析"主清单"
真实 HLS 是 两层 m3u8:主清单列出多个码率(1080p/720p/480p)→ 每个码率一份子清单。改造
parseM3u8.js让它返回{ master: bool, levels: [...] },识别#EXT-X-STREAM-INF行。
⭐⭐ 挑战 B · 进阶:接 mux.js 真正播放 ts 分片
安装
npm i mux.js——把 ts 字节流先 transmux 成 fmp4 再喂 MSE。 关键:new muxjs.mp4.Transmuxer()→ on('data', fmp4 → mse.appendChunk)
⭐⭐⭐ 挑战 C · 现代化:把弹幕 Engine 搬进 OffscreenCanvas + Worker
主线程负责
currentTime → postMessage,Worker 负责弹幕计算 + 绘制 →transferControlToOffscreen。 收益:主线程零渲染负担,4K 视频上仍稳 60fps——这就是抖音 / B 站新版做的事。
🎉 恭喜!你已经完成了 JS 综合案例的第三关(终极关)。
此时你应该有一个真正能用的 jsplayer——MSE 流水线、迷你 HLS、Canvas 弹幕、PiP、Media Session、WebVTT 全套。这份代码所覆盖的"Web 平台宿主 API 密度"已经超过 99% 的前端项目。
✅ 三大综合案例总成绩单:
01.待办清单事件驱动:JS 语法 + DOM + LocalStorage02.异步订阅流式解析:异步 + 流式 + 限流 + 缓存03.视频播放器自实现:Web Component + MSE + Canvas + 平台 API➡ 下一程:第三卷《JavaScript 进阶》——TypeScript / 工程化 / 测试 / Node.js 后端 / 跨端框架(React Native / 小程序 / Hippy / Kuikly)。
你写下的这套 jsplayer 在那里会被真正打磨成可发布的开源库。