直播是眼下最为火爆的行业,而弹幕无疑是直播平台中最风行、最重要的性能之一。本文将讲述如何实现兼容 PC 浏览器和挪动浏览器的弹幕。

基本功能

并发与队列

一般来说,弹幕数据会通过异步申请或 socket 音讯传到前端,这里会存在一个隐患——数据量可能十分大。如果一收到弹幕数据就马上渲染进去,在量大的时候:

显示区域不足以搁置这么多的弹幕,弹幕会重叠在一起;
渲染过程会占用大量 CPU 资源,导致页面卡顿。
所以在接管和渲染数据之间,要引入队列做缓冲。把收到的弹幕数据都存入数组(即下文代码中的 this._queue),再通过轮询该数组,把弹幕逐条渲染进去:

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 占用上有劣势。

本文将以 DOM 计划实现弹幕的滚动,并通过 CSS 的 transition 和 transform 来实现动画,这样能够利用浏览器渲染过程中的「合成层」机制(有趣味能够查阅这篇文章),进步性能。弹幕滚动的示例代码如下:

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 文档外面:

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%,所以它在显示区域之外。这样能够在用户看到这条弹幕之前,做一些“暗箱操作”,包含获取弹幕的尺寸、占用的轨道数、总位移、位移工夫、位移速度。接下来的问题是,要把弹幕显示在哪个地位呢?

首先,弹幕的文字大小不肯定统一,从而占用的高度也不尽相同。为了能充分利用显示区域的空间,咱们能够把显示区域划分为多行,一行即为一条轨道。一条弹幕至多占用一条轨道。而存储构造方面,能够用二维数组记录每条轨道中存在的弹幕。下图是弹幕占用轨道及其对应存储构造的一个例子:

其次,要避免弹幕重叠。原理其实非常简单,请看上面这题数学题。假如有起点站、终点站和一条轨道,列车都以匀速运动形式从终点开到起点。列车 A 先发车,请问:如果在某个时刻,列车 B 发车的话,会不会在列车 A 齐全进站之前撞上列车 A?

聪慧的你可能曾经发现,这里的轨道所对应的就是弹幕显示区域外面的一行,列车对应的就是弹幕。解题之前,先过一下已知量:

  • 途程 S,对应显示区域的宽度;
  • 两车长度 la 和 lb,对应弹幕的宽度;
  • 两车速度 va 和 vb,曾经计算出来了;
  • 前车已行走间隔 sa,即弹幕元素以后的地位,能够通过读取款式值获取。

那在什么状况下,两车不会相撞呢?

  • 其一,如果列车 A 没有齐全出站(已行走间隔小于车长),则列车 B 不具备发车条件;
  • 其二,如果列车 B 的速度小于等于列车 A 的速度,因为 A 先发车,这是必定撞不上的;
  • 其三,如果列车 B 的速度大于列车 A 的速度,那就要看两者的速度差了:

    • 列车 A 追上列车 B 所需工夫 tba = (sa - la) / (vb - va);
    • 列车 A 齐全到站所需工夫 tad = (s + la - sa) / va;
    • tba > tad 时,两车不会撞上。

有了实践撑持,就能够编写对应的代码了。

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匀速公式计算
ChromeCPU 16%~20%CPU 13%~16%
Firefox能耗影响 3能耗影响 0.75
SafariCPU 8%~10%CPU 3%~5%
IECPU 7%~10%CPU 4%~7%

暂停和复原

首先要解释一下为什么要做暂停和复原,次要是两个方面的思考。

第一个思考是浏览器的兼容问题。弹幕渲染流程会频繁调用到 JS 的 setTimeout 以及 CSS 的 transition,如果把以后标签页切到后盾(浏览器最小化或切换到其余标签页),两者会有什么变动呢?请看测试后果:

浏览器setTimeouttransition
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.rolledDistancedistance = 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...