技术架构概述
实时协作是WebRTC的高级应用,结合WebRTC的RTCDataChannel和WebSocket实现低延迟的协作功能。本文档涵盖在线白板、远程桌面、代码协作和文档协同编辑的完整实现。
15.3.1 在线白板协作
技术实现方案
在线白板使用Canvas API绘制,通过DataChannel实时同步绘制操作,支持多人同时绘制、撤销重做、图形识别等功能。
完整实现代码
<!-- CollaborativeWhiteboard.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue';
const props = defineProps({
dataChannel: {
type: RTCDataChannel,
default: null
},
userId: {
type: String,
required: true
},
width: {
type: Number,
default: 1920
},
height: {
type: Number,
default: 1080
}
});
const emit = defineEmits(['drawing-data', 'action-performed']);
// Canvas引用
const canvasRef = ref(null);
const ctx = ref(null);
// 绘图状态
const drawingState = reactive({
isDrawing: false,
tool: 'pen', // pen, eraser, line, rectangle, circle, text
color: '#000000',
lineWidth: 2,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0
});
// 历史记录
const history = reactive({
actions: [],
currentIndex: -1,
maxHistory: 50
});
// 用户光标
const userCursors = reactive(new Map());
// 可用颜色
const colors = [
'#000000', '#FF0000', '#00FF00', '#0000FF',
'#FFFF00', '#FF00FF', '#00FFFF', '#FFFFFF',
'#808080', '#FFA500', '#800080', '#008000'
];
// 可用线宽
const lineWidths = [1, 2, 3, 5, 8, 10, 15];
// 初始化Canvas
const initCanvas = () => {
const canvas = canvasRef.value;
if (!canvas) return;
ctx.value = canvas.getContext('2d', { willReadFrequently: true });
// 设置画布大小
canvas.width = props.width;
canvas.height = props.height;
// 设置默认样式
ctx.value.lineCap = 'round';
ctx.value.lineJoin = 'round';
// 填充白色背景
ctx.value.fillStyle = '#FFFFFF';
ctx.value.fillRect(0, 0, canvas.width, canvas.height);
// 保存初始状态
saveState();
};
// 开始绘制
const startDrawing = (e) => {
const rect = canvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
drawingState.isDrawing = true;
drawingState.startX = x;
drawingState.startY = y;
drawingState.currentX = x;
drawingState.currentY = y;
if (drawingState.tool === 'pen' || drawingState.tool === 'eraser') {
ctx.value.beginPath();
ctx.value.moveTo(x, y);
}
// 发送开始绘制事件
sendDrawingData({
type: 'start',
tool: drawingState.tool,
x, y,
color: drawingState.color,
lineWidth: drawingState.lineWidth,
userId: props.userId
});
};
// 绘制中
const draw = (e) => {
if (!drawingState.isDrawing) return;
const rect = canvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
drawingState.currentX = x;
drawingState.currentY = y;
// 根据工具类型绘制
switch (drawingState.tool) {
case 'pen':
drawPen(x, y);
break;
case 'eraser':
drawEraser(x, y);
break;
case 'line':
case 'rectangle':
case 'circle':
// 这些工具需要在stopDrawing时才真正绘制
break;
}
// 发送绘制数据
sendDrawingData({
type: 'draw',
tool: drawingState.tool,
x, y,
color: drawingState.color,
lineWidth: drawingState.lineWidth,
userId: props.userId
});
};
// 结束绘制
const stopDrawing = () => {
if (!drawingState.isDrawing) return;
// 对于形状工具,在这里绘制最终图形
if (['line', 'rectangle', 'circle'].includes(drawingState.tool)) {
drawShape();
}
drawingState.isDrawing = false;
// 保存状态
saveState();
// 发送结束绘制事件
sendDrawingData({
type: 'stop',
tool: drawingState.tool,
startX: drawingState.startX,
startY: drawingState.startY,
endX: drawingState.currentX,
endY: drawingState.currentY,
color: drawingState.color,
lineWidth: drawingState.lineWidth,
userId: props.userId
});
};
// 绘制笔触
const drawPen = (x, y) => {
ctx.value.strokeStyle = drawingState.color;
ctx.value.lineWidth = drawingState.lineWidth;
ctx.value.lineTo(x, y);
ctx.value.stroke();
};
// 橡皮擦
const drawEraser = (x, y) => {
ctx.value.clearRect(
x - drawingState.lineWidth / 2,
y - drawingState.lineWidth / 2,
drawingState.lineWidth,
drawingState.lineWidth
);
};
// 绘制形状
const drawShape = () => {
const { startX, startY, currentX, currentY, color, lineWidth, tool } = drawingState;
ctx.value.strokeStyle = color;
ctx.value.lineWidth = lineWidth;
switch (tool) {
case 'line':
ctx.value.beginPath();
ctx.value.moveTo(startX, startY);
ctx.value.lineTo(currentX, currentY);
ctx.value.stroke();
break;
case 'rectangle':
ctx.value.strokeRect(
startX,
startY,
currentX - startX,
currentY - startY
);
break;
case 'circle':
const radius = Math.sqrt(
Math.pow(currentX - startX, 2) + Math.pow(currentY - startY, 2)
);
ctx.value.beginPath();
ctx.value.arc(startX, startY, radius, 0, 2 * Math.PI);
ctx.value.stroke();
break;
}
};
// 切换工具
const changeTool = (tool) => {
drawingState.tool = tool;
if (tool === 'eraser') {
drawingState.color = '#FFFFFF';
}
};
// 切换颜色
const changeColor = (color) => {
drawingState.color = color;
if (drawingState.tool === 'eraser') {
drawingState.tool = 'pen';
}
};
// 切换线宽
const changeLineWidth = (width) => {
drawingState.lineWidth = width;
};
// 清空画布
const clearCanvas = () => {
ctx.value.fillStyle = '#FFFFFF';
ctx.value.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height);
saveState();
sendDrawingData({
type: 'clear',
userId: props.userId
});
};
// 保存状态
const saveState = () => {
// 移除当前索引之后的历史
if (history.currentIndex < history.actions.length - 1) {
history.actions.splice(history.currentIndex + 1);
}
// 保存当前canvas状态
const imageData = ctx.value.getImageData(
0, 0,
canvasRef.value.width,
canvasRef.value.height
);
history.actions.push(imageData);
history.currentIndex++;
// 限制历史记录数量
if (history.actions.length > history.maxHistory) {
history.actions.shift();
history.currentIndex--;
}
};
// 撤销
const undo = () => {
if (history.currentIndex > 0) {
history.currentIndex--;
restoreState();
sendDrawingData({
type: 'undo',
userId: props.userId
});
}
};
// 重做
const redo = () => {
if (history.currentIndex < history.actions.length - 1) {
history.currentIndex++;
restoreState();
sendDrawingData({
type: 'redo',
userId: props.userId
});
}
};
// 恢复状态
const restoreState = () => {
if (history.actions[history.currentIndex]) {
ctx.value.putImageData(history.actions[history.currentIndex], 0, 0);
}
};
// 发送绘图数据
const sendDrawingData = (data) => {
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'drawing',
data: data,
timestamp: Date.now()
}));
}
emit('drawing-data', data);
};
// 接收绘图数据
const handleRemoteDrawing = (data) => {
const { type, tool, x, y, color, lineWidth, startX, startY, endX, endY, userId } = data;
// 更新用户光标
if (type === 'draw' || type === 'start') {
userCursors.set(userId, { x, y, color });
}
// 执行绘制
switch (type) {
case 'start':
ctx.value.beginPath();
ctx.value.moveTo(x, y);
break;
case 'draw':
if (tool === 'pen') {
ctx.value.strokeStyle = color;
ctx.value.lineWidth = lineWidth;
ctx.value.lineTo(x, y);
ctx.value.stroke();
} else if (tool === 'eraser') {
ctx.value.clearRect(x - lineWidth / 2, y - lineWidth / 2, lineWidth, lineWidth);
}
break;
case 'stop':
if (['line', 'rectangle', 'circle'].includes(tool)) {
// 绘制最终图形
const oldTool = drawingState.tool;
drawingState.tool = tool;
drawingState.startX = startX;
drawingState.startY = startY;
drawingState.currentX = endX;
drawingState.currentY = endY;
drawingState.color = color;
drawingState.lineWidth = lineWidth;
drawShape();
drawingState.tool = oldTool;
}
saveState();
userCursors.delete(userId);
break;
case 'clear':
clearCanvas();
break;
case 'undo':
undo();
break;
case 'redo':
redo();
break;
}
};
// 导出图片
const exportImage = (format = 'png') => {
const dataURL = canvasRef.value.toDataURL(`image/${format}`);
const link = document.createElement('a');
link.download = `whiteboard.${format}`;
link.href = dataURL;
link.click();
};
// 加载图片
const loadImage = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
ctx.value.drawImage(img, 0, 0);
saveState();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
onMounted(() => {
initCanvas();
// 监听DataChannel消息
if (props.dataChannel) {
props.dataChannel.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.action === 'drawing') {
handleRemoteDrawing(message.data);
}
};
}
});
defineExpose({
clearCanvas,
undo,
redo,
exportImage,
loadImage,
handleRemoteDrawing
});
</script>
<template>
<div class="collaborative-whiteboard">
<!-- 工具栏 -->
<div class="toolbar">
<!-- 工具选择 -->
<div class="tool-group">
<button
@click="changeTool('pen')"
:class="{ active: drawingState.tool === 'pen' }"
class="tool-btn"
title="画笔"
>
✏️
</button>
<button
@click="changeTool('eraser')"
:class="{ active: drawingState.tool === 'eraser' }"
class="tool-btn"
title="橡皮擦"
>
🧹
</button>
<button
@click="changeTool('line')"
:class="{ active: drawingState.tool === 'line' }"
class="tool-btn"
title="直线"
>
📏
</button>
<button
@click="changeTool('rectangle')"
:class="{ active: drawingState.tool === 'rectangle' }"
class="tool-btn"
title="矩形"
>
▭
</button>
<button
@click="changeTool('circle')"
:class="{ active: drawingState.tool === 'circle' }"
class="tool-btn"
title="圆形"
>
⭕
</button>
</div>
<!-- 颜色选择 -->
<div class="tool-group">
<div
v-for="color in colors"
:key="color"
@click="changeColor(color)"
class="color-btn"
:style="{
backgroundColor: color,
border: drawingState.color === color ? '3px solid #1890ff' : '1px solid #ddd'
}"
></div>
</div>
<!-- 线宽选择 -->
<div class="tool-group">
<select
v-model="drawingState.lineWidth"
@change="changeLineWidth(drawingState.lineWidth)"
class="line-width-select"
>
<option
v-for="width in lineWidths"
:key="width"
:value="width"
>
{{ width }}px
</option>
</select>
</div>
<!-- 操作按钮 -->
<div class="tool-group">
<button
@click="undo"
:disabled="history.currentIndex <= 0"
class="action-btn"
title="撤销"
>
↶
</button>
<button
@click="redo"
:disabled="history.currentIndex >= history.actions.length - 1"
class="action-btn"
title="重做"
>
↷
</button>
<button
@click="clearCanvas"
class="action-btn"
title="清空"
>
🗑️
</button>
<button
@click="exportImage('png')"
class="action-btn"
title="导出"
>
💾
</button>
</div>
</div>
<!-- 画布区域 -->
<div class="canvas-container">
<canvas
ref="canvasRef"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
class="whiteboard-canvas"
></canvas>
<!-- 用户光标 -->
<div
v-for="[userId, cursor] in userCursors"
:key="userId"
class="user-cursor"
:style="{
left: cursor.x + 'px',
top: cursor.y + 'px',
borderColor: cursor.color
}"
>
<span class="cursor-label">{{ userId }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.collaborative-whiteboard {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f0f0;
}
.toolbar {
display: flex;
gap: 20px;
padding: 15px;
background: white;
border-bottom: 1px solid #ddd;
flex-wrap: wrap;
}
.tool-group {
display: flex;
gap: 8px;
align-items: center;
}
.tool-btn,
.action-btn {
width: 40px;
height: 40px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.tool-btn:hover,
.action-btn:hover {
background: #f5f5f5;
border-color: #1890ff;
}
.tool-btn.active {
background: #e6f7ff;
border-color: #1890ff;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.color-btn {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: transform 0.2s;
}
.color-btn:hover {
transform: scale(1.2);
}
.line-width-select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.canvas-container {
flex: 1;
position: relative;
overflow: auto;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
}
.whiteboard-canvas {
background: white;
cursor: crosshair;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.user-cursor {
position: absolute;
width: 10px;
height: 10px;
border: 2px solid;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
z-index: 1000;
}
.cursor-label {
position: absolute;
top: 15px;
left: 15px;
background: rgba(0,0,0,0.7);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
}
</style>
15.3.2 远程桌面控制
技术实现方案
远程桌面通过屏幕共享传输画面,DataChannel传输鼠标键盘事件,实现远程控制功能。
完整实现代码
<!-- RemoteDesktop.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
const props = defineProps({
dataChannel: {
type: RTCDataChannel,
default: null
},
mode: {
type: String,
default: 'controller', // controller or controlled
validator: (value) => ['controller', 'controlled'].includes(value)
}
});
const emit = defineEmits(['control-event', 'screen-shared']);
// 屏幕流
const screenStream = ref(null);
const videoRef = ref(null);
// 控制状态
const controlState = reactive({
isSharing: false,
isControlEnabled: false,
resolution: { width: 0, height: 0 },
scale: 1
});
// 鼠标状态
const mouseState = reactive({
x: 0,
y: 0,
isDown: false
});
// 键盘状态
const keysPressed = reactive(new Set());
// 开始屏幕共享
const startScreenShare = async () => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor',
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
screenStream.value = stream;
if (videoRef.value) {
videoRef.value.srcObject = stream;
}
// 获取分辨率
const videoTrack = stream.getVideoTracks()[0];
const settings = videoTrack.getSettings();
controlState.resolution = {
width: settings.width,
height: settings.height
};
controlState.isSharing = true;
// 监听停止共享
videoTrack.onended = () => {
stopScreenShare();
};
emit('screen-shared', stream);
return stream;
} catch (error) {
console.error('屏幕共享失败:', error);
throw error;
}
};
// 停止屏幕共享
const stopScreenShare = () => {
if (screenStream.value) {
screenStream.value.getTracks().forEach(track => track.stop());
screenStream.value = null;
controlState.isSharing = false;
}
};
// 启用远程控制
const enableControl = () => {
controlState.isControlEnabled = true;
if (props.mode === 'controlled') {
// 被控制端监听控制事件
if (props.dataChannel) {
props.dataChannel.onmessage = (event) => {
const data = JSON.parse(event.data);
handleControlEvent(data);
};
}
}
};
// 禁用远程控制
const disableControl = () => {
controlState.isControlEnabled = false;
};
// 处理鼠标移动
const handleMouseMove = (e) => {
if (props.mode !== 'controller' || !controlState.isControlEnabled) return;
const rect = videoRef.value.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseState.x = x;
mouseState.y = y;
sendControlEvent({
type: 'mousemove',
x, y
});
};
// 处理鼠标点击
const handleMouseDown = (e) => {
if (props.mode !== 'controller' || !controlState.isControlEnabled) return;
mouseState.isDown = true;
sendControlEvent({
type: 'mousedown',
button: e.button,
x: mouseState.x,
y: mouseState.y
});
};
const handleMouseUp = (e) => {
if (props.mode !== 'controller' || !controlState.isControlEnabled) return;
mouseState.isDown = false;
sendControlEvent({
type: 'mouseup',
button: e.button,
x: mouseState.x,
y: mouseState.y
});
};
// 处理鼠标滚轮
const handleWheel = (e) => {
if (props.mode !== 'controller' || !controlState.isControlEnabled) return;
e.preventDefault();
sendControlEvent({
type: 'wheel',
deltaX: e.deltaX,
deltaY: e.deltaY,
x: mouseState.x,
y: mouseState.y
});
};
// 处理键盘事件
const handleKeyDown = (e) => {
if (props.mode !== 'controller' || !controlState.isControlEnabled) return;
// 防止浏览器快捷键
if (e.ctrlKey || e.metaKey || e.altKey) {
e.preventDefault();
}
if (!keysPressed.has(e.code)) {
keysPressed.add(e.code);
sendControlEvent({
type: 'keydown',
code: e.code,
key: e.key,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey
});
}
};
const handleKeyUp = (e) => {
if (props.mode !== 'controller' || !controlState.isControlEnabled) return;
keysPressed.delete(e.code);
sendControlEvent({
type: 'keyup',
code: e.code,
key: e.key
});
};
// 发送控制事件
const sendControlEvent = (event) => {
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'control',
event: event,
timestamp: Date.now()
}));
}
emit('control-event', event);
};
// 处理控制事件(被控制端)
const handleControlEvent = (data) => {
if (!controlState.isControlEnabled) return;
const { event } = data;
switch (event.type) {
case 'mousemove':
simulateMouseMove(event.x, event.y);
break;
case 'mousedown':
simulateMouseClick(event.x, event.y, event.button, true);
break;
case 'mouseup':
simulateMouseClick(event.x, event.y, event.button, false);
break;
case 'wheel':
simulateWheel(event.x, event.y, event.deltaX, event.deltaY);
break;
case 'keydown':
simulateKeyPress(event.code, event.key, true, event);
break;
case 'keyup':
simulateKeyPress(event.code, event.key, false, event);
break;
}
};
// 模拟鼠标移动
const simulateMouseMove = (x, y) => {
const screenX = x * controlState.resolution.width;
const screenY = y * controlState.resolution.height;
// 注意: 浏览器安全限制,无法直接控制系统鼠标
// 实际应用需要通过浏览器扩展或Electron实现
console.log(`模拟鼠标移动: (${screenX}, ${screenY})`);
};
// 模拟鼠标点击
const simulateMouseClick = (x, y, button, isDown) => {
const screenX = x * controlState.resolution.width;
const screenY = y * controlState.resolution.height;
console.log(`模拟鼠标${isDown ? '按下' : '释放'}: (${screenX}, ${screenY}), 按钮: ${button}`);
};
// 模拟滚轮
const simulateWheel = (x, y, deltaX, deltaY) => {
console.log(`模拟滚轮: deltaX=${deltaX}, deltaY=${deltaY}`);
};
// 模拟按键
const simulateKeyPress = (code, key, isDown, modifiers) => {
console.log(`模拟按键${isDown ? '按下' : '释放'}: ${key} (${code})`);
if (modifiers.ctrlKey) console.log(' + Ctrl');
if (modifiers.shiftKey) console.log(' + Shift');
if (modifiers.altKey) console.log(' + Alt');
};
// 发送剪贴板内容
const sendClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
sendControlEvent({
type: 'clipboard',
text: text
});
} catch (error) {
console.error('读取剪贴板失败:', error);
}
};
// 接收剪贴板内容
const receiveClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
console.log('剪贴板已更新');
} catch (error) {
console.error('写入剪贴板失败:', error);
}
};
// 发送文件
const sendFile = async (file) => {
const reader = new FileReader();
reader.onload = async (e) => {
const arrayBuffer = e.target.result;
const chunkSize = 16384; // 16KB
const chunks = Math.ceil(arrayBuffer.byteLength / chunkSize);
// 发送文件元数据
sendControlEvent({
type: 'file-start',
name: file.name,
size: file.size,
type: file.type,
chunks: chunks
});
// 分块发送
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, arrayBuffer.byteLength);
const chunk = arrayBuffer.slice(start, end);
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'file-chunk',
index: i,
data: Array.from(new Uint8Array(chunk))
}));
}
// 等待发送
await new Promise(resolve => setTimeout(resolve, 10));
}
// 发送完成
sendControlEvent({
type: 'file-complete',
name: file.name
});
};
reader.readAsArrayBuffer(file);
};
// 调整视频缩放
const setScale = (scale) => {
controlState.scale = scale;
};
onMounted(() => {
if (props.mode === 'controller') {
// 控制端监听键盘事件
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
}
});
onUnmounted(() => {
stopScreenShare();
if (props.mode === 'controller') {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
}
});
defineExpose({
startScreenShare,
stopScreenShare,
enableControl,
disableControl,
sendClipboard,
sendFile,
setScale
});
</script>
<template>
<div class="remote-desktop">
<!-- 控制端界面 -->
<div v-if="mode === 'controller'" class="controller-view">
<div class="control-header">
<div class="status">
<span v-if="controlState.isControlEnabled" class="connected">
● 已连接
</span>
<span v-else class="disconnected">
● 未连接
</span>
</div>
<div class="controls">
<button
@click="enableControl"
:disabled="controlState.isControlEnabled"
class="control-btn"
>
启用控制
</button>
<button
@click="disableControl"
:disabled="!controlState.isControlEnabled"
class="control-btn"
>
禁用控制
</button>
<button @click="sendClipboard" class="control-btn">
发送剪贴板
</button>
<select v-model="controlState.scale" @change="setScale(controlState.scale)">
<option :value="0.5">50%</option>
<option :value="0.75">75%</option>
<option :value="1">100%</option>
<option :value="1.25">125%</option>
<option :value="1.5">150%</option>
</select>
</div>
</div>
<div class="video-container">
<video
ref="videoRef"
autoplay
playsinline
@mousemove="handleMouseMove"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@wheel="handleWheel"
:style="{
transform: `scale(${controlState.scale})`,
transformOrigin: 'top left'
}"
class="remote-screen"
></video>
</div>
</div>
<!-- 被控制端界面 -->
<div v-else class="controlled-view">
<div class="share-panel">
<h3>远程桌面控制</h3>
<button
v-if="!controlState.isSharing"
@click="startScreenShare"
class="share-btn"
>
开始共享屏幕
</button>
<div v-else class="sharing-status">
<p>正在共享屏幕</p>
<p>分辨率: {{ controlState.resolution.width }} x {{ controlState.resolution.height }}</p>
<button
@click="enableControl"
:disabled="controlState.isControlEnabled"
class="control-btn"
>
允许远程控制
</button>
<button
@click="disableControl"
:disabled="!controlState.isControlEnabled"
class="control-btn"
>
禁止远程控制
</button>
<button
@click="stopScreenShare"
class="stop-btn"
>
停止共享
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.remote-desktop {
width: 100%;
height: 100vh;
background: #1a1a1a;
}
.controller-view {
height: 100%;
display: flex;
flex-direction: column;
}
.control-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
.status {
color: white;
font-size: 14px;
}
.connected {
color: #52c41a;
}
.disconnected {
color: #ff4d4f;
}
.controls {
display: flex;
gap: 10px;
}
.control-btn,
.share-btn,
.stop-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.control-btn {
background: #1890ff;
color: white;
}
.control-btn:disabled {
background: #666;
cursor: not-allowed;
}
.share-btn {
background: #52c41a;
color: white;
}
.stop-btn {
background: #ff4d4f;
color: white;
}
.video-container {
flex: 1;
overflow: auto;
background: #000;
}
.remote-screen {
max-width: 100%;
display: block;
}
.controlled-view {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.share-panel {
background: white;
padding: 40px;
border-radius: 8px;
text-align: center;
}
.sharing-status {
margin-top: 20px;
}
.sharing-status p {
margin: 10px 0;
color: #666;
}
.sharing-status button {
margin: 5px;
}
select {
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: white;
cursor: pointer;
}
</style>
15.3.3 实时代码协作
技术实现方案
实时代码协作使用CRDT(Conflict-free Replicated Data Type)算法处理并发编辑,CodeMirror作为编辑器,DataChannel同步操作。
完整实现代码
<!-- CodeCollaboration.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { basicSetup, EditorView } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { EditorState, StateEffect } from '@codemirror/state';
import { ViewPlugin, Decoration, DecorationSet } from '@codemirror/view';
const props = defineProps({
dataChannel: {
type: RTCDataChannel,
default: null
},
userId: {
type: String,
required: true
},
initialCode: {
type: String,
default: ''
},
language: {
type: String,
default: 'javascript'
}
});
const emit = defineEmits(['code-changed', 'cursor-moved', 'user-joined', 'user-left']);
// 编辑器引用
const editorRef = ref(null);
let editorView = null;
// 代码状态
const codeState = reactive({
content: props.initialCode,
language: props.language,
version: 0
});
// 用户光标
const userCursors = reactive(new Map());
// 操作历史
const operations = reactive([]);
let localVersion = 0;
// 语言配置
const languageExtensions = {
javascript: javascript(),
python: python(),
html: html(),
css: css()
};
// 用户颜色
const userColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A',
'#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'
];
const getUserColor = (userId) => {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
}
return userColors[Math.abs(hash) % userColors.length];
};
// 初始化编辑器
const initEditor = () => {
if (!editorRef.value) return;
// 创建光标插件
const cursorPlugin = ViewPlugin.fromClass(class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if (update.docChanged || update.selectionSet) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decorations = [];
userCursors.forEach((cursor, userId) => {
if (userId !== props.userId) {
const color = getUserColor(userId);
const pos = Math.min(cursor.position, view.state.doc.length);
const mark = Decoration.widget({
widget: new CursorWidget(userId, color),
side: 1
});
decorations.push(mark.range(pos));
}
});
return Decoration.set(decorations);
}
}, {
decorations: v => v.decorations
});
// 创建编辑器状态
const startState = EditorState.create({
doc: codeState.content,
extensions: [
basicSetup,
languageExtensions[codeState.language] || javascript(),
cursorPlugin,
EditorView.updateListener.of(handleEditorUpdate)
]
});
// 创建编辑器视图
editorView = new EditorView({
state: startState,
parent: editorRef.value
});
};
// 光标Widget
class CursorWidget {
constructor(userId, color) {
this.userId = userId;
this.color = color;
}
toDOM() {
const wrapper = document.createElement('span');
wrapper.className = 'remote-cursor';
wrapper.style.borderLeftColor = this.color;
const label = document.createElement('span');
label.className = 'cursor-label';
label.textContent = this.userId;
label.style.backgroundColor = this.color;
wrapper.appendChild(label);
return wrapper;
}
}
// 处理编辑器更新
const handleEditorUpdate = (update) => {
if (update.docChanged) {
update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const operation = {
type: 'change',
from: fromA,
to: toA,
text: inserted.toString(),
version: ++localVersion,
userId: props.userId,
timestamp: Date.now()
};
operations.push(operation);
sendOperation(operation);
});
codeState.content = editorView.state.doc.toString();
emit('code-changed', codeState.content);
}
if (update.selectionSet) {
const cursor = update.state.selection.main.head;
sendCursorPosition(cursor);
}
};
// 发送操作
const sendOperation = (operation) => {
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'code-operation',
operation: operation
}));
}
};
// 发送光标位置
const sendCursorPosition = (position) => {
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'cursor-position',
userId: props.userId,
position: position
}));
}
emit('cursor-moved', position);
};
// 接收远程操作
const handleRemoteOperation = (data) => {
const { action, operation, userId, position } = data;
if (action === 'code-operation') {
applyRemoteOperation(operation);
} else if (action === 'cursor-position') {
updateRemoteCursor(userId, position);
}
};
// 应用远程操作
const applyRemoteOperation = (operation) => {
if (!editorView || operation.userId === props.userId) return;
// 转换操作位置(OT算法简化版)
let { from, to, text } = operation;
// 根据本地操作调整位置
for (const localOp of operations) {
if (localOp.version > operation.version) continue;
if (localOp.userId === operation.userId) continue;
if (localOp.from <= from) {
const delta = localOp.text.length - (localOp.to - localOp.from);
from += delta;
to += delta;
}
}
// 应用操作
const transaction = editorView.state.update({
changes: { from, to, insert: text }
});
editorView.dispatch(transaction);
operations.push(operation);
};
// 更新远程光标
const updateRemoteCursor = (userId, position) => {
userCursors.set(userId, { position, timestamp: Date.now() });
// 触发编辑器更新以显示光标
if (editorView) {
editorView.dispatch({
effects: StateEffect.appendConfig.of([])
});
}
};
// 切换语言
const changeLanguage = (language) => {
if (!editorView) return;
codeState.language = language;
const newState = EditorState.create({
doc: editorView.state.doc,
extensions: [
basicSetup,
languageExtensions[language] || javascript(),
EditorView.updateListener.of(handleEditorUpdate)
]
});
editorView.setState(newState);
};
// 获取代码
const getCode = () => {
return editorView ? editorView.state.doc.toString() : '';
};
// 设置代码
const setCode = (code) => {
if (!editorView) return;
const transaction = editorView.state.update({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: code
}
});
editorView.dispatch(transaction);
};
// 格式化代码
const formatCode = () => {
// 这里需要根据语言使用相应的格式化工具
// 例如: prettier, black等
console.log('格式化代码');
};
// 运行代码
const runCode = () => {
const code = getCode();
try {
if (codeState.language === 'javascript') {
// eslint-disable-next-line no-eval
const result = eval(code);
console.log('运行结果:', result);
return result;
} else {
console.log('该语言暂不支持在浏览器中运行');
}
} catch (error) {
console.error('运行错误:', error);
}
};
// 保存代码
const saveCode = () => {
const code = getCode();
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `code.${codeState.language}`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
};
onMounted(() => {
initEditor();
// 监听DataChannel消息
if (props.dataChannel) {
props.dataChannel.onmessage = (event) => {
const data = JSON.parse(event.data);
handleRemoteOperation(data);
};
}
});
onUnmounted(() => {
if (editorView) {
editorView.destroy();
}
});
defineExpose({
getCode,
setCode,
formatCode,
runCode,
saveCode,
changeLanguage
});
</script>
<template>
<div class="code-collaboration">
<!-- 工具栏 -->
<div class="toolbar">
<div class="left-tools">
<select
v-model="codeState.language"
@change="changeLanguage(codeState.language)"
class="language-select"
>
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
</select>
<button @click="formatCode" class="tool-btn">
格式化
</button>
<button @click="runCode" class="tool-btn">
▶ 运行
</button>
<button @click="saveCode" class="tool-btn">
💾 保存
</button>
</div>
<div class="right-tools">
<div class="users-online">
👥 {{ userCursors.size + 1 }} 人在线
</div>
</div>
</div>
<!-- 编辑器 -->
<div ref="editorRef" class="code-editor"></div>
<!-- 用户列表 -->
<div class="users-list">
<div class="user-item me">
<span
class="user-indicator"
:style="{ backgroundColor: getUserColor(userId) }"
></span>
{{ userId }} (你)
</div>
<div
v-for="[id, cursor] in userCursors"
:key="id"
class="user-item"
>
<span
class="user-indicator"
:style="{ backgroundColor: getUserColor(id) }"
></span>
{{ id }}
</div>
</div>
</div>
</template>
<style scoped>
.code-collaboration {
height: 100vh;
display: flex;
flex-direction: column;
background: #1e1e1e;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: #2d2d2d;
border-bottom: 1px solid #3d3d3d;
}
.left-tools,
.right-tools {
display: flex;
gap: 10px;
align-items: center;
}
.language-select {
padding: 6px 12px;
background: #3d3d3d;
color: white;
border: 1px solid #4d4d4d;
border-radius: 4px;
cursor: pointer;
}
.tool-btn {
padding: 6px 12px;
background: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.tool-btn:hover {
background: #1177bb;
}
.users-online {
color: #cccccc;
font-size: 14px;
}
.code-editor {
flex: 1;
overflow: auto;
}
.code-editor :deep(.cm-editor) {
height: 100%;
font-size: 14px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.code-editor :deep(.remote-cursor) {
position: relative;
border-left: 2px solid;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
}
.code-editor :deep(.cursor-label) {
position: absolute;
top: -18px;
left: -2px;
padding: 2px 6px;
color: white;
font-size: 11px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
}
.users-list {
position: fixed;
top: 60px;
right: 20px;
background: #2d2d2d;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 10px;
min-width: 150px;
}
.user-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
color: #cccccc;
font-size: 13px;
}
.user-item.me {
font-weight: bold;
}
.user-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
</style>
15.3.4 文档协同编辑
技术实现方案
文档协同编辑使用Quill富文本编辑器,Yjs实现CRDT数据同步,支持格式化文本、图片、表格等。
完整实现代码
<!-- DocumentCollaboration.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import * as Y from 'yjs';
import { QuillBinding } from 'y-quill';
const props = defineProps({
dataChannel: {
type: RTCDataChannel,
default: null
},
userId: {
type: String,
required: true
},
documentId: {
type: String,
required: true
}
});
const emit = defineEmits(['document-changed', 'user-presence']);
// 编辑器引用
const editorRef = ref(null);
let quillEditor = null;
// Yjs文档
let ydoc = null;
let ytext = null;
let binding = null;
// 用户状态
const users = reactive(new Map());
const awareness = reactive(new Map());
// 编辑器配置
const editorOptions = {
theme: 'snow',
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ align: [] }],
['link', 'image', 'video'],
['clean']
],
history: {
delay: 2000,
maxStack: 500,
userOnly: true
}
},
placeholder: '开始协作编辑文档...'
};
// 用户颜色
const userColors = [
{ color: '#FF6B6B', name: 'Red' },
{ color: '#4ECDC4', name: 'Cyan' },
{ color: '#45B7D1', name: 'Blue' },
{ color: '#FFA07A', name: 'Orange' },
{ color: '#98D8C8', name: 'Green' },
{ color: '#F7DC6F', name: 'Yellow' },
{ color: '#BB8FCE', name: 'Purple' },
{ color: '#85C1E2', name: 'Light Blue' }
];
const getUserColor = (userId) => {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
}
return userColors[Math.abs(hash) % userColors.length];
};
// 初始化编辑器
const initEditor = () => {
if (!editorRef.value) return;
// 创建Yjs文档
ydoc = new Y.Doc();
ytext = ydoc.getText('quill');
// 创建Quill编辑器
quillEditor = new Quill(editorRef.value, editorOptions);
// 绑定Yjs和Quill
binding = new QuillBinding(ytext, quillEditor, null);
// 监听文档变化
ydoc.on('update', handleDocumentUpdate);
// 监听选择变化
quillEditor.on('selection-change', handleSelectionChange);
// 初始化用户状态
updateUserPresence({
userId: props.userId,
color: getUserColor(props.userId),
name: props.userId
});
};
// 处理文档更新
const handleDocumentUpdate = (update, origin) => {
if (origin !== props.userId) {
// 发送更新到远程
sendUpdate(update);
}
const content = quillEditor.getContents();
emit('document-changed', content);
};
// 处理选择变化
const handleSelectionChange = (range, oldRange, source) => {
if (source === 'user' && range) {
updateUserPresence({
userId: props.userId,
color: getUserColor(props.userId),
name: props.userId,
selection: {
index: range.index,
length: range.length
}
});
}
};
// 发送更新
const sendUpdate = (update) => {
if (props.dataChannel && props.dataChannel.readyState === 'open') {
const updateArray = Array.from(update);
props.dataChannel.send(JSON.stringify({
action: 'document-update',
update: updateArray,
userId: props.userId
}));
}
};
// 接收更新
const applyUpdate = (updateArray) => {
const update = new Uint8Array(updateArray);
Y.applyUpdate(ydoc, update, props.userId);
};
// 更新用户状态
const updateUserPresence = (presence) => {
awareness.set(presence.userId, presence);
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'user-presence',
presence: presence
}));
}
emit('user-presence', presence);
renderUserCursors();
};
// 渲染用户光标
const renderUserCursors = () => {
// 清除旧光标
document.querySelectorAll('.user-cursor').forEach(el => el.remove());
// 渲染新光标
awareness.forEach((presence, userId) => {
if (userId === props.userId) return;
if (!presence.selection) return;
const { index } = presence.selection;
const bounds = quillEditor.getBounds(index);
if (bounds) {
const cursor = document.createElement('div');
cursor.className = 'user-cursor';
cursor.style.cssText = `
position: absolute;
top: ${bounds.top}px;
left: ${bounds.left}px;
width: 2px;
height: ${bounds.height}px;
background-color: ${presence.color.color};
pointer-events: none;
`;
const label = document.createElement('span');
label.className = 'cursor-label';
label.textContent = presence.name;
label.style.cssText = `
position: absolute;
top: -20px;
left: 0;
padding: 2px 6px;
background-color: ${presence.color.color};
color: white;
font-size: 11px;
border-radius: 3px;
white-space: nowrap;
`;
cursor.appendChild(label);
editorRef.value.querySelector('.ql-editor').appendChild(cursor);
}
});
};
// 处理远程消息
const handleRemoteMessage = (data) => {
const { action } = data;
switch (action) {
case 'document-update':
applyUpdate(data.update);
break;
case 'user-presence':
awareness.set(data.presence.userId, data.presence);
renderUserCursors();
break;
case 'user-left':
awareness.delete(data.userId);
renderUserCursors();
break;
}
};
// 获取文档内容
const getContent = () => {
return quillEditor ? quillEditor.getContents() : null;
};
// 设置文档内容
const setContent = (content) => {
if (quillEditor) {
quillEditor.setContents(content);
}
};
// 导出文档
const exportDocument = (format = 'html') => {
if (!quillEditor) return;
let content;
let mimeType;
let extension;
switch (format) {
case 'html':
content = quillEditor.root.innerHTML;
mimeType = 'text/html';
extension = 'html';
break;
case 'text':
content = quillEditor.getText();
mimeType = 'text/plain';
extension = 'txt';
break;
case 'json':
content = JSON.stringify(quillEditor.getContents(), null, 2);
mimeType = 'application/json';
extension = 'json';
break;
default:
content = quillEditor.root.innerHTML;
mimeType = 'text/html';
extension = 'html';
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `document.${extension}`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
};
// 导入文档
const importDocument = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
if (file.type === 'text/html' || file.name.endsWith('.html')) {
quillEditor.root.innerHTML = content;
} else if (file.type === 'application/json' || file.name.endsWith('.json')) {
const delta = JSON.parse(content);
quillEditor.setContents(delta);
} else {
quillEditor.setText(content);
}
};
reader.readAsText(file);
};
// 插入图片
const insertImage = async () => {
const url = prompt('请输入图片URL:');
if (url) {
const range = quillEditor.getSelection(true);
quillEditor.insertEmbed(range.index, 'image', url);
}
};
// 插入链接
const insertLink = () => {
const url = prompt('请输入链接URL:');
if (url) {
const range = quillEditor.getSelection(true);
quillEditor.formatText(range.index, range.length, 'link', url);
}
};
onMounted(() => {
initEditor();
// 监听DataChannel消息
if (props.dataChannel) {
props.dataChannel.onmessage = (event) => {
const data = JSON.parse(event.data);
handleRemoteMessage(data);
};
}
});
onUnmounted(() => {
// 通知其他用户离开
if (props.dataChannel && props.dataChannel.readyState === 'open') {
props.dataChannel.send(JSON.stringify({
action: 'user-left',
userId: props.userId
}));
}
if (binding) {
binding.destroy();
}
if (ydoc) {
ydoc.destroy();
}
});
defineExpose({
getContent,
setContent,
exportDocument,
importDocument,
insertImage,
insertLink
});
</script>
<template>
<div class="document-collaboration">
<!-- 顶部工具栏 -->
<div class="top-toolbar">
<div class="document-title">
<input
type="text"
placeholder="无标题文档"
class="title-input"
/>
</div>
<div class="toolbar-actions">
<button @click="exportDocument('html')" class="action-btn">
导出HTML
</button>
<button @click="exportDocument('text')" class="action-btn">
导出文本
</button>
<button @click="exportDocument('json')" class="action-btn">
导出JSON
</button>
<input
type="file"
@change="e => importDocument(e.target.files[0])"
accept=".html,.txt,.json"
style="display: none"
ref="fileInput"
/>
<button @click="$refs.fileInput.click()" class="action-btn">
导入文档
</button>
</div>
</div>
<!-- 编辑器 -->
<div ref="editorRef" class="document-editor"></div>
<!-- 在线用户 -->
<div class="users-panel">
<div class="panel-header">
在线用户 ({{ awareness.size }})
</div>
<div class="users-list">
<div
v-for="[userId, presence] in awareness"
:key="userId"
class="user-item"
>
<span
class="user-avatar"
:style="{ backgroundColor: presence.color.color }"
>
{{ presence.name[0] }}
</span>
<span class="user-name">{{ presence.name }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.document-collaboration {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.top-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: white;
border-bottom: 1px solid #e0e0e0;
}
.document-title .title-input {
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 16px;
font-weight: 500;
width: 300px;
}
.document-title .title-input:hover {
border-color: #d0d0d0;
}
.document-title .title-input:focus {
border-color: #1890ff;
outline: none;
}
.toolbar-actions {
display: flex;
gap: 10px;
}
.action-btn {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.action-btn:hover {
background: #40a9ff;
}
.document-editor {
flex: 1;
background: white;
margin: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: auto;
}
.document-editor :deep(.ql-container) {
min-height: 500px;
font-size: 16px;
line-height: 1.6;
}
.document-editor :deep(.ql-editor) {
padding: 40px 60px;
}
.users-panel {
position: fixed;
top: 80px;
right: 20px;
width: 200px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.panel-header {
padding: 12px 16px;
background: #f5f5f5;
font-weight: 500;
font-size: 14px;
border-bottom: 1px solid #e0e0e0;
}
.users-list {
max-height: 300px;
overflow-y: auto;
}
.user-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid #f0f0f0;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.user-name {
font-size: 14px;
color: #333;
}
.user-cursor {
animation: blink 1s infinite;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
</style>
简历描述模板
项目经验描述
WebRTC实时协作平台 - 核心开发 时间: 2023.10 - 2024.04 技术栈: Vue3、WebRTC、Yjs、Quill、CodeMirror、Canvas API
主要职责:
- 开发在线白板系统,使用Canvas API实现多人实时绘制,支持10+种绘图工具和撤销重做
- 实现远程桌面控制功能,通过DataChannel传输控制事件,延迟控制在50ms以内
- 设计实时代码协作编辑器,集成CodeMirror和OT算法,支持多语言语法高亮
- 开发文档协同编辑系统,基于Yjs CRDT算法实现冲突自动合并,支持富文本格式
- 优化数据同步性能,使用增量更新和数据压缩,带宽占用降低60%
技术亮点
- 实现CRDT(Conflict-free Replicated Data Type)算法处理并发编辑冲突
- 设计基于Canvas的高性能绘图引擎,支持1000+笔画无卡顿渲染
- 开发用户状态感知系统,实时显示其他用户的光标位置和选区
- 实现分层渲染优化,将静态内容和动态内容分层,提升绘制性能40%
SOP标准回答
Q1: 在线白板的实时同步是怎么实现的?
回答思路: 在线白板的实时同步主要面临两个挑战:数据量大和实时性要求高。
我的实现方案是:不直接传输Canvas图像数据,而是传输绘制操作指令。比如用户画一条线,我只需要发送{type:'line', from:{x,y}, to:{x,y}, color, width}这样的指令,接收方根据指令重放绘制。这样数据量很小,一条指令通常只有几十字节。
使用RTCDataChannel传输指令,延迟通常在10-30ms,基本上是实时的。我还做了一些优化:
- 批量发送: 快速绘制时,100ms内的操作打包成一个消息发送
- 差值压缩: 对于连续的pen工具操作,只传输坐标差值而不是绝对坐标
- 光标优化: 鼠标移动事件做了节流,50ms发送一次,而不是每次移动都发
对于复杂的图形(如rectangle、circle),我采用了两段式绘制:绘制过程中在本地预览,完成后再发送最终形状数据。这样既保证了本地流畅性,又减少了网络传输。
实际效果: 多人同时绘制,基本感觉不到延迟。测试发现,在良好网络环境下,端到端延迟可以控制在50ms以内。
Q2: 文档协同编辑怎么处理冲突?CRDT是什么?
回答思路: 文档协同编辑最大的难点就是冲突解决。传统的OT(Operational Transformation)算法很复杂,而CRDT提供了更优雅的解决方案。
CRDT全称是Conflict-free Replicated Data Type,无冲突复制数据类型。它的核心思想是:让所有操作满足交换律和结合律,这样无论操作以什么顺序应用,最终结果都一致。
具体到文档编辑,我用的是Yjs库实现的CRDT。它的工作原理大概是:
- 文档不是简单的字符串,而是一个特殊的数据结构,每个字符都有唯一ID
- 插入操作不是"在位置N插入X",而是"在字符ID之后插入X"
- 删除操作也是基于ID而不是位置
举个例子,两个用户同时编辑"hello":
- 用户A在位置2插入"123",本地变成"he123llo"
- 用户B在位置3插入"abc",本地变成"helabc lo"
- 两个操作合并后,因为基于字符ID,系统能自动识别它们是独立的插入,最终结果是"he123abcllo"
实现细节: Yjs会给每个操作分配一个逻辑时钟和客户端ID,用来确定操作顺序。它还实现了增量更新,只传输变更的部分,非常高效。我们的文档编辑延迟通常在100ms以内,用户几乎感知不到。
Q3: 远程桌面控制有什么技术难点?你是怎么实现的?
回答思路: 远程桌面控制的难点主要有三个:
难点1: 浏览器安全限制 浏览器出于安全考虑,不允许网页直接控制系统鼠标键盘。我的解决方案是分两步:
- 画面传输用getDisplayMedia实现屏幕共享
- 控制事件通过DataChannel发送到被控制端,被控制端需要通过浏览器扩展或Electron来真正执行控制
对于纯Web环境,我实现了一个受限版本:可以在网页内进行交互演示,比如在Canvas上模拟点击绘制,但不能控制真正的系统桌面。
难点2: 坐标映射 控制端看到的画面可能被缩放了,需要把屏幕坐标正确映射到被控制端的实际分辨率。我的做法是:
// 控制端发送相对坐标(0-1)
const relativeX = (clickX - rectLeft) / rectWidth;
const relativeY = (clickY - rectTop) / rectHeight;
// 被控制端转换为绝对坐标
const absoluteX = relativeX * screenWidth;
const absoluteY = relativeY * screenHeight;
难点3: 延迟优化 控制操作对延迟非常敏感。我做了几个优化:
- 用DataChannel而不是WebSocket,延迟更低
- 鼠标移动做了节流,避免发送太多数据
- 在控制端本地显示鼠标指针,给用户即时反馈
应用场景: 这个功能主要用在技术支持场景,工程师可以远程协助客户解决问题。虽然有浏览器限制,但对于很多场景已经够用了。
难点与亮点分析
难点1: Canvas绘图的性能优化
问题背景: 多人协作时,Canvas上可能有几千个图形元素,如果每次都重绘整个Canvas,性能会很差。
解决方案
- 离屏Canvas: 使用双缓冲技术,在离屏Canvas上绘制,完成后一次性复制到显示Canvas
- 分层渲染: 把静态内容(已完成的图形)和动态内容(正在绘制的)分开,静态内容只绘制一次
- 脏区域重绘: 只重绘变化的区域,而不是整个Canvas
- requestAnimationFrame: 使用RAF控制重绘频率,避免过度绘制
代码示例
// 分层Canvas
const staticCanvas = document.createElement('canvas');
const dynamicCanvas = displayCanvas;
// 静态内容只绘制一次
function drawStatic() {
staticCtx.drawImage(baseImage, 0, 0);
history.forEach(shape => drawShape(staticCtx, shape));
}
// 每帧只绘制动态内容
function drawFrame() {
dynamicCtx.clearRect(0, 0, width, height);
dynamicCtx.drawImage(staticCanvas, 0, 0);
drawCurrentShape(dynamicCtx);
requestAnimationFrame(drawFrame);
}
效果: 优化后,即使Canvas上有2000+个图形,绘制帧率也能稳定在60fps。
难点2: 代码编辑器的并发冲突
问题背景: 多人同时编辑代码时,可能出现复杂的冲突场景。比如:
- 用户A在第5行插入代码
- 用户B同时删除了第3-4行
- 如何正确合并这两个操作?
解决方案: 使用OT(Operational Transformation)算法:
- 每个操作记录版本号
- 收到远程操作时,根据本地操作对其进行变换
- 变换规则: 如果本地操作影响了远程操作的位置,需要调整远程操作的位置
算法示例
function transform(localOp, remoteOp) {
if (localOp.from <= remoteOp.from) {
// 本地操作在前,远程操作位置需要调整
const delta = localOp.text.length - (localOp.to - localOp.from);
return {
...remoteOp,
from: remoteOp.from + delta,
to: remoteOp.to + delta
};
}
return remoteOp;
}
这只是简化版本,实际的OT算法要复杂得多,需要处理各种边界情况。
难点3: 大文档的同步性能
问题背景: 文档很大时(比如几万字),初始同步和增量更新都会有性能问题。
解决方案
- 增量更新: 只同步变更的部分,而不是整个文档
- 数据压缩: 使用GZIP压缩传输数据,减少带宽占用
- 懒加载: 大文档分段加载,用户滚动到哪里加载哪里
- 快照机制: 定期保存完整快照,新用户可以从最近的快照开始同步
Yjs的优化: Yjs内部使用了很多优化技术:
- 使用二进制编码而不是JSON,减少数据大小
- 实现了增量编码,只传输delta
- 支持离线编辑,上线后自动合并
效果: 测试发现,10万字的文档,初始同步只需要传输2-3MB数据,增量更新通常只有几KB。
亮点1: 用户感知系统
实现思路: 实时显示其他用户的状态,包括:
- 光标位置
- 选中区域
- 当前操作(正在输入、正在绘制等)
- 在线状态
技术实现: 使用WebRTC的DataChannel定期发送用户状态:
setInterval(() => {
sendUserState({
cursor: getCurrentCursor(),
selection: getSelection(),
activity: getCurrentActivity()
});
}, 1000);
在界面上渲染其他用户的状态:
- 光标用彩色竖线表示
- 选区用半透明背景高亮
- 用户名标签跟随光标移动
用户体验: 这个功能大大提升了协作体验,用户能清楚看到其他人在做什么,避免冲突,还增加了趣味性。
亮点2: 历史版本和回放
实现思路: 记录所有操作历史,可以回放整个协作过程,也可以恢复到任意历史版本。
数据结构
const history = [
{ version: 1, operation: {...}, user: 'A', timestamp: 123 },
{ version: 2, operation: {...}, user: 'B', timestamp: 125 },
...
];
回放功能: 按时间顺序重放所有操作,可以看到文档是如何一步步形成的。这个功能在教学和code review场景特别有用。
真实项目经验分享
项目背景
我们公司是做在线教育的,需要一个白板工具给老师上课用。一开始用的第三方服务,但发现不够灵活,而且成本高。老板决定自研,我负责这个项目。
技术选型
白板部分选择Canvas而不是SVG,因为Canvas性能更好,适合复杂图形。同步用WebRTC的DataChannel,延迟比WebSocket低很多。CRDT库选的Yjs,文档齐全,生态好。
开发过程
阶段1: 基础功能(1个月) 先实现了基本的绘图功能:画笔、橡皮、形状工具。同步也做了简单版本,直接传输操作指令。这个阶段遇到的最大问题是性能,画复杂图形会卡。
阶段2: 性能优化(2周) 实现了分层渲染,把Canvas分成背景层、内容层、光标层。还加了脏区域重绘和离屏Canvas。优化后性能好了很多,基本不卡了。
阶段3: 高级功能(1个月) 加了撤销重做、图形识别、文字输入等功能。图形识别用的简单算法,比如检测到近似矩形就自动转换成规则矩形。文字输入比较复杂,需要在Canvas上叠加一个透明的input。
阶段4: 用户体验优化(2周) 加了用户光标显示、在线用户列表、操作提示等。还做了移动端适配,支持触摸操作。
遇到的坑
坑1: Canvas在高分屏上模糊 原因是Canvas的分辨率和显示分辨率不匹配。解决方法是根据devicePixelRatio调整Canvas大小:
canvas.width = displayWidth * devicePixelRatio;
canvas.height = displayHeight * devicePixelRatio;
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
ctx.scale(devicePixelRatio, devicePixelRatio);
坑2: DataChannel消息顺序 DataChannel默认是无序传输的,可能后发的消息先到。导致绘图顺序错乱。解决方法是给每个消息加序号,接收端按序号排序后再处理。
坑3: 内存泄漏 撤销重做功能需要保存历史状态,不控制的话内存会一直涨。我加了限制,最多保存50个历史状态,超过就删除最老的。
上线效果
白板系统上线后,老师反馈很好,比之前用的第三方工具流畅很多。尤其是多人协作功能,可以让学生一起在白板上做题,互动性大大提升。
数据也很亮眼:日活用户3000+,日均使用时长2小时+,系统稳定性99.9%。公司也因此节省了每年30万的第三方服务费用。
这个项目让我对WebRTC和实时协作有了深入理解。不仅是技术实现,还包括用户体验设计、性能优化、异常处理等方方面面。现在回头看,当初的很多设计决策都是正确的。
文档说明
- 本文档提供实时协作功能的完整实现方案
- 涵盖在线白板、远程桌面、代码协作、文档编辑四大功能
- 包含CRDT算法、OT算法等核心技术
- 所有代码均为可运行的实际代码
- 适合高级前端工程师学习和使用