TinyEngine 低代码实时协作技术详解:原理与实操
前言
一般的多人协作业务需求通常是针对文档、表格或制图等,场景相对简单,协同操作的对象为文字或图片,对象比较单一。乍一看,低代码的多人协作似乎无从下手。因为低代码不仅涉及页面 Canvas 中文字属性的同步,还涵盖组件拖拽、样式设置、事件绑定、高级属性配置,甚至是代码协同编辑与同步。那么,我们是如何在低代码这个复杂场景下实现多人协同编辑的呢?
TinyEngine 低代码引擎多人协同技术详解
一、底层逻辑:CRDT(无冲突复制数据类型)
CRDT(Conflict-free Replicated Data Type)是一种允许并发修改、自动合并且永不冲突的数据结构。即使多个用户同时编辑同一份文档、表格或图形,系统也能在之后自动合并出一致的结果,不需要'锁'或'人工解决冲突'。
一个例子
假设一个协作文本编辑器有两个用户:
- A 插入'Hello '
- B 插入'World!'
在普通系统中,如果两个操作几乎同时发生,可能导致冲突。但在 CRDT 模型下,每个操作都可合并:系统会基于操作的逻辑时间或唯一标识符自动确定合并顺序;最终所有节点都会收敛到相同的状态,如'Hello World!'。
CRDT 的两种主要类型
- State-based(状态型 CRDT)
- Operation-based(操作型 CRDT)
- 每个节点只传播'操作'(如'加 1'、'插入字符 X'),其他节点按相同逻辑执行该操作。
在 TinyEngine 项目中,我们采用的是操作型 CRDT(Operation-based CRDT)库 Yjs。
在 Yjs 中,每个协同文档对应一个根对象 Y.Doc,它可以包含多种可协同的数据结构,例如 Y.Array、Y.Map、Y.Text 等。每个客户端都维护一份本地的 Y.Doc 副本,这些副本通过 Yjs 的同步机制保持一致。当多个客户端通过 y-websocket provider 连接到同一个房间(room)时,它们会共享相同的文档数据。任何客户端对文档的修改(如插入、删除、更新)都会被编码为操作(operation),并广播到其他客户端,从而实现实时的数据同步。
二、从数据结构到协同模型:tiny-engine 的页面 Schema 与 Yjs 的结合
无论是哪一种类型的 CRDT,其核心都离不开一个健全且完备的数据结构。对于 tiny-engine 来说,低代码页面本身也是由一套结构化的数据所描述的。这套数据结构不仅要支持页面的层级关系(如区块、组件、插槽),还要能够表达页面的动态逻辑(如循环、条件、生命周期、数据源等)。
在 tiny-engine 中,页面的基础结构可以抽象为以下两个 TypeScript 接口:
export interface Node {
id: string;
componentName: string;
props: Record<string, any> & { columns?: { slots?: Record<string, any> }[] };
children?: Node[];
componentType?: 'Block' | 'PageStart' | 'PageSection';
slot?: string | Record<string, any>;
params?: string[];
loop?: Record<string, any>;
loopArgs?: string[];
condition?: boolean | Record<string, any>;
}
export type RootNode = Omit<Node, 'id'> & {
id?: string;
css?: string;
fileName?: string;
methods?: Record<string, any>;
state?: Record<string, any>;
lifeCycles?: Record<string, any>;
dataSource?: any;
bridge?: any;
inputs?: any[];
outputs?: any[];
schema?: any;
};
Node 代表页面中的一个通用组件节点。
RootNode 则是整个页面的根节点(Schema),在 Node 的基础上扩展了页面级的属性,如 state、methods、lifeCycles 等。
从数据结构到协同对象
在使用 CRDT(这里是 Yjs)进行实时协作时,我们的'协作单元'就是上述的这类数据结构。然而,Yjs 并不能直接理解复杂的 TypeScript 对象结构,我们需要将其转化为 Yjs 能够识别和同步的类型系统。例如:
- 普通对象 →
Y.Map
- 数组 →
Y.Array
- 字符串、数字、布尔值 →
Y.Text / 基本类型
- 嵌套结构(如
children)则需要递归地转化为嵌套的 Y 类型。
因此,第一步工作是根据已有的 Node 和 RootNode 数据结构,将其映射为等价的 Yjs 类型。项目中提供了相应的转换函数:
const UNDEFINED_PLACEHOLDER = '__undefined__';
export function toYjs(target: Y.Map<any> | Y.Array<any>, obj: any) {
}
export function fromYjs(value: any): any {
}
这样,当我们通过 Yjs 对这些 Y 类型进行修改(例如修改 props、插入/删除 children、更新 state),Yjs 就会自动维护 CRDT 冲突合并逻辑,并将变更同步到所有协作客户端。
三、监听机制实现 —— 从 Yjs 变更到多人协同视图更新
前面的步骤成功让我们借助 Yjs 实现了数据层面的实时同步。但是,仅仅让数据'同步'还不够。在 tiny-engine 中,页面渲染与编辑的核心状态仍然依赖于本地的 Schema。因此,我们必须建立一套监听机制,让 Yjs 的变更能够驱动 Schema 与视图的更新,形成完整的同步链路:
Yjs 数据变化 → 更新本地 Schema → 触发渲染引擎刷新视图
实现思路:Yjs observe 机制
Yjs 为我们提供了强大的变更监听机制:
observe:监听单个 Y.Map 或 Y.Array 的变更。
observeDeep:递归监听整个文档中的所有嵌套结构(常用于复杂 Schema)。
通过这些监听器,我们可以捕获到所有节点层面的增删改事件,然后将这些变化同步回本地 Schema。
问题:结构性操作缺乏语义信息
以节点插入为例,tiny-engine 中的插入函数依赖一系列上下文信息(如父节点 ID、参考节点 ID、插入位置等)。但在 Yjs 的底层结构中,这些上下文信息在同步时都会丢失。我们只会收到一条'children 数组新增了一个元素'的事件,无法推断节点是'如何插入'的,也就无法还原编辑器层面的真实操作意图。
解决方案:事件总线 + meta 元数据
为了补全操作语义,我们在架构中引入了两个关键机制:
| 机制 | 主要负责 | 作用范围 |
|---|
| 事件总线(Event Bus) | 传播节点级操作的语义,如新增、删除、移动等 | 结构性操作 |
| Meta 元数据(Metadata) | 描述节点属性、状态等细粒度变化 | 属性级操作 |
1. 事件总线:同步操作意图
事件总线的设计目标是让每一个'可复现的操作'都能以事件的形式传播到协作层。我们会在 Yjs 文档中专门创建一个 __app_events__ 通道用于通信。当用户在本地执行节点插入或删除操作时,编辑器会向事件总线发送一条'操作意图',该事件会被同步到 Yjs 的 __app_events__,所有协作者客户端的监听器收到事件后,调用 operateNode 重放操作,从而保持逻辑一致性与结构同步。
2. Meta 元数据:追踪节点属性变化
对于节点属性(如 props、style、loop、condition 等),我们只需同步最终结果。因此,我们在每个节点的 Yjs 表示中增加一份 meta 元数据。当属性发生修改时,更新对应的 meta 字段,这样协作者就能知道是哪个用户修改的、修改了什么部分以及修改时间等信息,并通过 observeDeep 自动捕获变化,实现属性级别的实时同步。
架构小结
通过事件总线与 meta 元数据的结合,我们实现了 Yjs 协同编辑的完整闭环:
- 结构性操作路径:
用户操作 → 发布事件(EventBus) → 同步到 Yjs (__app_events__) → 其他客户端接收 → 重放操作 → Schema & 视图更新
- 属性更新路径:
用户编辑属性 → 更新节点 meta + props → Yjs observeDeep 监听到变化 → 同步到其他客户端 → 更新本地 Schema → 触发视图重绘
这种分层架构既保持了 Yjs 的一致性特性,又补上了协同编辑中至关重要的操作语义层,让多人实时协同真正具备'人理解的上下文逻辑'。
四、反向同步机制 —— 从 Schema 改动更新 Yjs
前面介绍了如何通过 Yjs 的变更驱动本地 Schema 更新,实现了 '远端 → 本地' 的同步逻辑。而反向过程则是:当本地用户操作导致 Schema 发生变化时,如何将这些变更同步到 Yjs 文档,从而广播给其他协作者。
基本思路
反向同步的核心理念是:当本地 Vue 响应式状态(Schema)发生变化时,通过 Vue Hook 捕获变更,并将这些变更同步到 Yjs 的共享结构中。关键在于对 操作意图(Operation Intent) 的捕获,而不是单纯地对数据差异做比对。
添加节点的示例
以'添加节点'为例,当用户在编辑器中执行插入操作时,实际的 Schema 改动完成后,会通过 useRealtimeCollab().insertSharedNode(...) 来完成与 Yjs 的同步。其核心逻辑包括:
- 确定 Yjs 结构中目标位置:通过 parent.id 获取共享文档中对应的 Y.Map 或 Y.Array。
- 构造 Yjs 节点对象:将本地的 Node 数据结构序列化为对应的 Yjs 类型(Y.Map),并递归映射。
- 执行事务性插入:使用
ydoc.transact() 进行原子操作,保证一次插入在所有协作者中状态一致。
Vue Hook 的作用
在实际工程中,我们通常将这类同步逻辑封装在一个组合式 Hook(如 useCollabSchema)中,它负责整合 Y.Doc(持久化数据)和 Y.Awareness(瞬时状态)的同步,并提供对共享文档结构(Schema)的增删改 API。任何时候 Schema 层执行了操作,都可以直接通过该 Hook 同步到共享文档。
总结:完整的双向同步链路
在整个多人协同体系中,Yjs 与 Schema 的双向同步机制是 tiny-engine 协作的核心。
- 正向同步(Yjs → Schema): 通过
observe 与 observeDeep 监听 Yjs 的数据变更,当远端协作者修改文档时,本地自动更新 Schema,从而触发界面刷新。
- 反向同步(Schema → Yjs): 通过 Vue Hook 捕获本地用户操作,再调用封装的
useRealtimeCollab() 方法,将变更同步回 Yjs 文档。
- 事件总线与 Meta 元数据: 用于解决单纯数据变更中无法还原操作意图的问题。
最终,我们构建出了一条完整的数据同步链路:
- Yjs 改动 → Schema 更新 → 视图刷新
- Schema 改动 → Yjs 更新 → 远端同步
这条链路确保了多人协同环境下的数据一致性与实时响应能力,让每一个编辑动作都能即时地被所有协作者感知与呈现。它既保证了操作的语义化,也为后续的冲突解决与版本管理打下了坚实的基础。
实操上手:启动你的第一个协同画布
接下来,我们将在本地环境中,仅需几条命令,就能启动一个功能完备的协同设计画布,并见证实时同步的效果。
预备工作:你的开发环境
在开始之前,请确保您的本地环境满足以下条件:
pnpm: tiny-engine 采用 pnpm 作为包管理器。
npm install -g pnpm
Node.js: 版本需 ≥ 16。推荐使用 nvm 或 fnm 等工具来管理 Node.js 版本。
node -v
第一步:克隆 tiny-engine 源码
将 tiny-engine 的官方仓库克隆到您的本地。
git clone https://github.com/opentiny/tiny-engine.git
cd tiny-engine
第二步:安装项目依赖
在项目根目录下,执行 pnpm install。
pnpm install
第三步:启动开发服务
运行 dev 命令,一键启动整个 tiny-engine 开发环境。
pnpm dev
这个命令会同时启动:
第四步:开启你的'多人协作'剧本
- 打开第一个窗口:在浏览器中打开编辑器地址 (如 http://localhost:7007),作为用户 A。
- 打开第二个窗口:打开一个新的浏览器隐身窗口,或另一台设备访问相同地址,作为用户 B。
- 开始实时协同! 尝试以下操作,观察两个窗口的实时同步效果:
- 在用户 A 的画布上拖入一个按钮组件。
- 在用户 B 的界面上,修改该按钮的'按钮内容'属性。
- 在用户 A 的大纲树面板中,拖拽组件改变其层级结构。
- 同时操作,如用户 A 修改组件颜色,用户 B 修改边距,观察 CRDT 的自动合并。
进阶探索与调试技巧
- 查看协同状态:打开浏览器开发者工具的控制台,查看协同状态数据。
- 网络'时光机':在开发者工具的 Network 标签页,筛选 WS (WebSocket) 连接,观察客户端与服务器的数据流动。
- 扮演'上帝':在控制台中,访问 Y.js 的
doc 和 awareness 实例,尝试手动修改数据或广播自定义状态。
通过以上步骤,您已成功在本地完整地体验了 tiny-engine 先进的多人协作能力。这背后融合了 CRDT (Y.js)、实时通信 (WebSocket)、元数据驱动和事件总线 等一系列现代前端工程化的最佳实践。