基于Canvas和HTML+CSS3构建JavaScript弹幕库

tech2022-07-08  174

如今各大视频网站都有弹幕功能,貌似不存在没有弹幕的视频直播网站。弹幕元素比起留言板等交互性和实时性更高,深受广大基友们喜欢。

然后,我就在各大视频网站假装观看视频的同时,悄悄地按下了F12,想一探究竟。了解发现,目前弹幕的主要实现有两种方式:

CanvasHTML+CSS

一说到动画,大家第一时间能想到的就是Canvas。使用Canvas能很方便地绘制动画,并且获得非常不错的性能,目前前端不少动画都是通过Canvas去做。不过对于基于Canvas的动画而言,最大的问题就是“交互性”上面。

如果用HTML+CSS的方式实现,我们可以很简单地通过监听原生DOM事件去知道哪一条弹幕与用户鼠标发生了交互。但是通过Canvas,我们只能通过监听画布的事件,然后做一堆遍历计算坐标的骚操作去确定是哪一条弹幕。从鹅厂的视频网站可以看到,他们的弹幕是可交互的,所以他们使用了HTML+CSS的实现方式;而B站的弹幕是非交互的,它提供Canvas和HTML+CSS可选,默认是前者。

虽然功能性上两者的实现会有点差异,但弹幕的基本原理都是一样。

轨道

我们来看看B站的弹幕具体是什么样子的:

从上图可以看到,弹幕是很清晰地分成了一行一行,我把它们称为“轨道”。每一个弹幕都只在轨道上从右往左移动,不会越界。那么,要实现弹幕功能,首先我们必须把弹幕分成若干个轨道,然后再在合适的时间把弹幕“塞”进去让它平移。

每一个轨道会有两个属性:

barrages: T[] = [] offset: number = 0

barrages为一个弹幕数组,offset则是已占据的宽度。offset用于滚动弹幕时,弹幕轨道添加弹幕前判断最佳轨道;当弹幕类型时固定时无作用。barrages存放当前轨道上可现实的弹幕实例。

每一个轨道实例管理自己轨道中的数组,主要进行进行增、删、重置以及更新offest的操作。

添加新弹幕

push(...items: T[]) { this.barrages.push(...items) }

每一次添加新弹幕都把弹幕推入弹幕数组末尾即可。

删除弹幕

可以删除指定位置的弹幕:

remove(index: number) { if (index < 0 || index >= this.barrages.length) { return } this.barrages.splice(index, 1) }

同时,一般情况下,我们都是按数组的顺序渲染弹幕的。也就是说,每一次重新渲染,更新完弹幕位置之后,首先移除画布的弹幕必定是数组的第一个元素,因此,为了更方便移除弹幕数组顶部元素,轨道还拥有一个removeTop方法:

removeTop() { this.barrages.shift() }

重置轨道

重置应用的场景有不少,比如用户拖动进度条后,当前画布上的弹幕已经是脱离时间线了,因此会重新渲染弹幕。这时候就需要轨道清空弹幕数组并重置offset。

reset() { this.barrages = [] this.offset = 0 }

更新轨道剩余空间

随着每一帧弹幕的渲染,数组中的弹幕元素不断地向左移动,此时轨道右方的空间越来越大。在选择对应的轨道推入弹幕时,我们需要寻找剩余空间最大的轨道推入。因此,在执行完每一次渲染后,都需要轨道更新自己的轨道剩余空间。(滚动弹幕时)

updateOffset() { const endBarrage = this.barrages[this.barrages.length - 1] if (endBarrage && isScrollBarrage(endBarrage)) { const { speed } = endBarrage this.offset -= speed } }

而实际上,剩余空间 = 轨道宽度 - offset。

完整代码

interface TrackForEachHandler<T extends BarrageObject> { (track: T, index: number, array: T[]): void } export default class BarrageTrack<T extends BarrageObject> { barrages: T[] = [] offset: number = 0 forEach(handler: TrackForEachHandler<T>) { for (let i = 0; i < this.barrages.length; ++i) { handler(this.barrages[i], i, this.barrages) } } reset() { this.barrages = [] this.offset = 0 } push(...items: T[]) { this.barrages.push(...items) } removeTop() { this.barrages.shift() } remove(index: number) { if (index < 0 || index >= this.barrages.length) { return } this.barrages.splice(index, 1) } updateOffset() { const endBarrage = this.barrages[this.barrages.length - 1] if (endBarrage && isScrollBarrage(endBarrage)) { const { speed } = endBarrage this.offset -= speed } } }

指挥官

轨道管理轨道内弹幕的增、删操作,但不负责渲染。而我们知道,一个画布上有若干个轨道;同时,弹幕又分为滚动弹幕、顶部固定弹幕、底部固定弹幕。也就是说,滚动弹幕轨道上可能还叠加着固定弹幕轨道。如果更好地管理多个轨道的工作呢?答案就是“指挥官”。

弹幕主要分三种:滚动弹幕、顶部固定弹幕、底部固定弹幕。其中每一个类型的弹幕中有若干个轨道。我们把不同类型的弹幕轨道交给不同类型的指挥官。因此,我们获得了三种指挥官:滚动弹幕指挥官、顶部固定弹幕指挥官、底部固定弹幕指挥官。

指挥官的作用是管理自己的轨道渲染问题,因此核心工作从render方法开始:

render(): void { this._extractBarrage() const ctx = this.ctx const trackHeight = this.trackHeight this.forEach((track: Track<ScrollBarrageObject>, trackIndex) => { let removeTop = false track.forEach((barrage, barrageIndex) => { const { color, text, offset, speed, width, size } = barrage ctx.fillStyle = color ctx.font = `${size}px 'Microsoft Yahei'` ctx.fillText(text, offset, (trackIndex + 1) * trackHeight) barrage.offset -= speed if (barrageIndex === 0 && barrage.offset < 0 && Math.abs(barrage.offset) >= width) { removeTop = true } }) track.updateOffset() if (removeTop) { track.removeTop() } }) }

整个render函数主要的步骤有两个:

从等待队列中抽取合适的弹幕放入轨道遍历轨道数组,依次渲染轨道中的弹幕

从等待队列中添加弹幕到对应轨道

每一个指挥官都有一个等待队列waitingQueue,里面存在尚未渲染的弹幕。每次调用render函数时,都首先尽可能地将等待队列中的弹幕添加到合适的轨道。而这个过程,是通过this._extractBarrage实现的:

_extractBarrage(): void { let isIntered: boolean for (let i = 0; i < this.waitingQueue.length; ) { isIntered = this.add(this.waitingQueue[i]) if (!isIntered) { break } this.waitingQueue.shift() } }

_extractBarrage方法会从头开始遍历等待队列,依次按顺序执行this.add方法。当this.add返回True时,则说明该弹幕成功加入到合适的轨道中,否则说明目前没有合适的轨道。因此,只要有一次this.add方法返回False,则表示剩下的弹幕都无没有合适的轨道,提前结束计算。

因此,this.add的作用是添加弹幕到合适的轨道。但是,this.add为了实现正确的添加功能,还需要完成这些逻辑:为弹幕寻找合适的轨道、标准化弹幕格式。

add方法是指挥官抽象类的抽象方法,具体实现由不同类型的指挥官类来实现,这里以滚动弹幕为例:

add(barrage: ScrollBarrageObject): boolean { const trackId = this._findTrack() if (trackId === -1) { return false } const track = this.tracks[trackId] const trackOffset = track.offset const trackWidth = this.trackWidth let speed: number if (isEmptyArray(track.barrages)) { speed = this._defaultSpeed * this._randomSpeed } else { const { speed: preSpeed } = getArrayRight<ScrollBarrageObject>(track.barrages) speed = (trackWidth * preSpeed) / trackOffset } speed = Math.min(speed, this._defaultSpeed * 2) const normalizedBarrage = Object.assign({}, barrage, { offset: trackWidth, speed }) track.push(normalizedBarrage) track.offset = trackWidth + barrage.width * 1.2 return true }

为弹幕寻找合适的轨道

从B站、腾讯视频等网站可以看到,弹幕一般都是从上往下填充,也就是上面的弹幕先多起来,再往下填充。具体有没有用什么算法我这里没有很深入去了解,只是简单用了一个判断方法:从上往下寻找,只要找到空位就行。

const trackId = this._findTrack() if (trackId === -1) { return false }

如果找到了合适的轨道,那么就继续执行下面的逻辑;否则,直接返回False。

追及问题 & 标准化弹幕格式

以滚动弹幕为例,弹幕除了文本、颜色、大小外,还需要弹幕的平移速度和偏移量,这样我们才能够方便地渲染弹幕,这时候就要对传入的弹幕进行标准化。对于滚动弹幕来说,标准化主要进行的就是计算弹幕的速度。

对于弹幕的速度,这就涉及到我们的初中数学知识:追及问题。

“在长S的轨道上,弹幕A以x的速度匀速前进,当弹幕A距离终点T时,弹幕B从起点出发,以y的速度匀速前进。请问,如果弹幕A和弹幕B同时到达终点,那么弹幕B的速度应该是多少?”

通过小学生式的数学计算后,可以得出:y=Sx/(S-K)

其中,S为轨道宽度,是已知的;K为弹幕A和弹幕B之间的距离,通过弹幕宽度-偏移量得,而偏移量已知,因此,K也是已知的;x为弹幕A的速度,也是已知的。那么,我们就可以很轻松求出弹幕B的“非理想最大速度”了。

为什么是“非理想最大速度”?因为考虑到如果弹幕A和弹幕B之间相距很远,就可能会造成弹幕B的速度过快的问题。直接影响就是弹幕内容没看到,弹幕飞一下就过去了,整个体验非常不好。那么,这里就设定了一个理想最大速度。

为了照顾人类的眼球,我这里把理想最大速度限制在了平均速度的两倍(通过轨道宽度和弹幕生存时间求得)。同时,对于第一个弹幕而言,它是不存在追及问题的,因此,它的速度也是等于平均速度。

let speed: number if (isEmptyArray(track.barrages)) { speed = this._defaultSpeed * this._randomSpeed } else { const { speed: preSpeed } = getArrayRight<ScrollBarrageObject>(track.barrages) speed = (trackWidth * preSpeed) / trackOffset } speed = Math.min(speed, this._defaultSpeed * 2) const normalizedBarrage = Object.assign({}, barrage, { offset: trackWidth, speed })

这样的话,我们就求得弹幕的速度了,同时也得到了标准化弹幕。最后,放入到指定轨道中等待渲染即可。

依次渲染轨道中的弹幕

无论是指挥官类还是轨道类,这里都手动实现了forEach方法便于遍历。基于Canvas的弹幕需要用对应的context去画图,并且在初始化ABarrage类时,我们已经传入了一些弹幕的配置信息,比如轨道高度、弹幕时间、默认大小、默认颜色等,我们把配置与每一个弹幕合并后,得到最终的配置,然后调用context.fillText方法进行绘制。

render(): void { this._extractBarrage() const ctx = this.ctx const trackHeight = this.trackHeight this.forEach((track: Track<ScrollBarrageObject>, trackIndex) => { let removeTop = false track.forEach((barrage, barrageIndex) => { const { color, text, offset, speed, width, size } = barrage ctx.fillStyle = color ctx.font = `${size}px 'Microsoft Yahei'` ctx.fillText(text, offset, (trackIndex + 1) * trackHeight) barrage.offset -= speed if (barrageIndex === 0 && barrage.offset < 0 && Math.abs(barrage.offset) >= width) { removeTop = true } }) track.updateOffset() if (removeTop) { track.removeTop() } }) }

考虑到每一帧后弹幕的偏移量都会减少,因此还需要执行barrage.offset -= speed这一句进行偏移量更新。

除此之外,每一帧的渲染结束后,都需要去判断是否有弹幕已经完全超出的画布的范围,如果是,则将它从轨道中弹出。

if (removeTop) { track.removeTop() }

总结

至今日,弹幕已经流行于各大视频直播网站,目前弹幕的实现主要通过Canvas或者HTML+CSS3。笔者在这里只是介绍弹幕的思路,以Canvas的实现为核心。实际上,HTML+CSS3的实现也仅仅在渲染方式上略有不同,Canvas是通过画笔去绘制,而HTML则是通过操作DOM和transform样式去控制。对于交互式的弹幕,借助于DOM事件,使用HTML的实现会更加出彩。

最后,如果对完整的实现有兴趣的话,笔者这里安利自己写的一个弹幕库:ABarrage,里面实现了Canvas和HTML+CSS3两种渲染模式,并且是基于纯TypeScript编写的,没有任何第三方依赖。如果对你有帮助,希望能给个Star或者点个赞,作为对笔者这个菜逼的鼓励~感谢。

ABarrage 的Github地址:https://github.com/logcas/a-barrage

ABarrage Demo: https://logcas.github.io/a-barrage/example/

最新回复(0)