直播是眼下最爲火爆的行業,而彈幕無疑是直播平臺中最流行、最重要的功能之一。本文將講述如何實現兼容 PC 瀏覽器和移動瀏覽器的彈幕。javascript
通常來講,彈幕數據會經過異步請求或 socket 消息傳到前端,這裏會存在一個隱患——數據量可能很是大。若是一收到彈幕數據就立刻渲染出來,在量大的時候:css
顯示區域不足以放置這麼多的彈幕,彈幕會堆疊在一塊兒;
渲染過程會佔用大量 CPU 資源,致使頁面卡頓。
因此在接收和渲染數據之間,要引入隊列作緩衝。把收到的彈幕數據都存入數組(即下文代碼中的 this._queue),再經過輪詢該數組,把彈幕逐條渲染出來:html
class Danmaku { // 省略 N 行代碼... add(data) { this._queue.push(this._parseData(data)); if (!this._renderTimer) { this._render(); } } _render() { try { this._renderToDOM(); } finally { this._renderEnd(); } } _renderEnd() { if (this._queue.length > 0) { this._renderTimer = setTimeout(() => { this._render(); }, this._renderInterval); } else { this._renderTimer = null; } } // 省略 N 行代碼... }
彈幕的滾動本質上是位移動畫,從顯示區域的右側移動到左側。前端實現位移動畫有兩種方案——DOM 和 canvas。前端
DOM 方案實現的動畫較爲流暢,且一些特殊效果(如文字陰影)較容易實現(只要在 CSS 中設置對應的屬性便可)。
Canvas 方案的動畫流暢度要差一些,要作特殊效果也不那麼容易,可是它在 CPU 佔用上有優點。java
本文將以 DOM 方案實現彈幕的滾動,並經過 CSS 的 transition 和 transform 來實現動畫,這樣能夠利用瀏覽器渲染過程當中的「合成層」機制(有興趣能夠查閱這篇文章),提升性能。彈幕滾動的示例代碼以下:node
var $item = $('.danmaku-item').css({ left: '100%', 'transition-duration': '2s', 'transition-property': 'transform', 'will-change': 'transform' }); setTimeout(function() { $item.css( 'transform', `translateX(${-$item.width() + container.offsetWidth})` ); }, 1000);
在 DOM 方案下,每條彈幕對應一個 HTML 元素,把元素的樣式都設定好以後,就能夠添加到 HTML 文檔裏面:git
class Danmaku { // 省略 N 行代碼... _renderToDOM() { const data = this._queue[0]; let node = data.node; if (!node) { data.node = node = document.createElement('div'); node.innerText = data.msg; node.style.position = 'absolute'; node.style.left = '100%'; node.style.whiteSpace = 'nowrap'; node.style.color = data.fontColor; node.style.fontSize = data.fontSize + 'px'; node.style.willChange = 'transform'; this._container.appendChild(node); // 佔用軌道數 data.useTracks = Math.ceil(node.offsetHeight / this._trackSize); // 寬度 data.width = node.offsetWidth; // 總位移(彈幕寬度+顯示區域寬度) data.totalDistance = data.width + this._totalWidth; // 位移時間(若是數據裏面沒有指定,就按照默認方式計算) data.rollTime = data.rollTime || Math.floor(data.totalDistance * 0.0058 * (Math.random() * 0.3 + 0.7)); // 位移速度 data.rollSpeed = data.totalDistance / data.rollTime; // To be continued ... } } // 省略 N 行代碼... }
因爲元素的 left 樣式值設置爲 100%,因此它在顯示區域以外。這樣能夠在用戶看到這條彈幕以前,作一些「暗箱操做」,包括獲取彈幕的尺寸、佔用的軌道數、總位移、位移時間、位移速度。接下來的問題是,要把彈幕顯示在哪一個位置呢?github
首先,彈幕的文字大小不必定一致,從而佔用的高度也不盡相同。爲了能充分利用顯示區域的空間,咱們能夠把顯示區域劃分爲多行,一行即爲一條軌道。一條彈幕至少佔用一條軌道。而存儲結構方面,能夠用二維數組記錄每條軌道中存在的彈幕。下圖是彈幕佔用軌道及其對應存儲結構的一個例子:web
其次,要防止彈幕重疊。原理其實很是簡單,請看下面這題數學題。假設有起點站、終點站和一條軌道,列車都以勻速運動方式從起點開到終點。列車 A 先發車,請問:若是在某個時刻,列車 B 發車的話,會不會在列車 A 徹底進站以前撞上列車 A?算法
聰明的你可能已經發現,這裏的軌道所對應的就是彈幕顯示區域裏面的一行,列車對應的就是彈幕。解題以前,先過一下已知量:
那在什麼狀況下,兩車不會相撞呢?
其三,若是列車 B 的速度大於列車 A 的速度,那就要看二者的速度差了:
有了理論支撐,就能夠編寫對應的代碼了。
class Danmaku { // 省略 N 行代碼... // 把彈幕數據放置到合適的軌道 _addToTrack(data) { // 單條軌道 let track; // 軌道的最後一項彈幕數據 let lastItem; // 彈幕已經走的路程 let distance; // 彈幕數據最終坐落的軌道索引 // 有些彈幕會佔多條軌道,因此 y 是個數組 let y = []; for (let i = 0; i < this._tracks.length; i++) { track = this._tracks[i]; if (track.length) { // 軌道被佔用,要計算是否會重疊 // 只須要跟軌道最後一條彈幕比較便可 lastItem = track[track.length - 1]; // 獲取已滾動距離(即當前的 translateX) distance = -getTranslateX(lastItem.node); // 計算最後一條彈幕所有消失前,是否會與新增彈幕重疊 // (對應數學題分析中的三種狀況) // 若是不會重疊,則可使用當前軌道 if ( (distance > lastItem.width) && ( (data.rollSpeed <= lastItem.rollSpeed) || ((distance - lastItem.width) / (data.rollSpeed - lastItem.rollSpeed) > (this._totalWidth + lastItem.width - distance) / lastItem.rollSpeed) ) ) { y.push(i); } else { y = []; } } else { // 軌道未被佔用 y.push(i); } // 有足夠的軌道能夠用時,就能夠新增彈幕了,不然等下一次輪詢 if (y.length >= data.useTracks) { data.y = y; y.forEach((i) => { this._tracks[i].push(data); }); break; } } } // 省略 N 行代碼... }
只要彈幕成功入軌(data.y 存在),就能夠顯示在對應的位置並執行動畫了:
class Danmaku { // 省略 N 行代碼... _renderToDOM { const data = this._queue[0]; let node = data.node; if (!data.node) { // 省略 N 行代碼... } this._addToTrack(); if (data.y) { this._queue.shift(); // 軌道對應的 top 值 node.style.top = data.y[0] * this._trackSize + 'px'; // 動畫參數 node.style.transition = `transform ${data.rollTime}s linear`; node.style.transform = `translateX(-${data.totalDistance}px)`; // 動畫結束後移除 node.addEventListener('transitionend', () => { this._removeFromTrack(data.y, data.autoId); this._container.removeChild(node); }, false); } } // 省略 N 行代碼... }
至此,渲染流程結束,此時的彈幕效果見此 demo 頁。爲了可以讓你們看清楚渲染過程當中的「暗箱操做」,demo 頁中會把顯示區域之外的部分也展現出來。
上一節已經實現了彈幕的基本功能,但仍有一些細節須要完善。
仔細觀察上文的彈幕 demo 能夠發現,同一條軌道內,彈幕之間的距離偏大。而該 demo 中,隊列輪詢的間隔爲 150ms,理應不會有這麼大的間距。
回顧渲染的代碼能夠發現,該流程老是先檢查第一條彈幕能不能入軌,假若不能,那後續的彈幕都會被堵塞,從而致使彈幕密集度不足。然而,每條彈幕的長度、速度等參數不盡相同,第一條彈幕不具有入軌條件不表明後續的彈幕都不具有。因此,在單次渲染過程當中,若是第一條彈幕還不能入軌,能夠日後多嘗試幾條。
相關的代碼改動也不大,只要加個循環就好了:
_renderToDOM() { // 根據軌道數量每次處理必定數量的彈幕數據。數量越大,彈幕越密集,CPU 佔用越高 let count = Math.floor(totalTracks / 3), i; while (count && i < this._queue.length) { const data = this._queue[i]; // 省略 N 行代碼... if (data.y) { this._queue.splice(i, 1); // 省略 N 行代碼... } else { i++; } count--; } }
改動後的效果見此 demo 頁,能夠看到彈幕密集程度有明顯改善。
防重疊檢測是彈幕渲染過程當中執行得最爲頻繁的部分,所以其優化顯得特別重要。JavaScript 性能優化的關鍵是:儘量避免 DOM 操做。而整個防重疊檢測算法中涉及的惟一一處 DOM 操做,就是彈幕已滾動路程的獲取:
distance = -getTranslateX(data.node);
而實際上,這個路程不必定要經過讀取當前樣式值來獲取。由於在勻速運動的狀況下,路程=速度×時間,速度是已知的,而時間嘛,只須要用當前時間減去開始時間就能夠得出。先記錄開始時間:
_renderToDOM() { // 根據軌道數量每次處理必定數量的彈幕數據。數量越大,彈幕越密集,CPU 佔用越高 let count = Math.floor(totalTracks / 3), i; while (count && i < this._queue.length) { const data = this._queue[i]; // 省略 N 行代碼... if (data.y) { this._queue.splice(i, 1); // 省略 N 行代碼... node.addEventListener('transitionstart', () => { data.startTime = Date.now(); }, false); // 從設置動畫樣式到動畫開始有必定的時間差,因此加上 80 毫秒 data.startTime = Date.now() + 80; } else { i++; } count--; } }
注意,這裏設置了兩次開始時間,一次是在設置動畫樣式、綁定事件以後,另外一次是在 transitionstart 事件中。理論上只須要後者便可。之因此加上前者,仍是由於兼容性問題——並非全部瀏覽器都支持 transitionstart 事件。
而後,獲取彈幕已滾動路程的代碼就能夠優化成:
distance = data.rollSpeed * (Date.now() - data.startTime) / 1000;
別看這個改動很小,先後只涉及 5 行代碼,但效果是立竿見影的(見此 demo 頁):
瀏覽器 | getTranslateX | 勻速公式計算 |
---|---|---|
Chrome | CPU 16%~20% | CPU 13%~16% |
Firefox | 能耗影響 3 | 能耗影響 0.75 |
Safari | CPU 8%~10% | CPU 3%~5% |
IE | CPU 7%~10% | CPU 4%~7% |
首先要解釋一下爲何要作暫停和恢復,主要是兩個方面的考慮。
第一個考慮是瀏覽器的兼容問題。彈幕渲染流程會頻繁調用到 JS 的 setTimeout 以及 CSS 的 transition,若是把當前標籤頁切到後臺(瀏覽器最小化或切換到其餘標籤頁),二者會有什麼變化呢?請看測試結果:
瀏覽器 | setTimeout | transition |
---|---|---|
Chrome/Edge | 延遲加大 | 若是動畫未開始,則等待標籤頁切到前臺後纔開始 |
Safari/IE 11 | 正常 | 若是動畫未開始,則等待標籤頁切到前臺後纔開始 |
Firefox | 正常 | 正常 |
可見,不一樣瀏覽器的處理方式不盡相同。而從實際場景上考慮,標籤頁切到後臺以後,即便渲染彈幕用戶也看不見,白白消耗硬件資源。索性引入一個機制:標籤頁切到後臺,則彈幕暫停,切到前臺再恢復:
let hiddenProp, visibilityChangeEvent; if (typeof document.hidden !== 'undefined') { hiddenProp = 'hidden'; visibilityChangeEvent = 'visibilitychange'; } else if (typeof document.msHidden !== 'undefined') { hiddenProp = 'msHidden'; visibilityChangeEvent = 'msvisibilitychange'; } else if (typeof document.webkitHidden !== 'undefined') { hiddenProp = 'webkitHidden'; visibilityChangeEvent = 'webkitvisibilitychange'; } document.addEventListener(visibilityChangeEvent, () => { if (document[hiddenProp]) { this.pause(); } else { // 必須異步執行,不然恢復後動畫速度可能會加快,從而致使彈幕消失或重疊,緣由不明 this._resumeTimer = setTimeout(() => { this.resume(); }, 200); } }, false);
先看下暫停滾動的主要代碼(注意已滾動路程 rolledDistance,將用於恢復播放和防重疊):
this._eachDanmakuNode((node, y, id) => { const data = this._findData(y, id); if (data) { // 獲取已滾動距離 data.rolledDistance = -getTranslateX(node); // 移除動畫,計算出彈幕所在的位置,固定樣式 node.style.transition = ''; node.style.transform = `translateX(-${data.rolledDistance}px)`; } });
接下來是恢復滾動的主要代碼:
this._eachDanmakuNode((node, y, id) => { const data = this._findData(y, id); if (data) { // 從新計算滾完剩餘距離須要多少時間 data.rollTime = (data.totalDistance - data.rolledDistance) / data.rollSpeed; data.startTime = Date.now(); node.style.transition = `transform ${data.rollTime}s linear`; node.style.transform = `translateX(-${data.totalDistance}px)`; } }); this._render();
防重疊的計算公式也須要修改:
// 新增了 lastItem.rolledDistance distance = lastItem.rolledDistance + lastItem.rollSpeed * (now - lastItem.startTime) / 1000;
修改後效果見此 demo 頁,能夠留意切換瀏覽器標籤頁後的效果並與前面幾個 demo 對比。
彈幕併發量大時,隊列中的彈幕數據會很是多,而在防重疊機制下,一屏能顯示的彈幕是有限的。這就會出現「供過於求」,致使彈幕「滯銷」,用戶看到的彈幕將再也不「新鮮」(好比視頻已經播到第 10 分鐘,但還在顯示第 3 分鐘時發的彈幕)。
爲了應對這種狀況,要引入丟棄機制,若是彈幕的庫存比較多,並且這批庫存已經放了好久,就扔掉它。相關代碼改動以下:
while (count && i < this._queue.length) { const data = this._queue[i]; let node = data.node; if (!node) { if (this._queue.length > this._tracks.length * 2 && Date.now() - data.timestamp > 5000 ) { this._queue.splice(i, 1); continue; } } // ... }
修改後效果見此 demo 頁。
DOM 的渲染徹底是由瀏覽器控制的,也就是說實際渲染狀況與 JavaScript 算出來的存在誤差,通常狀況下誤差不大,渲染效果就是正常的。可是在極端狀況下,誤差較大時,彈幕就可能會出現輕微重疊。這一點也是 DOM 不如 canvas 的一個方面,canvas 的每一幀都是能夠控制的。
最後附上 demo 的 Github 倉庫:https://github.com/heeroluo/d... 。
本文同時發表於做者我的博客:https://mrluo.life/article/de...