每天一个高级前端知识 - Day 20
今日主题:WebSocket 高级应用 - 实时协作、游戏同步与大规模消息推送
核心概念:WebSocket不仅是通信协议,更是实时应用的基石
从简单的聊天室到复杂的多人游戏,WebSocket让真正的实时交互成为可能。
🎮 WebSocket 架构模式
┌─────────────────────────────────────────────────────────┐
│ 客户端架构 │
├─────────────┬─────────────┬─────────────┬───────────────┤
│ 连接管理 │ 心跳保活 │ 消息队列 │ 断线重连 │
├─────────────┴─────────────┴─────────────┴───────────────┤
│ 业务逻辑层 │
│ 游戏同步 │ 协作编辑 │ 聊天室 │ 实时看板 │ 推送 │
└─────────────────────────────────────────────────────────┘
↕ WebSocket/WSS
┌─────────────────────────────────────────────────────────┐
│ 服务端架构 │
├─────────────┬─────────────┬─────────────┬───────────────┤
│ 连接管理 │ 房间管理 │ 消息路由 │ 负载均衡 │
├─────────────┴─────────────┴─────────────┴───────────────┤
│ 业务逻辑层 │
│ 状态同步 │ 冲突解决 │ 广播 │ 持久化 │ 认证授权 │
└─────────────────────────────────────────────────────────┘
🔌 WebSocket 高级客户端封装
// ============ WebSocket 高级客户端 ============
class AdvancedWebSocket {
constructor(url, options = {}) {
this.url = url;
this.options = {
reconnect: true,
reconnectInterval: 1000,
maxReconnectAttempts: 10,
heartbeatInterval: 30000,
heartbeatMessage: 'ping',
timeout: 5000,
...options
};
this.ws = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.messageQueue = [];
this.handlers = new Map();
this.messageId = 0;
this.pendingRequests = new Map();
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.init();
}
init() {
this.connect();
}
connect() {
try {
this.ws = new WebSocket(this.url);
this.setupEventHandlers();
} catch (error) {
console.error('WebSocket 连接失败:', error);
this.handleReconnect();
}
}
setupEventHandlers() {
this.ws.onopen = () => {
console.log('WebSocket 已连接');
this.isConnected = true;
this.reconnectAttempts = 0;
this.startHeartbeat();
this.flushMessageQueue();
this.emit('connect');
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = (event) => {
console.log('WebSocket 已断开:', event.code, event.reason);
this.isConnected = false;
this.stopHeartbeat();
this.emit('disconnect', { code: event.code, reason: event.reason });
this.handleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
this.emit('error', error);
};
}
handleMessage(data) {
try {
const message = JSON.parse(data);
// 处理心跳响应
if (message.type === 'pong') {
return;
}
// 处理请求响应
if (message.id && this.pendingRequests.has(message.id)) {
const { resolve, reject } = this.pendingRequests.get(message.id);
this.pendingRequests.delete(message.id);
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message.data);
}
return;
}
// 触发事件处理器
const handlers = this.handlers.get(message.type) || [];
handlers.forEach(handler => handler(message.data, message));
} catch (error) {
console.error('消息解析失败:', error);
}
}
// 发送消息(支持请求-响应模式)
send(type, data, options = {}) {
const message = {
type,
data,
timestamp: Date.now()
};
// 请求-响应模式
if (options.expectResponse) {
return new Promise((resolve, reject) => {
const id = ++this.messageId;
message.id = id;
this.pendingRequests.set(id, { resolve, reject });
// 超时处理
const timeout = setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('请求超时'));
}
}, options.timeout || 5000);
this.pendingRequests.get(id).timeout = timeout;
this.doSend(message);
});
}
this.doSend(message);
}
doSend(message) {
if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
}
}
flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.doSend(message);
}
}
// 心跳保活
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.isConnected) {
this.send('ping', null, { expectResponse: true })
.catch(() => {
console.warn('心跳失败,尝试重连');
this.reconnect();
});
}
}, this.options.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// 断线重连
handleReconnect() {
if (!this.options.reconnect) return;
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
console.error('达到最大重连次数,停止重连');
this.emit('reconnect_failed');
return;
}
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
const delay = this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts);
console.log(`${delay}ms 后进行第 ${this.reconnectAttempts + 1} 次重连`);
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
reconnect() {
if (this.ws) {
this.ws.close();
}
this.connect();
}
// 事件订阅
on(type, handler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, []);
}
this.handlers.get(type).push(handler);
}
off(type, handler) {
if (!this.handlers.has(type)) return;
const handlers = this.handlers.get(type);
const index = handlers.indexOf(handler);
if (index !== -1) handlers.splice(index, 1);
}
emit(type, data) {
const handlers = this.handlers.get(type) || [];
handlers.forEach(handler => handler(data));
}
// 连接状态
getState() {
if (!this.ws) return 'closed';
switch (this.ws.readyState) {
case WebSocket.CONNECTING: return 'connecting';
case WebSocket.OPEN: return 'open';
case WebSocket.CLOSING: return 'closing';
case WebSocket.CLOSED: return 'closed';
default: return 'unknown';
}
}
// 关闭连接
close() {
this.options.reconnect = false;
this.stopHeartbeat();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) this.ws.close();
}
}
🎮 实时游戏同步(帧同步)
// ============ 帧同步游戏客户端 ============
class FrameSyncGame {
constructor(roomId, playerId) {
this.roomId = roomId;
this.playerId = playerId;
this.ws = null;
this.frameRate = 60; // 60帧
this.frameInterval = 1000 / this.frameRate;
this.currentFrame = 0;
this.inputQueue = [];
this.frameBuffer = new Map(); // 缓存帧数据
this.localInputs = new Map(); // 本地输入历史
this.gameState = null;
this.players = new Map();
this.isRunning = false;
this.isLockstep = true; // 锁步模式
this.init();
}
init() {
this.ws = new AdvancedWebSocket(`wss://game.example.com/ws/${this.roomId}`, {
heartbeatInterval: 15000
});
this.setupHandlers();
}
setupHandlers() {
this.ws.on('connect', () => {
console.log('游戏服务器已连接');
this.joinRoom();
});
this.ws.on('frame_data', (data) => {
this.handleFrameData(data);
});
this.ws.on('game_state', (state) => {
this.gameState = state;
});
this.ws.on('player_joined', (player) => {
this.players.set(player.id, player);
this.onPlayerJoined?.(player);
});
this.ws.on('player_left', (playerId) => {
this.players.delete(playerId);
this.onPlayerLeft?.(playerId);
});
}
joinRoom() {
this.ws.send('join_game', {
playerId: this.playerId,
roomId: this.roomId
});
}
// 发送本地输入
sendInput(input) {
const frameInput = {
frame: this.currentFrame,
playerId: this.playerId,
input,
timestamp: Date.now()
};
this.localInputs.set(this.currentFrame, input);
this.ws.send('player_input', frameInput);
}
// 处理服务器下发的帧数据
handleFrameData(data) {
const { frame, inputs } = data;
// 缓存帧数据
this.frameBuffer.set(frame, inputs);
// 如果当前帧的输入已就绪,可以提前计算
if (frame === this.currentFrame + 1 && this.isAllInputsReady(frame)) {
this.executeFrame(frame);
}
}
// 检查所有玩家的输入是否都已收到
isAllInputsReady(frame) {
const frameInputs = this.frameBuffer.get(frame);
if (!frameInputs) return false;
const expectedPlayers = Array.from(this.players.keys());
const receivedPlayers = Object.keys(frameInputs);
return expectedPlayers.every(p => receivedPlayers.includes(p));
}
// 执行帧逻辑
executeFrame(frame) {
const frameInputs = this.frameBuffer.get(frame);
if (!frameInputs) return;
// 根据所有玩家的输入更新游戏状态
this.gameState = this.updateGameState(this.gameState, frameInputs);
// 渲染
this.render();
// 清理已执行的帧
this.frameBuffer.delete(frame);
this.currentFrame = frame;
// 预判执行下一帧
const nextFrame = frame + 1;
if (this.frameBuffer.has(nextFrame) && this.isAllInputsReady(nextFrame)) {
requestAnimationFrame(() => this.executeFrame(nextFrame));
}
}
updateGameState(state, inputs) {
// 深度克隆状态
const newState = JSON.parse(JSON.stringify(state));
// 应用每个玩家的输入
for (const [playerId, input] of Object.entries(inputs)) {
const player = newState.players.find(p => p.id === playerId);
if (player) {
// 移动逻辑
player.x += input.dx * player.speed;
player.y += input.dy * player.speed;
// 边界检测
player.x = Math.max(0, Math.min(1000, player.x));
player.y = Math.max(0, Math.min(600, player.y));
// 攻击逻辑
if (input.attack) {
this.handleAttack(newState, player);
}
}
}
// 碰撞检测
this.handleCollisions(newState);
return newState;
}
handleAttack(state, attacker) {
// 检测攻击范围内的其他玩家
for (const player of state.players) {
if (player.id !== attacker.id) {
const dx = player.x - attacker.x;
const dy = player.y - attacker.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < attacker.attackRange) {
player.health -= attacker.damage;
if (player.health <= 0) {
// 玩家死亡逻辑
this.onPlayerDeath?.(player.id, attacker.id);
}
}
}
}
}
handleCollisions(state) {
// 简单的碰撞检测
for (let i = 0; i < state.players.length; i++) {
for (let j = i + 1; j < state.players.length; j++) {
const p1 = state.players[i];
const p2 = state.players[j];
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = p1.radius + p2.radius;
if (distance < minDistance) {
// 推开
const angle = Math.atan2(dy, dx);
const overlap = minDistance - distance;
const moveX = Math.cos(angle) * overlap / 2;
const moveY = Math.sin(angle) * overlap / 2;
p1.x += moveX;
p1.y += moveY;
p2.x -= moveX;
p2.y -= moveY;
}
}
}
}
render() {
// Canvas 渲染逻辑
if (!this.canvas) return;
const ctx = this.canvas.getContext('2d');
ctx.clearRect(0, 0, 1000, 600);
// 渲染所有玩家
for (const player of this.gameState.players) {
ctx.fillStyle = player.id === this.playerId ? '#00ff00' : '#ff0000';
ctx.beginPath();
ctx.arc(player.x, player.y, player.radius, 0, Math.PI * 2);
ctx.fill();
// 渲染血条
ctx.fillStyle = '#ff0000';
ctx.fillRect(player.x - 25, player.y - 20, 50, 5);
ctx.fillStyle = '#00ff00';
ctx.fillRect(player.x - 25, player.y - 20, 50 * (player.health / 100), 5);
}
}
start() {
this.isRunning = true;
this.gameLoop();
}
gameLoop() {
if (!this.isRunning) return;
const now = Date.now();
const elapsed = now - this.lastFrameTime;
if (elapsed >= this.frameInterval) {
this.lastFrameTime = now;
// 如果当前帧的输入已就绪,执行
if (this.isAllInputsReady(this.currentFrame + 1)) {
this.executeFrame(this.currentFrame + 1);
} else {
// 等待更多输入,或进行预测
this.predictAndRender();
}
}
requestAnimationFrame(() => this.gameLoop());
}
predictAndRender() {
// 简单预测:基于上一帧的状态和本地输入
const lastInput = this.localInputs.get(this.currentFrame);
if (lastInput && this.gameState) {
const predictedState = this.updateGameState(
JSON.parse(JSON.stringify(this.gameState)),
{ [this.playerId]: lastInput }
);
this.renderPredicted(predictedState);
}
}
onPlayerDeath(playerId, killerId) {
console.log(`玩家 ${playerId} 被 ${killerId} 击杀`);
// 更新分数,播放特效等
}
}
✏️ 实时协作编辑(CRDT)
// ============ CRDT 实时协作编辑器 ============
class CollaborativeEditor {
constructor(docId, userId, userName) {
this.docId = docId;
this.userId = userId;
this.userName = userName;
this.ws = null;
this.characters = []; // 字符数组
this.pendingOps = []; // 待同步的操作
this.remoteOps = []; // 远程操作队列
this.snapshot = null; // 文档快照
this.selection = { start: 0, end: 0 };
this.users = new Map(); // 在线用户及光标位置
this.init();
}
async init() {
this.ws = new AdvancedWebSocket(`wss://collab.example.com/ws/${this.docId}`);
this.setupHandlers();
await this.loadSnapshot();
}
setupHandlers() {
this.ws.on('connect', () => {
this.joinDocument();
});
this.ws.on('operation', (op) => {
this.applyRemoteOperation(op);
});
this.ws.on('cursor', (cursorData) => {
this.updateRemoteCursor(cursorData);
});
this.ws.on('user_joined', (user) => {
this.users.set(user.id, user);
this.onUserJoined?.(user);
});
this.ws.on('user_left', (userId) => {
this.users.delete(userId);
this.onUserLeft?.(userId);
});
}
async loadSnapshot() {
const response = await fetch(`/api/docs/${this.docId}/snapshot`);
this.snapshot = await response.json();
this.characters = this.snapshot.characters;
this.render();
}
// 插入字符(带位置标识符)
insert(position, text) {
const op = {
type: 'insert',
position: this.generatePosition(position),
text,
userId: this.userId,
timestamp: Date.now()
};
this.applyLocalOperation(op);
this.sendOperation(op);
}
delete(position, length) {
const op = {
type: 'delete',
position: this.characters[position].id,
length,
userId: this.userId,
timestamp: Date.now()
};
this.applyLocalOperation(op);
this.sendOperation(op);
}
// 生成位置标识符(用于 CRDT)
generatePosition(index) {
if (this.characters.length === 0) {
return { base: 'root', offset: 0 };
}
if (index === 0) {
return { base: this.characters[0].id, side: 'left' };
}
if (index >= this.characters.length) {
return { base: this.characters[this.characters.length - 1].id, side: 'right' };
}
const left = this.characters[index - 1].id;
const right = this.characters[index].id;
return { left, right };
}
// 应用本地操作
applyLocalOperation(op) {
if (op.type === 'insert') {
const char = {
id: this.generateCharId(),
value: op.text,
userId: op.userId,
timestamp: op.timestamp
};
const index = this.findPositionIndex(op.position);
this.characters.splice(index, 0, char);
} else if (op.type === 'delete') {
const index = this.findCharIndex(op.position);
if (index !== -1) {
this.characters.splice(index, op.length);
}
}
this.render();
this.onDocumentChange?.(this.getText());
}
// 应用远程操作
applyRemoteOperation(op) {
// CRDT 冲突解决:基于时间戳和用户ID
if (this.hasConflict(op)) {
this.resolveConflict(op);
}
this.applyLocalOperation(op);
}
hasConflict(op) {
// 检查是否有并发冲突
// 简化实现:检查操作位置是否被其他操作影响
return false;
}
resolveConflict(op) {
// CRDT 冲突解决算法
// 基于用户ID的时间戳排序
console.log('解决冲突:', op);
}
// 发送光标位置
updateCursor(position) {
this.selection = position;
this.ws.send('cursor', {
userId: this.userId,
userName: this.userName,
position: this.selection,
timestamp: Date.now()
});
}
updateRemoteCursor(cursorData) {
const user = this.users.get(cursorData.userId);
if (user) {
user.cursor = cursorData.position;
user.lastActive = cursorData.timestamp;
this.renderCursor(user);
}
}
render() {
const text = this.characters.map(c => c.value).join('');
this.editorElement.value = text;
}
renderCursor(user) {
// 在编辑器中渲染其他用户的光标
const cursorElement = document.createElement('div');
cursorElement.className = 'remote-cursor';
cursorElement.style.left = `${this.getCursorX(user.cursor.start)}px`;
cursorElement.style.top = `${this.getCursorY(user.cursor.start)}px`;
cursorElement.textContent = user.userName;
cursorElement.style.backgroundColor = user.color;
this.cursorContainer.appendChild(cursorElement);
}
getText() {
return this.characters.map(c => c.value).join('');
}
findPositionIndex(position) {
if (position.base === 'root') return position.offset;
for (let i = 0; i < this.characters.length; i++) {
if (this.characters[i].id === position.base) {
return position.side === 'left' ? i : i + 1;
}
}
return this.characters.length;
}
findCharIndex(charId) {
return this.characters.findIndex(c => c.id === charId);
}
generateCharId() {
return `${this.userId}_${Date.now()}_${Math.random()}`;
}
joinDocument() {
this.ws.send('join_doc', {
userId: this.userId,
userName: this.userName,
snapshotVersion: this.snapshot.version
});
}
sendOperation(op) {
this.ws.send('operation', op);
}
}
🎯 今日挑战
实现一个实时协作白板,要求:
- WebSocket 连接管理和断线重连
- 多人实时绘图同步
- 支持多种工具(画笔、矩形、圆形、文字)
- 实现撤销/重做功能(需同步到所有用户)
- 用户光标实时显示
- 房间管理和权限控制
// 使用示例
const whiteboard = new CollaborativeWhiteboard({
roomId: 'room-123',
userId: 'user-456',
userName: 'Alice',
tools: ['pen', 'rect', 'circle', 'text']
});
whiteboard.on('draw', (data) => {
console.log('绘图数据:', data);
});
whiteboard.on('user_joined', (user) => {
console.log(`${user.name} 加入了房间`);
});
whiteboard.start();
明日预告:跨端开发 - React Native 与 Flutter 的深度对比与实践
💡 实时应用箴言:"延迟是永恒的,用户体验是判断延迟是否可接受的唯一标准"——用预测和插值让用户感知不到延迟!