使用ts重构React弹幕库rc-bullets

lxf2023-04-25 21:06:02

起因

最近因为做项目需要实现一个互动大屏的功能,翻找了npm上的第三方库之后终于找到了rc-bullets这个简单易用的React弹幕库, 在引入项目实现完功能后down了其源码进行学习,发现其是使用js进行编写的。出于学习以及练手的初衷,在征得原作者zerosoul同意后用ts重构这个库并以rc-bullets-ts库发布了出来。此文章用于记录在此次重构项目中的学习心得。

项目架构

作为一个大屏弹幕项目,rc-bullets这个项目在架构上实现的很简单,大体上可以分为两个部分:

    1. 用于包裹及渲染弹幕的容器bulletScreen
    1. 一个自带的提供基础弹幕样式的容器styleBullet

bulletScreen里实现了绑定项目容器元素,push弹幕以及渲染弹幕这三个核心方法,以及诸如弹幕暂停,清空等暴露给外部的接口。

具体实现

弹幕轨道

弹幕轨道有三个状态:

  • idle: 闲置
  • feed: 执行播放完毕的
  • running: 执行播放中

弹幕的轨道数量是根据所设置的弹幕高度除以当前所绑定的容器大小高度得到的,进行初始化时还需要将所有弹幕轨道的状态置为闲置的,以便当弹幕传入时能够正确的选择空闲的轨道进行渲染。

/**
   * 初始化弹幕轨道
   * @param trackHeight 
   */
  initBulletTrack(trackHeight: number) {
    const { height } = this.target.getBoundingClientRect();
    this.tracks = new Array(Math.floor(height / trackHeight)).fill('idle'); // idle代表闲置状态的轨道
    const { position } = getComputedStyle(this.target);
    if (position === 'static') {
      this.target.style.position = 'relative';
    }
  }

当有新弹幕进入时需要进行弹幕轨道的选取。这里所实现的是一个无遮挡优先的弹幕系统,所以取轨道数时优先索取空闲轨道,其次索取播放完毕状态的弹幕轨道,如果所有轨道都为执行播放中,则添加到等待队列中,等待有轨道空出时填入。

/**
   * 获取播放轨道
   * @returns 
   */
  private _getTrack() {
    const readyIdxs: number[] = [];
    let idx = -1;
    // 优先取空闲状态的
    this.tracks.forEach((status, index) => {
      if (status === 'idle') {
        readyIdxs.push(index);
      }
    });
    if (readyIdxs.length) {
      idx = readyIdxs[Math.floor(Math.random() * readyIdxs.length)];
    }
    if (idx === -1) {
      // 其次是可以接上状态的
      this.tracks.forEach((status, index) => {
        if (status === 'feed') {
          readyIdxs.push(index);
        }
      });
      if (readyIdxs.length) {
        idx = readyIdxs[Math.floor(Math.random() * readyIdxs.length)];
      }
    }
    if (idx !== -1) {
      this.tracks[idx] = 'running';
    }
    return idx;
  }

发送弹幕

填入弹幕至弹幕轨道主要通过push()方法实现,主要实现逻辑是获取到弹幕的基本信息,如onStart,onEnd,top属性。通过这些属性获取到一个单条弹幕容器,里面存放一些弹幕样式与动画的基本属性。通过上述获取弹幕轨道的方法获取到当前应该填充的弹幕轨道index或者将当前弹幕传入等待队列中,如果有传入onStart,onEnd等自定义方法,这里也涉及绑定相关逻辑。其中onEnd的默认方法是在弹幕动画播放完毕后将当前弹幕移除dom结构,以免造成页面dom结构过多引起卡顿。

push(item: pushItem, opts: ScreenOpsTypes | object = {}) {
    const options = { ...this.options, opts };
    const { onStart, onEnd, top } = options;
    const bulletContainer = getContainer({
      ...options,
      currScreen: this,
    });
    // 加入当前存在的弹幕列表
    this.bullets.push(bulletContainer);
    const currIdletrack = this._getTrack(); // 获取播放的弹幕轨道
    if (currIdletrack === -1 || this.allPaused) {
      // 全部暂停
      this.queues.push([item, bulletContainer, top]);
    } else {
      this._render(item, bulletContainer, currIdletrack, top);
    }

    if (onStart) {
      // 监听弹幕动画开始时间
      bulletContainer.addEventListener('animationstart', () => {
        onStart.call(null, bulletContainer.id, this);
      });
    }

    bulletContainer.addEventListener('animationend', () => {
      if (onEnd) {
        onEnd.call(null, bulletContainer.id, this);
      }
      this.bullets = this.bullets.filter((obj) => obj.id !== bulletContainer.id);
      ReactDOM.unmountComponentAtNode(bulletContainer);// react移除虚拟dom
      bulletContainer.remove(); // 移除真实dom
    });

    return bulletContainer.id;
  }

渲染弹幕

在弹幕发送后需要进行对弹幕的渲染,这里依托了React框架,使用ReactDOM.render()方法来执行具体的渲染逻辑。大体的步骤如下:

  • 将单条弹幕容器dom添加到所绑定的弹幕容器的子节点中
  • 调用ReactDOM.render(), 将传入的弹幕组件渲染到单条弹幕容器中
  • 添加回调方法,该回调将在组件被渲染或更新之后被执行。回调方法中主要执行观察目标元素(即此处的单条弹幕容器)与其祖先元素(即此处的弹幕容器)交叉状态,可根据返回值中的intersectionRatio值是否大于等于1来判断元素是否完全展示,从而判断是否渲染等待队列中的下一个元素或改变当前弹幕轨道执行状态值。
private _render = (item: pushItem, container: HTMLElement, track: number, top?: string) => {
    this.target.appendChild(container);
    const { gap, trackHeight } = this.options;
    ReactDOM.render(
      this.getRenderDom(item),
      container,
      () => {
        const trackTop = track * trackHeight;
        container.dataset.track = `${track}`;
        container.style.top = typeof (top) !== 'undefined' ? top : `${trackTop}px`;
        const options = {
          root: this.target,
          rootMargin: `0px ${gap} 0px 0px`,
          threshold: 1.0, // 完全处于可视范围中
        };
        const observer = new IntersectionObserver(enteries => {
          for (const entry of enteries) {
            const { intersectionRatio, target, isIntersecting } = entry;
            console.log('bullet id', target.id, intersectionRatio, isIntersecting);
            console.log('resTarget', this.target, entry);
            if (intersectionRatio >= 1) {
              const curTaget = target as HTMLElement;
              const trackIdx = typeof (curTaget.dataset.track) === 'undefined' ? undefined : +curTaget.dataset.track;
              if (this.queues.length && trackIdx !== undefined) {
                const queue = this.queues.shift();
                if (queue) {
                  const [item, container, customTop] = queue;
                  this._render(item, container, trackIdx, customTop);
                }
              } else {
                if (typeof (trackIdx) !== 'undefined') {
                  this.tracks[trackIdx] = 'feed';
                }
              }
            }
          }
        }, options);
        observer.observe(container);
      }
    );
  };

使用项目

重构后的项目已开源并已发布npm,后续如果有空余时间也会进一步的对该项目进行长期维护及特性更新,欢迎提issues~
github: github.com/slatejack/r…
npm: www.npmjs.com/package/rc-…