| name | danmaku-player |
| description | Guide for implementing real-time danmaku (bullet comments) player using Canvas. Invoke when building video players with danmaku support or debugging rendering/performance issues. |
弹幕播放器开发指南
基于 Canvas 的高性能弹幕播放器实现,参考 Bilibili 弹幕系统。包含完整的技术方案、性能优化和常见陷阱。
核心架构
1. 技术选型
使用 Canvas 2D 渲染(推荐)
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
animationRef.current = requestAnimationFrame(renderDanmaku);
避免使用 DOM 渲染
{danmakuList.map(d => <div key={d.id}>{d.text}</div>)}
原因:
- Canvas 单 DOM 节点,GPU 加速
- DOM 方案每个弹幕一个节点,重渲染开销大
- Canvas 支持批量渲染,性能优异
2. 组件结构
interface DanmakuPlayerProps {
videoRef: React.RefObject<VideoPlayerRef | null>;
width: number;
height: number;
isPaused: boolean;
videoId: number;
episodeIndex: number;
}
const danmakuListRef = useRef<DanmakuMessageDTO[]>([]);
const activeDanmakuRef = useRef<Array<{...}>>([]);
const tracksRef = useRef<DanmakuTrack[]>([]);
const animationRef = useRef<number | null>(null);
3. 播放器集成
VideoPlayer 必须暴露 videoRef
export interface VideoPlayerRef {
getCurrentTime: () => number;
getVideoElement: () => HTMLVideoElement | null;
}
export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
({ ... }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
getCurrentTime: () => videoRef.current?.currentTime || 0,
getVideoElement: () => videoRef.current,
}));
}
);
核心实现
1. 弹幕加载(独立不阻塞)
useEffect(() => {
const loadDanmaku = async () => {
try {
const response = await animeApi.getDanmakuList(videoId, episodeIndex);
if (response.success && response.data) {
danmakuListRef.current = response.data.danmakuList || [];
}
} catch (err) {
console.error('加载弹幕失败:', err);
}
};
loadDanmaku();
}, [videoId, episodeIndex]);
关键点:
- ✅ 弹幕层自己加载数据
- ✅ 不通过父组件传递弹幕列表
- ✅ 避免父子组件状态同步导致的重渲染
2. 弹幕渲染循环
const renderDanmaku = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const player = videoRef.current;
const video = player ? player.getVideoElement() : null;
if (!canvas || !ctx || !video) {
animationRef.current = requestAnimationFrame(renderDanmaku);
return;
}
const currentTime = video.currentTime;
if (currentTime !== lastTimeRef.current && !isPaused) {
lastTimeRef.current = currentTime;
const newDanmaku = danmakuListRef.current.filter(d => {
if (Math.abs(d.timePosition - currentTime) >= 0.3) return false;
const key = `${d.id}-${d.timePosition}`;
if (danmakuPoolRef.current.has(key as any)) return false;
return true;
});
newDanmaku.forEach(d => {
let trackIndex = -1;
for (let i = 0; i < NUM_TRACKS; i++) {
const track = tracksRef.current[i];
if (!track || track.endTime < currentTime) {
trackIndex = i;
break;
}
}
if (trackIndex === -1) trackIndex = Math.floor(Math.random() * NUM_TRACKS);
const y = trackIndex * DANMAKU_HEIGHT + 10;
const x = width;
const speed = DANMAKU_SPEED * (0.8 + Math.random() * 0.4);
tracksRef.current[trackIndex] = {
y,
endTime: currentTime + (width + textWidth + DANMAKU_PADDING) / speed,
};
activeDanmakuRef.current.push({
danmaku: d,
x,
y,
speed,
width: textWidth,
});
});
}
ctx.clearRect(0, 0, width, height);
activeDanmakuRef.current = activeDanmakuRef.current.filter(item => {
if (isPaused) return true;
item.x -= item.speed * 0.016;
return item.x > -item.width - DANMAKU_PADDING;
});
activeDanmakuRef.current.forEach(item => {
const { danmaku, x, y } = item;
ctx.font = 'bold 22px "Microsoft YaHei", sans-serif';
ctx.fillStyle = danmaku.color || '#ffffff';
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.fillText(danmaku.content, x, y + 18);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
});
animationRef.current = requestAnimationFrame(renderDanmaku);
}, [width, height, isPaused, videoRef]);
useEffect(() => {
animationRef.current = requestAnimationFrame(renderDanmaku);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [renderDanmaku]);
3. 轨道系统(防重叠)
interface DanmakuTrack {
y: number;
endTime: number;
}
const NUM_TRACKS = 12;
const DANMAKU_HEIGHT = 30;
let trackIndex = -1;
for (let i = 0; i < NUM_TRACKS; i++) {
const track = tracksRef.current[i];
if (!track || track.endTime < currentTime) {
trackIndex = i;
break;
}
}
if (trackIndex === -1) {
trackIndex = Math.floor(Math.random() * NUM_TRACKS);
}
4. 弹幕池管理(防重复)
const danmakuPoolRef = useRef<Map<number, DanmakuMessageDTO>>(new Map());
const key = `${d.id}-${d.timePosition}`;
if (danmakuPoolRef.current.has(key as any)) return false;
danmakuPoolRef.current.set(`${d.id}-${d.timePosition}` as any, d);
性能优化
1. 避免无限循环
❌ 错误:依赖变化导致循环
useEffect(() => {
const interval = setInterval(() => {
}, 500);
return () => clearInterval(interval);
}, [videoRef, videoHeight]);
✅ 正确:使用 Ref 存储变化值
const videoHeightRef = useRef(videoHeight);
useEffect(() => {
videoHeightRef.current = videoHeight;
}, [videoHeight]);
useEffect(() => {
const interval = setInterval(() => {
const vh = videoHeightRef.current;
}, 500);
return () => clearInterval(interval);
}, [videoRef]);
2. 避免阻塞视频
❌ 错误:弹幕加载阻塞视频
const { danmakuList, loadDanmaku } = useDanmakuList();
useEffect(() => {
loadDanmaku(videoId, episodeIndex);
}, [videoId]);
✅ 正确:弹幕独立加载
useEffect(() => {
const loadDanmaku = async () => {
};
loadDanmaku();
}, [videoId, episodeIndex]);
3. 避免点击穿透问题
❌ 错误:阻挡视频控制
<canvas style={{ pointerEvents: 'auto' }} />
✅ 正确:允许点击穿透
<div style={{ pointerEvents: 'none' }}>
<canvas style={{ pointerEvents: 'none' }} />
</div>
4. React 状态优化
❌ 错误:频繁 setState
useEffect(() => {
const animate = () => {
setDanmakuList(prev => [...prev]);
animationRef.current = requestAnimationFrame(animate);
};
}, []);
✅ 正确:使用 Ref
const activeDanmakuRef = useRef<Array<{...}>>([]);
const renderDanmaku = () => {
activeDanmakuRef.current = filtered;
};
常见陷阱
1. 弹幕不显示
原因:
- 弹幕层依赖
currentTime props,但父组件没传递
- 弹幕池重复检测逻辑错误
- 轨道全满时没有降级处理
解决:
const video = player ? player.getVideoElement() : null;
const currentTime = video.currentTime;
if (trackIndex === -1) {
trackIndex = Math.floor(Math.random() * NUM_TRACKS);
}
2. 视频卡顿
原因:
- 弹幕加载阻塞主线程
- 频繁 setState 触发重渲染
- useEffect 依赖导致 interval 不断重建
解决:
useEffect(() => {
loadDanmaku();
}, []);
const danmakuListRef = useRef([]);
const intervalRef = useRef<number | null>(null);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(checkDanmaku, 500);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [videoRef]);
3. 弹幕重叠
原因:轨道管理失效
解决:
tracksRef.current[trackIndex] = {
y,
endTime: currentTime + (width + textWidth + DANMAKU_PADDING) / speed,
};
activeDanmakuRef.current = activeDanmakuRef.current.filter(item => {
if (item.x > -item.width - DANMAKU_PADDING) return true;
return false;
});
4. 右键菜单无法显示
原因:canvas 的 pointerEvents: 'none' 导致无法捕获右键事件
解决:保留右键支持但允许其他点击穿透
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
const clickedDanmaku = activeDanmakuRef.current.find(item => {
return mouseX >= item.x && mouseX <= item.x + item.width;
});
if (clickedDanmaku) {
setShowContextMenu(true);
}
};
canvas.addEventListener('contextmenu', handleContextMenu);
配置参数
const NUM_TRACKS = 12;
const DANMAKU_HEIGHT = 30;
const DANMAKU_PADDING = 50;
const DANMAKU_SPEED = 200;
const CHECK_INTERVAL = 500;
const TIME_THRESHOLD = 0.3;
完整示例
组件使用
const videoPlayerRef = useRef<VideoPlayerRef>(null);
const [videoSize, setVideoSize] = useState({ width: 0, height: 0 });
const [isPaused, setIsPaused] = useState(false);
return (
<div className="relative">
<VideoPlayer
ref={videoPlayerRef}
src={videoSrc}
onSizeChange={(w, h) => setVideoSize({ width: w, height: h })}
onPauseChange={(paused) => setIsPaused(paused)}
/>
{videoSize.width > 0 && (
<DanmakuPlayer
videoRef={videoPlayerRef}
width={videoSize.width}
height={videoSize.height}
isPaused={isPaused}
videoId={videoId}
episodeIndex={episodeIndex}
/>
)}
<DanmakuInput
videoId={videoId}
episodeIndex={episodeIndex}
videoRef={videoPlayerRef}
/>
</div>
);
总结
关键要点
- Canvas 渲染 - 性能最优,60fps 流畅
- 独立加载 - 弹幕层自己加载数据,不阻塞视频
- 轨道系统 - 防止弹幕重叠
- Ref 管理 - 避免频繁 setState
- pointerEvents - 允许点击穿透
- 弹幕池 - 防止重复显示
性能对比
| 方案 | FPS | DOM 节点 | 内存占用 |
|---|
| Canvas(推荐) | 60 | 1 | 低 |
| DOM 方案 | 15-30 | N(弹幕数) | 高 |
适用场景
- ✅ 实时弹幕(直播、视频播放)
- ✅ 大量弹幕(>100 条/分钟)
- ✅ 需要流畅动画
- ❌ 静态评论(使用普通列表)
参考资料