前言
目前成熟的白板工具已经很多了,想探索下内部的实现原理,为远程团队协作、在线教育、设计评审和头脑风暴场景设计,通过高效的 Konva 渲染引擎和 Yjs 协同算法,实现多人实时操作的无缝协同体验,同时保持高性能的图形处理能力,流畅的操作体验。
现存的白板工具仍有些小问题,例如:难以集成到项目中、二开难度大、开发者不友好等,本应用意在提供完整白板功能的基础上,解决上诉提到的难点,并创新添加一些新特性,用于丰富白板的数据展示能力。

数据结构设计
在应用开发之前,我们先设计一下应用的数据存储结构,一个好的数据结构,可以为我们省去很多麻烦。
- 为了实现图形节点附加文本的效果(双击添加文本),我们利用
konva Group组的概念,将图形节点和文本节点,添加到组中,以实现整体的平移、缩放、变换等效果。
ts
const group = new Konva.Group();
const shape = new Konva.Rect({...});
const text = new Konva.Text({...});
group.add(shape, text);
- 本例采用 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
}
- 抽象数据操作示例如下:
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();
为了实现精细化管理,针对图层、图形、数据部分进行抽象及封装,架构如下: 
- 协同层主要维护应用数据,监听数据变化,驱动视图更新,同时处理
UndoManager,实现撤销管理; - 用户的页面操作,会映射为图形的具体操作,例如添加图形、更新图形、删除图形,用户并非真实在操作图形,而是通过操作代理在操作数据,引发
appData的变化; - 数据变化后,会调用
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 重新set 到 Map 上,引发更新:
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();

大家好好理解一下架构,就可以理解上面的代码了哈 
实现动态绘制
我们需要在页面上实现动态绘制,这里有几个注意事项:
- 不能在
mouse-xxx事件执行过程中进行数据操作; - 需要中继画布实现暂时绘制
什么意思呢?我们知道,每对一次数据操作,都会引发 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 公式: 支持动态公式编辑
性能优化
-
视口裁剪:大画布下的渲染性能优化
-
增量更新:脏矩形渲染减少重绘区域
-
内存管理:图形缓存和垃圾回收策略
通过这些功能的实现,白板将从一个简单的绘图工具升级为功能丰富的协同创作平台,满足教育、设计、会议等多样化场景的需求。
期待与您在下一篇文章中继续探索白板开发的精彩世界! 🎨✨
也 欢迎感兴趣的小伙伴,一起加入白板的开发,目前正在稳步推进,欢迎加入哦~