目录
一、西瓜播放器介绍
二、学习任务
三、插件系统和事件机制
四、重要问题
五、学习总结
一、西瓜播放器介绍
西瓜播放器是字节跳动开源的一款带解析器、能节省流量的Web视频播放器类库,它本着一切都是组件化的原则设计了独立可拆卸的 UI 组件。
从底层解析 MP4、HLS、FLV 探索更大的视频播放可控空间。摆脱视频加载、缓冲、格式支持对 video 的依赖。支持清晰度无缝切换、加载控制、节省视频流量。同时,它也集成了对 FLV、HLS、DASH 的点播和直播支持。
点击查看演示页
(图片来源 —— http://h5player.bytedance.com/)
1. 核心功能特色
- 易拓展:灵活的插件体系、PC/移动端自动切换、安全的白名单机制;
- 更丰富:强大的MP4控制、点播的无缝切换、有效的带宽节省;
- 较完整:完整的产品机制、错误的监控上报、自动的降级处理 ;
项目官网:http://h5player.bytedance.com/
开源地址:https://github.com/bytedance/xgplayer
2. 快速上手
上手西瓜播放器只需三个步骤:安装、DOM占位和实例化即可,官方demo如下:
1.安装
$ npm install xgplayer
// 或 CDN 引入
<script src="//cdn.jsdelivr.net/npm/xgplayer/browser/index.js"
type="text/javascript"
></script>
2.DOM 占位
<div id="mse"></div>
3.实例化
let player = new Player({
id: 'mse',
url: '//abc.com/**/*.mp4'
});
即可快速上手西瓜播放器。
二、学习任务
此次学习 西瓜播放器架构设计 的目的如下:
- 提升自己框架设计能力和源码阅读能力;
- 为团队接下来插件化项目实战做基础;
- 理解插件化和事件机制的实际应用;
从官网中对产品主要功能特色的描述,罗列出可以学习的内容,包括:
- 如何设计灵活的插件体系?
- 为什么需要实现 PC /移动端自动切换,如何实现?
- 什么是安全白名单?作用是什么?如何实现?
- 如何有效的节省带宽?点播如何无缝切换?
- 完整的产品机制包括哪些方面?
- 错误监控如何上报?
- 为什么要自动降级,如何降级?
本文主要学习和思考 插件设计及事件机制 相关知识。
三、插件系统和事件机制
首先我们大致了解一下西瓜播放器的插件系统和事件机制架构图,后面详细介绍:
图中每个步骤分别是:
- 引入西瓜播放器框架;
- 注册所有内置插件,并保存在插件对象中;
- 实例化播放器Player,初始化UI和插件;
- 执行 Proxy 构造方法,创建播放器 video 元素;
- 添加 Player 和 Proxy 上的事件监听到事件总线,完成实例化;
- 播放器调用销毁,执行相关销毁任务。
1. 导入插件
在引入西瓜播放器框架时,便开始导入所有插件:
// index.js
import Player from './player'
import * as Controls from './control/*.js' // 导入所有插件
import './style/index.scss'
export default Player
2. 注册插件
以 localPreview
插件为例(control\localPreview.js ),注册流程如下:
插件中引入 Player
对象,实现完插件功能,通过调用 Player.install
方法,注册插件。
// localPreview.js
import Player from '../player'
let localPreview = function () {
// ...
}
Player.install('localPreview', localPreview) // 执行注册
这样即可注册一个插件,接着了解下 Player.install
是如何实现:
// player.js 447行开始
static install (name, descriptor) {
if (!Player.plugins) {
Player.plugins = {}
}
Player.plugins[name] = descriptor // 保存插件
}
在注册插件时,接收两个参数 name
和 descriptor
,对应之前 Player.install('localPreview', localPreview)
的两个参数。
接着在 Player.plugin
对象上,将 name
作为 key
, descriptor
作为 value
进行保存。
这样就将插件保存到 Player.plugins
对象中。
3. 初始化插件
插件注册完成后,保存到 Player.plugins
对象中,在播放器实例化时,会进行插件初始化。并且西瓜播放器提供 ignores
配置项,来让开发者可以过滤指定内置插件不初始化:
// player.js
// 初始化读取合并配置
this.config = util.deepCopy({
// ...
ignores: [], // 需要过滤不初始化的内置插件
// ...
}, options)
pluginsCall () {
// ...
if (Player.plugins) {
let ignores = this.config.ignores
Object.keys(Player.plugins).forEach(name => {
let descriptor = Player.plugins[name] // 获取每一个插件的描述方法
if (!ignores.some(item => name === item)) { // 插件过滤操作
// 初始化插件,执行插件的描述方法,进行初始化
if (['pc', 'tablet', 'mobile'].some(type => type === name)) {
if (name === sniffer.device) {
setTimeout(() => {
descriptor.call(self, self)
}, 0)
}
} else {
descriptor.call(this, this)
}
}
})
}
}
4. 插件生命周期
在研究 插件生命周期 中,本节以“播放器贴图(poster)”内置插件为例介绍,该插件使用方法如下。
// poster.js
let player = new Player({
el:document.querySelector('#mse'),
url: 'video_url',
poster: "//s2.pstatp.com/cdn/expire-1-M/byted-player-videos/1.0.0/poster.jpg",
});
“播放器贴图(poster)”插件的实现原理:插件基于 EventEmitter
事件机制,通过监听播放器的 play
事件来隐藏 poster
播放器贴图,并通过监听播放器的 destroy
事件实现事件移除(包含 play
事件和 destory
)。
一个好的插件系统设计,对于插件生命周期包含以下四个模版方法:创建 DOM 元素,注册事件监听,移除事件监听,销毁 DOM 元素:
4.1 创建 DOM 元素
从源码可知,该插件了 DOM 元素 <xg-poster>
播放器贴图元素,并将播放器实例化时传入的配置项 poster
字段的值作为 <xg-poster>
元素的背景图 url
。
// poster.js
let poster = util.createDom('xg-poster', '', {}, 'xgplayer-poster');
let root = player.root
if (player.config.poster) {
poster.style.backgroundImage = `url(${player.config.poster})`
root.appendChild(poster)
}
4.2 注册事件监听
在插件中利用 EventEmitter
事件机制注册了 play
方法:
// poster.js
function playFunc () {
poster.style.display = 'none'
}
player.on('play', playFunc)
4.3 移除事件监听
在插件中,对 destroy
进行了一次性事件监听( once
),用来监听事件移除:
// poster.js
function destroyFunc () {
player.off('play', playFunc)
player.off('destroy', destroyFunc)
}
player.once('destroy', destroyFunc)
通过定义 destroyFunc
方法,在内部调用播放器 off
方法来移除 EventEmitter
的事件监听器。
4.4 销毁 DOM 元素
在西瓜播放器的插件中,没有看到销毁 DOM 元素的操作,考虑到插件生命周期的完整性,可参考 Player 实例实现的销毁播放器的方法,下面按照我个人思考进行修改源码。
考虑到 poster
插件中当 player.config.poster
存在时,才会在播放器插入 poster
的 DOM 元素。
// rotate.js
if (player.config.poster) {
poster.style.backgroundImage = `url(${player.config.poster})`
root.appendChild(poster)
}
所以在修改 destroyFunc
方法时也需要考虑该情况,即修改后如下:
// rotate.js
function destroyFunc () {
player.off('play', playFunc)
player.off('destroy', destroyFunc)
if (player.config.poster) {
root.removeChild(poster) // 移除 root 上的 poster 元素
}
}
player.once('destroy', destroyFunc)
5. 自定义插件
西瓜播放器中,可以理解为一切功能皆为插件。
当西瓜播放器内置插件不满足我们的业务时,我们可以自定义一个插件,只需两步操作:
5.1 定义插件
在 /control/
目录下新建一个插件文件(如 MyPlugin.js),并实现插件功能。
这里以一个很简单的例子演示,当播放器实例化时,传入参数 alertMsg
并指定参数值为一个字符串,实现调用插件会全局提示一个 alert
框,并展示参数指定的字符串。
// MyPlugin.js
import Player from 'xgplayer';
let MyPlugin=function(){
// 实现插件功能 如定义一个
if (player.config.alertMsg) {
alert(alertMsg);
}
}
Player.install('MyPlugin',MyPlugin);
建议设计自定义插件时,也参考 1.4插件生命周期 进行设计。
5.2 使用插件
使用插件时,需要知道,在导入西瓜播放器框架时,就已经一起导入插件了:
import * as Controls from './control/*.js' // 导入所有插件
和使用其他插件一样:
// 业务代码文件
import Player from 'xgplayer';
let player = new Player({
id: 'xg',
url: 'my_video_url.mp4',
alertMsg: '你好,西瓜播放器!'
})
6. 事件机制介绍
在西瓜播放器中,插件通信机制通过 事件总线 实现事件驱动。
插件中有 EventEmitter
和 JS EventListener
两类事件。
6.1 插件内部事件机制
通过 插件内部事件机制 实现插件间通信,参照上图左侧部分。
- 事件注册监听:
在播放器实例化时,会在插件初始化过程中,将插件中的事件注册到事件总线上(插件通过 on
/ once
方法注册到 EventEmitter
,插件也可以通过 addEventListener
方法注册到 JS EventListener
)。
// poster.js
player.on('play', playFunc)
player.once('destroy', destroyFunc)
// rotate.js
btn.addEventListener('click',() => { player.rotate() })
- 事件移除监听:
在 destroy
过程中,也会从事件总线中进行移除事件监听( off
通过 EventEmitter
移除监听, removeEventListener
通过 JS EventListener
移除监听)。
// poster.js
player.off('play', playFunc)
player.off('destroy', destroyFunc)
// volume.js
window.removeEventListener('mousemove', move)
window.removeEventListener('touchmove', move)
window.removeEventListener('mouseup', up)
window.removeEventListener('touchend', up)
- 业务逻辑触发事件监听:
在业务逻辑中触发,EventEmitter
使用 player.emit
触发事件。
// rotate.js
player.emit('rotate', rotateDeg * 360)
6.2 播放器内部事件机制
将播放器中的事件分 Player
事件和 Proxy
事件,参照上图右侧部分。
- 事件注册监听:
在播放器实例化时,批量将实例中的事件注册到事件总线上(通过 on
/ once
方法注册到 EventEmitter
,通过 addEventListener
注册到 JS EventListener
)。
// proxy.js
// 定义事件名称
this.ev = ['play', 'playing', /* 省略其他方法 */
].map((item) => {
return {
[item]: `on${item.charAt(0).toUpperCase()}${item.slice(1)}`
}
})
// player.js 监听事件
this.ev.forEach((item) => {
let evName = Object.keys(item)[0]
let evFunc = this[item[evName]]
if (evFunc) {
this.on(evName, evFunc)
}
});
// proxy.js 监听事件
this.ev.forEach(item => {
self.evItem = Object.keys(item)[0]
let name = Object.keys(item)[0]
self.video.addEventListener(Object.keys(item)[0], function () {
// 省略
})
})
- 事件移除监听:
在 destroy
过程中,也会从事件总线中进行移除事件监听(通过 off
方法从 EventEmitter
移除,通过 removeEventListener
方法从 JS EventListener
中移除。
// player.js
destroy (isDelDom = true) {
// ...
// 移除单个事件监听
if(this.playFunc) {
this.off('play', this.playFunc)
}
// 批量移除事件监听
['focus', 'blur'].forEach(item => {
this.off(item, this['on' + item.charAt(0).toUpperCase() + item.slice(1)])
})
}
7. 播放器销毁
西瓜播放器中提供一个 destroy
实例方法,用于销毁播放器。在 destroy
方法中,主要做了几件事:
- 移除定时器相关;
- 移除事件监听相关(EventEmitter、JS事件);
- 移除DOM相关;
- 移除实例对象属性相关;
下面简要贴出一些代码:
// player.js
destroy (isDelDom = true) {
// 遍历移除定时器相关
for (let k in this._interval) {
clearInterval(this._interval[k])
this._interval[k] = null
}
// 遍历移除EventEmitter事件监听
this.ev.forEach((item) => {
if (evFunc) {
this.off(evName, evFunc)
}
});
// 省略,移除部指定事件
// 遍历移除 addEventListener 事件监听
['video', 'controls'].forEach(item => {
if (this[item]) {
this[item].removeEventListener('keydown', /*...*/)
}
})
// 销毁播放器 DOM 结构
if (isDelDom) {
parentNode.removeChild(this.root)
}
// 省略移除 this 所有属性
}
四、重要问题
1. 西瓜播放器的 video 元素如何创建?
在源码中,西瓜播放器的 video
元素是在 proxy.js
构造函数中初始化:
// proxy.js
this.video = util.createDom(
this.videoConfig.mediaType,
textTrackDom,
this.videoConfig,
''
)
在后面代码中,都将 video
事件挂载到 this.video
对象上。
比如我们点击播放按钮的时候,实际上也是调用了 this.video
对象上的 play()
方法:
// player.js
start (url = this.config.url) {
// 省略其他代码
this.canPlayFunc = function () {
let playPromise = player.video.play()
}
}
2. 各个插件如何跟主播放器关联起来?
核心是 Player 通过继承 Proxy 类,Proxy 又通过 EventEmitter.call
实现构造继承。然后每个插件内部都注入 Player 类的实例,然后利用 EventEmitter 的 API 来实现事件驱动。
① Player 类继承 Proxy 类:
// player.js
class Player extends Proxy {
// ...
}
② Proxy 通过 EventEmitter.call 实现构造继承:
// proxy.js 78行
EventEmitter(this)
③ 插件注入 Player 类的实例,通过 EventEmitter API 实现事件驱动:
这里以 play 插件为例(control/play.js)。
// proxy.js 84行
player.on('play', playFunc)
// ...
player.on('pause', pauseFunc)
五、知识点总结
1. 插件可插拔
在设计插件系统,可以考虑插件的可插拔性。使插件系统更灵活,易拓展,优化用户体验和提升加载速度。
插件可插拔一般分为:热插拔和冷插拔。
1.1 热插拔
一般情况下,热插拔的插件是在打包阶段就打进整体的包里面,实现方式介绍:
- 西瓜播放器过滤插件
西瓜播放器通过 ignores
配置项支持热插拔,使得我们可以在使用阶段,可以通过 ignores
数组来过滤不需要使用的内置插件:
// player.js
pluginsCall () {
// ... 省略其他代码
let ignores = this.config.ignores // 获取需要过滤的内置插件数组
Object.keys(Player.plugins).forEach(name => {
let descriptor = Player.plugins[name]
if (!ignores.some(item => name === item)) { // 执行过滤操作
// 初始化插件
}
})
}
弊端:全量打包时包体积较大,当开发者明确不需要使用某些内置插件时,插件还是全量插件,占用插件包的体积。
比如:为了使用获取DOM元素的方法或使用Ajax请求的方法而将整个jQuery引入项目。
- 动态导入模块
使用 import()
作为函数调用,将其作为参数传递给模块的路径。 它返回一个promise
,它用一个模块对象来实现,让我们可以访问该对象的导出。
let loadModule = document.querySelector('.load');
loadModule.addEventListener('click', () => {
import('/modules/leo.js').then((Module) => {
loadModule.draw();
})
});
详细介绍可以查看《动态加载模块》。
使用场景:在实现页面懒加载时,可以采用,当加载项目时,不会完整加载,而是等到进入某些指定页面,才会去加载相应的模块文件。
弊端:动态加载模块时,比较影响用户体验,如果模块比较大,加载偏慢,影响体验。
1.2 冷插拔
即在插件包打包时,就将需要支持的插件一起打包进去,并且不提供过滤插件的方法。实现方式有:
- 通过 Webpack DefinePlugin 配置
通过 Webpack DefinePlugin
读取打包配置,可以打轻量版或完整版的包,比较灵活,如果轻量版功能不够,可以再根据需要打增量的轻量版覆盖。
// plugin.js
const litePlugin = [ module1, module2, module9 ];
const fullPlugin = [ module1, module2, ..., module9 ];
const plugin = {litePlugin, fullPlugin};
const version = pluginVersion;
export default plugin[version];
// webpack.lite.js
plugins: [
// ...
new webpack.DefinePlugin({
'pluginVersion': JSON.stringify("litePlugin"),
})
]
// webpack.full.js
plugins: [
// ...
new webpack.DefinePlugin({
'pluginVersion': JSON.stringify("fullPlugin"),
})
]
2. 插件生命周期和抽象类
设计插件时,考虑在抽象类中定义插件生命周期,在实际插件开发中去实现生命周期的方法。
实际上就是在插件层上,添加一层通用插件层,用来定义需要实现的插件方法。
这样有几个好处:
- 抽象类中统一对所有插件进行操作,便于管理;
- 规定相同开发模式,降低开发难度;
简单实例:
// Plugin.ts
abstract class EFTPlugin{
constructor(){}
abstract creatDOM():void
abstract addEventListener():void
abstract removeEventListener():void
abstract destoryDOM():void
}
// TestPlugin.ts
class TestPlugin extends EFTPlugin{
constructor(){
super()
}
creatDOM(){
// ...
}
addEventListener(){
// ...
}
removeEventListener(){
// ...
}
destoryDOM(){
// ...
}
}
const testPlugin = new TestPlugin()
testPlugin.creatDOM();
testPlugin.addEventListener();
testPlugin.removeEventListener();
testPlugin.destoryDOM();
export default testPlugin;
3. querySelector 兼容处理
参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991
querySelector
方法使用 CSS3 选择器,用来查找DOM。CSS3 中的 ID 选择器不支持以数字开头,需要降级使用 getElementById
。
// utils\util.js 代码第62行开始
util.findDom = function (el = document, sel) {
let dom
// fix querySelector IDs that start with a digit
// https://stackoverflow.com/questions/37270787/uncaught-syntaxerror-failed-to-execute-queryselector-on-document
try {
dom = el.querySelector(sel)
} catch (e) {
if (sel.startsWith('#')) {
dom = el.getElementById(sel.slice(1))
}
}
return dom
}
4.批量导入指定目录下的文件
参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991
在西瓜播放器项目中,通过import * as Controls from './control/*.js'
语句实现批量导入播放器的所有内置插件:
// index.js
import Player from './player'
import * as Controls from './control/*.js'
import './style/index.scss'
export default Player
实现批量导入指定目录下的文件,有两种方式:
- 借助 babel-plugin-bulk-import 插件实现;
- 借助 Webpack
require.context
API 来实现;
4.1 babel-plugin-bulk-import
这是一款 Babel 插件,用于批量导入。
安装:
npm install babel-plugin-bulk-import --save-dev
配置 .babelrc
:
{
"presets": ["es2015"],
"plugins": ["babel-plugin-bulk-import"]
}
使用:
import * as Controls from './control/*.js'
4.2 Webpack require.context
这是一个 webpack 的 api,通过执行 require.context
函数获取一个特定的上下文,主要用来实现自动化导入模块。
在前端工程化中,如果遇到从一个文件夹引入多个模块时,可以使用这个api,它会遍历文件夹中的指定文件,然后自动导入使得不需要每次显式的调用 import
导入模块。
另外,还有插件 babel-plugin-bulk-import 可以使用。
5. 解决 video 销毁时引起的浏览器奔溃问题
在源码中,注释了这么一个地址:
https://stackoverflow.com/questions/3258587/how-to-properly-unload-destroy-a-video-element
其中介绍的大概是为了解决 video 销毁时引起的浏览器奔溃问题:
**
var videoElement = document.getElementById('id_of_the_video_element_here');
videoElement.pause();
videoElement.removeAttribute('src'); // empty source
videoElement.load();
另外一篇文章中也介绍到这个情况:
https://html.spec.whatwg.org/multipage/media.html#best-practices-for-authors-using-media-elements
推荐一篇文章:《如何解决内存泄漏引发的血案》
6. 前端本地视频预览实现
参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991
6.1 源码解读
在西瓜播放器中,视频本地预览插件的核心代码如下:
// localPreview.js
let localPreview = function () {
let player = this; let util = Player.util
// 动态创建上传按钮
let preview = util.createDom('xg-preview', '<input type="file">', {}, 'xgplayer-preview')
let upload = preview.querySelector('input')
if (player.config.preview && player.config.preview.uploadEl) {
player.config.preview.uploadEl.appendChild(preview)
// 监听上传按钮变化的事件
upload.onchange = function () {
player.uploadFile = upload.files[0]
// 通过调用 URL.createObjectURL() 创建 URL 对象
// 建议:当结束使用这个 URL 对象时,使用 window.URL.revokeObjectURL(objectURL) ,
// 来释放这个 URL 对象对文件的引用。
let url = URL.createObjectURL(player.uploadFile)
if (util.hasClass(player.root, 'xgplayer-nostart')) {
player.config.url = url
player.start()
} else {
player.src = url
player.play()
}
}
}
}
Player.install("localPreview", localPreview);
6.2 实现原理
在西瓜播放器中,视频本地预览主要利用 URL.createObjectURL() API 实现。
实现过程如下:
- 创建上传按钮,通过
<input type="file">
获取本地 file 对象; - 将 File 对象通过
URL.createObjectURL()
方法转换为ObjectURL
地址; - 设置指定
video
标签的src
属性值为视频本地的ObjectURL
地址;
URL.createObjectURL()
静态方法将创建一个 DOMString
,参数是一个用于创建 URL 的 File
对象、Blob
对象或者 MediaSource
对象。最终返回一个DOMString
包含了一个对象URL,该URL可用于指定源 object
的内容。
下面整理一个非常简单的例子:
<input type="file" name="" id="">
<video src="" id="mini"></video>
<script>
let file = document.querySelector('input');
file.onchange = function(){
let url = URL.createObjectURL(file.files[0]);
let mini = document.getElementById("mini");
mini.setAttribute("src", url);
mini.play()
}
</script>
6.3 内存管理
在每次调用 createObjectURL()
方法时,都会创建一个新的 URL 对象。但是,当我们不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL()
方法来释放。
浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,建议应该在安全的时机主动释放掉它们。
6.4 拓展:图片预览
目前常见的前端本地图片预览实现方式有两种:通过 FileReader.readAsDataURL
或 URL.createObjectURL
的方式来实现。
readAsDataURL
方法会读取指定 Blob
或 File
对象。读取完成时,readyState
会变成已完成DONE
,并触发 [loadend]()
事件,同时 result
属性将包含一个data:
URL格式的字符串(base64编码)以表示所读取文件的内容。
<input type="file" onchange="previewFile()"><br>
<img src="" height="200" alt="Image preview...">
<script>
function previewFile() {
let preview = document.querySelector('img');
let file = document.querySelector('input[type=file]').files[0];
let reader = new FileReader();
reader.addEventListener("load", function () {
preview.src = reader.result;
}, false);
if (file) {
reader.readAsDataURL(file);
}
}
</script>
两者区别:
区别内容 | FileReader.readAsDataURL | URL.createObjectURL |
---|---|---|
是否同步 | 异步执行 | 同步执行 |
内存使用 | 返回 base64 格式字符串,比 Blob URL 方式更占内存空间,当不需要使用时,可通过系统垃圾回收机制自动进行回收。 | 返回本地 URL 地址对象,并一直保存在内存中,直到文档触发 unload 事件或者手动调用 revokeObjectURL。 |
兼容性 | 支持 IE 10 以上的主流浏览器,详细的兼容性查看 caniuse - createObjectURL。 | 支持 IE 10 以上的主流浏览器,详细兼容性查看 caniuse - readAsDataURL |
6.5 URL.createObjectURL 特性检测
function createObjectURL (file) {
if (window.webkitURL) {
return window.webkitURL.createObjectURL(file);
} else if (window.URL && window.URL.createObjectURL) {
return window.URL.createObjectURL(file);
} else {
return null;
}
}
7. 前端文件下载实现
参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991
在西瓜播放器中,场景如截图下载。
7.1 a标签 + initMouseEvent
使用 a
标签 + initMouseEvent
方法实现下载:
let saveScreenShot = function (data, filename) {
let saveLink = document.createElement('a')
saveLink.href = data
saveLink.download = filename
let event = document.createEvent('MouseEvents')
event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false,
false, false, 0, null)
saveLink.dispatchEvent(event)
}
7.2 a标签 + createObjectURL
使用 a
标签 + createObjectURL 方法实现下载:
let saveScreenShot = function (blob, filename) {
const a = document.createElement('a');
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}
8.播放器截图实现
参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991
8.1 原理分析
西瓜播放器截图流程如下:
- 创建截屏按钮并添加到播放器控制栏中;
- 创建 Canvas 元素和一个 Image 实例;
- 为截屏按钮绑定事件监听,如
click
和touchstart
等事件; - 当用户点击触发截屏,在回调函数中通过
drawImage()
方法截取当前视频帧,完成截图。
对于播放器截图功能,主要利用 CanvasRenderingContext2D.drawImage() API 来实现。 drawImage()
方法提供多种方式在 Canvas 上绘制图片,语法如下:
void ctx.drawImage(image, dx, dy); void ctx.drawImage(image, dx, dy, dWidth, dHeight); void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
详细参数可以查看文档 CanvasRenderingContext2D.drawImage() 。
8.2 Image 元素跨域
在 HTML5 中,一些 HTML 元素提供了对 CORS 的支持, 例如 audio
、image
、link
、script
和 video
均有一个跨域属性(crossOrigin property),它允许你配置元素获取数据的 CORS 请求。
这些属性是枚举的,并具有以下可能的值:
关键字 | 描述 |
---|---|
anonymous |
对此元素的 CORS 请求将不设置凭据标志。 |
use-credentials |
对此元素的 CORS 请求将设置凭证标志;这意味着请求将提供凭据。 |
"" |
设置一个空的值,如 crossorigin 或 crossorigin="" ,和设置 anonymous 的效果一样。 |
默认情况下(即未指定 crossOrigin 属性时),CORS 根本不会使用。如 Terminology section of the CORS specification 中的描述,在非同源情况下,设置 “anonymous” 关键字将不会通过 cookies,客户端 SSL 证书或 HTTP 认证交换用户凭据。即使是无效的关键字和空字符串也会被当作 anonymous
关键字使用。
参考资源 —— CORS_settings_attributes
使用案例:
<script src="https://example.com/example-framework.js" crossorigin="anonymous"></script>
8.3 生成图片地址
这里主要使用 HTMLCanvasElement.toDataURL()
方法生成,返回一个包含图片展示的data URI。可以使用 type
参数其类型,默认为 PNG 格式。图片的分辨率为96dpi。
- 如果画布的高度或宽度是0,那么会返回字符串“
data:,”。
- 如果传入的类型非“
image/png
”,但是返回的值以“data:image/png
”开头,那么该传入的类型是不支持的。 - Chrome支持“
image/webp
”类型。
语法如下:
canvas.toDataURL(type, encoderOptions);
type
可选,图片格式,默认为image/png
encoderOptions
可选,在指定图片格式为image/jpeg
或image/webp
的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92。其他参数会被忽略。
代码演示:<canvas>
元素:
<canvas id="canvas" width="5" height="5"></canvas>
获取一个 data-URL:
var canvas = document.getElementById("canvas");
var dataURL = canvas.toDataURL();
console.log(dataURL);
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
设置 jpegs 图片的质量:
var fullQuality = canvas.toDataURL("image/jpeg", 1.0);
// data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...9oADAMBAAIRAxEAPwD/AD/6AP/Z"
var mediumQuality = canvas.toDataURL("image/jpeg", 0.5);
var lowQuality = canvas.toDataURL("image/jpeg", 0.1);
8.4 下载图片
下载图片的实现,可以查看【第7点 前端文件下载实现】。
9. 获取 HTMLMediaElement 元素状态
9.1 网络状态
文档地址:https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLMediaElement/networkState
HTMLMediaElement.networkState
属性表示在网络上获取媒体的当前状态。语法如下:
const networkState = audioOrVideo.networkState;
返回值是一个 unsigned short。可能的值包括:
常量 | 值 | 描述 |
---|---|---|
NETWORK_EMPTY |
0 | 还没有数据。并且 readyState 的值是 HAVE_NOTHING 。 |
NETWORK_IDLE |
1 | HTMLMediaElement 是有效的并且已经选择了一个资源,,但是还没有使用网络。 |
NETWORK_LOADING |
2 | 浏览器正在下载 HTMLMediaElement 数据。 |
NETWORK_NO_SOURCE |
3 | 没有找到 HTMLMediaElement src。 |
代码演示:
这个例子监听audio元素以开始播放,然后检查是否仍然在加载数据。
<audio id="example" preload="auto">
<source src="sound.ogg" type="audio/ogg" />
</audio>
const obj = document.getElementById('example');
obj.addEventListener('playing', function() {
if (obj.networkState === 2) {
// Still loading...
}
});
西瓜播放器中的使用方式:
// proxy.js
get networkState () {
let status = [{
en: 'NETWORK_EMPTY',
cn: '音频/视频尚未初始化'
}, {
en: 'NETWORK_IDLE',
cn: '音频/视频是活动的且已选取资源,但并未使用网络'
}, {
en: 'NETWORK_LOADING',
cn: '浏览器正在下载数据'
}, {
en: 'NETWORK_NO_SOURCE',
cn: '未找到音频/视频来源'
}]
return this.lang ? this.lang[status[this.video.networkState].en]
: status[this.video.networkState].en
}
9.2 就绪状态
文档地址:https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLMediaElement/readyState
HTMLMediaElement.readyState
属性返回音频/视频的当前就绪状态。语法如下:
const readyState = audioOrVideo.readyState;
返回值是一个无符号整型 An unsigned short。可能的值包括:
Constant | Value | Description |
---|---|---|
HAVE_NOTHING | 0 | 没有关于音频/视频是否就绪的信息 |
HAVE_METADATA | 1 | 音频/视频已初始化 |
HAVE_CURRENT_DATA | 2 | 数据已经可以播放(当前位置已经加载) 但没有数据能播放下一帧的内容 |
HAVE_FUTURE_DATA | 3 | 当前及至少下一帧的数据是可用的(换句话来说至少有两帧的数据) |
HAVE_ENOUGH_DATA | 4 | 可用数据足以开始播放-如果网速得到保障 那么视频可以一直播放到底 |
代码演示:
这个例子会监听id为example的 audio 的数据. 他会检查当前位置是否可以播放, 会的话执行播放。
<audio id="example" preload="auto">
<source src="sound.ogg" type="audio/ogg" />
</audio>
const obj = document.getElementById('example');
obj.addEventListener('loadeddata', function() {
if(obj.readyState >= 2) {
obj.play();
}
});
西瓜播放器中的使用方式:
get readyState () {
let status = [{
en: 'HAVE_NOTHING',
cn: '没有关于音频/视频是否就绪的信息'
}, {
en: 'HAVE_METADATA',
cn: '关于音频/视频就绪的元数据'
}, {
en: 'HAVE_CURRENT_DATA',
cn: '关于当前播放位置的数据是可用的,但没有足够的数据来播放下一帧/毫秒'
}, {
en: 'HAVE_FUTURE_DATA',
cn: '当前及至少下一帧的数据是可用的'
}, {
en: 'HAVE_ENOUGH_DATA',
cn: '可用数据足以开始播放'
}]
return this.lang ? this.lang[status[this.video.readyState].en]
: status[this.video.readyState]
}