前端弹幕实现

前言

目前视频播放平台弹幕几乎都是使用js操作dom的方式实现,由于篇幅的原因这次只展示js操作dom的实现方案。

下文代码展示使用的是react 16.2版本库。

正文

功能

弹幕文字各种样式:字体大小、字体类型、字体颜色(字体透明度)

弹幕展示速度

弹幕行高度

弹幕事件:鼠标左右点击事件、鼠标滑入滑出事件

调用方式如下:

const div = document.createElement('div');
const div.innerText = 'hello word';
div.style.color = 'orange';
div.syle.fontSize = '20px';

<Barrage
  data={[
    {
    	text: 'hello'
    },
    {
    	text: 'word',
    	// 控制单个弹幕元素的样式
    	color: 'rgba(255, 255, 255, 0.7)',
    	speed: [3, 4]
    },
    div
  ]}
  fontSize={25} // 弹幕字体大小
  lineHeight={40} // 弹幕行高
  speed={[1, 2]} // 控制弹幕速度
  onMouseOver={}
  onMouseOut={}
/>

js+dom实现方案

在开始正式代码开发之前需要弄清楚这种方法实现的逻辑:

  1. 首先我们需要创建一个容器来承载弹幕元素,将监听函数写到这个容器上面
  2. 初始化弹幕信息(弹幕内容、样式、速度,同时判断对象是否是dom节点)、初始弹幕容器能够显示多少行
  3. 创建弹幕dom,设置属性,插入页面
  4. transition动画结束,删除弹幕dom

基本流程就是上面这几步了,下面我们进入每一步的程序编写。

初始项目

这一步要做的事情有:

  • 创建弹幕容器
  • 向弹幕容器添加监听器,我们将所有弹幕节点的监听事件都委托到弹幕容器节点上面,减少内存占用
  • 弹幕容器宽高存入state
import React, { Component } from 'react';

// 弹幕之间的最小距离
const barrageAway = 30;

export default class extends Component {
	// 容器宽高
  state = {
      width: 0,
      height: 0
  }
  barrageList = [] // 弹幕元素信息
  rowArr = [] // 容器可以展示弹幕的行
  timer = null // 存放定时器
  
  componentDidMount() {
      this.setSize(() => {
      		// 后面再展示这两个回调函数代码
          this.init();
          this.draw();
      });
      // 弹幕容器大小发生改变一般事因为屏幕大小改变导致的
      window.addEventListener('resize', this.setSize);
  }
  
  componentWillUnmount() {
      clearTimeout(this.timer);
      window.removeEventListener('resize', this.setSize);
  }

	// 获取弹幕容器的宽高
  setSize = cb => {
      const container = this.refs.container;
      const fn = typeof cb === 'function' ? cb : () => {};
      if (!isDom(container)) {
          return;
      }
      this.setState({
          width: container.clientWidth,
          height: container.clientHeight
      }, fn);
  }
  
  init = () => {/*初始行、初始弹幕信息*/}
  getIdleRow = () => {/*获取空闲行*/}
  getAwayRight = () => {/*获取元素距离容器右边框的距离*/}
  draw => () => {/*渲染弹幕元素*/}
  
	handleTransitionEnd = e => {/*delete dom*/}
	handleClick = () => {/*do something*/}
	handleContextMenu = () => {/*do something*/}
	handleMouseOver = () => {/*do something*/}
	handleMouseOut = () => {/*do something*/}
	
	render() {
		return (
			// 弹幕容器
			<div
				ref="container"
				onTransitionEnd={this.handleTransitionEnd}
				onClick={this.handleClick}
				onContextMenu={this.handleContextMenu}
				onMouseOver={this.handleMouseOver}
				onMouseOut={this.handleMouseOut}
				style={{
						position: 'absolute',
						width: '100%',
						height: '100%',
						backgroundColor: 'rgba(0, 0, 0, 0)',
						overflow: 'hidden',
						transform: 'translateZ(0)'
				}}
			/>
		);
	}
}

初始化弹幕信息

需要运行的任务有:

  • 初始化弹幕展示行数
  • 初始弹幕信息(需要判断对象是否是dom节点)
const defaultFont = {
  fontSize: 16,
  speed: [1, 3],
  color: '#000',
  fontFamily: 'microsoft yahei'
};

// 函数位置上面有标明
init = () => {
	const { data, lineHeight, font } = this.props;
        const { height } = this.state;
	filter(font, [null, undefined]);
	// 计算行数
	if (parseInt(height / lineHeight, 10) > this.rowArr.length) {
    // 可展示行数增加
    for (let i = 0; i < parseInt(height / lineHeight, 10) - this.rowArr.length; i++) {
      this.rowArr.push({ idle: true }); this.rowArr用来存放行容器是否空闲,以及当前行末尾元素
    }
  } else {
  	// 可展示行数减少
    this.rowArr.splice(-1, this.rowArr.length - parseInt(height / lineHeight, 10));
  }
  
  // 初始化弹幕信息
  data.forEach(item => {
      // 属性优先级如下:弹幕对象中定义 > 全局定义 > 默认样式
      let barrage = item;
      // 如果弹幕对象是一个dom节点
      if (isDom(item)) {
        barrage = {
          domContent: item,
          speed: item.speed || font.speed || defaultFont.speed
        };
      // 开发者传入的是普通对象
      }
      barrage = {
          ...defaultFont,
          ...font,
          ...item,
          ...barrage
      };
      barrage.speed = Math.random() * (barrage.speed[1] - barrage.speed[0]) + barrage.speed[0]; // 随机速度,让弹幕元素错开
      this.barrageList.push(barrage); // this.barrageList 用来存放弹幕信息列表
  });
}

创建弹幕dom

需要执行的任务有:

  • 随机获取空闲行
    • 随机一个行数,判断该行是否可以插入新的弹幕
      • 可以使用,就将该行行数返回
      • 不可以使用,就向后继续寻找可以使用的行
        • 找到了就返回对应的行数
        • 没找到,找随机行前面是否有可用的行,有就返回对应行数,没有就返回undefined
// 获取空闲行
getIdleRow = () => {
  if (this.rowArr.length === 0) {
    return;
  }

  const randomRow = Math.floor(Math.random() * this.rowArr.length);

  // 随机找到的行为空闲
  if (this.rowArr[randomRow].idle || this.getAwayRight(this.rowArr[randomRow].dom) >= barrageAway) {
    return randomRow;
  }

  // 随机找到的行被占用
  let increase = randomRow + 1;
  // 向后查找空闲的行
  while (increase < this.rowArr.length) {
    if (this.rowArr[increase].idle || this.getAwayRight(this.rowArr[increase].dom) >= barrageAway) {
      return increase;
    }
    increase++;
  }
  // 向前查找空闲的行
  let decrease = randomRow - 1;
  while (decrease > -1) {
    if (this.rowArr[decrease].idle || this.getAwayRight(this.rowArr[decrease].dom) >= barrageAway) {
      return decrease;
    }
    decrease--;
  }
  // 目前没有空闲的行容器
  return;
}

// 获取弹幕dom距离容器右边框的距离
getAwayRight = dom => {
  const container = this.refs.container;
  const { width } = this.state;
  const containerRect = container.getBoundingClientRect();
  const domRect = dom.getBoundingClientRect();
  return containerRect.left + width - domRect.left - dom.offsetWidth;
}
  • 创建弹幕dom
    • 需要判断是否有可用的行
      • 有,就可以创建dom
      • 没有,就跳出循环,等下一次再来创建
  • 设置dom属性
  • 弹幕dom写入弹幕容器中
  • 设置transition、tranform
    • 这里我们使用translate替换left将元素移动到容器最左边,同时开启硬件加速减少页面重排重绘,提高性能
draw = () => {
  const { lineHeight } = this.props;
  const { width } = this.state;

  for (const _ in this.barrageList) {
    const barrage = this.barrageList.shift();
    const { text, fontSize, color, fontFamily, speed } = barrage;
    const idleRowIndex = this.getIdleRow(); // 获取一个空闲行

		// 判断是否有可用的行
    if (idleRowIndex === undefined) {
      break;
    }

    const randomAway = Math.floor(Math.random() * width / 2); // 随机初始弹幕距离右边框距离,让弹幕错位
    // 常见弹幕dom,开发者传入的dom节点也存放到这个dom中
    const div = document.createElement('div');
    if (!barrage.domContent) {
      div.innerText = text;
    } else {
      div.appendChild(barrage.domContent);
    }
    // 设置弹幕样式
    div.style.fontSize = `${fontSize}px`;
    div.style.fontFamily = fontFamily;
    div.style.color = color;
    div.style.transform = `translate3d(${width + randomAway}px, 0, 0)`;
    div.style.position = 'absolute';
    div.style.left = 0;
    div.style.top = `${idleRowIndex * lineHeight}px`; // 根据空闲的行,计算对应的top值
    // 将弹幕dom插入弹幕容器中
    this.refs.container.appendChild(div);
    this.rowArr[idleRowIndex] = { dom: div, idle: false }; // 该行改成非空闲状态

		// 计算弹幕动画
    const divWidth = div.offsetWidth;
    const runTime = (width + divWidth) / (60 * speed); // 弹幕展示完需要多少时间
    div.style.transform = `translate3d(${-divWidth}px, 0, 0)`;
    div.style.transition = `transform ${runTime}s linear`;
  }

  // 没有空闲行,需要等100ms再渲染
  if (this.barrageList.length) {
    this.timer = setTimeout(this.draw, 100);
  }
}

删除弹幕dom

当弹幕展示完成以后我们需要将对应的弹幕dom从页面中移除,之前弹幕动画借助的是transition,因此我们可以通过监听transitionend事件

handleTransitionEnd = e => {
  this.refs.container.removeChild(e.target);
}

数据更新

前面实现只能展示第一次传入的数据,对于后面再传入的弹幕数据就不能展示出来,我们这里使用shouldComponentUpdate这个api将新的弹幕数据存入,并对之前的init函数做简单的修改。

shouldComponentUpdate(nextProps) {
  if (nextProps.data !== this.props.data) {
    const length = this.barrageList.length;
    this.init(nextProps);

    if (length === 0) {
      this.draw();
    }
  }
  return true;
}

init = nextProps => {
  const { data, lineHeight, font } = nextProps || this.props;
}

这样之后的传入的弹幕就能够展示了。

结语

以上就基本完成了一个简单的弹幕功能,这里还有很多拓展还没有做或者由于篇幅问题没有展示,例如:

  • 弹幕很多的时候我们如何控制弹幕速度
  • 弹幕停止运动
  • 屏幕变化如何控制弹幕显示的位置

文章来源于腾讯云开发者社区,点击查看原文