目录
一、西瓜播放器介绍
二、学习任务
三、插件系统和事件机制
四、重要问题
五、学习总结
一、西瓜播放器介绍
西瓜播放器是字节跳动开源的一款带解析器、能节省流量的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.jsimport 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.jsimport 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.ignoresObject.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.jslet 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.jslet poster = util.createDom('xg-poster', '', {}, 'xgplayer-poster');let root = player.rootif (player.config.poster) {poster.style.backgroundImage = `url(${player.config.poster})`root.appendChild(poster)}
4.2 注册事件监听
在插件中利用 EventEmitter 事件机制注册了 play 方法:
// poster.jsfunction playFunc () {poster.style.display = 'none'}player.on('play', playFunc)
4.3 移除事件监听
在插件中,对 destroy 进行了一次性事件监听( once ),用来监听事件移除:
// poster.jsfunction 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.jsif (player.config.poster) {poster.style.backgroundImage = `url(${player.config.poster})`root.appendChild(poster)}
所以在修改 destroyFunc 方法时也需要考虑该情况,即修改后如下:
// rotate.jsfunction 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.jsimport 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.jsplayer.on('play', playFunc)player.once('destroy', destroyFunc)// rotate.jsbtn.addEventListener('click',() => { player.rotate() })
- 事件移除监听:
在 destroy 过程中,也会从事件总线中进行移除事件监听( off 通过 EventEmitter 移除监听, removeEventListener 通过 JS EventListener 移除监听)。
// poster.jsplayer.off('play', playFunc)player.off('destroy', destroyFunc)// volume.jswindow.removeEventListener('mousemove', move)window.removeEventListener('touchmove', move)window.removeEventListener('mouseup', up)window.removeEventListener('touchend', up)
- 业务逻辑触发事件监听:
在业务逻辑中触发,EventEmitter 使用 player.emit 触发事件。
// rotate.jsplayer.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.jsdestroy (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.jsdestroy (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.jsthis.video = util.createDom(this.videoConfig.mediaType,textTrackDom,this.videoConfig,'')
在后面代码中,都将 video 事件挂载到 this.video 对象上。
比如我们点击播放按钮的时候,实际上也是调用了 this.video 对象上的 play() 方法:
// player.jsstart (url = this.config.url) {// 省略其他代码this.canPlayFunc = function () {let playPromise = player.video.play()}}
2. 各个插件如何跟主播放器关联起来?
核心是 Player 通过继承 Proxy 类,Proxy 又通过 EventEmitter.call 实现构造继承。然后每个插件内部都注入 Player 类的实例,然后利用 EventEmitter 的 API 来实现事件驱动。
① Player 类继承 Proxy 类:
// player.jsclass 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.jspluginsCall () {// ... 省略其他代码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.jsconst litePlugin = [ module1, module2, module9 ];const fullPlugin = [ module1, module2, ..., module9 ];const plugin = {litePlugin, fullPlugin};const version = pluginVersion;export default plugin[version];// webpack.lite.jsplugins: [// ...new webpack.DefinePlugin({'pluginVersion': JSON.stringify("litePlugin"),})]// webpack.full.jsplugins: [// ...new webpack.DefinePlugin({'pluginVersion': JSON.stringify("fullPlugin"),})]
2. 插件生命周期和抽象类
设计插件时,考虑在抽象类中定义插件生命周期,在实际插件开发中去实现生命周期的方法。
实际上就是在插件层上,添加一层通用插件层,用来定义需要实现的插件方法。
这样有几个好处:
- 抽象类中统一对所有插件进行操作,便于管理;
- 规定相同开发模式,降低开发难度;
简单实例:
// Plugin.tsabstract class EFTPlugin{constructor(){}abstract creatDOM():voidabstract addEventListener():voidabstract removeEventListener():voidabstract destoryDOM():void}// TestPlugin.tsclass 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-documenttry {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.jsimport Player from './player'import * as Controls from './control/*.js'import './style/index.scss'export default Player
实现批量导入指定目录下的文件,有两种方式:
- 借助 babel-plugin-bulk-import 插件实现;
- 借助 Webpack
require.contextAPI 来实现;
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 sourcevideoElement.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.jslet 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 = urlplayer.start()} else {player.src = urlplayer.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 = datasaveLink.download = filenamelet 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/pngencoderOptions可选,在指定图片格式为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.jsget 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]}

