Video.js 是目前最流行的开源 HTML5 视频播放器库之一,功能成熟、插件生态丰富,而弹幕则是国内视频类产品几乎必备的交互功能。把两者结合到 Vue 3 项目中,需要处理好组件生命周期、响应式数据与播放器实例之间的关系,否则很容易出现重复初始化、内存泄漏等隐蔽问题。本文将一步步实现一个带弹幕功能的 Video.js 播放器组件,并深入分析其中的关键实现细节。

一、集成 Video.js:安装与基础封装
首先安装依赖,Video.js 8.x 版本已经全面支持原生 ES Module,可以直接配合 Vite 使用:
npm install video.js # 或者 pnpm add video.js
安装完成后,在组件中导入 Video.js 的核心模块和默认样式。这里有一个常见的坑:样式文件必须在组件或全局入口中显式引入,否则播放器控件会显示错乱:
import videojs from 'video.js' import 'video.js/dist/video-js.css'
接下来封装一个基础的播放器组件。核心思路是在 onMounted 中初始化播放器实例,在 onBeforeUnmount 中调用 dispose() 销毁实例。Video.js 的实例本身包含大量 DOM 操作和事件绑定,如果不在组件卸载时销毁,切换路由后旧实例会残留在内存中,导致事件重复触发。
<template>
<div class="player-wrapper">
<video ref="videoRef" class="video-js vjs-big-play-centered" />
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import videojs from 'video.js'
import 'video.js/dist/video-js.css'
const videoRef = ref(null)
let player = null
onMounted(() => {
player = videojs(videoRef.value, {
controls: true,
autoplay: false,
preload: 'auto',
fluid: true, // 自适应容器宽度
playbackRates: [0.5, 1, 1.5, 2],
sources: [{
src: 'https://ipipp.com/static/demo.mp4',
type: 'video/mp4'
}]
})
player.on('timeupdate', () => {
// 每次 currentTime 变化都会触发,节流处理见下文
console.log('当前时间:', player.currentTime())
})
})
onBeforeUnmount(() => {
if (player) {
player.dispose()
player = null
}
})
</script>几个细节值得注意。第一,Video.js 8 不再要求 <video> 元素必须带 data-setup 属性,直接传入 ref 即可。第二,fluid: true 会让播放器随容器宽度等比缩放,适合响应式布局。第三,实例变量 player 不要用 ref 包裹,因为播放器实例是一个复杂的类实例,被 Proxy 代理后可能导致内部逻辑异常,用普通变量保存即可。
另外一个易犯的错误是在模板中直接对播放器实例做 v-if 控制。当 v-if 为 false 再变回 true 时,DOM 会被重建,但 onMounted 不会再次触发,播放器就变成了一个裸的 video 标签。正确做法是通过 :key 强制重建整个组件,或者把销毁和重建的逻辑封装在 watch 中统一管理。
二、弹幕功能:方案选型与轨道模型设计
弹幕的本质是「在特定时间点出现、横向滚动、一段时间后消失」的文本元素。实现弹幕首先要确定渲染方案,目前主流的有 DOM 渲染和 Canvas 渲染两种。
DOM 方案是把每条弹幕渲染成一个绝对定位的元素,用 CSS transition 或 animation 驱动移动。优点是实现简单、文字样式容易定制、方便绑定点击事件;缺点是弹幕量大时 DOM 节点数暴涨,几百条同屏弹幕就可能引起明显的掉帧。Canvas 方案则是在一个覆盖层上用 requestAnimationFrame 逐帧绘制文字,性能远优于 DOM 方案,可以轻松支持上千条同屏弹幕,代价是需要自己处理文字排版、点击命中和设备像素比适配。
对于一般的中后台或点播项目,DOM 方案配合「最大同屏数量限制」已经够用;如果目标是 B 站级别的弹幕密度,建议直接上 Canvas。本文以 DOM 方案讲解核心逻辑,文末会给出 Canvas 方案的思路。
先设计弹幕的数据模型。一条弹幕需要包含内容、出现时间和轨道编号:
// danmaku.js 弹幕数据与轨道管理
export function createTrackManager(trackCount = 5) {
// 记录每条轨道最后一个元素离开屏幕前还需占用的宽度
const tracks = new Array(trackCount).fill(null)
/**
* 碰撞检测:新弹幕入轨时,判断该轨道最后一条弹幕
* 的尾部是否已经移动出足够的安全距离
*/
function pickTrack(speed) {
const now = performance.now()
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i]
// 轨道空闲,或前一条弹幕尾部已远离右侧边界
if (!t || now - t.startTime > t.safeTime) {
return i
}
}
return -1 // 所有轨道都被占用,丢弃该弹幕
}
function occupy(trackIndex, info) {
tracks[trackIndex] = info
}
return { pickTrack, occupy }
}这里的 pickTrack 做的是简化版的碰撞检测:当新弹幕要进入时,检查每条轨道上最后一条弹幕是否已经「走远」。如果所有轨道都被占满,这条弹幕会被丢弃而不是强行塞入,避免弹幕重叠重叠在一起无法阅读。更精细的做法是根据两条弹幕的字体宽度和速度差来计算是否会追尾,速度慢的弹幕在前、速度快的在后时,需要预留更大的安全距离。
三、把弹幕接入播放器:组件完整实现
弹幕的调度依赖播放器的 currentTime。在 timeupdate 事件中,把当前时间与待播放弹幕队列比对,到达时间的弹幕就入轨渲染。由于 timeupdate 触发频率较高,建议对弹幕队列按时间排序,并用一个指针记录已消费的位置,避免每次全量扫描。
<template>
<div class="player-wrapper">
<video ref="videoRef" class="video-js vjs-big-play-centered" />
<div class="danmaku-layer" v-show="showDanmaku">
<div
v-for="item in activeDanmakus"
:key="item.id"
class="danmaku-item"
:style="item.style"
>{{ item.text }}</div>
</div>
<div class="danmaku-input">
<input v-model="draft" placeholder="发个友善的弹幕见证当下" />
<button @click="sendDanmaku">发送</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import videojs from 'video.js'
import 'video.js/dist/video-js.css'
import { createTrackManager } from './danmaku'
const videoRef = ref(null)
const activeDanmakus = ref([])
const draft = ref('')
const showDanmaku = ref(true)
let player = null
let queue = [] // 按时间排序的弹幕队列
let cursor = 0 // 已消费指针
let uid = 0
const trackManager = createTrackManager(6)
const TRACK_HEIGHT = 36 // 每条轨道高度,单位 px
onMounted(() => {
player = videojs(videoRef.value, {
controls: true, fluid: true,
sources: [{ src: '/demo.mp4', type: 'video/mp4' }]
})
player.on('timeupdate', () => {
const time = player.currentTime()
while (cursor < queue.length && queue[cursor].time <= time) {
spawn(queue[cursor])
cursor++
}
})
player.on('seeked', () => {
// 拖动进度条后重置指针,避免漏弹幕或弹幕堆积
cursor = queue.findIndex(d => d.time >= player.currentTime())
if (cursor < 0) cursor = queue.length
activeDanmakus.value = []
})
})
function spawn(danmaku) {
if (!showDanmaku.value) return
const trackIndex = trackManager.pickTrack()
if (trackIndex === -1) return
const id = ++uid
const duration = 8000 // 滚动 8 秒
const item = {
id,
text: danmaku.text,
style: {
top: trackIndex * TRACK_HEIGHT + 'px',
animationDuration: duration + 'ms'
}
}
trackManager.occupy(trackIndex, {
startTime: performance.now(),
safeTime: 2000 // 粗略的安全时间
})
activeDanmakus.value.push(item)
// 动画结束后移除节点
setTimeout(() => {
activeDanmakus.value = activeDanmakus.value.filter(i => i.id !== id)
}, duration)
}
function sendDanmaku() {
const text = draft.value.trim()
if (!text || !player) return
const danmaku = { time: player.currentTime(), text }
queue.push(danmaku)
queue.sort((a, b) => a.time - b.time)
cursor = queue.findIndex(d => d.time > player.currentTime())
if (cursor < 0) cursor = queue.length
spawn(danmaku)
draft.value = ''
}
onBeforeUnmount(() => {
if (player) player.dispose()
})
</script>对应的 CSS 动画从右侧滑入、滑出左侧边界:
.player-wrapper { position: relative; }
.danmaku-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none; /* 不遮挡播放器控件 */
z-index: 1;
}
.danmaku-item {
position: absolute;
left: 100%;
white-space: nowrap;
color: #fff;
font-size: 16px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
animation: danmaku-scroll linear forwards;
}
@keyframes danmaku-scroll {
from { transform: translateX(0); }
to { transform: translateX(calc(-100vw - 100%)); }
}这套实现中有几个关键点。弹幕层的 pointer-events: none 必须设置,否则弹幕层会盖住播放器的控制条,导致无法点击暂停。seeked 事件的处理同样重要:用户拖动进度条后,时间轴发生了跳跃,必须重置弹幕指针并清空当前屏幕上的弹幕,否则会出现弹幕堆积或长时间没有弹幕的情况。
四、进阶优化:性能、体验与 Canvas 方案
在基础版之上,还可以从三个方向继续优化。第一是性能方面,用 Vue 的响应式数组驱动弹幕渲染虽然方便,但每条弹幕的入队和移除都会触发虚拟 DOM 更新。当弹幕量大时,可以改为直接操作 DOM 或者切换到 Canvas 渲染,把响应式开销完全去掉。Canvas 方案的核心是一个绘制循环:每帧清空画布,遍历所有活跃弹幕,按各自的 x 坐标调用 fillText 绘制,x 坐标根据速度和经过的时间计算得出,天然不需要操作任何 DOM 节点。
第二是体验方面,可以补充暂停时弹幕同步冻结(通过 animation-play-state: paused 或者暂停 rAF 循环)、弹幕透明度调节、字号缩放、以及顶部固定和底部固定两种特殊弹幕类型。这些配置项可以集中到一个 reactive 对象中,通过 CSS 变量控制样式,例如 --danmaku-opacity,一行样式就能实现全局透明度调节。
第三是数据层面,真实的弹幕系统需要与服务端交互。播放前一次性拉取全片弹幕固然简单,但对长视频会造成较大的首屏开销。更常见的做法是分段拉取,比如每次请求当前时间点前后 60 秒的弹幕,在 timeupdate 中提前预加载下一段,同时把用户发送的弹幕先本地插入队列,再异步提交到服务端,失败时给出轻量提示。
最后提一下现有的轮子。社区里有 artplayer、xgplayer(西瓜播放器)这类对弹幕支持更完善的播放器,它们内置了弹幕引擎,可以直接开箱使用。如果项目对弹幕的要求只是基础水平,用 Video.js 加上本文的自研方案完全可控且可定制性更强;如果需要 B 站级的弹幕体验和密集性能要求,评估成熟播放器也是合理的选择。理解了弹幕的轨道分配、碰撞检测和调度原理之后,无论用哪个库,都能快速上手并做二次定制。