网页弹幕展示

  • 2020 年 3 月 28 日
  • 笔记

前言

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

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

大家可以在npm中安装page-construct-template_component_barrage这个插件来直接使用。

正文

功能

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

弹幕展示速度

弹幕行高度

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

调用方式如下:

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;  }  

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

结语

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

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