1. 程式人生 > >彈幕,是怎樣練成的?

彈幕,是怎樣練成的?

天下視訊唯彈幕不破

說起彈幕看過視訊的都不會陌生,那滿屏充滿著飄逸評論的效果,讓人如痴如醉,無法自拔

最近也是因為在學習關於canvas的知識,所以今天就想和大家分享一個關於彈幕的故事

那麼究竟彈幕是怎樣煉成的呢? 我們且往下看(look)

什麼?看效果

效果圖已經呈現給各位了,那麼是不是有點小激動呢?是的,感慨萬分,思緒寧亂,無語凝噎

無論以後我們的工作中是否會遇到這樣的需求,也請給自己一個增加技能的機會吧!!!

本次彈幕的效果,專案結構如下圖所示

專案整體已經給出,那麼我們就擼起袖子加油幹吧。

讓彈幕飛

上面我們提到了canvas的事情,所以呢,這就是製作彈幕的殺手鐗了。我們利用canvas繪圖來實現彈幕的功能

首先,我們先給出html的結構

// index.html檔案
<div class="wrap">
    <h1>聽媽媽的話 - 周杰倫</h1>
    <div class="main">
        <canvas id="canvas"></canvas>
        <video src="../source/mv.mp4" id="video" controls width="720" height="480"></video>
    </div>
    <div class="content"
> <input type="text" id="text"> <input type="button" value="發彈幕" id="btn"> <input type="color" id="color"> <input type="range" id="range" max="40" min="20"> </div> </div> // 引入index.js檔案用來實現彈幕功能 <script src="./index.js"></script> 複製程式碼

如需要視訊資源的,就點這裡吧(提取碼:tsei)

結構相對來說沒什麼高階的內容,主要就是寫上了canvas標籤還有video標籤,他們才是視訊網站中彈幕的絕佳拍檔

那麼不再賣關子了,趕緊進行主要活動吧

模擬資料

// index.js檔案
let data = [
    {value: '周杰倫的聽媽媽的話,讓我反覆迴圈再迴圈', time: 5, color: 'red', speed: 1, fontSize: 22},
    {value: '想快快長大,才能保護她', time: 10, color: '#00a1f5', speed: 1, fontSize: 30},
    {value: '聽媽媽的話吧,晚點再戀愛吧!愛呢?', time: 15},
];
複製程式碼

資料裡代表了什麼:

  • value:代表彈幕的內容 (必填)
  • time:代表彈幕展現的時間 (必填)
  • color:代表彈幕文字的顏色
  • speed:代表彈幕飄過的速度
  • fontSize:代表彈幕文字的大小
  • opacity:代表彈幕文字的透明度

除了彈幕的內容和展現的時間外,其他都是可選的,模擬的資料裡沒有這些引數也沒關係的

獲取dom元素

// index.js檔案
// 模擬資料
...省略

// 獲取到所有需要的dom元素
let doc = document;
let canvas = doc.getElementById('canvas');
let video = doc.getElementById('video');
let $txt = doc.getElementById('text');
let $btn = doc.getElementById('btn');
let $color = doc.getElementById('color');
let $range = doc.getElementById('range');
複製程式碼

Canvas渲染彈幕

下面我們將用面向物件的方式來實現canvas繪製彈幕的功能,之所以選擇用這種方式主要是方便複用和後續新增方法

下面我們先來建立一個CanvasBarrage類,主要用做canvas來渲染整個彈幕

在實現之前,我們先來呼叫一下,看看是如何建立例項的

// index.js檔案
// 模擬資料
...省略
// 獲取到所有需要的dom元素
...省略

// 建立CanvasBarrage類
class CanvasBarrage {
    // todo
}
// 建立CanvasBarrage例項
let canvasBarrage = new CanvasBarrage(canvas, video, { data });
複製程式碼

建立例項很簡單,沒有物件,只需要new一個就有了,哈哈。接下來,說回正事,我們趕緊完成上面程式碼中todo的部分,來完善CanvasBarrage類吧

實現CanvasBarrage

// index.js檔案
class CanvasBarrage {
    constructor(canvas, video, opts = {}) { 
        // opts = {}表示如果opts沒傳就設為{},防止報錯,ES6語法
        
        // 如果canvas和video都沒傳,那就直接returnif (!canvas || !video) return;
        
        // 直接掛載到this上
        this.video = video;
        this.canvas = canvas;
        // 設定canvas的寬高和video一致
        this.canvas.width = video.width;
        this.canvas.height = video.height;
        // 獲取畫布,操作畫布
        this.ctx = canvas.getContext('2d');
        
        // 設定預設引數,如果沒有傳就給帶上
        let defOpts = {
            color: '#e91e63',
            speed: 1.5,
            opacity: 0.5,
            fontSize: 20,
            data: []
        };
        // 合併物件並全都掛到this例項上
        Object.assign(this, defOpts, opts);
       
       // 添加個屬性,用來判斷播放狀態,預設是true暫停
       this.isPaused = true;
       // 得到所有的彈幕訊息
       this.barrages = this.data.map(item => new Barrage(item, this));
       // 渲染
       this.render();
       console.log(this);
    }
    // 渲染canvas繪製的彈幕
    render() {
        // todo
    }
}
複製程式碼

我們在“得到所有的彈幕訊息”那裡,通過陣列的map方法返回的還是個陣列,不過返回的內容是一個Barrage類,這是為什麼呢?

還記得之前說過麼,用類的好處就是方便擴充套件,後續再新增方法的話可以直接在該類中新增即可。

所以我們也不推崇直接map方法裡直接返回一個{}這種形式

// 不推薦
this.barrages = this.data.map(item => { item });
複製程式碼

說到這裡我們還要先寫一下Barrage這個類,不然接下來的console.log(this)會因為找不到Barrage類而報錯

// index.js檔案

++++++++++++++++++++++
// 建立Barrage類,用來例項化每一個彈幕元素
class Barrage {
    constructor(obj, ctx) {
        // todo
    }
}
++++++++++++++++++++++

class CanvasBarrage {
    ...省略
}
複製程式碼

Now,通過上面程式碼中的console.log(this),我們可以看到,所有掛載到this例項上的屬性和原型上的方法都呈現眼前了

render一下

接著上面的CanvasBarrage類裡render方法繼續寫,我們來把todo完成

// index.js檔案
class CanvasBarrage {
    constructor(canvas, video, opts = {}) {
        ...省略
        // 渲染
        this.render();
    }
    render() {
        // 渲染的第一步是清除原來的畫布,方便複用寫成clear方法來呼叫
        this.clear();
        // 渲染彈幕
        this.renderBarrage();
        // 如果沒有暫停的話就繼續渲染
        if (this.isPaused === false) {
            // 通過raf渲染動畫,遞迴進行渲染
            requestAnimationFrame(this.render.bind(this));
        }
    }
    clear() {
        // 清除整個畫布
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    }
}
複製程式碼

todo都做了什麼?

  1. 清除之前畫布所有的繪製,防止繪製重疊的影響
    • this.clear()
  2. 渲染真正的彈幕資料 (還未實現)
    • this.renderBarrage()
  3. 判斷是否繼續渲染彈幕
    • this.isPaused為false時表示為播放狀態
  4. 遞迴呼叫render
    • 通過requestAnimationFrame來遞迴呼叫render
    • 要比setInterval這樣的方式好很多

渲染整個彈幕render方法就完成了,那麼要繼續寫了,應該是剛才未實現的renderBarrage方法了

But,在此之前,我們要先寫個別的,它就是Barrage類

因為還需要它來大顯身手一下呢,每一個彈幕的例項都由它來製造

建立Barrage類

彈幕製造者來了,下面我們就來實現一下這個Barrage類,看它都具備哪些屬性和方法,繼續todo吧

// index.js檔案
class Barrage {
    constructor(obj, ctx) {
        this.value = obj.value; // 彈幕的內容
        this.time = obj.time;   // 彈幕出現時間
        // 把obj和ctx都掛載到this上方便獲取
        this.obj = obj;
        this.context = ctx;
    }
    // 初始化彈幕
    init() {
        // 如果資料裡沒有涉及到下面4種引數,就直接取預設引數
        this.color = this.obj.color || this.context.color;
        this.speed = this.obj.speed || this.context.speed;
        this.opacity = this.obj.opacity || this.context.opacity;
        this.fontSize = this.obj.fontSize || this.context.fontSize;
        
        // 為了計算每個彈幕的寬度,我們必須建立一個元素p,然後計算文字的寬度
        let p = document.createElement('p');
        p.style.fontSize = this.fontSize + 'px';
        p.innerHTML = this.value;
        document.body.appendChild(p);
        
        // 把p元素新增到body裡了,這樣就可以拿到寬度了
        // 設定彈幕的寬度
        this.width = p.clientWidth;
        // 得到了彈幕的寬度後,就把p元素從body中刪掉吧
        document.body.removeChild(p);
        
        // 設定彈幕出現的位置
        this.x = this.context.canvas.width;
        this.y = this.context.canvas.height * Math.random();
        // 做下超出範圍處理
        if (this.y < this.fontSize) {
            this.y = this.fontSize;
        } else if (this.y > this.context.canvas.height - this.fontSize) {
            this.y = this.context.canvas.height - this.fontSize;
        }
    }
    // 渲染每個彈幕
    render() {
        // 設定畫布文字的字號和字型
        this.context.ctx.font = `${this.fontSize}px Arial`;
        // 設定畫布文字顏色
        this.context.ctx.fillStyle = this.color;
        // 繪製文字
        this.context.ctx.fillText(this.value, thix.x, this.y);
    }
}
複製程式碼

todo都做了什麼?

  1. 從傳入的obj中取到必要的value和time
    this.value = obj.value; // 內容
    this.time = obj.time;   // 時間
    複製程式碼
  2. 初始化彈幕
    • 對每個彈幕所需的引數進行設定,如果obj上沒有,就取預設引數
    • 計算每個彈幕的寬度
      • 由於不能直接操縱canvas畫布裡的元素,所以先建立一個p標籤
      • p標籤的寬度即為彈幕的寬 -> this.width = p.clientWidth
    • 設定每個彈幕的x和y座標 (起始位置)
      • 橫向x座標起始位置都是從右邊進入,即:畫布的寬度
        • this.x = this.context.canvas.width
      • 縱向y座標起始位置是不固定的,選在畫布之內的任意位置出現
        • this.y = this.context.canvas.height * Math.random()
    • 處理彈幕超出畫布區域
      • canvas是按照字號基線來展示字型的,如果小於這個字號大小
        • this.y = this.fontSize
      • 如果大於畫布高度 - 字號大小
        • this.y = this.context.canvas.height - this.fontSize
  3. 渲染每個彈幕
    • 繪製文字需要設定文字的字型字號顏色和文字的內容座標
    • 字型字號api
      • this.context.ctx.font = ${this.value}px Arial
    • 顏色api
      • this.context.ctx.fillStyle = this.color
    • 內容與座標api
      • this.context.ctx.fillText(this.value, this.x, this.y)

以上三步就是整個Barrage類所做的事情了。Barrage這個類都已經敲完了,那麼接下來開始真正的渲染步驟吧

renderBarrage才是主角

// index.js檔案
class CanvasBarrage {
    ...省略
    renderBarrage() {
        // 首先拿到當前視訊播放的時間
        // 要根據該時間來和彈幕要展示的時間做比較,來判斷是否展示彈幕
        let time = this.video.currentTime;
        
        // 遍歷所有的彈幕,每個barrage都是Barrage的例項
        this.barrages.forEach(barrage => {
            // 用一個flag來處理是否渲染,預設是false
            // 並且只有在視訊播放時間大於等於當前彈幕的展現時間時才做處理
            if (!barrage.flag && time >= barrage.time) {
                // 判斷當前彈幕是否有過初始化了
                // 如果isInit還是false,那就需要先對當前彈幕進行初始化操作
                if (!barrage.isInit) {
                    barrage.init();
                    barrage.isInit = true;
                }
                // 彈幕要從右向左渲染,所以x座標減去當前彈幕的speed即可
                barrage.x -= barrage.speed;
                barrage.render(); // 渲染當前彈幕
                
                // 如果當前彈幕的x座標比自身的寬度還小了,就表示結束渲染了
                if (barrage.x < -barrage.width) {
                    barrage.flag = true; // 把flag設為true下次就不再渲染
                }
            }
        });
    }
}
複製程式碼

此時我們再新增一個觸發彈幕的事件,讓彈幕飛起來

// index.js檔案
class CanvasBarrage {
    ...省略
}

// 建立CanvasBarrage例項
let canvasBarrage = new CanvasBarrage(canvas, video, { data });
++++++++++++++++++++++++++++++++++++++
// 設定video的play事件來呼叫CanvasBarrage例項的render方法
video.addEventListener('play', () => {
    canvasBarrage.isPaused = false;
    canvasBarrage.render(); // 觸發彈幕
});
++++++++++++++++++++++++++++++++++++++
複製程式碼

大家一起寫到了這裡,也是時候展示一下成果了,往下看

別急,讓彈幕再飛一會兒

渲染彈幕的功能,我們已經完成了,接下來讓我們馬不停蹄的寫下如何發彈幕吧。別猶豫,開擼!!!

發彈幕

// index.js檔案
class CanvasBarrage {
    ...省略
}
video.addEventListener('play', ...省略);

+++++++++++++++++++++++++++++++++++++++
// 傳送彈幕的方法
function send() {
    let value = $txt.value;  // 輸入的內容
    let time = video.currentTime; // 當前視訊時間
    let color = $color.value;   // 選取的顏色值
    let fontSize = $range.value; // 選取的字號大小
    let obj = { value, time, color, fontSize };
    // 新增彈幕資料
    canvasBarrage.add(obj);
    $txt.value = ''; // 清空輸入框
}
// 點選按鈕傳送彈幕
$btn.addEventListener('click', send);
// 回車傳送彈幕
$txt.addEventListener('keyup', e => {
    let key = e.keyCode;
    key === 13 && send();
});
+++++++++++++++++++++++++++++++++++++++
複製程式碼

發彈幕相對來說還是很簡單的,獲取到value, time, color, fontSize之後把他們當作物件傳給CanvasBarrage的add方法進行新增就好了

下面我們再寫一下add方法,回到CanvasBarrage類裡繼續寫

// index.js檔案
class CanvasBarrage {
    constructor() { ...省略}
    render() { ...省略 }
    renderBarrage() { ...省略 }
    clear() { ...省略 }
    +++++++++++++++++++++++++++
    add(obj) {
        // 實際上就是往barrages數組裡再新增一項Barrage的例項而已
        this.barrages.push(new Barrage(obj, this));
    }
    +++++++++++++++++++++++++++
}
複製程式碼

完成,漂亮,看看效果吧

寫到這裡我們已經完成了視訊網站上的彈幕功能了,可喜可賀

下面我們再來完善一下視訊播放時對彈幕的播放處理吧

暫停和拖動

  • 暫停就停止渲染彈幕
// index.js檔案
...省略
// 播放
video.addEventListener('play', () => {
    canvasBarrage.isPaused = false;
    canvasBarrage.render();
});
+++++++++++++++++++++++++++++++++++++++
// 暫停
video.addEventListener('pause', () => {
    // isPaused設為true表示暫停播放
    canvasBarrage.isPaused = true;
});
+++++++++++++++++++++++++++++++++++++++
複製程式碼
  • 回放時需要重新渲染該時刻的彈幕
// index.js檔案

// 暫停
video.addEventListener('pause', () => {
    canvasBarrage.isPaused = true;
});
+++++++++++++++++++++++++++++++++++++++
// 拖動進度條時觸發seeked事件
video.addEventListener('seeked', () => {
    // 呼叫CanvasBarrage類的replay方法進行回放,重新渲染彈幕
    canvasBarrage.replay();
});
+++++++++++++++++++++++++++++++++++++++
複製程式碼

讓我們再次回到CanvasBarrage這個類上

// index.js檔案
class CanvasBarrage {
    constructor() { ...省略}
    render() { ...省略 }
    renderBarrage() { ...省略 }
    clear() { ...省略 }
    add(obj) { ...省略 }
    +++++++++++++++++++++++++++
    replay() {
        this.clear(); //先清除畫布
        // 獲取當前視訊播放時間
        let time = this.video.currentTime;
        // 遍歷barrages彈幕陣列
        this.barrages.forEach(barrage => {
            // 當前彈幕的flag設為false
            barrage.flag = false;
            // 並且,當前視訊時間小於等於當前彈幕所展現的時間
            if (time <= barrage.time) {
                // 就把isInit重設為false,這樣才會重新初始化渲染
                barrage.isInit = false;
            } else { // 其他時間對比不匹配的,flag還是true不用重新渲染
                barrage.flag = true;
            }
        });
    }
    +++++++++++++++++++++++++++
}
複製程式碼

盡善盡美一下

OK,寫到這裡,所有關於彈幕功能的程式碼就全部結束了!!!如果工作中讓你開發彈幕功能,你也可以在多敲幾遍以上程式碼之後,得心應手的保證完成任務了

不過做事總是要做全套比較好,我們接下來再利用WebSocketredis來進行一下較為實戰的功能吧

大家之前看到過目錄結構,還有一個app.js檔案其實是沒有寫任何東西的,那麼接下來我們就開始寫寫看吧

WebSocket通訊和redis儲存

久違的app.js檔案,開始動手 首先我們需要安裝兩個包,一個是處理服務端WebSocket通訊的ws模組,另一個就是用來儲存redis資料的redis模組

npm i ws redis -S

安裝完成後可以繼續寫東西了

// app.js檔案
const WebSocket = require('ws');
const redis = require('redis');
const clientRedis = redis.createClient(); // 建立redis客戶端
const ws = new WebSocket.Server({ port: 9999 }); // 建立ws服務
// 用來儲存不同的socket例項,區分不同使用者
let clients = [];
// 監聽連線
ws.on('connection', socket => {
    clients.push(socket); // 把socket例項新增到陣列
    
    // 通過redis客戶端的lrange方法來獲取資料庫中key為barrages的資料
    clientRedis.lrange('barrages', 0, -1, (err, data) => {
        // 由於redis儲存的是key value型別,因此需要JSON.parse轉成物件
        data = data.map(item => JSON.parse(item));
        
        // 傳送給客戶端,send方法傳遞的是字串需要JSON.stringify
        // type為init是用來初始化彈幕資料的
        socket.send(JSON.stringify({
            type: 'init',
            data
        }));
    });
    // 監聽客戶端發來的訊息
    socket.on('message', data => {
        // redis客戶端通過rpush的方法把每個訊息都新增到barrages表的最後面
        clientRedis.rpush('barrages', data);
        
        // 每個socket例項(使用者)之間都可以發彈幕,並顯示在對方的畫布上
        // type為add表示此次操作為新增處理
        // 你可以開啟兩個index.html,分別發彈幕試試吧
        clients.forEach(sk => {
            sk.send(JSON.stringify({
                type: 'add',
                data: JSON.parse(data)
            }));
        });
        
    });
    // 當有socket例項斷開與ws服務端的連線時
    // 重新更新一下clients陣列,去掉斷開的使用者
    socket.on('close', () => {
        clients = clients.filter(client => client !== socket);
    });
});
複製程式碼

服務端的內容已經全部完事了,接下來我們再稍微改下客戶端的程式碼,回到熟悉的index.js中

// index.js檔案
class CanvasBarrage {
    ...省略
}
+++++++++++++++++++++++++++++++
// 建立CanvasBarrage例項
// let canvasBarrage = new CanvasBarrage(canvas, video, { data });
let canvasBarrage;
let ws = new WebSocket('ws://localhost:9999');

// 監聽與ws服務端的連線
ws.onopen = function () {
    // 監聽ws服務端發來的訊息
    ws.onmessage = function (e) {
        let msg = JSON.parse(e.data); //e.data裡是真正的資料
        
        // 判斷如果type為init就初始化彈幕的資料
        if (msg.type === 'init') {
            canvasBarrage = new CanvasBarrage(canvas, video, { data: msg.data });
        } else if (msg.type === 'add') { // 新增彈幕資料
            canvasBarrage.add(msg.data);
        }
    }
};
+++++++++++++++++++++++++++++++

// 傳送彈幕的方法
function send() {
    let value = $txt.value;
    let time = video.currentTime;
    let color = $color.value;
    let fontSize = $range.value;
    let obj = { value, time, color, fontSize };
    // 新增彈幕資料
    // canvasBarrage.add(obj);
    +++++++++++++++++++++++++++++++
    // 把新增的彈幕資料發給ws服務端
    // 由ws服務端拿到後新增到redis資料庫中
    ws.send(JSON.stringify(obj));
    +++++++++++++++++++++++++++++++
    $txt.value = '';
}
複製程式碼

前後端都搞定了,那麼我們接下來只需要連線一下redis資料庫就可以了

連線redis資料庫的正確方式

首先無論是windows還是mac都需要先安裝一下

windows系統

windows連線redis資料庫

進入下載解壓好的redis目錄,在命令列工具中輸入以下指令建立連線

redis-server.exe redis.windows.conf
複製程式碼

出現如下圖顯示的樣子就表示已經成功建立了連線

windows下的redis視覺化工具(Redis Desktop Manager)

mac系統

  • mac: brew install redis
  • 連線: brew services start redis

redis資料庫如果成功的連線了,那麼就可以直接啟動app.js的服務了,開啟index.html檔案,會發現可以拿到資料庫裡儲存的彈幕資料了

好了,這下大家滿足了吧,很厲害,我們每個人都可以敲出自己的彈幕了。

不斷的學習會讓我們一點一滴的進步下去,前端的路還很長,我們都在慢慢前行

對了,忘記重要的事情了,如果大家有什麼疑問可以看下原始碼地址進行參考

結束了

之後一段時間打算好好的研究一下canvas繪圖的知識點了,也希望在研究後可以很好的梳理一下分享給大家一起來學習

作為大前端來說,我們要學的東西實在太多了,一專多精才是王道,不負好時光,一起努力吧!謝謝大家的觀看了