直播是眼下最为火爆的行业,而弹幕无疑是直播平台中最流行、最重要的功能之一。本文将讲述如何实现兼容 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...