首先,咱們須要實現頁面佈局,在根目錄建立 index.html
佈局中咱們須要有一個 video
多媒體標籤引入咱們的本地視頻,添加輸入彈幕的輸入框、確認發送的按鈕、顏色選擇器、字體大小滑動條,建立一個 style.css
來調整頁面佈局的樣式,這裏咱們順便建立一個 index.js
文件用於後續實現咱們的核心邏輯,先引入到頁面當中。css
HTML 佈局代碼以下:html
<!-- 文件:index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> <title>視頻彈幕</title> </head> <body> <div id="cantainer"> <h2>Canvas + WebSocket + Redis 實現視頻彈幕</h2> <div id="content"> <canvas id="canvas"></canvas> <video id="video" src="./barrage.mp4" controls></video> </div> <!-- 輸入彈幕內容 --> <input type="text" id="text"> <!-- 添加彈幕按鈕 --> <button id="add">發送</button> <!-- 選擇文字顏色 --> <input type="color" id="color"> <!-- 調整字體大小 --> <input type="range" max="40" min="20" id="range"> </div> <script src="./index.js"></script> </body> </html>
CSS 樣式代碼以下:前端
/* 文件:style.css */ #cantainer { text-align: center; } #content { width: 640px; margin: 0 auto; position: relative; } #canvas { position: absolute; } video { width: 640px; height: 360px; } input { vertical-align: middle; }
佈局效果以下圖:web
咱們彈幕中的彈幕數據正常狀況下應該是經過與後臺數據交互請求回來,因此咱們須要先定義數據接口,並構造假數據來實現前端邏輯。redis
數據字段定義:數據庫
上面的 value
和 time
是必填參數,其餘的選填參數能夠在前端設置默認值。npm
前端定義的假數據以下:編程
// 文件:index.js let data = [ { value: "這是第一條彈幕", speed: 2, time: 0, color: "red", fontSize: 20 }, { value: "這是第二條彈幕", time: 1 } ];
咱們但願是把彈幕封裝成一個功能,只要有須要的地方就可使用,從而實現複用,那麼不一樣的地方使用這個功能一般的方式是 new
一個實例,傳入當前使用該功能對應的參數,咱們也使用這種方式來實現,因此咱們須要封裝一個統一的構造函數或者類,參數爲當前的 canvas
元素、video
元素和一個 options
對象,options
裏面的 data
屬性爲咱們的彈幕數據,之因此不直接傳入 data
是爲了後續參數的擴展,嚴格遵循開放封閉原則,這裏咱們就統一使用 ES6 的 class
類來實現。canvas
佈局時須要注意 Canvas 的默認寬爲 300px
,高爲 150px
,咱們要保證 Canvas 徹底覆蓋整個視頻,須要讓 Canvas 與 video
寬高相等。
由於咱們不肯定每個使用該功能的視頻的寬高都是同樣的,因此 Canvas 畫布的寬高並無經過 CSS 來設置,而是經過 JS 在類建立實例初始化屬性的時候動態設置。後端
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); } }
應該掛在實例上的屬性除了有當前的 canvas
元素、video
元素、彈幕數據的默認選項以及彈幕數據以外,還應該有一個表明當前是否渲染彈幕的參數,由於視頻暫停的時候,彈幕也是暫停的,因此沒有從新渲染,由於是否暫停與彈幕是否渲染的狀態是一致的,因此咱們這裏就用 isPaused
參數來表明當前是否暫停或從新渲染彈幕,值類型爲布爾值。
咱們知道,後臺返回給咱們的彈幕數據是一個數組,這個數組裏的每個彈幕都是一個對象,而對象上有着這條彈幕的信息,若是咱們須要在每個彈幕對象上再加一些新的信息或者在每個彈幕對象的處理時用到了當前彈幕功能類 CanvasBarrage
實例的一些屬性值,取值顯然是不太方便的,這樣爲了後續方便擴展,遵循開放封閉原則,咱們把每個彈幕的對象轉變成同一個類的實例,因此咱們建立一個名爲 Barrage
的類,讓咱們每一條彈幕的對象進入這個類裏面走一遭,掛上一些擴展的屬性。
// 文件:index.js class Barrage { constructor(item, ctx) { this.value = item.value; // 彈幕的內容 this.time = item.time; // 彈幕出現的時間 this.item = item; // 每個彈幕的數據對象 this.ctx = ctx; // 彈幕功能類的執行上下文 } }
在咱們的 CanvasBarrage
類上有一個存儲彈幕數據的數組 data
,此時咱們須要給 CanvasBarrage
增長一個屬性用來存放 「加工」 後的每條彈幕對應的實例。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); // ********** 如下爲新增代碼 ********** // 存放全部彈幕實例,Barrage 是創造每一條彈幕的實例的類 this.barrages = this.data.map(item => new Barrage(item, this)); // ********** 以上爲新增代碼 ********** } }
其實經過上面操做之後,咱們至關於把 data
裏面的每一條彈幕對象轉換成了一個 Barrage
類的一個實例,把當前的上下文 this
傳入後能夠隨時在每個彈幕實例上獲取 CanvasBarrage
類實例的屬性,也方便咱們後續擴展方法,遵循這種開放封閉原則的方式開發,意義是不言而喻的。
CanvasBarrage
的 render
方法是在建立彈幕功能實例的時候應該渲染 Canvas 因此應該在 CanvasBarrage
中調用,在 render
內部,每一次渲染以前都應該先將 Canvas 畫布清空,因此須要給當前的 CanvasBarrage
類新增一個屬性用於存儲 Canvas 畫布的內容。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); // 存放全部彈幕實例,Barrage 是創造每一條彈幕的實例的類 this.barrages = this.data.map(item => new Barrage(item, this)); // ********** 如下爲新增代碼 ********** // Canvas 畫布的內容 this.context = canvas.getContext("2d"); // 渲染全部的彈幕 this.render(); // ********** 以上爲新增代碼 ********** } // ********** 如下爲新增代碼 ********** render() { // 渲染整個彈幕 // 第一次先進行清空操做,執行渲染彈幕,若是沒有暫停,繼續渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染彈幕 this.renderBarrage(); if (this.isPaused == false) { // 遞歸渲染 requestAnimationFrame(this.render.bind(this)); } } // ********** 以上爲新增代碼 ********** }
在上面的 CanvasBarrage
的 render
函數中,清空時因爲 Canvas 性能比較好,因此將整個畫布清空,因此從座標 (0, 0)
點,清空的寬高爲整個 Canvas 畫布的寬高。
只要視頻是在播放狀態應該不斷的調用 render
方法實現清空畫布、渲染彈幕、判斷是否暫停,若是非暫停狀態繼續渲染,因此咱們用到了遞歸調用 render
去不斷的實現渲染,可是遞歸時若是直接調用 render
,性能特別差,程序甚至會掛掉,以往這種狀況咱們會在遞歸外層加一個 setTimeout
來定義一個短暫的遞歸時間,可是這個過程相似於動畫效果,若是使用 setTimeout
實際上是將同步代碼轉成了異步執行,會增長不肯定性致使畫面出現卡頓的現象。
這裏咱們使用 H5 的新 API requestAnimationFrame
,能夠在平均 1/60 S
內幫我執行一次該方法傳入的回調,咱們直接把 render
函數做爲回調函數傳入 requestAnimationFrame
,該方法是按照幀的方式執行,動畫流暢,須要注意的是,render
函數內使用了 this
,因此應該處理一下 this
指向問題。
因爲咱們使用面向對象的方式,因此渲染彈幕的具體細節,咱們抽離出一個單獨的方法 renderBarrage
,接下來看一下 renderBarrage
的實現。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); // 存放全部彈幕實例,Barrage 是創造每一條彈幕的實例的類 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 畫布的內容 this.context = canvas.getContext("2d"); // 渲染全部的彈幕 this.render(); } render() { // 渲染整個彈幕 // 第一次先進行清空操做,執行渲染彈幕,若是沒有暫停,繼續渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染彈幕 this.renderBarrage(); if (this.isPaused == false) { // 遞歸渲染 requestAnimationFrame(this.render.bind(this)); } } // ********** 如下爲新增代碼 ********** renderBarrage() { // 將數組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執行渲染此彈幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // 當視頻時間大於等於了彈幕設置的時間,那麼開始渲染(時間都是以秒爲單位) if (time >= barrage.time) { // 初始化彈幕的各個參數,只有在彈幕將要出現的時候再去初始化,節省性能,初始化後再進行繪製 // 若是沒有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化後下次再渲染就不須要再初始化了,因此建立一個標識 isInited barrage.init(); barrage.isInited = true; } } }); } // ********** 以上爲新增代碼 ********** }
此處的 renderBarrage
方法內部主要對每一條彈幕實例所設置的出現時間和視頻的播放時間作對比,若是視頻的播放時間大於等於了彈幕出現的時間,說明彈幕須要繪製在 Canvas 畫布內。
以前咱們的每一條彈幕實例的屬性可能不全,彈幕的其餘未傳參數並無初始化,因此爲了最大限度的節省性能,咱們在彈幕該第一次繪製的時候去初始化參數,等到視頻播放的時間變化再去從新繪製時,再也不初始化參數,因此初始化參數的方法放在了判斷彈幕出現時間的條件裏面執行,又設置了表明彈幕實例是否是初始化了的參數 isInited
,初始化函數 init
執行過一次後,立刻修改 isInited
的值,保證只初始化參數一次。
在 renderBarrage
方法中咱們能夠看出來,其實咱們是循環了專門存放每一條彈幕實例(Barrage
類的實例)的數組,咱們在內部用實例去調用的方法 init
應該是在 Barrage
類的原型上,下面咱們去 Barrage
類上實現 init
的邏輯。
// 文件:index.js class Barrage { constructor(item, ctx) { this.value = item.value; // 彈幕的內容 this.time = item.time; // 彈幕出現的時間 this.item = item; // 每個彈幕的數據對象 this.ctx = ctx; // 彈幕功能類的執行上下文 } // ********** 如下爲新增代碼 ********** init() { this.opacity = this.item.opacity || this.ctx.opacity; this.color = this.item.color || this.ctx.color; this.fontSize = this.item.fontSize || this.ctx.fontSize; this.speed = this.item.speed || this.ctx.speed; // 求本身的寬度,目的是用來校驗當前是否還要繼續繪製(邊界判斷) let span = document.createElement("span"); // 能決定寬度的只有彈幕的內容和文字的大小,和字體,字體默認爲微軟雅黑,咱們就不作設置了 span.innerText = this.value; span.style.font = this.fontSize + 'px "Microsoft YaHei'; // span 爲行內元素,取不到寬度,因此咱們經過定位給轉換成塊級元素 span.style.position = "absolute"; document.body.appendChild(span); // 放入頁面 this.width = span.clientWidth; // 記錄彈幕的寬度 document.body.removeChild(span); // 從頁面移除 // 存儲彈幕出現的橫縱座標 this.x = this.ctx.canvas.width; this.y = this.ctx.canvas.height; // 處理彈幕縱向溢出的邊界處理 if (this.y < this.fontSize) { this.y = this.fontSize; } if (this.y > this.ctx.canvas.height - this.fontSize) { this.y = this.ctx.canvas.height - this.fontSize; } } // ********** 以上爲新增代碼 ********** }
在上面代碼的 init
方法中咱們其實能夠看出,每條彈幕實例初始化的時候初始的信息除了以前說的彈幕的基本參數外,還獲取了每條彈幕的寬度(用於後續作彈幕是否已經徹底移出屏幕的邊界判斷)和每一條彈幕的 x
和 y
軸方向的座標併爲了防止彈幕在 y
軸顯示不全作了邊界處理。
咱們當時在 CanvasBarrage
類的 render
方法中的渲染每一個彈幕的方法 renderBarrage
中(原諒這麼囉嗦,由於到如今內容已經比較多,說的具體一點方便知道是哪一個步驟,哈哈)只作了對每一條彈幕實例的初始化操做,並無渲染在 Canvas 畫布中,這時咱們主要作兩部操做,實現每條彈幕渲染在畫布中和左側移出屏幕再也不渲染的邊界處理。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); // 存放全部彈幕實例,Barrage 是創造每一條彈幕的實例的類 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 畫布的內容 this.context = canvas.getContext("2d"); // 渲染全部的彈幕 this.render(); } render() { // 渲染整個彈幕 // 第一次先進行清空操做,執行渲染彈幕,若是沒有暫停,繼續渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染彈幕 this.renderBarrage(); if (this.isPaused == false) { // 遞歸渲染 requestAnimationFrame(this.render.bind(this)); } } renderBarrage() { // 將數組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執行渲染此彈幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // ********** 如下爲改動的代碼 ********** // 當視頻時間大於等於了彈幕設置的時間,那麼開始渲染(時間都是以秒爲單位) if (!barrage.flag && time >= barrage.time) { // ********** 以上爲改動的代碼 ********** // 初始化彈幕的各個參數,只有在彈幕將要出現的時候再去初始化,節省性能,初始化後再進行繪製 // 若是沒有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化後下次再渲染就不須要再初始化了,因此建立一個標識 isInited barrage.init(); barrage.isInited = true; } // ********** 如下爲新增代碼 ********** barrage.x -= barrage.speed; barrage.render(); // 渲染該條彈幕 if (barrage.x < barrage.width * -1) { barrage.flag = true; // 是否出去了,出去了,作中止渲染的操做 } // ********** 以上爲新增代碼 ********** } }); } }
每一個彈幕實例都有一個 speed
屬性,該屬性表明着彈幕移動的速度,換個說法其實就是每次減小的 x
軸的差值,因此咱們實際上是經過改變 x
軸的值再從新渲染而實現彈幕的左移,咱們建立了一個標識 flag
掛在每一個彈幕實例下,表明是否已經離開屏幕,若是離開則更改 flag
的值,使外層的 CanvasBarrage
類的 render
函數再次遞歸時不進入渲染程序。
每一條彈幕具體是怎麼渲染的,經過代碼能夠看出每一個彈幕實例在 x
座標改變後都調用了實例方法 render
函數,注意此 render
非彼 render
,該 render
函數屬於 Barrage
類,目的是爲了渲染每一條彈幕,而 CanvasBarrage
類下的 render
,是爲了在視頻時間變化時清空並從新渲染整個 Canvas 畫布。
// 文件:index.js class Barrage { constructor(item, ctx) { this.value = item.value; // 彈幕的內容 this.time = item.time; // 彈幕出現的時間 this.item = item; // 每個彈幕的數據對象 this.ctx = ctx; // 彈幕功能類的執行上下文 } init() { this.opacity = this.item.opacity || this.ctx.opacity; this.color = this.item.color || this.ctx.color; this.fontSize = this.item.fontSize || this.ctx.fontSize; this.speed = this.item.speed || this.ctx.speed; // 求本身的寬度,目的是用來校驗當前是否還要繼續繪製(邊界判斷) let span = document.createElement("span"); // 能決定寬度的只有彈幕的內容和文字的大小,和字體,字體默認爲微軟雅黑,咱們就不作設置了 span.innerText = this.value; span.style.font = this.fontSize + 'px "Microsoft YaHei'; // span 爲行內元素,取不到寬度,因此咱們經過定位給轉換成塊級元素 span.style.position = "absolute"; document.body.appendChild(span); // 放入頁面 this.width = span.clientWidth; // 記錄彈幕的寬度 document.body.removeChild(span); // 從頁面移除 // 存儲彈幕出現的橫縱座標 this.x = this.ctx.canvas.width; this.y = this.ctx.canvas.height; // 處理彈幕縱向溢出的邊界處理 if (this.y < this.fontSize) { this.y = this.fontSize; } if (this.y > this.ctx.canvas.height - this.fontSize) { this.y = this.ctx.canvas.height - this.fontSize; } } // ********** 如下爲新增代碼 ********** render() { this.ctx.context.font = this.fontSize + 'px "Microsoft YaHei"'; this.ctx.context.fillStyle = this.color; this.ctx.context.fillText(this.value, this.x, this.y); } // ********** 以上爲新增代碼 ********** }
從上面新增代碼咱們能夠看出,其實 Barrage
類的 render
方法只是將每一條彈幕的字號、顏色、內容、座標等屬性經過 Canvas 的 API 添加到了畫布上。
還記得咱們的 CanvasBarrage
類裏面有一個屬性 isPaused
,屬性值控制了咱們是否遞歸渲染,這個屬性與視頻暫停的狀態是一致的,咱們在播放的時候,彈幕不斷的清空並從新繪製,當暫停的時候彈幕也應該跟着暫停,說白了就是不在調用 CanvasBarrage
類的 render
方法,其實就是在暫停、播放的過程當中不斷的改變 isPaused
的值便可。
還記得咱們以前構造的兩條假數據 data
吧,接下來咱們添加播放、暫停事件,來嘗試使用一下咱們的彈幕功能。
// 文件:index.js // 實現一個簡易選擇器,方便獲取元素,後面獲取元素直接調用 $ const $ = document.querySelector.bind(document); // 獲取 Canvas 元素和 Video 元素 let canvas = $("#canvas"); let video = $("#video"); let canvasBarrage = new CanvasBarrage(canvas, video, { data }); // 添加播放事件 video.addEventListener("play", function() { canvasBarrage.isPaused = false; canvasBarrage.render(); }); // 添加暫停事件 video.addEventListener("pause", function() { canvasBarrage.isPaused = true; });
// 文件:index.js $("#add").addEventListener("click", function() { let time = video.currentTime; // 發送彈幕的時間 let value = $("#text").value; // 發送彈幕的文字 let color = $("#color").value; // 發送彈幕文字的顏色 let fontSize = $("#range").value; // 發送彈幕的字體大小 let sendObj = { time, value, color, fontSize }; //發送彈幕的參數集合 canvasBarrage.add(sendObj); // 發送彈幕的方法 });
其實咱們發送彈幕時,就是向 CanvasBarrage
類的 barrages
數組裏添加了一條彈幕的實例,咱們單獨封裝了一個 add
的實例方法。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); // 存放全部彈幕實例,Barrage 是創造每一條彈幕的實例的類 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 畫布的內容 this.context = canvas.getContext("2d"); // 渲染全部的彈幕 this.render(); } render() { // 渲染整個彈幕 // 第一次先進行清空操做,執行渲染彈幕,若是沒有暫停,繼續渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染彈幕 this.renderBarrage(); if (this.isPaused == false) { // 遞歸渲染 requestAnimationFrame(this.render.bind(this)); } } renderBarrage() { // 將數組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執行渲染此彈幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // 當視頻時間大於等於了彈幕設置的時間,那麼開始渲染(時間都是以秒爲單位) if (!barrage.flag && time >= barrage.time) { // 初始化彈幕的各個參數,只有在彈幕將要出現的時候再去初始化,節省性能,初始化後再進行繪製 // 若是沒有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化後下次再渲染就不須要再初始化了,因此建立一個標識 isInited barrage.init(); barrage.isInited = true; } barrage.x -= barrage.speed; barrage.render(); // 渲染該條彈幕 if (barrage.x < barrage.width * -1) { barrage.flag = true; // 是否出去了,出去了,作中止渲染的操做 } } }); } // ********** 如下爲新增代碼 ********** add(item) { this.barrages.push(new Barrage(item, this)); } // ********** 以上爲新增代碼 ********** }
其實咱們發現,彈幕雖然實現了正常的播放、暫停以及發送,可是當咱們拖動進度條的時候彈幕應該是跟着視頻時間同步播放的,如今的彈幕一旦播放過不管怎樣拉動進度條彈幕都不會再出現,咱們如今就來解決這個問題。
// 文件:index.js // 拖動進度條事件 video.addEventListener("seeked", function() { canvasBarrage.reset(); });
咱們在事件內部其實只是調用了一下 CanvasBarrage
類的 reset
方法,這個方法就是在拖動進度條的時候來幫咱們初始化彈幕的狀態。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是沒有傳入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 當前的 canvas 元素 this.video = video; // 當前的 video 元素 // 設置 canvas 與 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默認暫停播放,表示不渲染彈幕 this.isPaused = true; // 沒傳參數的默認值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上 Object.assign(this, defaultOptions, options); // 存放全部彈幕實例,Barrage 是創造每一條彈幕的實例的類 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 畫布的內容 this.context = canvas.getContext("2d"); // 渲染全部的彈幕 this.render(); } render() { // 渲染整個彈幕 // 第一次先進行清空操做,執行渲染彈幕,若是沒有暫停,繼續渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染彈幕 this.renderBarrage(); if (this.isPaused == false) { // 遞歸渲染 requestAnimationFrame(this.render.bind(this)); } } renderBarrage() { // 將數組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執行渲染此彈幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // 當視頻時間大於等於了彈幕設置的時間,那麼開始渲染(時間都是以秒爲單位) if (!barrage.flag && time >= barrage.time) { // 初始化彈幕的各個參數,只有在彈幕將要出現的時候再去初始化,節省性能,初始化後再進行繪製 // 若是沒有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化後下次再渲染就不須要再初始化了,因此建立一個標識 isInited barrage.init(); barrage.isInited = true; } barrage.x -= barrage.speed; barrage.render(); // 渲染該條彈幕 if (barrage.x < barrage.width * -1) { barrage.flag = true; // 是否出去了,出去了,作中止渲染的操做 } } }); } add(item) { this.barrages.push(new Barrage(item, this)); } // ********** 如下爲新增代碼 ********** reset() { // 先清空 Canvas 畫布 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); let time = this.video.currentTime; // 循環每一條彈幕實例 this.barrages.forEach(barrage => { // 更改已經移出屏幕的彈幕狀態 barrage.flag = false; // 當拖動到的時間小於等於當前彈幕時間是,從新初始化彈幕的數據,實現渲染 if (time <= barrage.time) { barrage.isInited = false; } else { barrage.flag = true; // 不然將彈幕的狀態設置爲以移出屏幕 } }); } // ********** 以上爲新增代碼 ********** }
其實 reset
方法中值作了幾件事:
從而實現了拖動進度條彈幕的 「前進」 和 「後退」 功能。
要使用 WebSocket 和 Redis 首先須要去安裝 ws
、redis
依賴,在項目根目錄執行下面命令:
npm install ws redis
咱們建立一個 server.js
文件,用來寫服務端的代碼:
// 文件:index.js const WebSocket = require("ws"); // 引入 WebSocket const redis = require("redis"); // 引入 redis // 初始化 WebSocket 服務器,端口號爲 3000 let wss = new WebSocket.Server({ port: 3000 }); // 建立 redis 客戶端 let client = redis.createClient(); // key value // 原生的 websocket 就兩個經常使用的方法 on('message')、on('send') wss.on("connection", function(ws) { // 監聽鏈接 // 鏈接上須要當即把 redis 數據庫的數據取出返回給前端 client.lrange("barrages", 0, -1, function(err, applies) { // 因爲 redis 的數據都是字符串,因此須要把數組中每一項轉成對象 applies = applies.map(item => JSON.parse(item)); // 使用 websocket 服務器將 redis 數據庫的數據發送給前端 // 構建一個對象,加入 type 屬性告訴前端當前返回數據的行爲,並將數據轉換成字符串 ws.send( JSON.stringify({ type: "INIT", data: applies }) ); }); // 當服務器收到消息時,將數據存入 redis 數據庫 ws.on("message", function(data) { // 向數據庫存儲時存的是字符串,存入並打印數據,用來判斷是否成功存入數據庫 client.rpush("barrages", data, redis.print); // 再將當前這條數據返回給前端,一樣添加 type 字段告訴前端當前行爲,並將數據轉換成字符串 ws.send( JSON.stringify({ type: "ADD", data: JSON.parse(data) }) ); }); });
服務器的邏輯很清晰,在 WebSocket 鏈接上時,當即獲取 Redis 數據庫的全部彈幕數據返回給前端,當前端點擊發送彈幕按鈕發送數據時,接收數據存入 Redis 數據庫中並打印驗證數據是否成功存入,再經過 WebSocket 服務把當前這一條數返回給前端,須要注意一下幾點:
JSON.parse
方法進行解析;JSON.stringify
從新轉換成字符串發送;type
值告訴前端,當前的操做行爲。在沒有實現後端代碼以前,前端使用的是 data
的假數據,是在添加彈幕事件中,將獲取的新增彈幕信息經過 CanvasBarrage
類的 add
方法直接建立 Barrage
類的實例,並加入到存放彈幕實例的 barrages
數組中。
如今咱們須要更正一下交互邏輯,在發送彈幕事件觸發時,咱們應該先將獲取的單條彈幕數據經過 WebSocket 發送給後端服務器,在服務器從新將消息返還給咱們的時候,去將這條數據經過 CanvasBarrage
類的 add
方法加入到存放彈幕實例的 barrages
數組中。
還有在頁面初始化時,咱們以前在建立 CanvasBarrage
類實例的時候直接傳入了 data
假數據,如今須要經過 WebSocket 的鏈接事件,在監聽到鏈接 WebSocket 服務時,去建立 CanvasBarrage
類的實例,並直接把服務端返回 Redis 數據庫真實的數據做爲參數傳入,前端代碼修改以下:
// 文件:index.js // ********** 下面代碼被刪掉了 ********** // let canvasBarrage = new CanvasBarrage(canvas, video, { // data // }); // ********** 上面代碼被刪掉了 ********** // ********** 如下爲新增代碼 ********** let canvasBarrage; // 建立 WebSocket 鏈接 let socket = new WebSocket("ws://localhost:3000"); // 監聽鏈接事件 socket.onopen = function() { // 監聽消息 socket.onmessage = function(e) { // 將收到的消息從字符串轉換成對象 let message = JSON.parse(e.data); // 根據不一樣狀況判斷是初始化仍是發送彈幕 if (message.type === "INIT") { // 建立 CanvasBarrage 的實例添加彈幕功能,傳入真實的數據 canvasBarrage = new CanvasBarrage(canvas, video, { data: message.data }); } else if (message.type === "ADD") { // 若是是添加彈幕直接將 WebSocket 返回的單條彈幕存入 barrages 中 canvasBarrage.add(message.data); } }; }; // ********** 以上爲新增代碼 ********** $("#add").addEventListener("click", function() { let time = video.currentTime; // 發送彈幕的時間 let value = $("#text").value; // 發送彈幕的文字 let color = $("#color").value; // 發送彈幕文字的顏色 let fontSize = $("#range").value; // 發送彈幕的字體大小 let sendObj = { time, value, color, fontSize }; //發送彈幕的參數集合 // ********** 如下爲新增代碼 ********** socket.send(JSON.stringify(sendObj)); // ********** 以上爲新增代碼 ********** // ********** 下面代碼被刪掉了 ********** // canvasBarrage.add(sendObj); // 發送彈幕的方法 // ********** 上面代碼被刪掉了 ********** });
如今咱們能夠打開 index.html
文件並啓動 server.js
服務器,就能夠實現真實的視頻彈幕操做了,可是咱們仍是差了最後一步,當前的服務只能同時服務一我的,但真實的場景是同時看視頻的有不少人,並且發送的彈幕是共享的。
咱們須要處理兩件事情:
// 文件:server.js const WebSocket = require("ws"); // 引入 WebSocket const redis = require("redis"); // 引入 redis // 初始化 WebSocket 服務器,端口號爲 3000 let wss = new WebSocket.Server({ port: 3000 }); // 建立 redis 客戶端 let client = redis.createClient(); // key value // ********** 如下爲新增代碼 ********** // 存儲全部 WebSocket 用戶 let clientsArr = []; // ********** 以上爲新增代碼 ********** // 原生的 websocket 就兩個經常使用的方法 on('message')、on('send') wss.on("connection", function(ws) { // ********** 如下爲新增代碼 ********** // 將全部經過 WebSocket 鏈接的用戶存入數組中 clientsArr.push(ws); // ********** 以上爲新增代碼 ********** // 監聽鏈接 // 鏈接上須要當即把 redis 數據庫的數據取出返回給前端 client.lrange("barrages", 0, -1, function(err, applies) { // 因爲 redis 的數據都是字符串,因此須要把數組中每一項轉成對象 applies = applies.map(item => JSON.parse(item)); // 使用 websocket 服務器將 redis 數據庫的數據發送給前端 // 構建一個對象,加入 type 屬性告訴前端當前返回數據的行爲,並將數據轉換成字符串 ws.send( JSON.stringify({ type: "INIT", data: applies }) ); }); // 當服務器收到消息時,將數據存入 redis 數據庫 ws.on("message", function(data) { // 向數據庫存儲時存的是字符串,存入並打印數據,用來判斷是否成功存入數據庫 client.rpush("barrages", data, redis.print); // ********** 如下爲修改後的代碼 ********** // 循環數組,將某一我的新發送的彈幕在存儲到 Redis 以後返回給全部用戶 clientsArr.forEach(w => { // 再將當前這條數據返回給前端,一樣添加 type 字段告訴前端當前行爲,並將數據轉換成字符串 w.send( JSON.stringify({ type: "ADD", data: JSON.parse(data) }) ); }); // ********** 以上爲修改後的代碼 ********** }); // ********** 如下爲新增代碼 ********** // 監聽關閉鏈接事件 ws.on("close", function() { // 當某一我的關閉鏈接離開時,將這我的從當前存儲用戶的數組中移除 clientsArr = clientsArr.filter(client => client != ws); }); // ********** 以上爲新增代碼 ********** });
上面就是 Canvas + WebSocket + Redis 視頻彈幕的實現,實現過程可能有些複雜,但整個過程寫的仍是比較詳細,可能須要必定的耐心慢慢的讀完,並最好一步一步跟着寫一寫,但願這篇文章可讓讀到的人解決視頻彈幕相似的需求,真正理解整個過程和開放封閉原則,認識到前端面向對象編程思想的美。