西瓜播放器(xgplayer)架构设计学习笔记——插件设计篇 - 图2

目录

一、西瓜播放器介绍
二、学习任务
三、插件系统和事件机制
四、重要问题
五、学习总结

一、西瓜播放器介绍

西瓜播放器是字节跳动开源的一款带解析器能节省流量的Web视频播放器类库,它本着一切都是组件化的原则设计了独立可拆卸的 UI 组件。
从底层解析 MP4、HLS、FLV 探索更大的视频播放可控空间。摆脱视频加载、缓冲、格式支持对 video 的依赖。支持清晰度无缝切换、加载控制、节省视频流量。同时,它也集成了对 FLV、HLS、DASH 的点播和直播支持。

image.png
点击查看演示页
(图片来源 —— http://h5player.bytedance.com/)

**

1. 核心功能特色

  • 易拓展:灵活的插件体系、PC/移动端自动切换、安全的白名单机制;
  • 更丰富:强大的MP4控制、点播的无缝切换、有效的带宽节省;
  • 较完整:完整的产品机制、错误的监控上报、自动的降级处理 ;

image.png

项目官网:http://h5player.bytedance.com/
开源地址:https://github.com/bytedance/xgplayer

2. 快速上手

西瓜播放器快速上手.png

上手西瓜播放器只需三个步骤:安装、DOM占位和实例化即可,官方demo如下:

1.安装

  1. $ npm install xgplayer
  2. // CDN 引入
  3. <script src="//cdn.jsdelivr.net/npm/xgplayer/browser/index.js"
  4. type="text/javascript"
  5. ></script>

2.DOM 占位

  1. <div id="mse"></div>

3.实例化

  1. let player = new Player({
  2. id: 'mse',
  3. url: '//abc.com/**/*.mp4'
  4. });

即可快速上手西瓜播放器。

二、学习任务

此次学习 西瓜播放器架构设计 的目的如下:

  1. 提升自己框架设计能力和源码阅读能力;
  2. 为团队接下来插件化项目实战做基础;
  3. 理解插件化和事件机制的实际应用;

从官网中对产品主要功能特色的描述,罗列出可以学习的内容,包括:

  1. 如何设计灵活的插件体系?
  2. 为什么需要实现 PC /移动端自动切换,如何实现?
  3. 什么是安全白名单?作用是什么?如何实现?
  4. 如何有效的节省带宽?点播如何无缝切换?
  5. 完整的产品机制包括哪些方面?
  6. 错误监控如何上报?
  7. 为什么要自动降级,如何降级?

本文主要学习和思考 插件设计及事件机制 相关知识。

三、插件系统和事件机制

首先我们大致了解一下西瓜播放器的插件系统事件机制架构图,后面详细介绍:
西瓜播放器架构设计图2 (1).png

图中每个步骤分别是:

  1. 引入西瓜播放器框架;
  2. 注册所有内置插件,并保存在插件对象中;
  3. 实例化播放器Player,初始化UI和插件;
  4. 执行 Proxy 构造方法,创建播放器 video 元素;
  5. 添加 Player 和 Proxy 上的事件监听到事件总线,完成实例化;
  6. 播放器调用销毁,执行相关销毁任务。

1. 导入插件

在引入西瓜播放器框架时,便开始导入所有插件:

  1. // index.js
  2. import Player from './player'
  3. import * as Controls from './control/*.js' // 导入所有插件
  4. import './style/index.scss'
  5. export default Player

2. 注册插件

localPreview 插件为例(control\localPreview.js ),注册流程如下:
插件注册.png

插件中引入 Player 对象,实现完插件功能,通过调用 Player.install 方法,注册插件。

  1. // localPreview.js
  2. import Player from '../player'
  3. let localPreview = function () {
  4. // ...
  5. }
  6. Player.install('localPreview', localPreview) // 执行注册

这样即可注册一个插件,接着了解下 Player.install 是如何实现:

  1. // player.js 447行开始
  2. static install (name, descriptor) {
  3. if (!Player.plugins) {
  4. Player.plugins = {}
  5. }
  6. Player.plugins[name] = descriptor // 保存插件
  7. }

在注册插件时,接收两个参数 namedescriptor ,对应之前 Player.install('localPreview', localPreview) 的两个参数。
接着在 Player.plugin 对象上,将 name 作为 keydescriptor 作为 value 进行保存。
这样就将插件保存到 Player.plugins 对象中。

3. 初始化插件

插件注册完成后,保存到 Player.plugins 对象中,在播放器实例化时,会进行插件初始化。并且西瓜播放器提供 ignores 配置项,来让开发者可以过滤指定内置插件不初始化

  1. // player.js
  2. // 初始化读取合并配置
  3. this.config = util.deepCopy({
  4. // ...
  5. ignores: [], // 需要过滤不初始化的内置插件
  6. // ...
  7. }, options)
  8. pluginsCall () {
  9. // ...
  10. if (Player.plugins) {
  11. let ignores = this.config.ignores
  12. Object.keys(Player.plugins).forEach(name => {
  13. let descriptor = Player.plugins[name] // 获取每一个插件的描述方法
  14. if (!ignores.some(item => name === item)) { // 插件过滤操作
  15. // 初始化插件,执行插件的描述方法,进行初始化
  16. if (['pc', 'tablet', 'mobile'].some(type => type === name)) {
  17. if (name === sniffer.device) {
  18. setTimeout(() => {
  19. descriptor.call(self, self)
  20. }, 0)
  21. }
  22. } else {
  23. descriptor.call(this, this)
  24. }
  25. }
  26. })
  27. }
  28. }

4. 插件生命周期

在研究 插件生命周期 中,本节以“播放器贴图(poster)”内置插件为例介绍,该插件使用方法如下。

  1. // poster.js
  2. let player = new Player({
  3. el:document.querySelector('#mse'),
  4. url: 'video_url',
  5. poster: "//s2.pstatp.com/cdn/expire-1-M/byted-player-videos/1.0.0/poster.jpg",
  6. });

“播放器贴图(poster)”插件的实现原理:插件基于 EventEmitter 事件机制,通过监听播放器的 play 事件来隐藏 poster 播放器贴图,并通过监听播放器的 destroy 事件实现事件移除(包含 play 事件和 destory )。

一个好的插件系统设计,对于插件生命周期包含以下四个模版方法:创建 DOM 元素,注册事件监听,移除事件监听,销毁 DOM 元素

插件生命周期.png

4.1 创建 DOM 元素

从源码可知,该插件了 DOM 元素 <xg-poster> 播放器贴图元素,并将播放器实例化时传入的配置项 poster 字段的值作为 <xg-poster> 元素的背景图 url

  1. // poster.js
  2. let poster = util.createDom('xg-poster', '', {}, 'xgplayer-poster');
  3. let root = player.root
  4. if (player.config.poster) {
  5. poster.style.backgroundImage = `url(${player.config.poster})`
  6. root.appendChild(poster)
  7. }

4.2 注册事件监听

在插件中利用 EventEmitter 事件机制注册了 play 方法:

  1. // poster.js
  2. function playFunc () {
  3. poster.style.display = 'none'
  4. }
  5. player.on('play', playFunc)

4.3 移除事件监听

在插件中,对 destroy 进行了一次性事件监听( once ),用来监听事件移除:

  1. // poster.js
  2. function destroyFunc () {
  3. player.off('play', playFunc)
  4. player.off('destroy', destroyFunc)
  5. }
  6. player.once('destroy', destroyFunc)

通过定义 destroyFunc 方法,在内部调用播放器 off 方法来移除 EventEmitter 的事件监听器。

4.4 销毁 DOM 元素

在西瓜播放器的插件中,没有看到销毁 DOM 元素的操作,考虑到插件生命周期的完整性,可参考 Player 实例实现的销毁播放器的方法,下面按照我个人思考进行修改源码。

考虑到 poster 插件中当 player.config.poster 存在时,才会在播放器插入 poster 的 DOM 元素。

  1. // rotate.js
  2. if (player.config.poster) {
  3. poster.style.backgroundImage = `url(${player.config.poster})`
  4. root.appendChild(poster)
  5. }

所以在修改 destroyFunc 方法时也需要考虑该情况,即修改后如下:

  1. // rotate.js
  2. function destroyFunc () {
  3. player.off('play', playFunc)
  4. player.off('destroy', destroyFunc)
  5. if (player.config.poster) {
  6. root.removeChild(poster) // 移除 root 上的 poster 元素
  7. }
  8. }
  9. player.once('destroy', destroyFunc)

5. 自定义插件

西瓜播放器中,可以理解为一切功能皆为插件。
当西瓜播放器内置插件不满足我们的业务时,我们可以自定义一个插件,只需两步操作:

5.1 定义插件

/control/ 目录下新建一个插件文件(如 MyPlugin.js),并实现插件功能。
这里以一个很简单的例子演示,当播放器实例化时,传入参数 alertMsg 并指定参数值为一个字符串,实现调用插件会全局提示一个 alert 框,并展示参数指定的字符串。

  1. // MyPlugin.js
  2. import Player from 'xgplayer';
  3. let MyPlugin=function(){
  4. // 实现插件功能 如定义一个
  5. if (player.config.alertMsg) {
  6. alert(alertMsg);
  7. }
  8. }
  9. Player.install('MyPlugin',MyPlugin);

建议设计自定义插件时,也参考 1.4插件生命周期 进行设计。

5.2 使用插件

使用插件时,需要知道,在导入西瓜播放器框架时,就已经一起导入插件了:

  1. import * as Controls from './control/*.js' // 导入所有插件

和使用其他插件一样:

  1. // 业务代码文件
  2. import Player from 'xgplayer';
  3. let player = new Player({
  4. id: 'xg',
  5. url: 'my_video_url.mp4',
  6. alertMsg: '你好,西瓜播放器!'
  7. })

6. 事件机制介绍

在西瓜播放器中,插件通信机制通过 事件总线 实现事件驱动。
插件中有 EventEmitterJS EventListener 两类事件。

事件机制.png

6.1 插件内部事件机制

通过 插件内部事件机制 实现插件间通信,参照上图左侧部分。

  • 事件注册监听:

在播放器实例化时,会在插件初始化过程中,将插件中的事件注册到事件总线上(插件通过 on / once 方法注册到 EventEmitter,插件也可以通过 addEventListener 方法注册到 JS EventListener)。

  1. // poster.js
  2. player.on('play', playFunc)
  3. player.once('destroy', destroyFunc)
  4. // rotate.js
  5. btn.addEventListener('click',() => { player.rotate() })
  • 事件移除监听:

destroy 过程中,也会从事件总线中进行移除事件监听( off 通过 EventEmitter 移除监听, removeEventListener 通过 JS EventListener 移除监听)。

  1. // poster.js
  2. player.off('play', playFunc)
  3. player.off('destroy', destroyFunc)
  4. // volume.js
  5. window.removeEventListener('mousemove', move)
  6. window.removeEventListener('touchmove', move)
  7. window.removeEventListener('mouseup', up)
  8. window.removeEventListener('touchend', up)
  • 业务逻辑触发事件监听:

在业务逻辑中触发,EventEmitter 使用 player.emit 触发事件。

  1. // rotate.js
  2. player.emit('rotate', rotateDeg * 360)

6.2 播放器内部事件机制

将播放器中的事件分 Player 事件和 Proxy 事件,参照上图右侧部分。

  • 事件注册监听:

在播放器实例化时,批量将实例中的事件注册到事件总线上(通过 on / once 方法注册到 EventEmitter,通过 addEventListener 注册到 JS EventListener)。

  1. // proxy.js
  2. // 定义事件名称
  3. this.ev = ['play', 'playing', /* 省略其他方法 */
  4. ].map((item) => {
  5. return {
  6. [item]: `on${item.charAt(0).toUpperCase()}${item.slice(1)}`
  7. }
  8. })
  9. // player.js 监听事件
  10. this.ev.forEach((item) => {
  11. let evName = Object.keys(item)[0]
  12. let evFunc = this[item[evName]]
  13. if (evFunc) {
  14. this.on(evName, evFunc)
  15. }
  16. });
  17. // proxy.js 监听事件
  18. this.ev.forEach(item => {
  19. self.evItem = Object.keys(item)[0]
  20. let name = Object.keys(item)[0]
  21. self.video.addEventListener(Object.keys(item)[0], function () {
  22. // 省略
  23. })
  24. })
  • 事件移除监听:

destroy 过程中,也会从事件总线中进行移除事件监听(通过 off 方法从 EventEmitter移除,通过 removeEventListener 方法从 JS EventListener 中移除。

  1. // player.js
  2. destroy (isDelDom = true) {
  3. // ...
  4. // 移除单个事件监听
  5. if(this.playFunc) {
  6. this.off('play', this.playFunc)
  7. }
  8. // 批量移除事件监听
  9. ['focus', 'blur'].forEach(item => {
  10. this.off(item, this['on' + item.charAt(0).toUpperCase() + item.slice(1)])
  11. })
  12. }

7. 播放器销毁

西瓜播放器中提供一个 destroy 实例方法,用于销毁播放器。在 destroy 方法中,主要做了几件事:

  • 移除定时器相关;
  • 移除事件监听相关(EventEmitter、JS事件);
  • 移除DOM相关;
  • 移除实例对象属性相关;

下面简要贴出一些代码:

  1. // player.js
  2. destroy (isDelDom = true) {
  3. // 遍历移除定时器相关
  4. for (let k in this._interval) {
  5. clearInterval(this._interval[k])
  6. this._interval[k] = null
  7. }
  8. // 遍历移除EventEmitter事件监听
  9. this.ev.forEach((item) => {
  10. if (evFunc) {
  11. this.off(evName, evFunc)
  12. }
  13. });
  14. // 省略,移除部指定事件
  15. // 遍历移除 addEventListener 事件监听
  16. ['video', 'controls'].forEach(item => {
  17. if (this[item]) {
  18. this[item].removeEventListener('keydown', /*...*/)
  19. }
  20. })
  21. // 销毁播放器 DOM 结构
  22. if (isDelDom) {
  23. parentNode.removeChild(this.root)
  24. }
  25. // 省略移除 this 所有属性
  26. }

四、重要问题

1. 西瓜播放器的 video 元素如何创建?

在源码中,西瓜播放器的 video 元素是在 proxy.js 构造函数中初始化:

  1. // proxy.js
  2. this.video = util.createDom(
  3. this.videoConfig.mediaType,
  4. textTrackDom,
  5. this.videoConfig,
  6. ''
  7. )

在后面代码中,都将 video 事件挂载到 this.video 对象上。

比如我们点击播放按钮的时候,实际上也是调用了 this.video 对象上的 play() 方法:

  1. // player.js
  2. start (url = this.config.url) {
  3. // 省略其他代码
  4. this.canPlayFunc = function () {
  5. let playPromise = player.video.play()
  6. }
  7. }

2. 各个插件如何跟主播放器关联起来?

核心是 Player 通过继承 Proxy 类,Proxy 又通过 EventEmitter.call 实现构造继承。然后每个插件内部都注入 Player 类的实例,然后利用 EventEmitter 的 API 来实现事件驱动。

① Player 类继承 Proxy 类:

  1. // player.js
  2. class Player extends Proxy {
  3. // ...
  4. }

② Proxy 通过 EventEmitter.call 实现构造继承:

  1. // proxy.js 78行
  2. EventEmitter(this)

插件注入 Player 类的实例,通过 EventEmitter API 实现事件驱动:

这里以 play 插件为例(control/play.js)。

  1. // proxy.js 84行
  2. player.on('play', playFunc)
  3. // ...
  4. player.on('pause', pauseFunc)

image.png

五、知识点总结

1. 插件可插拔

在设计插件系统,可以考虑插件的可插拔性。使插件系统更灵活,易拓展,优化用户体验和提升加载速度。
插件可插拔一般分为:热插拔冷插拔
插件可插拔设计.png

1.1 热插拔

一般情况下,热插拔的插件是在打包阶段就打进整体的包里面,实现方式介绍:

  • 西瓜播放器过滤插件

西瓜播放器通过 ignores 配置项支持热插拔,使得我们可以在使用阶段,可以通过 ignores 数组来过滤不需要使用的内置插件:

  1. // player.js
  2. pluginsCall () {
  3. // ... 省略其他代码
  4. let ignores = this.config.ignores // 获取需要过滤的内置插件数组
  5. Object.keys(Player.plugins).forEach(name => {
  6. let descriptor = Player.plugins[name]
  7. if (!ignores.some(item => name === item)) { // 执行过滤操作
  8. // 初始化插件
  9. }
  10. })
  11. }

弊端:全量打包时包体积较大,当开发者明确不需要使用某些内置插件时,插件还是全量插件,占用插件包的体积。
比如:为了使用获取DOM元素的方法使用Ajax请求的方法而将整个jQuery引入项目。

  • 动态导入模块

使用 import() 作为函数调用,将其作为参数传递给模块的路径。 它返回一个promise,它用一个模块对象来实现,让我们可以访问该对象的导出。

  1. let loadModule = document.querySelector('.load');
  2. loadModule.addEventListener('click', () => {
  3. import('/modules/leo.js').then((Module) => {
  4. loadModule.draw();
  5. })
  6. });

详细介绍可以查看《动态加载模块》

使用场景:在实现页面懒加载时,可以采用,当加载项目时,不会完整加载,而是等到进入某些指定页面,才会去加载相应的模块文件。
弊端:动态加载模块时,比较影响用户体验,如果模块比较大,加载偏慢,影响体验。

1.2 冷插拔

即在插件包打包时,就将需要支持的插件一起打包进去,并且不提供过滤插件的方法。实现方式有:

  • 通过 Webpack DefinePlugin 配置

通过 Webpack DefinePlugin 读取打包配置,可以打轻量版或完整版的包,比较灵活,如果轻量版功能不够,可以再根据需要打增量的轻量版覆盖。

  1. // plugin.js
  2. const litePlugin = [ module1, module2, module9 ];
  3. const fullPlugin = [ module1, module2, ..., module9 ];
  4. const plugin = {litePlugin, fullPlugin};
  5. const version = pluginVersion;
  6. export default plugin[version];
  7. // webpack.lite.js
  8. plugins: [
  9. // ...
  10. new webpack.DefinePlugin({
  11. 'pluginVersion': JSON.stringify("litePlugin"),
  12. })
  13. ]
  14. // webpack.full.js
  15. plugins: [
  16. // ...
  17. new webpack.DefinePlugin({
  18. 'pluginVersion': JSON.stringify("fullPlugin"),
  19. })
  20. ]

2. 插件生命周期和抽象类

设计插件时,考虑在抽象类中定义插件生命周期,在实际插件开发中去实现生命周期的方法。
实际上就是在插件层上,添加一层通用插件层,用来定义需要实现的插件方法。
这样有几个好处:

  • 抽象类中统一对所有插件进行操作,便于管理;
  • 规定相同开发模式,降低开发难度;

简单实例:

  1. // Plugin.ts
  2. abstract class EFTPlugin{
  3. constructor(){}
  4. abstract creatDOM():void
  5. abstract addEventListener():void
  6. abstract removeEventListener():void
  7. abstract destoryDOM():void
  8. }
  9. // TestPlugin.ts
  10. class TestPlugin extends EFTPlugin{
  11. constructor(){
  12. super()
  13. }
  14. creatDOM(){
  15. // ...
  16. }
  17. addEventListener(){
  18. // ...
  19. }
  20. removeEventListener(){
  21. // ...
  22. }
  23. destoryDOM(){
  24. // ...
  25. }
  26. }
  27. const testPlugin = new TestPlugin()
  28. testPlugin.creatDOM();
  29. testPlugin.addEventListener();
  30. testPlugin.removeEventListener();
  31. testPlugin.destoryDOM();
  32. export default testPlugin;

3. querySelector 兼容处理

参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991

querySelector 方法使用 CSS3 选择器,用来查找DOM。CSS3 中的 ID 选择器不支持以数字开头,需要降级使用 getElementById

  1. // utils\util.js 代码第62行开始
  2. util.findDom = function (el = document, sel) {
  3. let dom
  4. // fix querySelector IDs that start with a digit
  5. // https://stackoverflow.com/questions/37270787/uncaught-syntaxerror-failed-to-execute-queryselector-on-document
  6. try {
  7. dom = el.querySelector(sel)
  8. } catch (e) {
  9. if (sel.startsWith('#')) {
  10. dom = el.getElementById(sel.slice(1))
  11. }
  12. }
  13. return dom
  14. }

4.批量导入指定目录下的文件


参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991

在西瓜播放器项目中,通过import * as Controls from './control/*.js' 语句实现批量导入播放器的所有内置插件:

  1. // index.js
  2. import Player from './player'
  3. import * as Controls from './control/*.js'
  4. import './style/index.scss'
  5. export default Player

实现批量导入指定目录下的文件,有两种方式:

4.1 babel-plugin-bulk-import

这是一款 Babel 插件,用于批量导入。
安装:

  1. npm install babel-plugin-bulk-import --save-dev

配置 .babelrc

  1. {
  2. "presets": ["es2015"],
  3. "plugins": ["babel-plugin-bulk-import"]
  4. }

使用:

  1. import * as Controls from './control/*.js'

4.2 Webpack require.context

这是一个 webpack 的 api,通过执行 require.context 函数获取一个特定的上下文,主要用来实现自动化导入模块
在前端工程化中,如果遇到从一个文件夹引入多个模块时,可以使用这个api,它会遍历文件夹中的指定文件,然后自动导入使得不需要每次显式的调用 import 导入模块。

另外,还有插件 babel-plugin-bulk-import 可以使用。

image.png

5. 解决 video 销毁时引起的浏览器奔溃问题

在源码中,注释了这么一个地址:
https://stackoverflow.com/questions/3258587/how-to-properly-unload-destroy-a-video-element

其中介绍的大概是为了解决 video 销毁时引起的浏览器奔溃问题:
**

  1. var videoElement = document.getElementById('id_of_the_video_element_here');
  2. videoElement.pause();
  3. videoElement.removeAttribute('src'); // empty source
  4. videoElement.load();

另外一篇文章中也介绍到这个情况:
https://html.spec.whatwg.org/multipage/media.html#best-practices-for-authors-using-media-elements
image.png

推荐一篇文章:《如何解决内存泄漏引发的血案》

6. 前端本地视频预览实现

参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991

6.1 源码解读

在西瓜播放器中,视频本地预览插件的核心代码如下:

  1. // localPreview.js
  2. let localPreview = function () {
  3. let player = this; let util = Player.util
  4. // 动态创建上传按钮
  5. let preview = util.createDom('xg-preview', '<input type="file">', {}, 'xgplayer-preview')
  6. let upload = preview.querySelector('input')
  7. if (player.config.preview && player.config.preview.uploadEl) {
  8. player.config.preview.uploadEl.appendChild(preview)
  9. // 监听上传按钮变化的事件
  10. upload.onchange = function () {
  11. player.uploadFile = upload.files[0]
  12. // 通过调用 URL.createObjectURL() 创建 URL 对象
  13. // 建议:当结束使用这个 URL 对象时,使用 window.URL.revokeObjectURL(objectURL) ,
  14. // 来释放这个 URL 对象对文件的引用。
  15. let url = URL.createObjectURL(player.uploadFile)
  16. if (util.hasClass(player.root, 'xgplayer-nostart')) {
  17. player.config.url = url
  18. player.start()
  19. } else {
  20. player.src = url
  21. player.play()
  22. }
  23. }
  24. }
  25. }
  26. Player.install("localPreview", localPreview);

6.2 实现原理

在西瓜播放器中,视频本地预览主要利用 URL.createObjectURL() API 实现。

实现过程如下:

  1. 创建上传按钮,通过 <input type="file"> 获取本地 file 对象;
  2. 将 File 对象通过 URL.createObjectURL() 方法转换为 ObjectURL 地址;
  3. 设置指定 video 标签的 src 属性值为视频本地的 ObjectURL 地址;

URL.createObjectURL() 静态方法将创建一个 DOMString ,参数是一个用于创建 URL 的 File 对象、Blob 对象或者 MediaSource 对象。最终返回一个DOMString包含了一个对象URL,该URL可用于指定源 object的内容。

下面整理一个非常简单的例子:

  1. <input type="file" name="" id="">
  2. <video src="" id="mini"></video>
  3. <script>
  4. let file = document.querySelector('input');
  5. file.onchange = function(){
  6. let url = URL.createObjectURL(file.files[0]);
  7. let mini = document.getElementById("mini");
  8. mini.setAttribute("src", url);
  9. mini.play()
  10. }
  11. </script>

6.3 内存管理

在每次调用 createObjectURL() 方法时,都会创建一个新的 URL 对象。但是,当我们不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL() 方法来释放。

浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,建议应该在安全的时机主动释放掉它们。

6.4 拓展:图片预览

目前常见的前端本地图片预览实现方式有两种:通过 FileReader.readAsDataURLURL.createObjectURL 的方式来实现。

readAsDataURL 方法会读取指定 BlobFile 对象。读取完成时,readyState 会变成已完成DONE,并触发 [loadend]() 事件,同时 result 属性将包含一个data:URL格式的字符串(base64编码)以表示所读取文件的内容。

  1. <input type="file" onchange="previewFile()"><br>
  2. <img src="" height="200" alt="Image preview...">
  3. <script>
  4. function previewFile() {
  5. let preview = document.querySelector('img');
  6. let file = document.querySelector('input[type=file]').files[0];
  7. let reader = new FileReader();
  8. reader.addEventListener("load", function () {
  9. preview.src = reader.result;
  10. }, false);
  11. if (file) {
  12. reader.readAsDataURL(file);
  13. }
  14. }
  15. </script>

两者区别:

区别内容 FileReader.readAsDataURL URL.createObjectURL
是否同步 异步执行 同步执行
内存使用 返回 base64 格式字符串,比 Blob URL 方式更占内存空间,当不需要使用时,可通过系统垃圾回收机制自动进行回收。 返回本地 URL 地址对象,并一直保存在内存中,直到文档触发 unload 事件或者手动调用 revokeObjectURL。
兼容性 支持 IE 10 以上的主流浏览器,详细的兼容性查看 caniuse - createObjectURL 支持 IE 10 以上的主流浏览器,详细兼容性查看 caniuse - readAsDataURL

6.5 URL.createObjectURL 特性检测

  1. function createObjectURL (file) {
  2. if (window.webkitURL) {
  3. return window.webkitURL.createObjectURL(file);
  4. } else if (window.URL && window.URL.createObjectURL) {
  5. return window.URL.createObjectURL(file);
  6. } else {
  7. return null;
  8. }
  9. }

7. 前端文件下载实现

参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991
在西瓜播放器中,场景如截图下载。

7.1 a标签 + initMouseEvent

使用 a 标签 + initMouseEvent 方法实现下载:

  1. let saveScreenShot = function (data, filename) {
  2. let saveLink = document.createElement('a')
  3. saveLink.href = data
  4. saveLink.download = filename
  5. let event = document.createEvent('MouseEvents')
  6. event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false,
  7. false, false, 0, null)
  8. saveLink.dispatchEvent(event)
  9. }

7.2 a标签 + createObjectURL

使用 a 标签 + createObjectURL 方法实现下载:

  1. let saveScreenShot = function (blob, filename) {
  2. const a = document.createElement('a');
  3. const url = window.URL.createObjectURL(blob);
  4. a.href = url;
  5. a.download = filename;
  6. a.click();
  7. window.URL.revokeObjectURL(url);
  8. }

8.播放器截图实现

参考文章:https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4#c453c991

8.1 原理分析

西瓜播放器截图流程如下:

  1. 创建截屏按钮并添加到播放器控制栏中;
  2. 创建 Canvas 元素和一个 Image 实例;
  3. 为截屏按钮绑定事件监听,如 clicktouchstart 等事件;
  4. 当用户点击触发截屏,在回调函数中通过 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);

image.png
详细参数可以查看文档 CanvasRenderingContext2D.drawImage()

8.2 Image 元素跨域

在 HTML5 中,一些 HTML 元素提供了对 CORS 的支持, 例如 audioimagelinkscriptvideo 均有一个跨域属性(crossOrigin property),它允许你配置元素获取数据的 CORS 请求。

这些属性是枚举的,并具有以下可能的值:

关键字 描述
anonymous 对此元素的 CORS 请求将不设置凭据标志。
use-credentials 对此元素的 CORS 请求将设置凭证标志;这意味着请求将提供凭据。
"" 设置一个空的值,如 crossorigincrossorigin="",和设置 anonymous 的效果一样。

默认情况下(即未指定 crossOrigin 属性时),CORS 根本不会使用。如 Terminology section of the CORS specification 中的描述,在非同源情况下,设置 “anonymous” 关键字将不会通过 cookies,客户端 SSL 证书或 HTTP 认证交换用户凭据。即使是无效的关键字和空字符串也会被当作 anonymous 关键字使用。

参考资源 —— CORS_settings_attributes

使用案例:

  1. <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”类型。

语法如下:

  1. canvas.toDataURL(type, encoderOptions);
  • type 可选,图片格式,默认为 image/png
  • encoderOptions 可选,在指定图片格式为 image/jpegimage/webp 的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92。其他参数会被忽略。

代码演示:
<canvas> 元素:

  1. <canvas id="canvas" width="5" height="5"></canvas>

获取一个 data-URL:

  1. var canvas = document.getElementById("canvas");
  2. var dataURL = canvas.toDataURL();
  3. console.log(dataURL);
  4. // "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
  5. // blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"

设置 jpegs 图片的质量:

  1. var fullQuality = canvas.toDataURL("image/jpeg", 1.0);
  2. // data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...9oADAMBAAIRAxEAPwD/AD/6AP/Z"
  3. var mediumQuality = canvas.toDataURL("image/jpeg", 0.5);
  4. 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 属性表示在网络上获取媒体的当前状态。语法如下:

  1. 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元素以开始播放,然后检查是否仍然在加载数据。

  1. <audio id="example" preload="auto">
  2. <source src="sound.ogg" type="audio/ogg" />
  3. </audio>
  1. const obj = document.getElementById('example');
  2. obj.addEventListener('playing', function() {
  3. if (obj.networkState === 2) {
  4. // Still loading...
  5. }
  6. });

西瓜播放器中的使用方式:

  1. // proxy.js
  2. get networkState () {
  3. let status = [{
  4. en: 'NETWORK_EMPTY',
  5. cn: '音频/视频尚未初始化'
  6. }, {
  7. en: 'NETWORK_IDLE',
  8. cn: '音频/视频是活动的且已选取资源,但并未使用网络'
  9. }, {
  10. en: 'NETWORK_LOADING',
  11. cn: '浏览器正在下载数据'
  12. }, {
  13. en: 'NETWORK_NO_SOURCE',
  14. cn: '未找到音频/视频来源'
  15. }]
  16. return this.lang ? this.lang[status[this.video.networkState].en]
  17. : status[this.video.networkState].en
  18. }

9.2 就绪状态

文档地址:https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLMediaElement/readyState

HTMLMediaElement.readyState 属性返回音频/视频的当前就绪状态。语法如下:

  1. 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 的数据. 他会检查当前位置是否可以播放, 会的话执行播放。

  1. <audio id="example" preload="auto">
  2. <source src="sound.ogg" type="audio/ogg" />
  3. </audio>
  1. const obj = document.getElementById('example');
  2. obj.addEventListener('loadeddata', function() {
  3. if(obj.readyState >= 2) {
  4. obj.play();
  5. }
  6. });

西瓜播放器中的使用方式:

  1. get readyState () {
  2. let status = [{
  3. en: 'HAVE_NOTHING',
  4. cn: '没有关于音频/视频是否就绪的信息'
  5. }, {
  6. en: 'HAVE_METADATA',
  7. cn: '关于音频/视频就绪的元数据'
  8. }, {
  9. en: 'HAVE_CURRENT_DATA',
  10. cn: '关于当前播放位置的数据是可用的,但没有足够的数据来播放下一帧/毫秒'
  11. }, {
  12. en: 'HAVE_FUTURE_DATA',
  13. cn: '当前及至少下一帧的数据是可用的'
  14. }, {
  15. en: 'HAVE_ENOUGH_DATA',
  16. cn: '可用数据足以开始播放'
  17. }]
  18. return this.lang ? this.lang[status[this.video.readyState].en]
  19. : status[this.video.readyState]
  20. }