使用react+ts实现一个弹幕组件

lxf2023-05-07 16:50:02

最近在写一些营销页,所以常见的宣传类型,比如抽奖(转盘,九宫格)还有弹幕都自己写了一下下,觉得自己的弹幕写的还行,简单分享一下。

1.效果

首先先看下一个简单的小demo,来看看这个弹幕组件,你是否觉得还ok!!!

使用react+ts实现一个弹幕组件

2.如何实现

在这部分,我将具体讲述弹幕轨道的计算、如何增加弹幕、弹幕动画的实现以及弹幕增加和删除的时机。

2.1 弹幕轨道的计算

自我感觉弹幕轨道的计算还是比较简单的。

弹幕容器的高度const { height } = this.target.getBoundingClientRect();

弹幕轨道的高度trackHeight

可以知道未来将拥有几个弹幕轨道 this.tracks = new Array(Math.floor(height / trackHeight)).fill('idle')

2.2 弹幕动画的实现

在整之前,调研(参考)网上很多的思想,最后决定使用css3animation动画。

首先看下animation的介绍

CSS animation 属性是 animation-nameanimation-durationanimation-timing-functionanimation-delayanimation-iteration-countanimation-directionanimation-fill-mode 和 animation-play-state 属性的一个简写属性形式 ...

各位看官可以去mdn官网自行看一下,弹幕的动画效果是不是和尝试一下效果非常一样呢?再加上如此丰富的api,有搞头。

下面一起看下,我这里的实现吧。

**
 * 给弹幕容器增加支持弹幕的样式
 * 给html文件注入弹幕位移的动画(animation-name)
 */
const initBulletAnimate = (screen: HTMLElement | null) => {
  if (!screen) {
    return;
  }
  const style = document.createElement('style');
  // 弹幕容器宽度
  const width = screen.clientWidth;
  style.append(`@keyframes RightToLeft { 
    from {
      transform: translateX(${width}px); 
    }
    to { 
      transform: translateX(-100%); 
    }
  }`);
  document.head.appendChild(style);
};
// 这段代码功能还是比较直白的,定义了弹幕的动画名,也是弹幕的位移轨迹。

弹幕动画完成了,但这还仅仅是一小步,接下来要完成它的主题动画内容,也就是具体animation的应用

// 创建单条弹幕的容器
const bulletContainer = document.createElement('div');
  // 随机ID
  bulletContainer.id = Math.random().toString(36).substring(2);
  // 设置弹幕容器的初始样式
  bulletContainer.style.position = 'absolute';
  bulletContainer.style.left = '0';

  // https://developer.mozilla.org/zh-CN/docs/Web/CSS/animation
  //动画名称,默认前面设置的RightToLeft string
  bulletContainer.style.animationName = animate;
  // 动画循环次数 infinite无限次 1 一次 string|number
  bulletContainer.style.animationIterationCount = loopCount as string;
  // 动画延迟开始
  bulletContainer.style.animationDelay = (
    isNaN(delay as number) ? delay : `${delay}s`
  ) as string;
  // 是否反向播放 可以通过此值,实现从左到右
  bulletContainer.style.animationDirection = direction;
  // 一个动画周期的时长
  bulletContainer.style.animationDuration = (
    isNaN(duration as number) ? duration : `${duration}s`
  ) as string;
  // 动画周期的节奏
  bulletContainer.style.animationTimingFunction = animateTimeFun;
  // 鼠标进入触发区域时(这里指当前div)触发暂停,离开时移动
  // pause on hover
  if (pauseOnHover) {
    // 动画暂停
    bulletContainer.addEventListener(
      'mouseenter',
      () => {
        bulletContainer.style.animationPlayState = 'paused';
      },
      false,
    );
    // 动画继续
    bulletContainer.addEventListener(
      'mouseleave',
      () => {
        if (!currScreen.allPaused && !bulletContainer.dataset.clicked) {
          bulletContainer.style.animationPlayState = 'running';
        }
      },
      false,
    );
  }

可以看到,基本每一个和animation相关的都通过porps来进行赋值,这也代表我们可以灵活更改每一条弹幕的动画。

到这里,弹幕动画方面的其实都已经完成了,接下来我们需要解决的问题,就是:

1. 弹幕不覆盖
2. 每一条弹幕的增加和删除时机

2.3 弹幕不覆盖

弹幕不覆盖,就意味着我们要尽可能的合理的在较为空旷的轨道中增加最新的弹幕。为此,我给轨道分配了三种状态

idle 闲置

data 有值

running 上次添加弹幕状态 不可添加

默认轨道都是idle(空闲)状态;如果当前轨道已经有弹幕,则该轨道将会变成data(有值)状态;如果当前轨道刚刚发了条弹幕,则将会变成running(发送中)状态。

整体的规则如下:

  • 如果有空闲状态,优先空闲状态轨道
  • 如果没有空闲状态,优先弹道内弹幕*长度最小的弹道,且该弹道,不是上次发送的
  • 如果弹道内没有弹幕,则该弹道为idle 空闲状态
  • 如果该弹道刚刚发送了弹幕,则该弹道为running状态
  • 如果该弹道有弹幕,且不是刚刚发送弹幕,则该弹道为data状态

来看下这部分的逻辑代码吧

   getTrackIndex(trackHeight: number) {
    // console.log('this.tracks',this.tracks);

    // 空闲的轨道下标
    const readyIndex: number[] = [];
    let index = -1;

    // 优先取空闲状态的
    this.tracks.forEach((status, index) => {
      if (status === 'idle') {
        readyIndex.push(index);
      }
    });

    // 如果有空闲轨道
    if (readyIndex.length) {
      index = readyIndex[Math.floor(Math.random() * readyIndex.length)];
    }

    // 如果没有空闲轨道
    if (index === -1) {
      //
      const domChildren = this.target!.childNodes;
      // 权重 dom个数*dom宽度,越小,越优先塞入新弹幕
      // 获取弹幕容器的高度
      const { height } = this.target!.getBoundingClientRect();
      // 初始化弹幕轨道,默认全部是空闲状态 idle 空闲标志
      const widthCount = new Array(Math.floor(height / trackHeight)).fill(0);
      // 拿到每一个轨道当前拥有所有的dom的宽度
      domChildren?.forEach((dom) => {
        this.tracks.forEach((_tracks, index) => {
          // 获取dom的轨道下标
          if (index === Number((dom as HTMLDivElement).dataset.track)) {
            // 当前轨道宽度相加
            widthCount[index] += (dom as HTMLDivElement).clientWidth;
          }
        });
      });
      let smallCount = {
        width: 9999,
        index: [] as number[],
      };
      // 将宽度为0的置为空闲轨道,并使用该轨道发送弹幕
      widthCount.forEach((item, index) => {
        if (item === 0) {
          this.tracks[index] = 'idle';
          readyIndex.push(index);
        }
      });
      if (readyIndex.length > 0) {
        index = readyIndex[Math.floor(Math.random() * readyIndex.length)];
      } else {
        widthCount.forEach((item, index) => {
          // 向宽度最小的轨道(不是刚刚发过的轨道)中发送弹幕
          if (
            smallCount.width >= item &&
            this.tracks[index] !== 'running' &&
            !readyIndex.length
          ) {
            // 如果不是最小值,重置
            if (smallCount.width > item) {
              smallCount.width = item;
              smallCount.index = [index];
              // 如果是最小值,将所有下标收集
            } else {
              smallCount.index.push(index);
            }
          }
        });
        // 从当前权重最低的轨道下标中,拿到再domChildren中最靠前的
        domChildren.forEach((dom) => {
          if (index > -1) return;
          if (
            smallCount.index.includes(
              Number((dom as HTMLDivElement).dataset.track),
            )
          ) {
            index = Number((dom as HTMLDivElement).dataset.track);
            return;
          }
        });
      }
    }
    // 当前发送弹幕的轨道状态为running,无法向running状态的轨道发送新弹幕
    if (index !== -1) {
      this.tracks[index] = 'running';
    }
    // 获取当前弹幕轨道中为running但不是刚刚置为该状态的轨道,然后将其重置为data
    const indices = this.tracks.reduce((acc, val, idx) => {
      if (val === 'running') {
        acc.push(idx);
      }
      return acc;
    }, [] as number[]).filter(item => item !== index);
    indices.forEach(index => {
      this.tracks.splice(index,1,'data');
    })    
    return index;
  }

通过这个方法,我们拿到了弹幕轨道的下标,接下来我们就对弹幕进行渲染吧。

2.4 弹幕渲染

  /**
   * 渲染弹幕方法
   * @param item 弹幕内容
   * @param container 承载弹幕的容器
   * @param track 弹幕轨道下标
   * @param top 高度
   */
  renderBullet = (
    item: any,
    container: HTMLDivElement,
    track: number,
    top?: number,
  ) => {
    const { gap, trackHeight } = this.options;

    // 弹幕渲染进屏幕,如果是有效组件
    if (React.isValidElement(item) || typeof item === 'string') {
      // 将弹幕容器的位置放到对应的轨道中
      const update = () => {
        const trackTop = track * trackHeight;
        container.dataset.track = `${track}`;
        container.style.top = `${top ?? trackTop}px`;
        const options = {
          root: this.target,
          rootMargin: `0px ${gap} 0px 0px`,
          threshold: 1.0,
        };
        // 拿到当前弹幕的信息
        // 提供了一种异步观察目标元素与其祖先元素或顶级文档视口(viewport)交叉状态的方法。其祖先元素或视口被称为根(root)
        // https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
        const observer = new IntersectionObserver((entries) => {
          entries.forEach((entry) => {
            const { intersectionRatio } = entry;
            // 如果0,则目标在视野外
            if (intersectionRatio >= 1) {
              // 当前弹幕渲染后,判断是否有存积的弹幕,有则渲染
              if (this.queues.length) {
                const { item, container, customTop } = this.queues.shift()!;
                this.renderBullet(
                  item,
                  container,
                  track,
                  customTop,
                );
              }
            }
          });
        }, options);
        observer.observe(container);
      };
      const root = createRoot(container);
      root.render(typeof item === 'string' ? <span>{item}</span> : item);
      update();      
      // 将渲染出来的弹幕dom节点,添加到弹幕容器中
      this.target?.appendChild(container);      
    }
  };

3.源码

jn-bullet-screen自我感觉比较关键的地方,都丢出去了,其他感兴趣的可以看看源码。 注释还是比较完备的,抽几分钟就能够看完。

4.api

字段含义类型默认值
trackHeight弹幕轨道的高度number50
gap弹幕轨道之间的距离string10px
animate动画方向stringRightToLeft
pauseOnHover悬停方法booleanfalse
pauseOnClick点击方法booleantrue
onStart开始方法() => void-
onEnd结束方法() => void-
loopCount循环次数string | number1
duration持续时间number | string10
delay开始延迟number | string0
direction是否反方向stringnormal
animateTimeFun动画方法stringlinear
top距离顶部高度number-

5. 总结

至此,一个简单的弹幕就实现了。代码已经放到了githup中了,欢迎一起交流。代码是人写的,不确定是否会有问题,因为我们的使用场景比较简单,我这个组件的功能很多都没有用到。如果有问题,欢迎指正。

使用react+ts实现一个弹幕组件