仿bilibili弹幕的实现

3,325 阅读5分钟

背景

之前写过一篇简单的单条弹幕实现文章,对于 bilibili 这一类视频网站的弹幕,该如何实现呢?如何将一个输入的弹幕推入到当前的屏幕中,选择合适的位置推入,保证当前弹幕不与屏幕上的弹幕不重复呢?

Aug-11-2021 21-22-01.gif

需要解决的问题

  • 多轨道弹幕
  • 将已有弹幕列表均匀分配到多条轨道上
  • 每条轨道上的弹幕在轨道上运动时候保持不重叠
  • 一些其他功能,例如控制弹幕的样式、弹幕的速度、弹幕的运动状态(是否停止)

实现

对于弹幕组件而言,可以做成受控组件和非受控组件两种形式

  • 受控组件 组件暴露出可以改变内部状态和行为的一些钩子,通过这些钩子,可以改变控制组件
  • 非受控组件 外界传入初始属性之后,组件的行为和内部状态完全由自己管理

根据业务需求,你可以选择该组件的实现方式,在本文中,该组件的实现为非受控组件

组件属性定义

export interface IBarrageProps {
  // 弹幕轨道高度 默认为50 px
  trackHeight: number
  // 弹幕轨道数量 默认为 4
  trackLines: number
  // 开始播放弹幕回调
  onStart?: () => void
  // 结束播放弹幕回调
  onEnd?: () => void
  // 每条轨道的单条弹幕展示时间,如果是数组,则表示每个轨道定义自己的弹幕展示时间 s
  duration: number | number[]
  // 为加入弹幕轨道的弹幕集合
  barrageList: IBarrageItem[]
  // 单条弹幕渲染规则 trackLineIndex: 轨道号 1 2 3 4
  renderItem?: (item: IBarrageItem, trackLineIndex) => ReactNode
  className?: string
  // 控制弹幕是否停止
  autoScroll?: boolean
}
export interface IBarrageItem {
  text: string
  avatar?: string
}

trackHeight * trackLines 就是弹幕容器的高度

弹幕运动模式

对于单条弹幕而言,被推入某条轨道后,有两种运动模式

  • 固定速度的弹幕

    以预设的速度进行匀速运动,这样每条轨道上的弹幕速度都是一样的,待推入的弹幕只要在轨道内的最后一条弹幕的尾部已经完全进入轨道内后推入,就能保证在运动过程中不会与上一条弹幕重叠;
  • 固定时间的弹幕

    弹幕的首部开始进入轨道到尾部完全离开轨道所经历的时间对于每条弹幕而言都是给定的固定的 duration; 第一种模式实现起来比较简单,这里就不介绍了,我们着重解决第二种运动模式

固定时间的弹幕

每条轨道上的每条弹幕内容确定之后,弹幕本身的一些属性已经确定了 容器宽度 + 弹幕宽度 width = 弹幕需要运动的距离 弹幕速度 speed = 弹幕需要运动的距离 / 弹幕运动时间 duration

每条弹幕开始推入的时间记为 startTime

之后每一帧弹幕该在哪个位置也就确定了

dt = Date.now() - startTime

截止当前帧 弹幕运动的距离

distance = speed * dt

export interface IBarrageItemProps {
  // 弹幕唯一标识id
  key: string
  // 弹幕元素宽度 px
  width: number
  // 弹幕元素高度 px
  height: number
  // 当前偏移距离 px
  distance: number
  // 弹幕元素的展示持续时间 s
  duration: number
  // 弹幕速度 width 确定后这个也确定了
  speed: number
  // 弹幕元素相对于开始播放时刻的出现时机 ms
  startTime: number
  // 弹幕列表信息
  item: IBarrageItem
}

那么问题变为两个

  • 如何在弹幕推入之前知道弹幕的宽度
  • 弹幕能否推入当前轨道并且保证待推入的弹幕与轨道上存在的弹幕不重叠

思路

如何在弹幕推入之前知道弹幕的宽度

要知道待推入弹幕的宽度,当然要在浏览器里渲染后才能拿到弹幕的位置大小数据,对于传入的大量弹幕列表数据,全部弹幕如果一次性渲染,可以获得所有弹幕的样式数据,但是会带来很大的渲染开销,这是不能接受的,为了解决这个问题,可以提供一个临时容器,用于渲染当前待推入的弹幕,获取该弹幕的大小和位置,每一帧期间将待推入弹幕置于临时容器内,判断是否可以推入某条轨道,可以的话下一帧继续下一个弹幕的判断;不行的话下一帧对该弹幕继续判断; const { width, height } = tempBarrageElementRef.current.getBoundingClientRect()

弹幕能否推入当前轨道并且保证待推入的弹幕与轨道上存在的弹幕不重叠

判断弹幕能否推入当前轨道并且保证待推入的弹幕与轨道上存在的弹幕不重叠,只需要判断当前待推入的弹幕与当前轨道上的最近推入的弹幕进行比较,如果通过不重叠判断,那么就可以推入当前轨道;

不重叠判断

其实就是初中的追及问题

const checkBarrage = useCallback((trackLineIndex: number, waitPushBarrage: IBarrageItemProps): boolean => {
    // 判定弹幕是否可以放入当前选定轨道
    // containerPos 弹幕容器属性 existBarrageList 已推入弹幕的数据
    const currentBarrageTrack = existBarrageList[trackLineIndex]
    const lastBullet = currentBarrageTrack[currentBarrageTrack.length - 1]
    if (lastBullet && containerPos) {
      const { startTime, speed, width } = lastBullet
      const now = Date.now()
      const distance = (now - startTime) / 1000 * speed
      const rightPos = containerPos.right + width - distance
      const leftPos = containerPos.right - distance
      // 轨道中最后一个元素要求已经全部进入展示区域 留个空余距离 15px
      if (rightPos > containerPos.right - 10) {
        return false
      }

      // 基本公式:s = v * t
      const lastS = leftPos - containerPos.left + width
      const lastT = lastS / speed

      const newS = containerPos.width - 10 // 留个空余距离 10px
      const newT = newS / waitPushBarrage.speed

      // 追及问题
      if (speed < waitPushBarrage.speed && lastT > newT) {
        return false
      }
    }
    return true
  }, [containerPos, existBarrageList])
// 容器属性
export interface ContainerPops {
  width: number
  height: number
  top: number
  left: number
  right: number
  bottom: number
}

已推入的弹幕运动出容器外,应该被清除掉

newExistBarrageList = newPositionExistBarrageList.map(trackBarrageList => trackBarrageList.filter(item => item.distance <= item.width + containerPos.width + 100)) 这里预留100px

动画

至于每一帧的动画,肯定是用 requestAnimationFrame 实现