跳到主要内容

设计直播弹幕系统

问题

如何设计一个高性能的直播弹幕系统?从前端架构、通信协议、渲染引擎到服务端分发,请详细说明核心模块的设计思路与关键技术实现。

答案

直播弹幕系统是一个典型的高并发实时通信场景,需要同时解决海量消息分发低延迟传输流畅渲染三大核心挑战。一个完整的弹幕系统涉及 WebSocket 长连接管理、消息协议设计、弹幕渲染引擎、队列限流等多个关键模块。


一、需求分析

功能需求

模块功能点
发送弹幕用户输入文本发送、支持表情/图片、字体颜色/大小选择
渲染弹幕从右向左滚动、顶部/底部固定、不同样式区分(VIP、管理员)
弹幕管理屏蔽关键词、屏蔽用户、弹幕密度控制、弹幕开关
互动功能弹幕点赞、超级弹幕(付费特效)、弹幕回放

非功能需求

指标目标
高并发单房间支持 10 万+ 同时在线,峰值百万级消息/秒
低延迟端到端延迟 < 500ms(从发送到其他用户看到)
流畅渲染60fps 渲染,同屏 200+ 条弹幕不卡顿
可靠性连接断开自动重连,消息不重复
扩展性支持多种弹幕类型、特效弹幕、弹幕审核
关键约束

弹幕系统可以容忍少量消息丢失(不同于 IM 聊天),但对实时性渲染性能要求极高。这一特点决定了我们在架构设计上可以采用"宁可丢弃也不堆积"的策略。


二、整体架构

数据流转过程

架构要点
  • 接入层服务层分离,WebSocket 网关只负责连接管理和消息转发,不做业务逻辑
  • 使用消息队列解耦生产者和消费者,应对流量峰值
  • Redis 缓存房间信息和在线用户列表,支持快速查询

三、核心模块设计

3.1 WebSocket 连接管理

WebSocket 是弹幕系统的通信基础,需要处理心跳保活、断线重连、房间隔离等问题。

danmaku/ws-manager.ts
interface WSConfig {
url: string;
roomId: string;
token: string;
heartbeatInterval?: number; // 心跳间隔,默认 30s
reconnectDelay?: number; // 重连延迟,默认 1s
maxReconnectAttempts?: number; // 最大重连次数,默认 5
}

type MessageHandler = (data: DanmakuMessage) => void;

class WebSocketManager {
private ws: WebSocket | null = null;
private config: Required<WSConfig>;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private handlers: Map<string, Set<MessageHandler>> = new Map();
private isManualClose = false; // 区分主动关闭和异常断开

constructor(config: WSConfig) {
this.config = {
heartbeatInterval: 30000,
reconnectDelay: 1000,
maxReconnectAttempts: 5,
...config,
};
}

/** 建立连接 */
connect(): void {
const { url, roomId, token } = this.config;
this.ws = new WebSocket(`${url}?roomId=${roomId}&token=${token}`);

this.ws.binaryType = 'arraybuffer'; // 使用二进制传输

this.ws.onopen = () => {
console.log('[WS] 连接成功');
this.reconnectAttempts = 0;
this.startHeartbeat();
// 发送加入房间消息
this.send({ type: 'join', roomId });
};

this.ws.onmessage = (event: MessageEvent) => {
this.handleMessage(event.data);
};

this.ws.onclose = (event: CloseEvent) => {
console.log('[WS] 连接关闭:', event.code, event.reason);
this.stopHeartbeat();
if (!this.isManualClose) {
this.reconnect(); // 非主动关闭才重连
}
};

this.ws.onerror = () => {
console.error('[WS] 连接错误');
};
}

/** 心跳保活 */
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(new Uint8Array([0x00])); // 最小心跳包:1 字节
}
}, this.config.heartbeatInterval);
}

private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}

/** 指数退避重连 */
private reconnect(): void {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
console.error('[WS] 超过最大重连次数');
this.emit('error', { type: 'MAX_RECONNECT' } as any);
return;
}

const delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts);
console.log(`[WS] ${delay}ms 后重连 (第 ${this.reconnectAttempts + 1} 次)`);

this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}

/** 发送消息 */
send(data: Record<string, unknown>): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}

/** 消息分发 */
private handleMessage(raw: ArrayBuffer | string): void {
const message = this.decode(raw);
if (message) {
this.emit(message.type, message);
}
}

/** 解码消息(支持二进制和 JSON) */
private decode(raw: ArrayBuffer | string): DanmakuMessage | null {
try {
if (raw instanceof ArrayBuffer) {
// Protobuf 解码(见下文协议设计)
return decodeProtobuf(raw);
}
return JSON.parse(raw as string);
} catch {
console.error('[WS] 消息解码失败');
return null;
}
}

/** 事件订阅 */
on(type: string, handler: MessageHandler): void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler);
}

private emit(type: string, data: DanmakuMessage): void {
this.handlers.get(type)?.forEach((handler) => handler(data));
}

/** 主动断开 */
disconnect(): void {
this.isManualClose = true;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.ws?.close(1000, 'Manual close');
}
}
心跳注意事项
  • 心跳包应尽量小(1 字节标记位即可),避免浪费带宽
  • 服务端也应有心跳超时检测,超时未收到心跳主动断开,释放资源
  • 移动端切后台时 WebSocket 可能被系统杀掉,需要在 visibilitychange 事件中处理重连

3.2 消息协议设计

弹幕系统消息量巨大,协议设计直接影响带宽和解析性能。

Protobuf vs JSON 对比

维度JSONProtobuf
编码大小较大(含字段名)小 50-70%(只含字段编号)
编解码速度较慢(字符串解析)快 5-10 倍(二进制直接读)
可读性好(文本格式)差(二进制)
Schema无(灵活但不安全)有(.proto 文件强类型)
浏览器支持原生需要 protobuf.js 库
适用场景调试阶段、低频消息高频弹幕消息
推荐方案

生产环境使用 Protobuf 传输弹幕消息(减少 50%+ 带宽),控制信令(加入/离开房间)可以使用 JSON 方便调试。

消息类型定义

danmaku/protocol.ts
/** 弹幕消息类型 */
enum MessageType {
// 弹幕消息
DANMAKU = 1, // 普通弹幕
SUPER_DANMAKU = 2, // 超级弹幕(付费特效)

// 控制消息
JOIN_ROOM = 10, // 加入房间
LEAVE_ROOM = 11, // 离开房间
HEARTBEAT = 12, // 心跳
HEARTBEAT_ACK = 13, // 心跳响应(携带在线人数)

// 系统消息
GIFT = 20, // 礼物
SYSTEM_NOTICE = 21, // 系统公告
ONLINE_COUNT = 22, // 在线人数更新
}

/** 弹幕位置模式 */
enum DanmakuMode {
SCROLL = 1, // 从右向左滚动(默认)
TOP = 2, // 顶部固定
BOTTOM = 3, // 底部固定
}

/** 弹幕消息结构 */
interface DanmakuMessage {
type: MessageType;
id: string; // 消息唯一 ID(用于去重)
roomId: string;
userId: string;
nickname: string;
content: string;
color: string; // 颜色 hex,如 '#FFFFFF'
fontSize: number; // 字体大小,默认 24
mode: DanmakuMode; // 弹幕模式
timestamp: number; // 服务端时间戳(用于弹幕回放)
priority: number; // 优先级:0-普通 1-VIP 2-管理员 3-超级弹幕
}

/** 二进制协议帧格式 */
// +--------+--------+--------+----------+
// | 总长度 | 类型 | 序列号 | 数据 |
// | 4 byte | 2 byte | 4 byte | N byte |
// +--------+--------+--------+----------+

/** 编码消息为二进制帧 */
function encodeFrame(type: MessageType, seqId: number, payload: Uint8Array): ArrayBuffer {
const headerSize = 10; // 4 + 2 + 4
const totalLength = headerSize + payload.byteLength;
const buffer = new ArrayBuffer(totalLength);
const view = new DataView(buffer);

view.setUint32(0, totalLength); // 总长度
view.setUint16(4, type); // 消息类型
view.setUint32(6, seqId); // 序列号

const body = new Uint8Array(buffer, headerSize);
body.set(payload);

return buffer;
}

/** 解码二进制帧 */
function decodeFrame(buffer: ArrayBuffer): {
type: MessageType;
seqId: number;
payload: Uint8Array;
} {
const view = new DataView(buffer);
const totalLength = view.getUint32(0);
const type = view.getUint16(4) as MessageType;
const seqId = view.getUint32(6);
const payload = new Uint8Array(buffer, 10, totalLength - 10);

return { type, seqId, payload };
}

3.3 弹幕渲染引擎

渲染引擎是弹幕系统最核心的前端模块,直接决定用户体验。

Canvas vs DOM 渲染对比

维度DOM 渲染Canvas 渲染
性能差(>50 条明显卡顿)好(200+ 条仍流畅)
交互好(原生事件支持)差(需手动计算命中区域)
样式丰富(CSS 全部可用)有限(手动绘制)
内存高(每条弹幕一个 DOM 节点)低(只有一个 Canvas)
SEO无影响(弹幕无需 SEO)无影响
适用场景弹幕量少(<50 条/屏)大量弹幕(主流方案)
业界方案

B 站、抖音等主流平台均使用 Canvas 渲染作为弹幕引擎。对于需要交互的弹幕(如点赞、举报),可以在 Canvas 上叠加一个透明的 DOM 层处理事件。

轨道分配算法

弹幕需要在不重叠的前提下尽可能紧凑地排列,核心是轨道分配

danmaku/track-manager.ts
interface Track {
id: number;
y: number; // 轨道 Y 坐标
lastDanmaku: ActiveDanmaku | null; // 该轨道最后一条弹幕(用于碰撞检测)
}

interface ActiveDanmaku {
id: string;
text: string;
x: number; // 当前 X 坐标
y: number; // Y 坐标
width: number; // 文本宽度(像素)
speed: number; // 移动速度(像素/帧)
color: string;
fontSize: number;
mode: DanmakuMode;
startTime: number; // 开始渲染的时间
}

class TrackManager {
private tracks: Track[] = [];
private canvasWidth: number;
private canvasHeight: number;
private trackHeight: number;

constructor(canvasWidth: number, canvasHeight: number, trackHeight = 32) {
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
this.trackHeight = trackHeight;
this.initTracks();
}

/** 初始化轨道 */
private initTracks(): void {
const trackCount = Math.floor(this.canvasHeight / this.trackHeight);
this.tracks = Array.from({ length: trackCount }, (_, i) => ({
id: i,
y: i * this.trackHeight,
lastDanmaku: null,
}));
}

/**
* 为新弹幕分配轨道
* 核心逻辑:找到一条不会发生碰撞的轨道
*/
allocateTrack(danmaku: { width: number; speed: number }): Track | null {
for (const track of this.tracks) {
if (this.isTrackAvailable(track, danmaku)) {
return track;
}
}
return null; // 所有轨道都被占用,弹幕将被丢弃
}

/**
* 碰撞检测:判断轨道是否可用
* 条件:上一条弹幕的尾部已经完全进入画布(不会追尾)
*/
private isTrackAvailable(
track: Track,
newDanmaku: { width: number; speed: number }
): boolean {
const last = track.lastDanmaku;
if (!last) return true; // 空轨道,直接可用

// 计算上一条弹幕的尾部是否已经完全进入画布
const lastTailX = last.x + last.width;
if (lastTailX > this.canvasWidth) {
return false; // 尾部还在画布外,会碰撞
}

// 追尾检测:新弹幕速度更快,可能追上前面的弹幕
if (newDanmaku.speed > last.speed) {
// 计算新弹幕走完画布需要的时间
const newTime = (this.canvasWidth + newDanmaku.width) / newDanmaku.speed;
// 在这段时间内,前面弹幕还需走多远才出画布
const lastRemainingDist = last.x + last.width;
const lastRemainingTime = lastRemainingDist / last.speed;
if (newTime < lastRemainingTime) {
return false; // 会追尾
}
}

return true;
}

/** 更新轨道上最后一条弹幕的引用 */
updateTrack(trackId: number, danmaku: ActiveDanmaku): void {
this.tracks[trackId].lastDanmaku = danmaku;
}
}

Canvas 渲染引擎核心实现

danmaku/renderer.ts
class DanmakuRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private offscreenCanvas: OffscreenCanvas; // 离屏 Canvas
private offscreenCtx: OffscreenCanvasRenderingContext2D;
private activeDanmakus: ActiveDanmaku[] = []; // 当前在屏弹幕
private trackManager: TrackManager;
private rafId: number | null = null;
private objectPool: DanmakuObjectPool; // 对象池(减少 GC)
private isRunning = false;

constructor(container: HTMLElement) {
// 主 Canvas
this.canvas = document.createElement('canvas');
this.canvas.width = container.clientWidth;
this.canvas.height = container.clientHeight;
this.canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d')!;

// 离屏 Canvas(用于文本宽度测量,避免在主线程频繁操作主 Canvas)
this.offscreenCanvas = new OffscreenCanvas(1, 1);
this.offscreenCtx = this.offscreenCanvas.getContext('2d')!;

this.trackManager = new TrackManager(this.canvas.width, this.canvas.height);
this.objectPool = new DanmakuObjectPool(200); // 预分配 200 个对象
}

/** 添加弹幕 */
add(message: DanmakuMessage): void {
// 测量文本宽度
this.offscreenCtx.font = `${message.fontSize}px "Microsoft YaHei", sans-serif`;
const textWidth = this.offscreenCtx.measureText(message.content).width;

// 计算速度(弹幕越长速度越快,保证在画面内停留时间相近)
const baseDuration = 8000; // 基础通过时间 8 秒
const totalDistance = this.canvas.width + textWidth;
const speed = totalDistance / (baseDuration / 16.67); // 每帧移动距离(60fps)

// 分配轨道
const track = this.trackManager.allocateTrack({ width: textWidth, speed });
if (!track) return; // 无可用轨道,丢弃

// 从对象池获取对象(而非 new)
const danmaku = this.objectPool.acquire();
danmaku.id = message.id;
danmaku.text = message.content;
danmaku.x = this.canvas.width; // 从画布右侧开始
danmaku.y = track.y + message.fontSize; // 轨道 Y + 字体基线
danmaku.width = textWidth;
danmaku.speed = speed;
danmaku.color = message.color;
danmaku.fontSize = message.fontSize;
danmaku.mode = message.mode;
danmaku.startTime = performance.now();

this.activeDanmakus.push(danmaku);
this.trackManager.updateTrack(track.id, danmaku);
}

/** 启动渲染循环 */
start(): void {
if (this.isRunning) return;
this.isRunning = true;
this.render();
}

/** 核心渲染循环 */
private render = (): void => {
if (!this.isRunning) return;

const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

// 从后往前遍历,方便原地删除
for (let i = this.activeDanmakus.length - 1; i >= 0; i--) {
const d = this.activeDanmakus[i];

// 更新位置
d.x -= d.speed;

// 检查是否移出画布
if (d.x + d.width < 0) {
this.objectPool.release(d); // 回收到对象池
this.activeDanmakus.splice(i, 1); // 从列表移除
continue;
}

// 绘制弹幕
this.drawDanmaku(ctx, d);
}

this.rafId = requestAnimationFrame(this.render);
};

/** 绘制单条弹幕 */
private drawDanmaku(ctx: CanvasRenderingContext2D, d: ActiveDanmaku): void {
ctx.font = `bold ${d.fontSize}px "Microsoft YaHei", sans-serif`;
ctx.textBaseline = 'top';

// 描边(黑色边框让弹幕在任何背景上都清晰可见)
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.strokeText(d.text, d.x, d.y);

// 填充文字颜色
ctx.fillStyle = d.color;
ctx.fillText(d.text, d.x, d.y);
}

/** 暂停 */
pause(): void {
this.isRunning = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}

/** 清空所有弹幕 */
clear(): void {
this.activeDanmakus.forEach((d) => this.objectPool.release(d));
this.activeDanmakus = [];
}

/** 销毁 */
destroy(): void {
this.pause();
this.clear();
this.canvas.remove();
}
}

3.4 弹幕队列与限流

当消息量巨大时(如热门直播间),不能把所有弹幕都渲染出来,需要缓冲队列限流策略

danmaku/queue.ts
interface QueueConfig {
maxQueueSize: number; // 队列最大长度
dispatchRate: number; // 每秒分发弹幕数
priorityEnabled: boolean; // 是否启用优先级
}

class DanmakuQueue {
private queue: DanmakuMessage[] = [];
private priorityQueue: DanmakuMessage[] = []; // 高优先级队列(VIP、管理员)
private config: QueueConfig;
private renderer: DanmakuRenderer;
private dispatchTimer: ReturnType<typeof setInterval> | null = null;

constructor(renderer: DanmakuRenderer, config: Partial<QueueConfig> = {}) {
this.renderer = renderer;
this.config = {
maxQueueSize: 100,
dispatchRate: 30, // 每秒最多渲染 30 条
priorityEnabled: true,
...config,
};
}

/** 消息入队 */
enqueue(message: DanmakuMessage): void {
// 高优先级消息走优先队列
if (this.config.priorityEnabled && message.priority >= 2) {
this.priorityQueue.push(message);
return;
}

// 普通队列满了,执行丢弃策略
if (this.queue.length >= this.config.maxQueueSize) {
this.queue.shift(); // 丢弃最早的消息(FIFO 丢弃)
}

this.queue.push(message);
}

/** 启动定时分发 */
startDispatch(): void {
const interval = 1000 / this.config.dispatchRate;

this.dispatchTimer = setInterval(() => {
// 优先级队列优先处理
const message = this.priorityQueue.shift() || this.queue.shift();
if (message) {
this.renderer.add(message);
}
}, interval);
}

/** 停止分发 */
stopDispatch(): void {
if (this.dispatchTimer) {
clearInterval(this.dispatchTimer);
this.dispatchTimer = null;
}
}

/** 获取队列状态(用于监控) */
getStats(): { queueSize: number; prioritySize: number } {
return {
queueSize: this.queue.length,
prioritySize: this.priorityQueue.length,
};
}

/** 动态调整分发速率 */
adjustRate(newRate: number): void {
this.config.dispatchRate = newRate;
this.stopDispatch();
this.startDispatch();
}
}
限流策略选择
策略说明适用场景
FIFO 丢弃队列满时丢弃最早的消息通用场景(推荐)
随机丢弃随机丢弃部分消息保证均匀性
采样按比例采样(如只显示 1/3)消息量极大时
优先级丢弃只丢弃低优先级消息VIP 弹幕不能丢

一般推荐 FIFO 丢弃 + 优先级保护 的组合策略。


四、关键技术实现

4.1 对象池(减少 GC 压力)

弹幕频繁创建和销毁对象会触发大量垃圾回收,导致渲染卡顿。对象池是解决这个问题的经典方案。

danmaku/object-pool.ts
class DanmakuObjectPool {
private pool: ActiveDanmaku[] = [];
private maxSize: number;

constructor(initialSize: number) {
this.maxSize = initialSize * 2;
// 预分配对象
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.createEmpty());
}
}

/** 创建空弹幕对象 */
private createEmpty(): ActiveDanmaku {
return {
id: '',
text: '',
x: 0,
y: 0,
width: 0,
speed: 0,
color: '#FFFFFF',
fontSize: 24,
mode: 1, // DanmakuMode.SCROLL
startTime: 0,
};
}

/** 从池中获取对象 */
acquire(): ActiveDanmaku {
return this.pool.pop() || this.createEmpty();
}

/** 归还对象到池中 */
release(obj: ActiveDanmaku): void {
if (this.pool.length < this.maxSize) {
// 重置对象属性(避免内存泄漏)
obj.id = '';
obj.text = '';
this.pool.push(obj); // 不创建新对象,复用旧对象
}
// 超过最大池大小的对象直接丢弃,交给 GC
}

/** 池状态 */
get size(): number {
return this.pool.length;
}
}

4.2 完整的弹幕系统组装

danmaku/index.ts
class DanmakuSystem {
private wsManager: WebSocketManager;
private renderer: DanmakuRenderer;
private queue: DanmakuQueue;
private messageIds = new Set<string>(); // 消息去重

constructor(container: HTMLElement, config: WSConfig) {
// 1. 初始化渲染引擎
this.renderer = new DanmakuRenderer(container);

// 2. 初始化消息队列
this.queue = new DanmakuQueue(this.renderer, {
maxQueueSize: 100,
dispatchRate: 30,
});

// 3. 初始化 WebSocket
this.wsManager = new WebSocketManager(config);

// 4. 监听弹幕消息
this.wsManager.on('danmaku', (msg: DanmakuMessage) => {
// 消息去重
if (this.messageIds.has(msg.id)) return;
this.messageIds.add(msg.id);

// 防止 Set 无限增长
if (this.messageIds.size > 10000) {
const iterator = this.messageIds.values();
for (let i = 0; i < 5000; i++) {
this.messageIds.delete(iterator.next().value!);
}
}

this.queue.enqueue(msg);
});
}

/** 启动弹幕系统 */
start(): void {
this.wsManager.connect();
this.renderer.start();
this.queue.startDispatch();
}

/** 发送弹幕 */
send(content: string, options?: Partial<DanmakuMessage>): void {
this.wsManager.send({
type: MessageType.DANMAKU,
content,
color: options?.color || '#FFFFFF',
fontSize: options?.fontSize || 24,
mode: options?.mode || DanmakuMode.SCROLL,
});
}

/** 销毁 */
destroy(): void {
this.wsManager.disconnect();
this.renderer.destroy();
this.queue.stopDispatch();
this.messageIds.clear();
}
}

// 使用示例
const danmaku = new DanmakuSystem(
document.getElementById('video-container')!,
{
url: 'wss://danmaku.example.com/ws',
roomId: 'room_123',
token: 'user_token',
}
);

danmaku.start();
danmaku.send('Hello 弹幕!', { color: '#FF0000' });

五、性能优化

5.1 渲染优化

优化手段说明效果
requestAnimationFrame替代 setInterval,与浏览器刷新频率同步消除丢帧
离屏 Canvas文本测量在 OffscreenCanvas 中进行减少主线程阻塞
Canvas 分层静态弹幕(顶部/底部固定)和滚动弹幕分别渲染减少重绘面积
对象池复用弹幕对象,避免频繁 GC消除 GC 卡顿
批量绘制相同样式弹幕合并设置 ctx 属性减少状态切换
will-changeCanvas 元素添加 will-change: transform提升为合成层
danmaku/optimized-renderer.ts
/**
* 分层渲染:把固定弹幕和滚动弹幕分到不同 Canvas
* 固定弹幕不需要每帧重绘,只在内容变化时重绘
*/
class LayeredRenderer {
private scrollCanvas: HTMLCanvasElement; // 滚动弹幕层(每帧更新)
private fixedCanvas: HTMLCanvasElement; // 固定弹幕层(按需更新)
private scrollCtx: CanvasRenderingContext2D;
private fixedCtx: CanvasRenderingContext2D;
private fixedDirty = false; // 固定层是否需要重绘

constructor(container: HTMLElement) {
const width = container.clientWidth;
const height = container.clientHeight;

this.scrollCanvas = this.createCanvas(width, height);
this.fixedCanvas = this.createCanvas(width, height);

container.appendChild(this.scrollCanvas);
container.appendChild(this.fixedCanvas);

this.scrollCtx = this.scrollCanvas.getContext('2d')!;
this.fixedCtx = this.fixedCanvas.getContext('2d')!;
}

private createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.cssText = `
position: absolute; top: 0; left: 0;
pointer-events: none;
will-change: transform;
`;
return canvas;
}

render(scrollDanmakus: ActiveDanmaku[], fixedDanmakus: ActiveDanmaku[]): void {
// 滚动层:每帧清空重绘
this.scrollCtx.clearRect(0, 0, this.scrollCanvas.width, this.scrollCanvas.height);
// 批量设置相同样式,减少 Canvas 状态切换
let lastFont = '';
let lastColor = '';
for (const d of scrollDanmakus) {
const font = `bold ${d.fontSize}px "Microsoft YaHei", sans-serif`;
if (font !== lastFont) {
this.scrollCtx.font = font;
lastFont = font;
}
if (d.color !== lastColor) {
this.scrollCtx.fillStyle = d.color;
lastColor = d.color;
}
this.scrollCtx.fillText(d.text, d.x, d.y);
}

// 固定层:仅在内容变化时重绘
if (this.fixedDirty) {
this.fixedCtx.clearRect(0, 0, this.fixedCanvas.width, this.fixedCanvas.height);
for (const d of fixedDanmakus) {
this.fixedCtx.font = `bold ${d.fontSize}px "Microsoft YaHei", sans-serif`;
this.fixedCtx.fillStyle = d.color;
this.fixedCtx.textAlign = 'center';
this.fixedCtx.fillText(d.text, this.fixedCanvas.width / 2, d.y);
}
this.fixedDirty = false;
}
}

markFixedDirty(): void {
this.fixedDirty = true;
}
}

5.2 网络优化

danmaku/network-optimization.ts
/**
* 消息合并:服务端将多条弹幕打包成一个帧发送
* 减少 WebSocket 帧数量和 TCP 包头开销
*/
interface BatchMessage {
type: 'batch';
count: number;
messages: DanmakuMessage[]; // 一次传输多条弹幕
}

/**
* 客户端消息合并发送
* 将短时间内的多条消息合并为一个 WebSocket 帧
*/
class MessageBatcher {
private buffer: DanmakuMessage[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private flushInterval = 50; // 50ms 合并一次
private onFlush: (messages: DanmakuMessage[]) => void;

constructor(onFlush: (messages: DanmakuMessage[]) => void) {
this.onFlush = onFlush;
}

add(message: DanmakuMessage): void {
this.buffer.push(message);

if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flush();
}, this.flushInterval);
}
}

private flush(): void {
if (this.buffer.length > 0) {
this.onFlush([...this.buffer]);
this.buffer = [];
}
this.flushTimer = null;
}
}

5.3 内存优化总结

内存优化关键手段
  1. 对象池:复用弹幕对象,预分配 200 个,避免频繁 new 和 GC
  2. 消息 ID 去重集合:定期清理(超过 10000 条时清理一半),防止 Set 无限增长
  3. Canvas 复用:离屏 Canvas 只创建一次,反复使用
  4. 及时释放:弹幕移出画布后立即回收到对象池
  5. TypedArray:二进制协议使用 Uint8Array/DataView,比普通数组内存效率更高

六、扩展设计

6.1 高级弹幕类型

danmaku/advanced-types.ts
/** 高级弹幕基类 */
interface AdvancedDanmaku extends ActiveDanmaku {
type: 'scroll' | 'top' | 'bottom' | 'advanced';
duration: number; // 显示时长(固定弹幕)
opacity: number; // 透明度 0-1
}

/** 顶部/底部固定弹幕 */
interface FixedDanmaku extends AdvancedDanmaku {
type: 'top' | 'bottom';
duration: number; // 固定显示 5 秒
}

/** 特效弹幕(超级弹幕) */
interface SuperDanmaku extends AdvancedDanmaku {
type: 'advanced';
animation: 'fadeIn' | 'scale' | 'shake' | 'rainbow';
borderColor: string;
backgroundColor: string;
}

/** 绘制特效弹幕 */
function drawSuperDanmaku(
ctx: CanvasRenderingContext2D,
d: SuperDanmaku,
elapsed: number
): void {
ctx.save();

// 根据动画类型应用效果
switch (d.animation) {
case 'fadeIn': {
const alpha = Math.min(elapsed / 500, 1); // 500ms 渐入
ctx.globalAlpha = alpha;
break;
}
case 'scale': {
const scale = 1 + Math.sin(elapsed / 300) * 0.1; // 呼吸缩放
ctx.translate(d.x + d.width / 2, d.y);
ctx.scale(scale, scale);
ctx.translate(-(d.x + d.width / 2), -d.y);
break;
}
case 'rainbow': {
const hue = (elapsed / 10) % 360; // 彩虹色循环
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
break;
}
}

// 绘制背景框
if (d.backgroundColor) {
const padding = 8;
ctx.fillStyle = d.backgroundColor;
ctx.roundRect(
d.x - padding,
d.y - d.fontSize - padding,
d.width + padding * 2,
d.fontSize + padding * 2,
6
);
ctx.fill();
}

// 绘制文字
ctx.font = `bold ${d.fontSize}px "Microsoft YaHei", sans-serif`;
ctx.fillStyle = d.color;
ctx.fillText(d.text, d.x, d.y);

ctx.restore();
}

6.2 弹幕审核

danmaku/audit.ts
class DanmakuAuditor {
private sensitiveWords: Set<string>; // 敏感词库
private userBlacklist: Set<string>; // 用户黑名单

constructor(sensitiveWords: string[], blacklist: string[]) {
this.sensitiveWords = new Set(sensitiveWords);
this.userBlacklist = new Set(blacklist);
}

/** 前端本地预审核(减少无效请求) */
preAudit(content: string, userId: string): {
pass: boolean;
reason?: string;
} {
// 1. 黑名单用户
if (this.userBlacklist.has(userId)) {
return { pass: false, reason: '用户已被封禁' };
}

// 2. 内容长度检查
if (content.length > 50) {
return { pass: false, reason: '弹幕长度超过限制' };
}

// 3. 敏感词匹配(简单的前端预检)
for (const word of this.sensitiveWords) {
if (content.includes(word)) {
return { pass: false, reason: '包含敏感词' };
}
}

// 4. 频率限制(同一用户 2 秒内只能发一条)
if (this.isRateLimited(userId)) {
return { pass: false, reason: '发送过于频繁' };
}

return { pass: true };
}

private lastSendTime = new Map<string, number>();

private isRateLimited(userId: string): boolean {
const now = Date.now();
const last = this.lastSendTime.get(userId) || 0;
if (now - last < 2000) return true;
this.lastSendTime.set(userId, now);
return false;
}
}

6.3 弹幕回放

弹幕回放需要按视频时间轴精确还原弹幕出现的时机。

danmaku/replay.ts
interface ReplayDanmaku extends DanmakuMessage {
videoTime: number; // 弹幕对应的视频时间点(毫秒)
}

class DanmakuReplay {
private danmakus: ReplayDanmaku[] = [];
private currentIndex = 0;
private renderer: DanmakuRenderer;

constructor(renderer: DanmakuRenderer) {
this.renderer = renderer;
}

/** 加载弹幕数据(按 videoTime 排序) */
load(danmakus: ReplayDanmaku[]): void {
this.danmakus = danmakus.sort((a, b) => a.videoTime - b.videoTime);
this.currentIndex = 0;
}

/**
* 每帧调用,检查当前视频时间是否有弹幕需要显示
* @param currentTime 当前视频播放时间(毫秒)
*/
tick(currentTime: number): void {
while (
this.currentIndex < this.danmakus.length &&
this.danmakus[this.currentIndex].videoTime <= currentTime
) {
this.renderer.add(this.danmakus[this.currentIndex]);
this.currentIndex++;
}
}

/** 视频 seek 时重置索引 */
seek(targetTime: number): void {
this.renderer.clear();
// 二分查找定位到目标时间
this.currentIndex = this.binarySearch(targetTime);
}

private binarySearch(targetTime: number): number {
let left = 0;
let right = this.danmakus.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (this.danmakus[mid].videoTime < targetTime) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
}

常见面试问题

Q1: 弹幕系统为什么选择 Canvas 渲染而不是 DOM?

答案

核心原因是性能。DOM 渲染弹幕有以下问题:

  1. 大量 DOM 节点:每条弹幕是一个 <div>,100 条弹幕就是 100 个 DOM 节点,频繁创建/销毁触发 GC
  2. 重排重绘:每条弹幕移动都会触发 Layout 和 Paint,导致严重的性能瓶颈
  3. 内存消耗:每个 DOM 元素的内存开销远大于 Canvas 上的一次绘制调用

Canvas 的优势:

  • 只有一个 DOM 节点(Canvas 元素本身),所有弹幕都是绘制指令
  • 使用 requestAnimationFrame 驱动,每帧一次性清除 + 重绘所有弹幕
  • 配合对象池,几乎零 GC 开销
// DOM 方案:每条弹幕创建/销毁 DOM
const div = document.createElement('div');
div.textContent = '弹幕';
container.appendChild(div); // 触发 Layout
// ... 动画结束
container.removeChild(div); // 再次 Layout

// Canvas 方案:只是绘制指令
ctx.clearRect(0, 0, width, height); // 一次清空
for (const d of danmakus) {
ctx.fillText(d.text, d.x, d.y); // 绘制不触发 Layout
}
例外情况

如果弹幕量很小(< 30 条/屏)且需要丰富的交互(悬停、点击、链接),可以考虑 DOM + CSS transform 动画(利用 GPU 合成层,不触发 Layout)。

Q2: 如何处理弹幕碰撞和重叠?

答案

弹幕防碰撞的核心是轨道分配算法,具体逻辑如下:

  1. 将画布垂直划分为多条轨道,每条轨道的高度等于弹幕字体大小 + 间距
  2. 新弹幕到来时,从上到下遍历轨道,找到第一条"可用"的轨道
  3. 可用条件(两项都满足才行):
    • 轨道上最后一条弹幕的尾部已完全进入画布(否则新弹幕会和它的开头重叠)
    • 不会追尾:如果新弹幕速度更快,计算它是否会在画布范围内追上前面的弹幕
// 追尾检测核心逻辑
const willCatchUp = (
newSpeed: number,
lastSpeed: number,
lastX: number,
lastWidth: number,
canvasWidth: number,
newWidth: number
): boolean => {
if (newSpeed <= lastSpeed) return false; // 速度更慢,不会追上

const newTotalTime = (canvasWidth + newWidth) / newSpeed;
const lastRemainingTime = (lastX + lastWidth) / lastSpeed;
return newTotalTime < lastRemainingTime; // 新弹幕先走完 = 追上了
};
  1. 所有轨道都不可用时:丢弃该条弹幕(弹幕系统允许丢弃)

Q3: WebSocket 断线后如何保证不丢弹幕?

答案

实际上弹幕系统允许少量消息丢失,不需要像 IM 那样保证消息可靠性。但我们可以通过以下机制减少丢失:

  1. 指数退避重连:断线后 1s → 2s → 4s → 8s 递增重连间隔,避免服务端雪崩
  2. 断线期间消息补偿
    • 客户端记录断线时间戳
    • 重连成功后,请求 HTTP 接口拉取断线期间的弹幕
    • 服务端在 Redis 中缓存最近 N 秒的弹幕
// 重连成功后补偿
wsManager.on('reconnected', async () => {
const missedMessages = await fetch(
`/api/danmaku/history?roomId=${roomId}&since=${disconnectTimestamp}`
);
const messages: DanmakuMessage[] = await missedMessages.json();
messages.forEach((msg) => queue.enqueue(msg));
});
  1. 心跳检测:客户端 30 秒发一次心跳,服务端 60 秒未收到心跳则断开,客户端通过 onclose 触发重连
  2. visibilitychange 处理:页面切到后台时 WebSocket 可能被浏览器或系统杀掉,监听页面恢复事件主动检查连接状态

Q4: 如何应对 10 万人同时在线的高并发房间?

答案

高并发弹幕的核心挑战在服务端广播客户端渲染两个层面:

服务端:

  1. WebSocket 网关集群:单台服务器约支撑 5-10 万连接,通过多台网关横向扩展
  2. 消息队列分发:弹幕服务 → Kafka → 所有网关消费 → 广播给各自管理的用户
  3. 房间分片:超大房间按哈希分片到不同网关,每个网关只负责部分用户
  4. 服务端限流:单个房间每秒最多广播 N 条弹幕,多余的丢弃

客户端:

  1. 消息队列缓冲:收到的弹幕先入队列,按固定速率(如 30 条/秒)出队渲染
  2. 优先级保护:VIP、管理员弹幕进优先队列,保证不被丢弃
  3. 动态降级:检测帧率,如果低于 30fps,自动降低弹幕密度
// 动态降级策略
class AdaptiveController {
private lastFrameTime = 0;
private frameCount = 0;
private fps = 60;

checkPerformance(queue: DanmakuQueue): void {
this.frameCount++;
const now = performance.now();

if (now - this.lastFrameTime >= 1000) {
this.fps = this.frameCount;
this.frameCount = 0;
this.lastFrameTime = now;

// 根据帧率动态调整
if (this.fps < 30) {
queue.adjustRate(15); // 降低到 15 条/秒
} else if (this.fps > 50) {
queue.adjustRate(30); // 恢复到 30 条/秒
}
}
}
}

Q5: Protobuf 相比 JSON 在弹幕场景有多大优势?

答案

以一条典型的弹幕消息为例:

// JSON 编码 ≈ 180 bytes
{
"type": 1,
"id": "msg_abc123",
"roomId": "room_456",
"userId": "user_789",
"nickname": "测试用户",
"content": "Hello World",
"color": "#FFFFFF",
"fontSize": 24,
"mode": 1,
"timestamp": 1704067200000,
"priority": 0
}

// Protobuf 编码 ≈ 60-70 bytes(减少 60%+)

10 万人房间、每秒 100 条弹幕的场景下:

维度JSONProtobuf
单条大小~180 bytes~65 bytes
每秒带宽(单用户)18 KB/s6.5 KB/s
每秒带宽(10 万用户出口)1.8 GB/s0.65 GB/s
编解码 CPU低(快 5-10 倍)
注意

Protobuf 需要引入额外的库(protobuf.js,gzip 后约 20KB),对于弹幕量小的场景收益不大。建议当单房间并发超过 1000 人时才使用 Protobuf。

Q6: 弹幕回放和直播弹幕的实现区别是什么?

答案

维度直播弹幕弹幕回放
数据来源WebSocket 实时推送HTTP 接口预加载
时间基准收到即显示(实时)根据视频 currentTime 触发
排序无需排序(按到达顺序)必须按 videoTime 排序
Seek 处理需要二分查找定位索引
消息量需要限流丢弃全量展示(可按密度过滤)
存储Redis 短期缓存MongoDB/数据库持久化

弹幕回放的关键实现要点:

  1. 预加载:视频开始播放前,通过 HTTP 接口加载当前视频的所有弹幕,按 videoTime 排序
  2. 时间驱动:每帧检查 video.currentTime,将时间窗口内的弹幕加入渲染队列
  3. Seek 处理:用户拖动进度条时,用二分查找快速定位到目标时间的弹幕索引,清空当前屏幕弹幕
  4. 分段加载:对于长视频,按时间段分批请求弹幕,避免一次性加载过多数据

Q7: 如何实现弹幕的透明度渐变和特效动画?

答案

Canvas 绘制支持通过 ctx.globalAlphactx.save()/ctx.restore()ctx.translate()/ctx.scale() 等 API 实现各种效果:

/** 带渐隐效果的弹幕绘制 */
function drawWithFade(
ctx: CanvasRenderingContext2D,
d: ActiveDanmaku,
canvasWidth: number
): void {
ctx.save();

// 进入画面和离开画面时做淡入淡出
const fadeZone = 100; // 100px 渐变区域
if (d.x + d.width > canvasWidth - fadeZone) {
// 刚进入右侧:淡入
ctx.globalAlpha = (canvasWidth - d.x) / (fadeZone + d.width);
} else if (d.x < fadeZone) {
// 即将离开左侧:淡出
ctx.globalAlpha = (d.x + d.width) / (fadeZone + d.width);
}

ctx.font = `bold ${d.fontSize}px "Microsoft YaHei", sans-serif`;
ctx.fillStyle = d.color;
ctx.fillText(d.text, d.x, d.y);

ctx.restore(); // 恢复 globalAlpha 等状态
}

对于更复杂的特效(粒子效果、3D 变换),可以考虑使用 WebGL(通过 pixi.js 等库),但一般弹幕场景 Canvas 2D 足够。

Q8: 弹幕系统如何做到前后端解耦?

答案

通过消息协议分层架构实现解耦:

  1. 接入层与业务层分离

    • WebSocket 网关只负责连接管理和消息转发,不包含任何业务逻辑
    • 弹幕服务、审核服务、房间服务各自独立部署
  2. 消息队列解耦

    • 弹幕服务将消息发布到 Kafka Topic
    • WebSocket 网关作为消费者订阅 Topic
    • 两者通过消息队列完全解耦,可以独立扩缩容
  3. 协议统一

    • 定义清晰的 Protobuf Schema 文件
    • 前后端基于同一份 .proto 文件生成代码
    • 消息类型通过枚举明确,新增类型不影响已有逻辑
  4. 前端模块化

    • WebSocket 管理、消息队列、渲染引擎各自独立
    • 通过事件/回调组合,更换任一模块不影响其他模块

相关链接