手把手教你用Canvas WebSocket Redis 實現一個視頻彈幕【實踐】


手把手教你用Canvas WebSocket Redis 實現一個視頻彈幕【實踐】


作者:Panda Shen

轉發鏈接:https://www.overtaking.top/2018/06/21/20180621113025/

頁面佈局

首先,我們需要實現頁面佈局,在根目錄創建 index.html 佈局中我們需要有一個 video 多媒體標籤引入我們的本地視頻,添加輸入彈幕的輸入框、確認發送的按鈕、顏色選擇器、字體大小滑動條,創建一個 style.css 來調整頁面佈局的樣式,這裡我們順便創建一個 index.js 文件用於後續實現我們的核心邏輯,先引入到頁面當中。


HTML 佈局代碼如下


文件:index.html

<code>#cantainer {
  text-align: center;
}
#content {
  width: 640px;
  margin: 0 auto;
  position: relative;
}
#canvas {
  position: absolute;
}
video {
  width: 640px;
  height: 360px;
}
input {
  vertical-align: middle;
}/<code>

CSS 樣式代碼如下:

文件:style.css


<code>#cantainer {
  text-align: center;
}
#content {
  width: 640px;
  margin: 0 auto;
  position: relative;
}
#canvas {
  position: absolute;
}
video {
  width: 640px;
  height: 360px;
}
input {
  vertical-align: middle;
}/<code> 

佈局效果如下圖:


手把手教你用Canvas WebSocket Redis 實現一個視頻彈幕【實踐】

彈幕布局


定義接口,構造假數據

我們彈幕中的彈幕數據正常情況下應該是通過與後臺數據交互請求回來,所以我們需要先定義數據接口,並構造假數據來實現前端邏輯。

數據字段定義:

  • value:表示彈幕的內容(必填)
  • time:表示彈幕出現的時間(必填)
  • speed:表示彈幕移動的速度(選填)
  • color:表示彈幕文字的顏色(選填)
  • fontSize:表示彈幕的字體大小(選填)
  • opacity:表示彈幕文字的透明度(選填)

上面的 value 和 time 是必填參數,其他的選填參數可以在前端設置默認值。

前端定義的假數據如下:

文件:index.js

<code>const data = [
  {
    value: '這是第一條彈幕',
    speed: 2,
    time: 0,
    color: 'red',
    fontSize: 20
  },
  {
    value: '這是第二條彈幕',
    time: 1
  }
];/<code>

實現前端彈幕的邏輯

我們希望是把彈幕封裝成一個功能,只要有需要的地方就可以使用,從而實現複用,那麼不同的地方使用這個功能通常的方式是 new 一個實例,傳入當前使用該功能對應的參數,我們也使用這種方式來實現,所以我們需要封裝一個統一的構造函數或者類,參數為當前的 canvas 元素、video 元素和一個 options 對象,options 裡面的 data 屬性為我們的彈幕數據,之所以不直接傳入 data 是為了後續參數的擴展,嚴格遵循開放封閉原則,這裡我們就統一使用 ES6 的 class 類來實現。

創建彈幕功能的類及基本參數處理

佈局時需要注意 Canvas 的默認寬為 300px,高為 150px,我們要保證 Canvas 完全覆蓋整個視頻,需要讓 Canvas 與 video 寬高相等。因為我們不確定每一個使用該功能的視頻的寬高都是一樣的,所以 Canvas 畫布的寬高並沒有通過 CSS 來設置,而是通過 JS 在類創建實例初始化屬性的時候動態設置。

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const defaultOptions = {
      fontSize: 20,
      color: 'gold',
      speed: 2,
      opacity: 0.3,
      data: []
    };

    // 對象的合併,將默認參數對象的屬性和傳入對象的屬性統一放到當前實例上
    Object.assign(this, defaultOptions, options);
  }
}/<code>

應該掛在實例上的屬性除了有當前的 canvas 元素、video 元素、彈幕數據的默認選項以及彈幕數據之外,還應該有一個代表當前是否渲染彈幕的參數,因為視頻暫停的時候,彈幕也是暫停的,所以沒有重新渲染,因為是否暫停與彈幕是否渲染的狀態是一致的,所以我們這裡就用 isPaused 參數來代表當前是否暫停或重新渲染彈幕,值類型為布爾值。

創建構造每一條彈幕的類

我們知道,後臺返回給我們的彈幕數據是一個數組,這個數組裡的每一個彈幕都是一個對象,而對象上有著這條彈幕的信息,如果我們需要在每一個彈幕對象上再加一些新的信息或者在每一個彈幕對象的處理時用到了當前彈幕功能類 CanvasBarrage 實例的一些屬性值,取值顯然是不太方便的,這樣為了後續方便擴展,遵循開放封閉原則,我們把每一個彈幕的對象轉變成同一個類的實例,所以我們創建一個名為 Barrage 的類,讓我們每一條彈幕的對象進入這個類裡面走一遭,掛上一些擴展的屬性。

文件:index.js

<code>class Barrage {
  constructor(item, ctx) {
    this.value = item.value; // 彈幕的內容
    this.time = item.time; // 彈幕出現的時間
    this.item = item; // 每一個彈幕的數據對象
    this.ctx = ctx; // 彈幕功能類的執行上下文
  }
}/<code>

在我們的 CanvasBarrage 類上有一個存儲彈幕數據的數組 data,此時我們需要給 CanvasBarrage 增加一個屬性用來存放 “加工” 後的每條彈幕對應的實例。

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const 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));
    // ********** 以上為新增代碼 **********
  }
}/<code>

其實通過上面操作以後,我們相當於把 data 裡面的每一條彈幕對象轉換成了一個 Barrage 類的一個實例,把當前的上下文 this 傳入後可以隨時在每一個彈幕實例上獲取 CanvasBarrage 類實例的屬性,也方便我們後續擴展方法,遵循這種開放封閉原則的方式開發,意義是不言而喻的。

在 CanvasBarrage 類實現渲染所有彈幕的 render 方法

CanvasBarrage 的 render 方法是在創建彈幕功能實例的時候應該渲染 Canvas 所以應該在 CanvasBarrage 中調用,在 render 內部,每一次渲染之前都應該先將 Canvas 畫布清空,所以需要給當前的 CanvasBarrage 類新增一個屬性用於存儲 Canvas 畫布的內容。

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const 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));
    }
  }
  // ********** 以上為新增代碼 **********
}/<code>

在上面的 CanvasBarrage 的 render 函數中,清空時由於 Canvas 性能比較好,所以將整個畫布清空,所以從座標 (0, 0) 點,清空的寬高為整個 Canvas 畫布的寬高。

只要視頻是在播放狀態應該不斷的調用 render 方法實現清空畫布、渲染彈幕、判斷是否暫停,如果非暫停狀態繼續渲染,所以我們用到了遞歸調用 render 去不斷的實現渲染,但是遞歸時如果直接調用 render,性能特別差,程序甚至會掛掉,以往這種情況我們會在遞歸外層加一個 setTimeout 來定義一個短暫的遞歸時間,但是這個過程類似於動畫效果,如果使用 setTimeout 其實是將同步代碼轉成了異步執行,會增加不確定性導致畫面出現卡頓的現象。

這裡我們使用 H5 的新 API requestAnimationFrame,可以在平均 1/60 S 內幫我執行一次該方法傳入的回調,我們直接把 render 函數作為回調函數傳入 requestAnimationFrame,該方法是按照幀的方式執行,動畫流暢,需要注意的是,render 函數內使用了 this,所以應該處理一下 this 指向問題。

由於我們使用面向對象的方式,所以渲染彈幕的具體細節,我們抽離出一個單獨的方法 renderBarrage,接下來看一下 renderBarrage 的實現。

CanvasBarrage 類 render 內部 renderBarrage 的實現

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const 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;
        }
      }
    });
  }
  // ********** 以上為新增代碼 **********
}/<code> 

此處的 renderBarrage 方法內部主要對每一條彈幕實例所設置的出現時間和視頻的播放時間做對比,如果視頻的播放時間大於等於了彈幕出現的時間,說明彈幕需要繪製在 Canvas 畫布內。

之前我們的每一條彈幕實例的屬性可能不全,彈幕的其他未傳參數並沒有初始化,所以為了最大限度的節省性能,我們在彈幕該第一次繪製的時候去初始化參數,等到視頻播放的時間變化再去重新繪製時,不再初始化參數,所以初始化參數的方法放在了判斷彈幕出現時間的條件裡面執行,又設置了代表彈幕實例是不是初始化了的參數 isInited,初始化函數 init 執行過一次後,馬上修改 isInited 的值,保證只初始化參數一次。

在 renderBarrage 方法中我們可以看出來,其實我們是循環了專門存放每一條彈幕實例(Barrage 類的實例)的數組,我們在內部用實例去調用的方法 init 應該是在 Barrage 類的原型上,下面我們去 Barrage 類上實現 init 的邏輯。

Barrage 類 init 的實現

文件:index.js

<code>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; } } // ********** 以上為新增代碼 ********** }/<code>

在上面代碼的 init 方法中我們其實可以看出,每條彈幕實例初始化的時候初始的信息除了之前說的彈幕的基本參數外,還獲取了每條彈幕的寬度(用於後續做彈幕是否已經完全移出屏幕的邊界判斷)和每一條彈幕的 x 和 y 軸方向的座標併為了防止彈幕在 y 軸顯示不全做了邊界處理。

實現每條彈幕的渲染和彈幕移除屏幕的處理

我們當時在 CanvasBarrage 類的 render 方法中的渲染每個彈幕的方法 renderBarrage中(原諒這麼囉嗦,因為到現在內容已經比較多,說的具體一點方便知道是哪個步驟,哈哈)只做了對每一條彈幕實例的初始化操作,並沒有渲染在 Canvas 畫布中,這時我們主要做兩部操作,實現每條彈幕渲染在畫布中和左側移出屏幕不再渲染的邊界處理。

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const 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; // 是否出去了,出去了,做停止渲染的操作 } // ********** 以上為新增代碼 ********** } }); } }/<code>

每個彈幕實例都有一個 speed 屬性,該屬性代表著彈幕移動的速度,換個說法其實就是每次減少的 x 軸的差值,所以我們其實是通過改變 x 軸的值再重新渲染而實現彈幕的左移,我們創建了一個標識 flag 掛在每個彈幕實例下,代表是否已經離開屏幕,如果離開則更改 flag 的值,使外層的 CanvasBarrage 類的 render 函數再次遞歸時不進入渲染程序。

每一條彈幕具體是怎麼渲染的,通過代碼可以看出每個彈幕實例在 x 座標改變後都調用了實例方法 render 函數,注意此 render 非彼 render,該 render 函數屬於 Barrage 類,目的是為了渲染每一條彈幕,而 CanvasBarrage 類下的 render,是為了在視頻時間變化時清空並重新渲染整個 Canvas 畫布。

Barrage 類下的 render 方法的實現

文件:index.js

<code>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); } // ********** 以上為新增代碼 ********** }/<code>

從上面新增代碼我們可以看出,其實 Barrage 類的 render 方法只是將每一條彈幕的字號、顏色、內容、座標等屬性通過 Canvas 的 API 添加到了畫布上。

實現播放、暫停事件

還記得我們的 CanvasBarrage 類裡面有一個屬性 isPaused,屬性值控制了我們是否遞歸渲染,這個屬性與視頻暫停的狀態是一致的,我們在播放的時候,彈幕不斷的清空並重新繪製,當暫停的時候彈幕也應該跟著暫停,說白了就是不在調用 CanvasBarrage 類的 render 方法,其實就是在暫停、播放的過程中不斷的改變 isPaused 的值即可。

還記得我們之前構造的兩條假數據 data 吧,接下來我們添加播放、暫停事件,來嘗試使用一下我們的彈幕功能。

文件:index.js

<code>// 實現一個簡易選擇器,方便獲取元素,後面獲取元素直接調用 $
const $ = document.querySelector.bind(document);

// 獲取 Canvas 元素和 Video 元素
const canvas = $('#canvas');
const video = $('#video');

const canvasBarrage = new CanvasBarrage(canvas, video, {
  data
});

// 添加播放事件
video.addEventListener('play', function() {
  canvasBarrage.isPaused = false;
  canvasBarrage.render();
});

// 添加暫停事件
video.addEventListener('pause', function() {
  canvasBarrage.isPaused = true;
});/<code>

實現發送彈幕事件

文件:index.js

<code>$('#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); // 發送彈幕的方法
});/<code> 

其實我們發送彈幕時,就是向 CanvasBarrage 類的 barrages 數組裡添加了一條彈幕的實例,我們單獨封裝了一個 add 的實例方法。

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const 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));
  }
  // ********** 以上為新增代碼 **********
}/<code>

拖動進度條實現彈幕的前進和後退

其實我們發現,彈幕雖然實現了正常的播放、暫停以及發送,但是當我們拖動進度條的時候彈幕應該是跟著視頻時間同步播放的,現在的彈幕一旦播放過無論怎樣拉動進度條彈幕都不會再出現,我們現在就來解決這個問題。

文件:index.js

<code>// 拖動進度條事件
video.addEventListener('seeked', function() {
  canvasBarrage.reset();
});/<code>

我們在事件內部其實只是調用了一下 CanvasBarrage 類的 reset 方法,這個方法就是在拖動進度條的時候來幫我們初始化彈幕的狀態。

文件:index.js

<code>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;

    // 沒傳參數的默認值
    const 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; // 否則將彈幕的狀態設置為以移出屏幕 } }); } // ********** 以上為新增代碼 ********** }/<code>

其實 reset 方法中值做了幾件事:

  • 清空 Canvas 畫布;
  • 獲取當前進度條拖動位置的時間;
  • 循環存儲彈幕實例的數組;
  • 將所有彈幕更改為未移出屏幕;
  • 判斷拖動時間和每條彈幕的時間;
  • 在當前時間以後的彈幕重新初始化數據;
  • 以前的彈幕更改為已移出屏幕。

從而實現了拖動進度條彈幕的 “前進” 和 “後退” 功能。

使用 WebSocket 和 Redis 實現前後端通信及數據存儲

服務器代碼的實現

要使用 WebSocket 和 Redis 首先需要去安裝 ws、redis 依賴,在項目根目錄執行下面命令:

<code>npm install ws redis/<code>

我們創建一個 server.js 文件,用來寫服務端的代碼:

文件:index.js

<code>const WebSocket = require('ws'); // 引入 WebSocket
const redis = require('redis'); // 引入 redis

// 初始化 WebSocket 服務器,端口號為 3000
const wss = new WebSocket.Server({
  port: 3000
});

// 創建 redis 客戶端
const 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)
    }));
  });
});/<code>

服務器的邏輯很清晰,在 WebSocket 連接上時,立即獲取 Redis 數據庫的所有彈幕數據返回給前端,當前端點擊發送彈幕按鈕發送數據時,接收數據存入 Redis 數據庫中並打印驗證數據是否成功存入,再通過 WebSocket 服務把當前這一條數返回給前端,需要注意一下幾點:

  • 從 Redis 數據庫中取出全部彈幕數據的數組內部都存儲的是字符串,需要使用 JSON.parse 方法進行解析;
  • 將數據發送前端時,最外層要使用 JSON.stringify 重新轉換成字符串發送;
  • 在初始化階段 WebSocket 發送所有數據和前端添加新彈幕 WebSocket 將彈幕的單條數據重新返回時,需要添加對應的 type 值告訴前端,當前的操作行為。

前端代碼的修改

在沒有實現後端代碼之前,前端使用的是 data 的假數據,是在添加彈幕事件中,將獲取的新增彈幕信息通過 CanvasBarrage 類的 add 方法直接創建 Barrage 類的實例,並加入到存放彈幕實例的 barrages 數組中。

現在我們需要更正一下交互邏輯,在發送彈幕事件觸發時,我們應該先將獲取的單條彈幕數據通過 WebSocket 發送給後端服務器,在服務器重新將消息返還給我們的時候,去將這條數據通過 CanvasBarrage 類的 add 方法加入到存放彈幕實例的 barrages 數組中。

還有在頁面初始化時,我們之前在創建 CanvasBarrage 類實例的時候直接傳入了 data 假數據,現在需要通過 WebSocket 的連接事件,在監聽到連接 WebSocket 服務時,去創建 CanvasBarrage 類的實例,並直接把服務端返回 Redis 數據庫真實的數據作為參數傳入,前端代碼修改如下:

文件:index.js

<code>// ********** 下面代碼被刪掉了 **********
// let canvasBarrage = new CanvasBarrage(canvas, video, {
//     data
// });
// ********** 上面代碼被刪掉了 **********

// ********** 以下為新增代碼 **********
let canvasBarrage;

// 創建 WebSocket 連接
const 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); // 發送彈幕的方法
  // ********** 上面代碼被刪掉了 **********
});/<code>

現在我們可以打開 index.html 文件並啟動 server.js 服務器,就可以實現真實的視頻彈幕操作了,但是我們還是差了最後一步,當前的服務只能同時服務一個人,但真實的場景是同時看視頻的有很多人,而且發送的彈幕是共享的。

實現多端通信、彈幕共享

我們需要處理兩件事情:

  • 第一件事情是實現多端通信共享數據庫信息;
  • 第二件事情是當有人離開的時候清除關閉的 WebSocket 對象。

文件:server.js

<code>const WebSocket = require('ws'); // 引入 WebSocket
const redis = require('redis'); // 引入 redis

// 初始化 WebSocket 服務器,端口號為 3000
const wss = new WebSocket.Server({
    port: 3000
});

// 創建 redis 客戶端
const client = redis.createClient(); // key value

// ********** 以下為新增代碼 **********
// 存儲所有 WebSocket 用戶
const 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);
  });
  // ********** 以上為新增代碼 **********
});/<code>

上面就是 Canvas + WebSocket + Redis 視頻彈幕的實現,實現過程可能有些複雜,但整個過程寫的還是比較詳細,可能需要一定的耐心慢慢的讀完,並最好一步一步跟著寫一寫,希望這篇文章可以讓讀到的人解決視頻彈幕類似的需求,真正理解整個過程和開放封閉原則,認識到前端面向對象編程思想的美。

作者:Panda Shen

轉發鏈接:https://www.overtaking.top/2018/06/21/20180621113025/


分享到:


相關文章: