寫在前面
現在視訊業務越來越流行了,播放器也比較多,作為前端工程師如何打造一個屬於自己的播放器呢?最快最有效的方式是基於開源播放器深度定製,至於選擇哪個開源播放器仁者見仁智者見智,可以參考開源播放器列表選擇適合自己業務的播放器。
我們的播放器選擇了排名第一的video.js播放器,截至目前該播放器在Github擁有13,991 star, 4,059 fork,流行程度可見一斑。為了讓大家更多的瞭解它,我們細數下優點:
- 免費開源
這個意味著什麼就不多說了,附上專案地址
- 相容主流瀏覽器
在國內的前端開發環境往往需要支援到低階版本的IE瀏覽器,然後隨著Flash的退化,很多公司沒有配備Flash開發工程師,video.js提供了流暢的Flash播放器,而且在UI層面做到了和video的效果,實屬難得,比如全屏。
- UI自定義
不管開源專案在UI方面做的如何漂亮,對於各具特色的業務來說都要自定義UI,一個方便簡單的自定義方式顯得格外重要,更何況它還自帶了編譯工具,只能用一個”贊“字形容。具體怎麼實現的,這裡先簡單描述下是使用JavaScript(es6)構建物件,通過Less編寫樣式規則,最後藉助Grunt編譯。
- 靈活外掛機制
video.js提供一個外掛定義的介面,使外掛開發簡單易行。而且社群論壇也提供了一些好用的外掛供開發者使用。附外掛列表
- 比較完善的文件
完善的文件對於一個穩定的開源專案是多麼的重要,video.js提供了教程、API文件、外掛、示例、論壇等。官方地址
- 專案熱度
開源作者對專案的維護比較積極,提出的問題也能很快給予響應。開發者在使用過程中出現問題算是有一定保障。
書歸正傳,要想更自由的駕馭video.js,必然要了解內部原理。本文的宗旨就是通過核心程式碼演示講解原始碼執行機制,如果有興趣,不要離開,我們馬上開始了……
組織結構
由於原始碼量較大,很多同學不知道從何入手,我們先來說下它的組織結構。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
├── control-bar ├── menu ├── popup ├── slider ├── tech ├── tracks ├── utils ├── big-play-button.js ├── button.js ├── clickable-component.js ├── close-button.js ├── component.js ├── error-display.js ├── event-target.js ├── extend.js ├── fullscreen-api.js ├── loading-spinner.js ├── media-error.js ├── modal-dialog.js ├── player.js ├── plugins.js ├── poster-image.js ├── setup.js └── video.js |
其中control-bar,menu,popup,slider,tech,tracks,utils是目錄,其他是檔案。video.js是個非常優秀的物件導向的典型,所有的UI都是通過JavaScript物件組織實現的。
video.js是個入口檔案,看原始碼可以從這個檔案開始。
setup.js處理播放器的配置安裝即data-setup屬性。
poster-image.js處理播放器貼片。
plugins.js實現了外掛機制。
player.js構造了播放器類也是video.js的核心。
modal-dialog.js處理彈層相關。
media-error.js定義了各種錯誤描述,如果想理解video.js對各語言的支援,這個檔案是必看的,它是橋樑。
loading-spinner.js實現了播放器載入的標誌,如果不喜歡預設載入圖示在這裡修改吧。
fullscreen-api.js實現各個瀏覽器的全屏方案。
extend.js是對node 繼承 and babel’s 繼承的整合。
event-target.js 是event類和原生事件的相容處理。
error-display.js 主要處理展示錯誤的樣式設定。
component.js 是video.js框架中最重要的類,是所有類的基類,也是實現元件化的基石。
close-button.js 是對關閉按鈕的封裝,功能比較單一。
clickable-component.js 如果想實現一個支援點選事件和鍵盤事件具備互動功能的元件可以繼承該類,它幫你做了細緻的處理。
button.js 如果想實現一個按鈕瞭解下這個類是必要的。
big-play-button.js 這個按鈕是視訊還未播放時顯示的按鈕,官方將此按鈕放置在播放器左上角。
utils目錄顧名思義是一些常用的功能性類和函式。
tracks目錄處理的是音軌、字幕之類的功能。
tech目錄也是非常核心的類,包括對video封裝、flash的支援。
slider目錄主要是UI層面可拖動元件的實現,如進度條,音量條都是繼承的此類。
popup目錄包含了對彈層相關的類。
menu目錄包含了對選單UI的實現。
control-bar目錄是非常核心的UI類的集合了,播放器下方的控制器都在此目錄中。
通過對組織結構的描述,大家可以,想了解video.js的哪一部分內容可以快速入手。如果還想更深入的瞭解如何正確使用這些類,請繼續閱讀繼承關係一節。
繼承關係
video.js是JavaScript物件導向實現很經典的案例,你一定會好奇在頁面上一個DOM節點加上data-setup屬性簡單配置就能生成一個複雜的播放器,然而在程式碼中看不到對應的HTML”模板“。其實這都要歸功於”繼承“關係以及作者巧妙的構思。
在組織結構一節有提到,所有類的基類都是Component類,在基類中有個createEl方法這個就是JavaScript物件和DOM進行關聯的方法。在具體的類中也可以重寫該方法自定義DOM內容,然後父類和子類的DOM關係也因JavaScript物件的繼承關係被組織起來。
為了方便大家查閱video.js所有的繼承關係,整理了兩個圖表,一個是完整版,一個是核心版。
- 完整版
- 核心板
執行機制
video.js原始碼程式碼量比較大,我們要了解它的執行機制,首先確定它的主線是video.js檔案的videojs方法,videojs方法呼叫player.js的Player類,Player繼承component.js檔案的Component類,最後播放器成功執行。
我們來看下videojs方法的程式碼、Player的建構函式、Component的建構函式,通過對程式碼的講解基本整個執行機制就有了基本的瞭解,注意裡面用到的所有方法和其他類物件參照組織結構一節細細閱讀就可以掌握更多的執行細節。
- videojs方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function videojs(id, options, ready) { let tag; // id可以是選擇器也可以是DOM節點 if (typeof id === 'string') { if (id.indexOf('#') === 0) { id = id.slice(1); } //檢查播放器是否已被例項化 if (videojs.getPlayers()[id]) { if (options) { log.warn(`Player "${id}" is already initialised.Options will not be applied.`); } if (ready) { videojs.getPlayers()[id].ready(ready); } return videojs.getPlayers()[id]; } // 如果播放器沒有例項化,返回DOM節點 tag = Dom.getEl(id); } else { // 如果是DOM節點直接返回 tag = id; } if (!tag || !tag.nodeName) { throw new TypeError('The element or ID supplied is not valid. (videojs)'); } // 返回播放器例項 return tag.player || Player.players[tag.playerId] || new Player(tag, options, ready); } []() |
- Player的建構函式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
constructor(tag, options, ready) { // 注意這個tag是video原生標籤 tag.id = tag.id || `vjs_video_$ { Guid.newGUID() }`; // 選項配置的合併 options = assign(Player.getTagSettings(tag), options); // 這個選項要關掉否則會在父類自動執行載入子類集合 options.initChildren = false; // 呼叫父類的createEl方法 options.createEl = false; // 在移動端關掉手勢動作監聽 options.reportTouchActivity = false; // 檢查播放器的語言配置 if (!options.language) { if (typeof tag.closest === 'function') { const closest = tag.closest('[lang]'); if (closest) { options.language = closest.getAttribute('lang'); } } else { let element = tag; while (element && element.nodeType === 1) { if (Dom.getElAttributes(element).hasOwnProperty('lang')) { options.language = element.getAttribute('lang'); break; } element = element.parentNode; } } } // 初始化父類 super(null, options, ready); // 檢查當前物件必須包含techOrder引數 if (!this.options_ || !this.options_.techOrder || !this.options_.techOrder.length) { throw new Error('No techOrder specified. Did you overwrite ' + 'videojs.options instead of just changing the ' + 'properties you want to override?'); } // 儲存當前已被例項化的播放器 this.tag = tag; // 儲存video標籤的各個屬性 this.tagAttributes = tag && Dom.getElAttributes(tag); // 將預設的英文切換到指定的語言 this.language(this.options_.language); if (options.languages) { const languagesToLower = {}; Object.getOwnPropertyNames(options.languages).forEach(function (name) { languagesToLower[name.toLowerCase()] = options.languages[name]; }); this.languages_ = languagesToLower; } else { this.languages_ = Player.prototype.options_.languages; } // 快取各個播放器的各個屬性. this.cache_ = {}; // 設定播放器的貼片 this.poster_ = options.poster || ''; // 設定播放器的控制 this.controls_ = !! options.controls; // 預設是關掉控制 tag.controls = false; this.scrubbing_ = false; this.el_ = this.createEl(); const playerOptionsCopy = mergeOptions(this.options_); // 自動載入播放器外掛 if (options.plugins) { const plugins = options.plugins; Object.getOwnPropertyNames(plugins).forEach(function (name) { if (typeof this[name] === 'function') { this[name](plugins[name]); } else { log.error('Unable to find plugin:', name); } }, this); } this.options_.playerOptions = playerOptionsCopy; this.initChildren(); // 判斷是不是音訊 this.isAudio(tag.nodeName.toLowerCase() === 'audio'); if (this.controls()) { this.addClass('vjs-controls-enabled'); } else { this.addClass('vjs-controls-disabled'); } this.el_.setAttribute('role', 'region'); if (this.isAudio()) { this.el_.setAttribute('aria-label', 'audio player'); } else { this.el_.setAttribute('aria-label', 'video player'); } if (this.isAudio()) { this.addClass('vjs-audio'); } if (this.flexNotSupported_()) { this.addClass('vjs-no-flex'); } if (!browser.IS_IOS) { this.addClass('vjs-workinghover'); } Player.players[this.id_] = this; this.userActive(true); this.reportUserActivity(); this.listenForUserActivity_(); this.on('fullscreenchange', this.handleFullscreenChange_); this.on('stageclick', this.handleStageClick_); } |
- Component的建構函式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
constructor(player, options, ready) { // 之前說過所有的類都是繼承Component,不是所有的類需要傳player if (!player && this.play) { // 這裡判斷呼叫的物件是不是Player本身,是本身只需要返回自己 this.player_ = player = this; // eslint-disable-line } else { this.player_ = player; } this.options_ = mergeOptions({}, this.options_); options = this.options_ = mergeOptions(this.options_, options); this.id_ = options.id || (options.el && options.el.id); if (!this.id_) { const id = player && player.id && player.id() || 'no_player'; this.id_ = `$ { id } _component_$ { Guid.newGUID() }`; } this.name_ = options.name || null; if (options.el) { this.el_ = options.el; } else if (options.createEl !== false) { this.el_ = this.createEl(); } this.children_ = []; this.childIndex_ = {}; this.childNameIndex_ = {}; // 知道Player的建構函式為啥要設定initChildren為false了吧 if (options.initChildren !== false) { // 這個initChildren方法是將一個類的子類都例項化,一個類都對應著自己的el(DOM例項),通過這個方法父類和子類的DOM繼承關係也就實現了 this.initChildren(); } this.ready(ready); if (options.reportTouchActivity !== false) { this.enableTouchActivity(); } } |
這裡通過主線把基本的流程演示一下,輪廓出來了,更多細節還請繼續閱讀。
外掛機制
一個完善和強大的框架都會繼承外掛執行功能,給更多的開發者參與開發的機會進而實現框架功能的補充和延伸。我們來看下video.js的外掛是如何運作的。
- 外掛的定義
如果之前用過video.js外掛的同學或者看過外掛原始碼,一定有看到有這句話videojs.plugin= pluginName
,我們來看下原始碼:
1 2 3 4 5 6 7 |
import Player from './player.js'; // 將外掛種植到Player的原型鏈 const plugin = function (name, init) { Player.prototype[name] = init; }; // 暴露plugin介面 videojs.plugin = plugin; |
不難看出,原理就是將外掛(函式)掛載到Player物件的原型上,接下來看下是怎麼執行的。
- 外掛的執行
1 2 3 4 5 6 7 8 9 10 |
if (options.plugins) { const plugins = options.plugins; Object.getOwnPropertyNames(plugins).forEach(function (name) { if (typeof this[name] === 'function') { this[name](plugins[name]); } else { log.error('Unable to find plugin:', name); } }, this); } |
在Player的建構函式裡判斷是否有外掛這個配置,如果有則遍歷執行。
UI”繼承”的原理
在繼承關係一節中有提到video.js的所有DOM生成都不是採用的傳統模板的方式,都是通過JavaScript物件的繼承關係來實現的。
在Component基類中有個createEl方法,在這裡可以使用DOM類生成DOM例項。每個UI類都會有一個el屬性,會在例項化的時候自動生成,原始碼在Component的建構函式中:
1 2 3 4 5 |
if (options.el) { this.el_ = options.el; } else if (options.createEl !== false) { this.el_ = this.createEl(); } |
每個UI類有一個children屬性,用於新增子類,子類有可能扔具有children屬性,以此類推,播放器的DOM結構就是通過這樣的JavaScript物件結構實現的。
在Player的建構函式裡有一句程式碼this.initChildren();
啟動了UI的例項化。這個方法是在Component基類中定義的,我們來看下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
initChildren() { // 獲取配置的children選項 const children = this.options_.children; if (children) { const parentOptions = this.options_; const handleAdd = (child) => { const name = child.name; let opts = child.opts; if (parentOptions[name] !== undefined) { opts = parentOptions[name]; } if (opts === false) { return; } if (opts === true) { opts = {}; } opts.playerOptions = this.options_.playerOptions; const newChild = this.addChild(name, opts); if (newChild) { this[name] = newChild; } }; let workingChildren; const Tech = Component.getComponent('Tech'); if (Array.isArray(children)) { workingChildren = children; } else { workingChildren = Object.keys(children); } workingChildren .concat(Object.keys(this.options_) .filter(function(child) { return !workingChildren.some(function(wchild) { if (typeof wchild === 'string') { return child === wchild; } return child === wchild.name; }); })) .map((child) => { let name; let opts; if (typeof child === 'string') { name = child; opts = children[name] || this.options_[name] || {}; } else { name = child.name; opts = child; } return {name, opts}; }) .filter((child) => { const c = Component.getComponent(child.opts.componentClass || toTitleCase(child.name)); return c && !Tech.isTech(c); }) .forEach(handleAdd); } } |
通過這段程式碼不難看出大概的意思是通過initChildren獲取children屬性,然後遍歷通過addChild將子類例項化,例項化的過程會自動重複上述過程從而達到了”繼承“的效果。不得不為作者的構思點贊。如果你要問並沒看到DOM是怎麼關聯起來的,請繼續看addChild方法的原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
addChild(child, options = {}, index = this.children_.length) { let component; let componentName; if (typeof child === 'string') { componentName = child; if (!options) { options = {}; } if (options === true) { log.warn('Initializing a child component with `true` is deprecated. Children should be defined in an array when possible, but if necessary use an object instead of `true`.'); options = {}; } const componentClassName = options.componentClass || toTitleCase(componentName); options.name = componentName; const ComponentClass = Component.getComponent(componentClassName); if (!ComponentClass) { throw new Error(`Component ${componentClassName} does not exist`); } if (typeof ComponentClass !== 'function') { return null; } component = new ComponentClass(this.player_ || this, options); } else { component = child; } this.children_.splice(index, 0, component); if (typeof component.id === 'function') { this.childIndex_[component.id()] = component; } componentName = componentName || (component.name && component.name()); if (componentName) { this.childNameIndex_[componentName] = component; } if (typeof component.el === 'function' && component.el()) { const childNodes = this.contentEl().children; const refNode = childNodes[index] || null; this.contentEl().insertBefore(component.el(), refNode); } return component; } |
這段程式碼的大意就是提取子類的名稱,然後獲取類並例項化,最後通過最關鍵的一句話this.contentEl().insertBefore(component.el(), refNode);
完成了父類和子類的DOM關聯。相信inserBefore大家並不陌生吧,原生的DOM操作方法。
總結
至此,video.js的精華部分都描述完了,不知道大家是否有收穫。這裡簡單的總結一些閱讀video.js框架原始碼的心得:
- 找準播放器實現的主線流程,方便我們有條理的閱讀程式碼
- 瞭解框架程式碼的組織結構,有的放矢的研究相關功能的程式碼
- 理解類與類的繼承關係,方便自己構造外掛或者修改原始碼的時候知道從哪個類繼承
- 理解播放器的執行原理,有利於基於Component構造一個新類的實現
- 理解外掛的執行機制,學會自己構造外掛還是有必要的
- 理解UI的實現原理,就知道自己如何為播放器新增視覺層面的東西了
- 看看我的原始碼解讀吧,能幫一點是一點,哈哈