Skip to content

Canvas实现弹幕功能

效果展示

image.png

前置知识

动画原理

需求 假设在 60Hz 的屏幕下,我们想要实现一个 div 从左->右移动 500px ,并且 div 平滑移动,而不是一下到达终点。

实现思路 60Hz 的屏幕,意味着每 16.7ms 屏幕就会更新图像,如果我们在屏幕每次刷新图像前,将 div 元素向右移动 1px,这样的话,每次屏幕刷新的时候,我们的 div 元素都向右移动了一点点位置。但是由于间隔时间太短,我们的大脑和眼睛是无法处理的,所以我们会误认为 div 是平滑移动的。

所以我们的动画原理其实很简单,就是在很短的时间内不断更新相同间隔或者相同差别的图片,给我们人眼造成连续的假象。 我们常说的 60 帧电影,其实就是每秒 60 刷新 60 次。

前端实现动画的方式

  1. css animation https://developer.mozilla.org/zh-CN/docs/Web/CSS/animation 能用css实现的动画就不要用js
css
<style>
  div {
    position: fixed;
    left: 0;
    width: 100px;
    height: 100px;
    background-color: pink;
    /* 使用 */
    animation: move linear 5s;
  }
  /* 定义动画 */
  @keyframes move {
    form { left: 0px; }
    to { left: 500px; }
  }
</style>
  1. 定时器 setInterval 由于是异步的,定时器回调函数的代码会放入异步队列,因此执行时机不准确。 即使页面或浏览器窗口隐藏(页面暂停渲染),定时器仍然在跑,浪费性能。
html
<style>
  div {
    position: fixed;
    left: 0;
    width: 100px;
    height: 100px;
    background-color: pink;
  }
</style>

<script>
  var div = document.querySelector('div');
  let left = 0
  var move = setInterval(() => {
    left ++;
    div.style.left = left + 'px'
    if (left >= 500) clearInterval(move)
  }, 16.6)
</script>
  1. window.requestAnimationFrame 该API能以浏览器的显示频率来作为其动画动作的频率,比如浏览器每10ms刷新一次,动画回调也每10ms调用一次 窗口或页面隐藏,会暂停调用函数, 页面显示后又接着调用
html
<style>
  div {
    position: fixed;
    left: 0;
    width: 100px;
    height: 100px;
    background-color: pink;
  }
</style>

<script>
  var div = document.querySelector('div');
  let left = 0
  function move() {
    left++
    div.style.left = left + 'px'
    if (left < 500) window.requestAnimationFrame(move)
  }
  move()
</script>

画布 canvas

HTML5 的 canvas 元素使用 JavaScript 在网页上绘制图像。 画布是一个矩形区域,您可以控制其每一像素。 canvas 拥有多种绘制路径、矩形、圆形、字符以及添加图像的方法。

API https://www.w3school.com.cn/tags/html_ref_canvas.asp

弹幕轨迹原理解析

image.png

实现流程

1. Barrage类

作用: 用于初始化每一条弹幕,包括弹幕属性(颜色,字体大小,出现位置,出现时机,速度

js
class Barrage {
  constructor(barrage, ctx) {
    this.barrage = barrage;
    this.ctx = ctx;

    this.text = barrage.text;
    this.time = barrage.time;
  }

  // 初始化当前这个弹幕,
  init() {
    // 为这条弹幕添加一些属性
    this.color = this.barrage.color || this.ctx.color;
    this.fontSize = this.barrage.fontSize || this.ctx.fontSize;
    this.speed = this.barrage.speed || this.ctx.speed;

    // 求出 当前这条弹幕的长宽
    let span = document.createElement("span");
    span.innerText = this.text;
    span.style.font = this.fontSize + "px Microsoft YaHei";
    span.style.position = "absolute";
    document.body.appendChild(span);
    this.width = span.clientWidth; // 该条弹幕宽度
    this.height = span.clientHeight; // 该条弹幕高度
    document.body.removeChild(span);

    // 弹幕出现的位置
    this.x = this.ctx.canvas.width;
    // 注意,canvas里面文字的y是文字的基线,而不是左上角顶点
    this.y = this.ctx.canvas.height * Math.random();

    // 弹幕是都继续在画布上渲染
    if (this.y <= this.height) {
      // 不让文字超出canvas上边框
      this.y = this.height;
    }
    if (this.y > this.ctx.canvas.height) {
      // 不让文字超出canvas下边框
      this.y = this.ctx.canvas.height;
    }
  }

  // 将当前这个弹幕渲染到屏幕上
  render() {
    this.ctx.contxt.font = `${this.fontSize}px Microsoft YaHei`;
    this.ctx.contxt.fillStyle = this.color;
    this.ctx.contxt.fillText(this.text, this.x, this.y);
  }
}

2. Canvas类

作用:控制整个画布上的每一条弹幕

js
// 用于控制整个canvas上的弹幕
class Canvas {
  constructor(canvas, video, options) {
    if (!canvas || !video) {
      // 若没有canvas和视频播放器,则不进行以后的所有操作
      return;
    }
    this.canvas = canvas;
    this.video = video;
    this.options = options;
    this.canvas.width = video.clientWidth;
    this.canvas.height = video.clientHeight;
    this.contxt = canvas.getContext("2d"); // canvas上下文

    // 是否暂停, 默认是暂停
    this.isSuspend = true;

    // 设置弹幕默认属性,但是弹幕内容和弹幕出现的时间是必须给的,其余属性有就用,没有就取默认值
    let defaultOpt = {
      color: "red", // 默认颜色
      speed: 2, // 移动速度
      fontSize: 20, // 字号
      data: []
    };
    // 合并对象 并挂在在实例上面
    Object.assign(this, defaultOpt, options);
    // 用于存放所有弹幕
    this.allBarrage = this.options.data.map(
      barrage => new Barrage(barrage, this)
    );
    // 渲染弹幕
    this.render();
  }

  // 渲染弹幕
  render() {
    // 先清空画布
    this.contxt.clearRect(0, 0, this.canvas.width, this.canvas.height);
    //  渲染弹幕
    this.renderBarrage();
    // 如果当前是播放状态则开始递归渲染
    if (this.isSuspend === false) {
      requestAnimationFrame(this.render.bind(this));
    }
  }

  // 真正的渲染方法
  renderBarrage() {
    // 若当前视频的时间大于该条弹幕该出现的时间,则渲染该条弹幕
    // 1. 拿到当前视频播放的时间
    let currentTime = this.video.currentTime;
    this.allBarrage.forEach(barrabeItem => {
      /* barrabeItem是每个弹幕的实例 */
      if (!barrabeItem.flag && currentTime >= barrabeItem.time) {
        // 渲染之前,若没有初始化该条弹幕则先初始化该条弹幕
        if (!barrabeItem.isInit) {
          barrabeItem.init();
          barrabeItem.isInit = true;
        }
        // 更新位置
        barrabeItem.x -= barrabeItem.speed;
        barrabeItem.render(); // 渲染自己到画布上
        // 当弹幕移动出了canvas,则停止渲染
        if (barrabeItem.x <= -barrabeItem.width) {
          barrabeItem.flag = true;
        }
      }
    });
  }

  // 添加弹幕(用于输入框添加弹幕,并将弹幕画在画布上)
  addBarrage(barrage) {
    this.allBarrage.push(new Barrage(barrage, this));
  }

  // 重置弹幕(用于进度条拖动时候控制弹幕是否该出现)
  reset() {
    this.contxt.clearRect(0, 0, this.canvas.width, this.canvas.height); // 先清除所有弹幕
    // 拿到当前视频的时间
    let currentTime = this.video.currentTime;
    // 让每一个弹幕回到初始值
    this.allBarrage.forEach(item => {
      item.flag = false;
      // 若当前视频时间小于弹幕出现时间,则弹幕还应该出现,所以重新初始化弹幕
      if (currentTime <= item.time) {
        item.isInit = false;
      } else {
        // 否则不该出现
        item.flag = true;
      }
    });
  }
}

3. 一些必要的事件

js
/* 弹幕池 */
const data = [];

/* 控制画布的实例 */
const canvasBarrage = new Canvas(canvasEle, videoEle, { data: arg });

/*  播放视频时候开始播放弹幕 */
videoEle.addEventListener("play", function(e) {
  canvasBarrage.isSuspend = false;
  canvasBarrage.render();
});

/* 暂停视频时候暂停弹幕 */
videoEle.addEventListener("pause", function(e) {
  canvasBarrage.isSuspend = true;
});

/* 拖动进度条重置弹幕 */
videoEle.addEventListener("seeked", function(e) {
  canvasBarrage.reset();
});

/* 往画布上添加弹幕 */
let obj = {
  text: "xxxxxx", 
  time: videoEle.currentTime,
  color: colorEle.value,
  fontSize: rangeEle.value
};
canvasBarrage.addBarrage(obj);