揭秘高性能协同白板:轻松实现多人实时协作(一)

前言

目前成熟的白板工具已经很多了,想探索下内部的实现原理,为远程团队协作、在线教育、设计评审和头脑风暴场景设计,通过高效的 Konva 渲染引擎和 Yjs 协同算法,实现多人实时操作的无缝协同体验,同时保持高性能的图形处理能力,流畅的操作体验。

现存的白板工具仍有些小问题,例如:难以集成到项目中二开难度大开发者不友好等,本应用意在提供完整白板功能的基础上,解决上诉提到的难点,并创新添加一些新特性,用于丰富白板的数据展示能力。

数据结构设计

在应用开发之前,我们先设计一下应用的数据存储结构,一个好的数据结构,可以为我们省去很多麻烦。

  1. 为了实现图形节点附加文本的效果(双击添加文本),我们利用 konva Group 组的概念,将图形节点和文本节点,添加到组中,以实现整体的平移、缩放、变换等效果。
ts 复制代码
const group = new Konva.Group();
const shape = new Konva.Rect({...});
const text = new Konva.Text({...});
group.add(shape, text);
  1. 本例采用 Yjs 分布式协同特性,因此,整个应用 AppData 设计为 Y.Map,如下:
ts 复制代码
// 定义整个应用的数据,key 为 shapeID,value 为 ShapeItem
export type AppData = Map<ShapeItem>;

// 每一个 Konva 图形的属性
export interface ShapeItem {
	id: string; // 图形id
	type: string; // 图形类型 - 用于判断图形类型 'group' | 'rect'|  'circle' | 'ellipse' | 'image' | 'text' | ...
	locked?: boolean; // 是否锁定
	visible?: boolean; // 是否可见
	children?: Array<string>; // 子节点ID数组(用于组合节点) - 用于判断图形是否为组合图形
	isCombined?: boolean; // 标记是否被组合
	attrs?: Record<string, unknown>; // 拓展属性
	group: GroupConfig; // Step 1 render 时,先根据 group 创建一个 group
	shape: ShapeConfig; // Step 2 render 时,根据 shape 创建一个 shape
	text: TextConfig; // Step 3 render 时,根据 text 创建一个 text
}
  1. 抽象数据操作示例如下:
ts 复制代码
// 根节点
const appData = this.doc.getMap('appData');

// 添加一个图形
const rectConfig = ShapeController.createShape('rect', {...});
appData.set(rectConfig.id, rectConfig)

// 删除一个图形
appData.delete(rectConfig.id);

// 修改图形属性
appData.set(rectConfig.id, {...rectConfig, x: 100, y: 100 });

注意: 修改属性请使用 map.set() 而不是 shape.x = 100,这种模式不能引起 Y.Map 的数据变化回调。

协同控制中心

在协同应用场景,应当包含以下几个协同模块:

用户感知

用户感知是协同中的重要部分,可以通过感知获取其他用户的位置信息及实时操作状态

ts 复制代码
		// 创建 awareness 实例
		this.awareness = new Awareness(this.doc);

		// 添加本地感知状态
		const { userId, userName, userColor } = this.user.getUserInfo();
		this.awareness.setLocalState({ userId, userName, userColor });
		// 监听感知状态
		encodeAwarenessUpdate(
			this.awareness,
			Array.from(this.awareness.getStates().keys())
		);
		this.awareness.on('update', this.handleAwarenessUpdate.bind(this));

除了用户信息,还可以将用户光标位置等信息,一同发送给其他客户端,因此需要提供更新光标的方法:

ts 复制代码
	/**
	 * @description 更新感知状态
	 * @param { [key: string]: any  } 更新的数据
	 * @returns
	 */
	public updateAwareness(data: Record<string, unknown> = {}) {
		this.awareness.setLocalState({
			...this.awareness.getLocalState(),
			...data,
		});
	}

后续的具体光标实现,到绘制图层时,在详细说明哈

提供程序

提供程序是协同的重点,不同的提供程序对协同设计有着不同的效果,选择合适的提供程序,也是协同时效性、稳定性的考验

ts 复制代码
		// websocket 方式实现协同 - 提供程序默认支持传递 awareness
		this.provider = new WebsocketProvider(url, roomname, this.doc, {
			awareness,
			params,
		});

撤销管理

协同应用,较难的就是分布式撤销,Yjs内置的 Y.UndoManager 支持分布式撤销,这为我们协同设计提供了便利

ts 复制代码
this.undoManager = new UndoManager(doc, {
	captureTimeout: 500,
	 // 添加用户源(特别注意这里,需要与 transaction origin保持一致!)
	trackedOrigins: new Set([origin]),
});

Y.UndoManager 直接关联的就是 history 历史记录,具体如下:

ts 复制代码
	constructor(collaborateManager: CollaborateManager) {
		this.undoManager = collaborateManager.getUndoManager();
	}

	public undo() {
		// 如果不可撤销
		if (!this.undoManager.canUndo()) return console.warn('⚠️ 不可撤销!');
		this.undoManager.undo();
	}

	public redo() {
		// 如果不可重做
		if (!this.undoManager.canRedo()) return console.warn('⚠️ 不可重做!');
		this.undoManager.redo();
	}

用户系统

用户系统指的是当前协同的用户信息,包括用户ID 、userName、以及用户协同颜色,同时,在撤销管理和协同事件源中,都有一个origin,是与当前用户相关联的信息,也将其纳入用户信息管理中

ts 复制代码
		this.userId = userId;
		this.userName = userName;
		this.userColor = userColor;

		const originId = generateKey();
		this.origin = { originId, userId: this.userId };

协同入口

入口文件提供必要的方法,其中最重要的,就是 transaction 事务执行方法,将对数据的操作,封装到事务中,便于 UndoManager 记录

ts 复制代码
	public transaction = (fn: () => void) => {
		// 使用与 UndoManager 中一致的 origin 对象
		const origin = this.user.getOrigin();
		this.doc.transact<void>(fn, origin);
	};

绘制实现

既然是白板应用,那相关的绘制实现肯定是最重要,也是最难的,下面将这部分详细讲解下。Konva 是以树形结构组织的结构,stage 就是根节点,所有的图层都需要添加到 stage 上

ts 复制代码
// 初始化 konva
this.stage = this.initStage();

为了实现精细化管理,针对图层、图形、数据部分进行抽象及封装,架构如下:

  1. 协同层主要维护应用数据,监听数据变化,驱动视图更新,同时处理 UndoManager,实现撤销管理;
  2. 用户的页面操作,会映射为图形的具体操作,例如添加图形、更新图形、删除图形,用户并非真实在操作图形,而是通过操作代理在操作数据,引发 appData 的变化;
  3. 数据变化后,会调用 render 进行视图更新,在这个方法内,就需要真实的操作 Konva 类,将数据转换为页面上的图形。

图层管理

为了后续对图层的操作更便捷,应该将图层的相关方法抽离为独立的模块:

  • backgroundLayer:背景图层,实现背景颜色、网格线绘制;
  • shapeLayer:形状图层,实现元素的绘制、缩放、平移等操作区;
  • toolbarLayer: 工具图层,用于绘制协同光标、选区、可视区、其他辅助信息。

这样,后续的所有图层操作,都可以精细到具体的图层,例如,修改背景颜色、绘制用户光标、绘制对齐线等

图形管理

本例采用的是数据驱动的形式实现的图形绘制,因此,需要劫持用户对图形的操作,将其转换为数据处理,如下:

  • 原模式:直接操作konva进行绘制
ts 复制代码
const rect = new Konva.Rect({...})
layer.add(rect)
  • 数据驱动模式:对数据进行维护,使得数据驱动视图更新
ts 复制代码
const appData = this.doc.getMap()
appData.observeDeep(this.draw.render)

// 这里会返回 具体的配置项 而不是具体的图形
const rectConfig = ShapeController.createShape('rect') 

// 将返回的配置项添加到 Y.Map 中
appData.set(rectConfig.id, rectConfig)

// 数据变化后,会引起 render 绘制
function render(){
	// 根据 appData 真实渲染 konva 图形
}

这种模式有一个好处,只需要关心数据的变化,在 render 中进行统一进行konva图形绘制处理即可。同时,还能劫持用户对图形的操作,实现更多拓展功能。

数据驱动

数据驱动是本例重要实现,需要根据 Y.Map 的数据,渲染出当前画布结构:

ts 复制代码
// 监听数据更新,驱动视图渲染
private appDataUpdateHandler(event: YMapEvent<ShapeItem>) {
	// 这个 key 是当前发生变化的 shapeItem.id
	event.changes.keys.forEach((change, key) =>
		this.draw.render(change, key)
	);
}

public render(change: YMapChange, id: string) {
	console.log('✨️ patch render');

	// 1. 获取当前图形 id 在数据中的配置
	const appData = this.collaborateManager.gettAppData();
	const shapeConfig = appData.get(id);

	// 2. 获取当前图层画布
	const shapeLayer = this.layerController.getShapeLayer();

	// 3. 通过 change 识别当前的操作类型 add | update | delete
	if (change.action === 'add' && shapeConfig) {
		shapeLayer.addShape(shapeConfig);
	} else if (change.action === 'update' && shapeConfig) {
		shapeLayer.updateShape(shapeConfig);
	} else if (change.action === 'delete') {
		shapeLayer.deleteShape(id);
	}
	// 4. 重新渲染图层
	this.stage.batchDraw();
}

同时,在 render 函数中,还可以做一些绘制优化,例如:实现增量更新(脏矩形渲染)、视口剔除(视口裁剪)等,可以在一定程度上减少大画布场景下的渲染压力

事件委托

本例采用数据驱动更新,因此图形可能随时在渲染更新,如果将事件绑定到具体的图形上,那么,我们就需要处理事件解绑及绑定的时机,处理起来比较麻烦。因此,本例将事件统一绑定到 stage 上,使用 stage 进行统一的事件处理。

ts 复制代码
const stage = this.draw.getStage();
stage.on('click', (e) => console.log('stage click', e));

在事件源中,currentTarget 始终指向事件源绑定的对象,也就是当前的 stage,而 target 则指向当前触发事件的对象,也就是当前点击click,是点击到谁上面,evt 是原生事件源,type 指向当前事件的类型,在多事件中,需要做区分使用。

图形操作

插入形状

插入图形实现原理就是在Y.Map 中插入一条数据,驱动试图更新,流程如下:

ts 复制代码
	// 创建矩形 - 这里得到的是关于这个矩形的所有可执行配置
	const rectConfig = shapeCntroller.createShape('rect', {
		x: 100,
		y: 100,
		width: 100,
		height: 100,
		fill: 'red',
		stroke: 'black',
		strokeWidth: 2,
	});
	
const appData = this.collaborateManager.gettAppData();

// 通过事务执行,将图形添加到 appData 中
this.collaborateManager.transaction(() => {
	appData.set(config.id, JSON.parse(JSON.stringify(config)));
});

上面执行后,就会引起视图更新,通过视图去真实创建 Konva.Rect:

ts 复制代码
// 这里解析 group shape text 属性
const { groupConfig, shapeConfig } = config;

// 创建真实 group
const groupNode = new Group({ ...groupConfig, draggable: true });

// 创建真实 shape 后续需要工厂模式实现图形创建
const shape = new Rect({ ...shapeConfig, draggable: false });

groupNode.add(shape);

this.layer.add(groupNode);

更新属性

更新图形属性,无非就是将更新后的 config 重新setMap 上,引发更新:

ts 复制代码
// 不然获取当前图形的属性,拼接为 ShapeItem 进行更新
const groupConfig = target.getAttrs();
const id = target.id();
const type = groupConfig.type;

// 获取shapeConfig
const shapeConfig = target.children[0].getAttrs();

// 获取textConfig
const textConfig = target.children[1]?.getAttrs();

// 触发数据更新
const config = {id, type, groupConfig, shapeConfig, textConfig};

// 触发更新
const appData = this.collaborateManager.gettAppData();
this.collaborateManager.transaction(() => {
	appData.set(config.id, JSON.parse(JSON.stringify(config)));
});

试图更新方法:

ts 复制代码
// 这里有几个属性需要更新,group shape text
const { groupConfig, shapeConfig, id } = config;
const groupNode = this.findOne<Group>(`#${id}`);
if (!groupNode) return;

groupNode.setAttrs(groupConfig);
const shape = groupNode.children[0];
shape.setAttrs(shapeConfig);

删除图形

删除图形,则直接调用 Map.delete 方法即可

ts 复制代码
// 不然执行删除
this.collaborateManager.transaction(() => {
	appData.delete(id);
});

试图更新方法:

ts 复制代码
this.findOne<Group>(`#${id}`)?.destroy();

大家好好理解一下架构,就可以理解上面的代码了哈

实现动态绘制

我们需要在页面上实现动态绘制,这里有几个注意事项:

  1. 不能在 mouse-xxx 事件执行过程中进行数据操作;
  2. 需要中继画布实现暂时绘制

什么意思呢?我们知道,每对一次数据操作,都会引发 Y.Map 的更新,同时,也会向 UndoManager 历史记录里插入操作记录 ,如果我们在 mousemove 过程中,频繁添加操作记录,那么撤销时,就会导致异常。因此,我们需要通过 最后的 mouseup 插入一条数据即可。

mousedown 主要是记录初始位置,并将必要的参数赋给 stage

ts 复制代码
// 记录下当前鼠标的位置
const { x, y } = draw.getStage().getPointerPosition()!;

const menuType = useStore().getState('activeMenu');

// 向 stage 添加属性
const stage = draw.getStage();
stage.setAttrs({ startX: x, startY: y, menuType, mousedownFlag: true, });

mousemove 处理移动中的位置以及实际的绘制参数

ts 复制代码
// 获取当前鼠标的位置
const { x, y } = stage.getPointerPosition()!;

// 向 toolbarLayer 添加属性,实现暂时绘制,最终的判断在 mouseup 事件中实现
const layerController = draw.getLayerController();
const toolbarLayer = layerController.getToolbarLayer();

// 设置绘制参数
const { menuType, startX = 0, startY = 0 } = stage.getAttrs();

toolbarLayer.setDrawParams({
	menuType,
	startX,
	startY,
	endX: x,
	endY: y,
});

toolbarLayer 通过工具类图层绘制展现效果,此时不会执行数据操作,也就不会添加记录

ts 复制代码
this.layer = new Layer({ id: 'toolbarLayer', listening: false });

// 通过矩形绘制实现
const rect = new Rect({
	id: 'toolbarRect',
	sceneFunc: this.handleSceneFunc.bind(this),
});

// handleSceneFunc 函数内,就是原生 canvas 操作了

mouseup 中,就是根据实际的绘制参数,进行操作代理:

ts 复制代码
	// 不然根据 menuType 调用操作代理,获取真实的图形配置,添加到 Y.Map 上
	const operationProxy = draw.getOperationProxy();

	let shapeConfig: ShapeItem | null = null;

	switch (menuType) {
		case 'rect':
			shapeConfig = operationProxy.createShape('rect', {
				x: startX,
				y: startY,
				width,
				height,
			});
			break;
	}

	if (shapeConfig) operationProxy.addShape(shapeConfig);

这样,就能实现在 mouseup 事件中,执行一次数据操作,只会生成一次历史记录。

总结

本文详细介绍了基于 Konva 和 Yjs 的协同白板应用的整体架构设计与核心实现。通过数据驱动的方式,将用户操作抽象为对 Y.Map 的数据操作,实现了多人实时协同的图形编辑功能。关键设计包括:

  • 分层架构:将协同层、操作代理层和渲染层分离,确保数据流清晰可控

  • 数据驱动渲染:通过监听 Y.Map 变化实现视图自动更新

  • 事件委托机制:在 Stage 级别统一处理事件,简化事件管理

  • 动态绘制优化:通过中继画布避免频繁数据操作,保证撤销重做的正确性

  • 完整的协同生态:集成用户感知、撤销管理、实时同步等协同核心功能

这一架构为后续功能扩展奠定了坚实基础,既保证了多人协同的实时性,又提供了良好的开发体验。

在下一篇文章中,我们将深入探讨白板的高级交互功能实现:

Konva 图形控制

  • 选中与变换:实现图形的单选、多选、旋转、缩放控制点

  • 对齐吸附:智能对齐线和网格吸附系统

  • 层级管理:前置、后置、置顶、置底等层级操作

分组与组合

  • 图形分组:多选图形创建分组,支持嵌套分组

  • 组合解组:临时组合与永久组合的实现策略

  • 组内编辑:在组内直接编辑单个图形的能力

高级视觉效果

  • 滤镜系统:模糊、阴影、颜色调整等实时滤镜

  • 渐变填充:线性渐变、径向渐变的动态配置

  • 纹理图案:自定义图案填充和背景纹理

数据可视化增强

  • 图表集成:集成 VCharts 等图表库实现数据图表

  • 手绘风格:模拟手绘效果的笔刷和图形渲染

  • Latex 公式: 支持动态公式编辑

性能优化

  • 视口裁剪:大画布下的渲染性能优化

  • 增量更新:脏矩形渲染减少重绘区域

  • 内存管理:图形缓存和垃圾回收策略

通过这些功能的实现,白板将从一个简单的绘图工具升级为功能丰富的协同创作平台,满足教育、设计、会议等多样化场景的需求。

期待与您在下一篇文章中继续探索白板开发的精彩世界! 🎨✨

欢迎感兴趣的小伙伴,一起加入白板的开发,目前正在稳步推进,欢迎加入哦~

相关推荐
wyjcxyyy1 小时前
polar靶场-MISC,WEB(中等)
前端·chrome
2301_816073832 小时前
SELinux 学习笔记
linux·运维·前端
秋天的一阵风2 小时前
😱一行代码引发的血案:展开运算符(...)竟让图表功能直接崩了!
前端·javascript·vue.js
Hilaku2 小时前
npm scripts的高级玩法:pre、post和--,你真的会用吗?
前端·javascript·vue.js
申阳2 小时前
Day 12:09. 基于Nuxt开发博客项目-使用NuxtContent构建博客模块
前端·后端·程序员
合作小小程序员小小店2 小时前
web网页开发,在线短视频管理系统,基于Idea,html,css,jQuery,java,springboot,mysql。
java·前端·spring boot·mysql·vue·intellij-idea
n***29322 小时前
前端动画性能优化,减少重绘重排
前端·性能优化
mCell2 小时前
React 如何处理高频的实时数据?
前端·javascript·react.js
Lsx_2 小时前
一文读懂 Uniapp 小程序登录流程
前端·微信小程序·uni-app