淺談前端彈幕的設計
大廠技術 高級進階 精選好文
點擊上方 程序員成長指北,關注公眾號
回復1,加入高級Node交流群
背景
為了創(chuàng)造更好的多媒體體驗,許多視頻網站都添加了社交機制,使用戶可以在媒體時間軸上的特定點發(fā)布評論和查看其他人的評論,其中一種機制被稱為彈幕(dàn mù),在日語中也稱為コメント(comment)或者弾幕(danmaku),在播放過程中,可能會出現(xiàn)大量評論和注釋,并且直接渲染在視頻上。彈幕最初是由日本視頻網站Niconico(ニコニコ)引入的。在中國,除了在Bilibili和AcFun等彈幕視頻網站中使用之外,其他主流視頻網站(例如騰訊視頻,愛奇藝視頻,優(yōu)酷視頻和咪咕視頻)中的視頻播放器也支持彈幕。

形式
單條彈幕的基本模式有三種:
滾動彈幕:自右向左滾動過屏幕的彈幕,以自上而下的優(yōu)先度展示。 頂部彈幕:自上而下靜止居中的彈幕、以自上而下的優(yōu)先度展示。 底部彈幕:自下而上靜止居中的彈幕、以自下而上的優(yōu)先度展示。
為什么需要彈幕
從用戶體驗角度出發(fā)——沒有彈幕之前
在沒有彈幕之前,我們一般是通過評論或者聊天室的方式去進行互動:
(如上,左邊視頻,右邊互動區(qū))
傳統(tǒng)互動方式帶來的問題是,當我們的人眼的關注點在視頻上時,是沒辦法進行“一眼二用”的,簡單的來說就是,你沒辦法讓你的兩顆眼珠子往不同的方向看。這樣帶來的弊端是,當用戶專注于視頻時,互動區(qū)的交互效果是很差的;而當用戶在看互動區(qū)的評論時,又沒辦法去關注整件事的主體內容,顧此失彼。
(你沒辦法“一眼二用”)
與此同時,對于世界上大多數的人來說,自小養(yǎng)成的習慣就是從左往右的閱讀習慣。像這種互動區(qū)的評論,通常都是從下往上進行自動滾動的,兩個方向的合起來的話整個文字就形成了一個傾斜的運動方向,使得用戶的閱讀產生了障礙。
(傾斜向上的文字移動,讓人沒辦法好好看字)
從用戶體驗角度出發(fā)——彈幕出現(xiàn)之后
彈幕出現(xiàn)后,我們的視角就集中到視頻主體上,當彈幕出現(xiàn)時,如果是滾動彈幕,那么一般都是從右往左出發(fā),非常適合我們的從左往右的閱讀習慣,并且,文字的移動方向只有一個,不會給我們的閱讀產生障礙。
除此之外的好處
互動性強:點播時讓你覺得不孤獨
在觀看視頻網站提供視頻時,觀看者在觀看視頻內容過程中根據內容啟發(fā)會有一些想法或者吐槽點,就想要發(fā)表出來和更多的人分享,這時就需要彈幕來滿足這個需求。通過彈幕,可以把同一時間觀看者的評論通過固定方向滾動的方式顯示在視頻區(qū)域中,或者靜止的顯示在視頻區(qū)域的頂部或底部,這樣可以增加觀看者和視頻的互動特性以及觀看者之間的互動。在相同時刻發(fā)送的彈幕基本上也具有相同的主題。
互動性強:直播時的互動及時
彈幕在視頻直播場景中也能夠成為主播與觀眾直接互動的方式。比起傳統(tǒng)的實時評論,主播能夠根據屏幕上彈幕的展現(xiàn)更直觀了解觀眾的需求和反饋,更方便地調整接下來的行動和處理,也能夠根據用戶的輸入進行交互操作。
氣氛渲染好:“前方高能”
當看一些比較恐怖、懸疑的內容時,“前方高能”可能會避免你心里落下童年陰影[手動狗頭]。
彈幕的實現(xiàn)方式
現(xiàn)如今,從B站、愛奇藝、騰訊視頻等各大媒體網站上按下 F12 時,很容易發(fā)現(xiàn)是通過 HTML+CSS 的方式實現(xiàn)的。另外,也有一小部分具備 Canvas 實現(xiàn)的彈幕,比如之前的B站(不過在截稿前好像找不到切換按鈕了)。
假如通過 HTML+CSS 實現(xiàn)
通過 DOM 元素實現(xiàn)彈幕,前端同學可以很方便地通過 CSS 修改彈幕樣式。同時,得益于瀏覽器原生的 DOM 事件機制,借助這個可以很快捷實現(xiàn)一系列彈幕交互功能:個性化、點贊、舉報等,以滿足產品的各種互動需求。很容易看到,目前像騰訊視頻、愛奇藝等都是通過 DOM 元素實現(xiàn)彈幕,這是目前主流的實現(xiàn)方式。
假如通過 Canvas 實現(xiàn)
Canvas 為動畫而生,但是基于 Canvas 實現(xiàn)一個彈幕系統(tǒng),會比基于 DOM 實現(xiàn)要復雜。暫且不說對于大部分前端同學而言,對 Canvas 的熟悉程度遠比 DOM 要低,更何況,Canvas 并沒有一套原生的事件系統(tǒng),這意味著,如果要實現(xiàn)一些互動功能,你必須要自己實現(xiàn)一套 Canvas 的事件機制……
彈幕的設計
首先是整體設計,主要是三個部分:舞臺、軌道、彈幕池。
舞臺
舞臺是整個彈幕的主控制,它維護著多個軌道、一個等待隊列、一個彈幕池。舞臺要做的事情是控制整個彈幕的節(jié)奏,當每一幀進行渲染時,都判斷其中的軌道是否有空位,從等待隊列中取合適的彈幕送往合適的軌道。
舞臺的能力可以通過實現(xiàn)舞臺基類以及對應的抽象函數,讓具體類型的舞臺去實現(xiàn)對應的舞臺邏輯。從而實現(xiàn)不同渲染能力(Canvas、HTML+CSS)以及不同類型(滾動、頂部固定、底部固定)的彈幕控制。無法復制加載中的內容
不管是通過 Canvas 還是 DOM 實現(xiàn)彈幕,需要的方法都是相似的:添加新彈幕到等待隊列、尋找合適的軌道、從等待隊列中抽取彈幕并放入軌道、整體渲染、清空。因此 BaseStage 可以通過編排抽象方法,讓具體的子類去進行具體實現(xiàn)。
export default abstract class BaseStage<T extends BarrageObject> extends EventEmitter {
protected trackWidth: number
protected trackHeight: number
protected duration: number
protected maxTrack: number
protected tracks: Track<T>[] = []
waitingQueue: T[] = []
// 添加彈幕到等待隊列
abstract add(barrage: T): boolean
// 尋找合適的軌道
abstract _findTrack(): number
// 從等待隊列中抽取彈幕并放入軌道
abstract _extractBarrage(): void
// 渲染函數
abstract render(): void
// 清空
abstract reset(): void
}
Canvas 版本
比如,Canvas的舞臺基類需要傳入Canvas元素,獲取Context。最后通過實現(xiàn) BaseStage 的抽象方法實現(xiàn)具體的邏輯。
export default abstract class BaseCanvasStage<T extends BarrageObject> extends BaseStage<
T
> {
protected canvas: HTMLCanvasElement
protected ctx: CanvasRenderingContext2D
constructor(canvas: HTMLCanvasElement, config: Config) {
super(config)
this.canvas = canvas
this.ctx = canvas.getContext('2d')!
}
}
HTML + CSS 版本
而對于HTML+CSS的實現(xiàn),就需要維護一個彈幕池domPool、彈幕實例與DOM的映射關系(objToElm、elmToObj)以及一些必要的事件處理方法(_mouseMoveEventHandler 、_mouseClickEventHandler)。
export default abstract class BaseCssStage<T extends BarrageObject> extends BaseStage<T> {
el: HTMLDivElement
objToElm: WeakMap<T, HTMLElement> = new WeakMap()
elmToObj: WeakMap<HTMLElement, T> = new WeakMap()
freezeBarrage: T | null = null
domPool: Array<HTMLElement> = []
constructor(el: HTMLDivElement, config: Config) {
super(config)
this.el = el
const wrapper = config.wrapper
if (wrapper && config.interactive) {
wrapper.addEventListener('mousemove', this._mouseMoveEventHandler.bind(this))
wrapper.addEventListener('click', this._mouseClickEventHandler.bind(this))
}
}
createBarrage(text: string, color: string, fontSize: string, left: string) {
if (this.domPool.length) {
const el = this.domPool.pop()
return _createBarrage(text, color, fontSize, left, el)
} else {
return _createBarrage(text, color, fontSize, left)
}
}
removeElement(target: HTMLElement) {
if (this.domPool.length < this.poolSize) {
this.domPool.push(target)
return
}
this.el.removeChild(target)
}
_mouseMoveEventHandler(e: Event) {
const target = e.target
if (!target) {
return
}
const newFreezeBarrage = this.elmToObj.get(target as HTMLElement)
const oldFreezeBarrage = this.freezeBarrage
if (newFreezeBarrage === oldFreezeBarrage) {
return
}
this.freezeBarrage = null
if (newFreezeBarrage) {
this.freezeBarrage = newFreezeBarrage
newFreezeBarrage.freeze = true
setHoverStyle(target as HTMLElement)
this.$emit('hover', newFreezeBarrage, target as HTMLElement)
}
if (oldFreezeBarrage) {
oldFreezeBarrage.freeze = false
const oldFreezeElm = this.objToElm.get(oldFreezeBarrage)
oldFreezeElm && setBlurStyle(oldFreezeElm)
this.$emit('blur', oldFreezeBarrage, oldFreezeElm)
}
}
_mouseClickEventHandler(e: Event) {
const target = e.target
const barrageObject = this.elmToObj.get(target as HTMLElement)
if (barrageObject) {
this.$emit('click', barrageObject, target)
}
}
reset() {
this.forEach(track => {
track.forEach(barrage => {
const el = this.objToElm.get(barrage)
if (!el) {
return
}
this.removeElement(el)
})
track.reset()
})
}
}
彈幕池
無法復制加載中的內容 通過HTML+CSS實現(xiàn)的彈幕,每一個彈幕會對應一個 DOM 元素,為了減少頻繁的創(chuàng)建,會在屏幕的左側把上一輪已經滾出舞臺的彈幕存到池子中,當有新彈幕時會重新復用。
軌道

從我們平常見到的彈幕中可以看到,其實舞臺中間會存在多條平行的軌道,舞臺和軌道之間的關系是1對多的關系。當彈幕運行時,依次渲染軌道中的彈幕。所以,軌道中會存在一個彈幕數組,代表著目前正在軌道上展示的彈幕;以及一個叫offset的變量,代表著目前軌道已被占據的寬度。
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)
}
// 更新 Offset,只需要關注軌道中最后一個彈幕
updateOffset() {
const endBarrage = this.barrages[this.barrages.length - 1]
if (endBarrage) {
const { speed } = endBarrage
this.offset -= speed
}
}
}

碰撞
彈幕的碰撞控制以及彈幕的呈現(xiàn)方式,其實全憑產品需求和個人喜好決定。以大多數彈幕為例,除了 B站的實現(xiàn)比較多樣化之外,更多的實現(xiàn)是通過平行軌道的方式實現(xiàn)。如果需要考慮彈幕的碰撞問題,一般有兩種方法:
每個彈幕的速度都是相同的,所以也就不存在碰撞問題,但是效果非常死板。 每個彈幕的速度都是不一樣的,但是需要解決碰撞問題。
為了實現(xiàn)不同的速度,最簡單有效的方式其實就是通過『追及問題』求出彈幕的最大速度。
通過『追及問題』,很容易求出彈幕B的最大速度 VB 。但是 VB 不應該是彈幕的最終速度,考慮到距離 S 可能會比較大,那么 VB 的速度就會很大。于此同時,應該給彈幕的速度增加一點隨機性。因此,彈幕的速度比較好的呈現(xiàn)方式是:
S = Math.max(VB, Random * DefaultSpeed)
DefaultSpeed 第一個彈幕在軌道上的默認速度,它應該根據實際需求設置成一個合適的值,然后 VB 的最大值不能超過它,不然的話彈幕只能在軌道上『一閃而過』。
Demo
https://logcas.github.io/a-barrage/example/css3.html
https://logcas.github.io/a-barrage/example/canvas.html
參考資料
https://w3c.github.io/danmaku/usecase.zh.html
https://juejin.cn/post/6867689680670818317
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關的交流、學習、共建。下方加 考拉 好友回復「Node」即可。

“分享、點贊、在看” 支持一波 
