本文由周天意同学原创。
一般的多人协作业务需求一般是针对文档,表格或者是制图之类的,场景比较简单,协同操作的对象为文字或者图片,对象比较单一。
乍一看低代码的多人协作看似无从下手,因为低代码不仅涉及到页面 canvas 中一些文字属性的同步,还涉及到组件拖拽,样式,绑定事件,高级属性,甚至是代码协同编辑的编辑与同步。那我们是如何在低代码这个场景下实现多人协同编辑的呢。
TinyEngine低代码引擎多人协同技术详解
CRDT
我们首先来介绍一下实现低代码编辑的协同编辑的底层逻辑 —— CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)是一种允许并发修改、自动合并且永不冲突的数据结构。
即使多个用户同时编辑同一份文档、表格或图形,系统也能在之后自动合并出一致的结果,不需要“锁”或“人工解决冲突”。
一个例子
假设你有一个协作文本编辑器有两个用户:
A 插入“Hello ”
B 插入“World!”
在普通系统中,如果两个操作几乎同时发生,可能导致冲突(比如:谁的改动算数?)。但在 CRDT 模型下,每个操作都是可合并的:系统会基于操作的逻辑时间或唯一标识符自动确定合并顺序;最终所有节点都会收敛到相同的状态,比如 “Hello World!”。
CRDT 的两种主要类型
-
State-based(状态型 CRDT)
每个节点维护完整的状态副本,并定期将状态合并:
local_state = merge(local_state, remote_state) -
Operation-based(操作型 CRDT)
每个节点只传播“操作”,比如“加1”“插入字符X”,
其他节点按相同逻辑执行该操作。
在我们的项目中,我们采用的是 操作型 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(Conflict-free Replicated Data Type),其核心都离不开一个健全且完备的数据结构。
对于我们的 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>
}
// 根节点类型,即页面 Schema
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 需要在内部维护一份与 RootNode 对应的共享状态副本。
然而,Yjs 并不能直接理解复杂的 TypeScript 对象结构,我们需要将其转化为 Yjs 能够识别和同步的类型系统。
例如:
- 普通对象 →
Y.Map - 数组 →
Y.Array - 字符串、数字、布尔值 →
Y.Text/ 基本类型 - 嵌套结构(如 children)则需要递归地转化为嵌套的 Y 类型。
因此,我们的第一步工作是:
根据已有的
Node和RootNode数据结构,将其映射为等价的 Yjs 类型(如 Y.Map、Y.Array 等)。
这一过程可以抽象为一个通用的 “schema → YDoc” 转换函数。项目中:
const UNDEFINED_PLACEHOLDER = '__undefined__'
/**
* 将普通对象/数组递归转换成 Yjs 对象
* @param target Y.Map 或 Y.Array
* @param obj 要转换的对象
*/
// toYjs 函数优化后的版本
export function toYjs(target: Y.Map<any> | Y.Array<any>, obj: any) {
if (Array.isArray(obj)) {
if (!(target instanceof Y.Array)) {
throw new Error('Expected Y.Array as target for array input')
}
obj.forEach((item) => {
if (item === undefined) {
target.push([UNDEFINED_PLACEHOLDER])
} else if (item === null) {
target.push([null])
} else if (Array.isArray(item)) {
const childArr = new Y.Array()
toYjs(childArr, item)
target.push([childArr])
} else if (typeof item === 'object' && item !== null) {
// 明确排除 null
const childMap = new Y.Map()
toYjs(childMap, item)
target.push([childMap])
} else {
target.push([item])
}
})
} else if (typeof obj === 'object' && obj !== null) {
if (!(target instanceof Y.Map)) {
throw new Error('Expected Y.Map as target for object input')
}
Object.entries(obj).forEach(([key, val]) => {
if (val === undefined) {
target.set(key, UNDEFINED_PLACEHOLDER)
} else if (val === null) {
target.set(key, null)
} else if (Array.isArray(val)) {
const yArr = new Y.Array()
target.set(key, yArr)
toYjs(yArr, val)
} else if (typeof val === 'object' && val !== null) {
// 明确排除 null
const yMap = new Y.Map()
target.set(key, yMap)
toYjs(yMap, val)
} else {
target.set(key, val)
}
})
}
// 注意:如果 obj 不是对象或数组(如 string, number),函数将静默地不做任何事。这是符合预期的。
}
// 将 Yjs Map 转回普通对象(递归)
export function fromYjs(value: any): any {
if (value instanceof Y.Map) {
const obj: any = {}
value.forEach((v, k) => {
obj[k] = fromYjs(v)
})
return obj
} else if (value instanceof Y.Array) {
return value.toArray().map((item) => fromYjs(item))
} else if (value instanceof Y.Text) {
return value.toString()
} else if (value === UNDEFINED_PLACEHOLDER) {
return undefined // 还原 undefined
} else {
return value
}
}
这样,当我们通过 Yjs 对这些 Y 类型进行修改(例如修改 props、插入/删除 children、更新 state),Yjs 就会自动维护 CRDT 冲突合并逻辑,并将变更同步到所有协作客户端。
监听机制实现 —— 从 Yjs 变更到多人协同视图更新
前面的步骤成功让我们借助 Yjs 实现了数据层面的实时同步:
无论是哪位协作者修改了页面中的某个节点、属性或层级结构,这些变更都能被同步传播到所有客户端。
但是,仅仅让数据“同步”还不够。
在 tiny-engine 中,页面渲染与编辑的核心状态仍然依赖于本地的 Schema(即 RootNode 和 Node 的结构树)。
换句话说:
Yjs 负责维护协作的共享状态,但页面的实际渲染与交互仍是基于本地内存中的 Schema。
因此,我们必须建立一套监听机制,让 Yjs 的变更能够驱动 Schema 与视图的更新,形成如下的完整同步链路:
Yjs 数据变化 → 更新本地 Schema → 触发渲染引擎刷新视图
非常好 👍,你这里实际上引出了多人协同中最关键的一个设计点——“操作意图层”和“数据层”的解耦”。
你的思路已经非常正确:用事件总线处理结构性变更(如节点插入/删除),用 meta 元数据追踪属性变更。下面我帮你把这一节内容完整、系统地扩写成技术博客风格,同时保留你的原始语义与工程感。👇
实现思路:Yjs observe 机制
Yjs 为我们提供了非常强大的变更监听机制:
observe:监听单个Y.Map或Y.Array的变更;observeDeep:递归监听整个文档中的所有嵌套结构(常用于复杂 Schema)。
通过这些监听器,我们可以捕获到所有节点层面的增删改事件(包括 props、children 等),然后将这些变化同步回本地 Schema。
问题:结构性操作缺乏语义信息
在理论上,observe 能告诉我们「有节点被插入」,但在实际业务逻辑中,这个信息远远不够。
以节点插入为例,tiny-engine 中的插入函数如下所示:
const insertAfter = ({ parent, node, data }: InsertOptions) => {
if (!data.id) {
data.id = utils.guid()
}
useCanvas().operateNode({
type: 'insert',
parentId: parent.id || '',
newNodeData: data,
position: 'after',
referTargetNodeId: node.id
})
}
可以看到,插入一个节点不仅仅是向 children 数组中多 push 一个元素,而是依赖一系列上下文信息:
- 插入到哪个父节点(
parentId); - 相对哪个参考节点(
referTargetNodeId); - 插入位置(
position:before/after/append 等);
但是在 Yjs 的底层结构中,这些上下文信息在同步时都会丢失。
我们只会收到一条 “children 数组新增了一个元素” 的事件:
event.changes.added // => [Y.Map({ id: 'new-node-id', ... })]
这时我们无法推断出节点是“如何插入”的,也就无法还原编辑器层面的真实操作。
换句话说,Yjs 提供了数据变化的结果,但我们需要的是操作的意图。
解决方案:事件总线 + meta 元数据
为了解决这一问题,我们在架构中引入了两个关键机制:
| 机制 | 主要负责 | 作用范围 |
|---|---|---|
| 事件总线(Event Bus) | 传播节点级操作的语义,如新增、删除、移动等 | 结构性操作 |
| Meta 元数据(Metadata) | 描述节点属性、状态等细粒度变化 | 属性级操作 |
1. 事件总线:同步操作意图
事件总线的设计目标是让每一个“可复现的操作”都能以事件的形式传播到协作层中。
我们会在 Yjs 文档中专门创建一个 __app_events__ 通道,用于通信:
// 创建事件通道
const eventsMap = this.yDoc.getMap('__app_events__')
// 开启事务保证原子性
this.yDoc.transact(() => {
// 在目标节点上设置软删除标志,防止幽灵事件
targetNode.set('_node_deleted', true)
// 获取事件总线
const eventsMap = this.yDoc.getMap('__app_events__')
// 准备事件负载
const eventPayload = {
op: 'delete',
deletedNodeId: id,
// TODO: 可以在负载中包含被删除前的数据,便于远程客户端做一些高级处理(如 "恢复" 功能)
previousNodeData,
timestamp: Date.now()
}
// 使用唯一 ID 发布事件
const eventId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
eventsMap.set(eventId, eventPayload)
}, 'local-delete-operation')
监听器设计
// 设置一个专门的监听器来处理来自“事件总线”的自定义操作
// 处理无法被 initObserver 监听器很好处理的事件
public setupEventListeners(docName: string): void {
// 解绑旧的监听器,防止重复
if (this.eventListeners.has(docName)) {
const { map, cb } = this.eventListeners.get(docName)
map.unobserve(cb)
}
const docManager = DocManager.getInstance()
const ydoc = docManager.getOrCreateDoc(docName)
const eventsMap = ydoc.getMap('__app_events__')
const eventCallback = (event: Y.YMapEvent<any>, transaction: Y.Transaction) => {
if (transaction.local) return
event.changes.keys.forEach((change, key) => {
if (change.action === 'add') {
const payload: any = eventsMap.get(key)
if (payload && payload.op === 'move') {
const patch: DiffPatch = {
type: 'array-swap',
parentId: payload.parentId,
schemaId: payload.schemaId,
swapId: payload.swapId
}
this.applyPatches(docName, [patch])
} else if (payload && payload.op === 'insert') {
const patch: DiffPatch = {
type: 'array-insert',
parentId: payload.parentId,
newNodeData: payload.newNodeData,
position: payload.position,
referTargetNodeId: payload.referTargetNodeId
}
this.applyPatches(docName, [patch])
} else if (payload && payload.op === 'delete') {
const patch: DiffPatch = {
type: 'array-delete',
deletedId: payload.deletedNodeId,
previousNodeData: payload.previousNodeData
}
this.applyPatches(docName, [patch])
}
}
eventsMap.delete(key)
})
}
// 绑定监听器
eventsMap.observe(eventCallback)
this.eventListeners.set(docName, { map: eventsMap, cb: eventCallback })
}
这样,每当一个用户在本地执行节点插入或删除操作时:
a. 编辑器会向事件总线发送一条“操作意图”;
b. 该事件会被同步到 Yjs 的 __app_events__;
c. 所有协作者客户端的监听器收到事件后,调用 operateNode 重放操作;
d. 从而保持逻辑一致性与结构同步。
这种做法本质上是 “Yjs 同步结果 + EventBus 同步语义” 的结合。
2. Meta 元数据:追踪节点属性变化
而对于节点属性(如 props、style、loop、condition 等)而言,我们并不需要同步操作意图,只需同步最终结果即可。
因此我们在每个节点的 Yjs 表示中增加一份 meta 元数据:
const yNode = new Y.Map()
yNode.set('meta', new Y.Map({
lastModifiedBy: userId,
lastModifiedAt: Date.now(),
changeType: 'props'
}))
当属性发生修改时,我们更新对应的 meta 字段,这样协作者就能知道:
- 是哪个用户修改的;
- 修改了什么部分;
- 修改时间等信息。
并通过 observeDeep 自动捕获变化,实现属性级别的实时同步。
这种模式下,结构操作(增删节点)和属性操作(节点内部更新)各司其职,不会互相干扰。
架构小结
通过事件总线与 meta 元数据的结合,我们实现了 Yjs 协同编辑的完整闭环:
用户操作 → 发布事件(EventBus)
↓
同步到 Yjs (__app_events__)
↓
其他客户端接收 → 重放操作
↓
Schema & 视图更新
而对于属性更新的路径:
用户编辑属性 → 更新节点 meta + props
↓
Yjs observeDeep 监听到变化
↓
同步到其他客户端 → 更新本地 Schema
↓
触发视图重绘
这种分层架构既保持了 Yjs 的一致性特性,又补上了协同编辑中至关重要的 操作语义层,让多人实时协同真正具备“人理解的上下文逻辑”。
非常好,这一节正是整个 反向同步链路(Schema → Yjs) 的核心部分。下面是经过润色和扩展后的完整博客内容片段,可以直接用于技术文档或博客文章中👇
反向同步机制 —— 从 Schema 改动更新 Yjs
在前面我们已经介绍了如何通过 Yjs 的变更来驱动本地 Schema 的更新,实现了**“远端 → 本地”** 的同步逻辑。
而这一节要讲的,则是反向过程:当本地用户操作导致 Schema 发生变化时,如何将这些变更同步到 Yjs 文档,从而广播给其他协作者。
基本思路
反向同步的核心理念是:
当本地 Vue 响应式状态(Schema)发生变化时,我们通过 Vue Hook 捕获到变更,并将这些变更同步到 Yjs 的共享结构中。
这一机制的关键在于对 操作意图(Operation Intent) 的捕获,而不是单纯地对数据差异做比对。
也就是说,我们并不是在检测“数据变了多少”,而是在监听“用户执行了什么操作”——比如插入节点、删除节点、修改属性等。
添加节点的示例
以“添加节点”为例,当用户在编辑器中执行插入操作时,实际的 Schema 改动会通过以下函数完成:
export const insertNode = (
node: { node: Node; parent: Node; data: Node },
position: PositionType = POSITION.IN,
select = true
) => {
if (!node.parent) {
insertInner({ node: useCanvas().pageState.pageSchema!, data: node.data }, position)
} else {
switch (position) {
case POSITION.TOP:
case POSITION.LEFT:
insertBefore(node)
break
case POSITION.BOTTOM:
case POSITION.RIGHT:
insertAfter(node)
break
case POSITION.IN:
insertInner(node)
break
case POSITION.OUT:
insertContainer(node)
break
case POSITION.REPLACE:
insertReplace(node)
break
default:
insertInner(node)
break
}
}
if (select) {
setTimeout(() => selectNode(node.data.id))
}
getController().addHistory()
}
我们重点关注 insertBefore 函数的实现:
const insertBefore = ({ parent, node, data }: InsertOptions) => {
if (!data.id) {
data.id = utils.guid()
}
// 更新本地 Schema
useCanvas().operateNode({
type: 'insert',
parentId: parent.id || '',
newNodeData: data,
position: 'before',
referTargetNodeId: node.id
})
// 多人协作同步
useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.TOP)
}
可以看到,当本地 Schema 执行节点插入后,接下来就通过
useRealtimeCollab().insertSharedNode(...)
来完成与 Yjs 的同步。
核心逻辑:insertSharedNode
insertSharedNode 是整个反向同步机制的关键函数,它的主要职责是:
-
确定 Yjs 结构中目标位置
通过parent.id获取共享文档中对应的Y.Map或Y.Array,找到应插入的目标节点。 -
构造 Yjs 节点对象
将本地的Node数据结构序列化为对应的 Yjs 类型(Y.Map),并递归地将props、children等字段映射为 Yjs 可操作的数据结构。 -
执行事务性插入
使用ydoc.transact()进行原子操作,保证一次插入在所有协作者中状态一致。
下面是一个简化后的核心示例逻辑:
// 拖拽行为产生的节点插入
public insertNode({ node, parent, data }: InsertOptions, position: PositionType) {
let insertPos
let insertPosFinal
if (!parent) {
this.insert(useCanvas().pageState.pageSchema!.id as string, data, position)
} else {
switch (position) {
case POSITION.TOP:
case POSITION.LEFT:
this.insert(parent.id || '', data, 'before', node.id)
break
case POSITION.BOTTOM:
case POSITION.RIGHT:
this.insert(parent.id || '', data, 'after', node.id)
break
case POSITION.IN:
insertPos = ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
this.insert(node.id || '', data, insertPos)
break
case POSITION.OUT:
this.insert(parent.id || '', data, POSITION.OUT, node.id)
break
case POSITION.REPLACE:
this.insert(parent.id || '', data, 'replace', node.id)
break
default:
insertPosFinal = ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
this.insert(node.id || '', data, insertPosFinal)
break
}
}
}
// insert 操作
private insert(parentId: string, newNodeData: Node, position: string, referTargetNodeId?: string) {
this.operationHandler.insert({
type: 'insert',
parentId,
newNodeData,
position,
referTargetNodeId
})
}
其实就相当于重写了 insertNode 来实现 Yjs 的变动
Vue Hook 的作用
在实际工程中,我们通常会将这类同步逻辑封装在一个组合式 Hook 中,比如:
/**
* useCollabSchema Composable
* 职责:
* 1. 整合 Y.Doc (持久化数据) 和 Y.Awareness (瞬时状态) 的同步。
* 2. 提供对共享文档结构 (Schema) 的增删改 API。
* 3. 提供对远程用户实时状态的响应式数据和更新 API。
*/
export function useCollabSchema(options: UseCollabSchemaOptions) {
const { roomId, currentUser } = options
const { awareness, provider } = useYjs(roomId, { websocketUrl: `ws://localhost:${PORT}` })
const { remoteStates, updateLocalStateField } = useAwareness<SchemaAwarenessState>(awareness, currentUser)
// 获取 NodeSchemaModel 实例
const schemaManager = SchemaManager.getInstance()
const schemaModel = schemaManager.createSchema(roomId, provider.value!)
// 拖拽节点
const insertSharedNode = (
node: { node: Node | RootNode; parent: Node | RootNode; data: Node },
position: PositionType = POSITION.IN
) => {
// ...上面提到的核心逻辑
}
// ... 其他核心函数
// 组件卸载时取消监听
onUnmounted(() => {
schemaManager.destroyObserver(roomId)
provider.value?.off('sync', () => {})
// awareness.value?.destroy()
})
return {
remoteStates,
insertSharedNode,
// ... 其他核心函数
}
}
这样,任何时候 Schema 层执行了插入、删除、修改等操作,都可以直接通过 useCollabSchema() 来同步到共享文档。
总结
在整个多人协同体系中,Yjs 与 Schema 的双向同步机制是 tiny-engine 协作的核心。
-
正向同步(Yjs → Schema):
通过observe与observeDeep监听 Yjs 的数据变更,当远端协作者修改文档时,本地自动更新 Schema,从而触发界面刷新。 -
反向同步(Schema → Yjs):
通过 Vue Hook 捕获本地用户操作(如插入、删除、修改节点等),再调用封装的useRealtimeCollab()方法,将变更同步回 Yjs 文档。 -
事件总线与 Meta 元数据:
用于解决单纯数据变更中无法还原操作意图的问题。事件总线负责节点级别的创建与删除同步,而 Meta 则用于监听属性与状态的更改。
最终,我们构建出了一条完整的数据同步链路:
Yjs 改动 → Schema 更新 → 视图刷新
Schema 改动 → Yjs 更新 → 远端同步
这条链路确保了多人协同环境下的数据一致性与实时响应能力,让每一个编辑动作都能即时地被所有协作者感知与呈现。
它既保证了操作的语义化,也为后续的冲突解决与版本管理打下了坚实的基础。
实操上手:
接下来,我们将引导您在本地环境中,仅需几条命令,就能启动一个功能完备的协同设计画布,并见证实时同步的“魔法”。
预备工作:你的开发环境
在开始之前,请确保您的本地环境满足以下条件,这是保证顺利运行的基础:
- Node.js: 版本需
≥ 16。我们推荐使用nvm或fnm等工具来管理 Node.js 版本,以避免环境冲突。# 检查你的 Node.js 版本 node -v - pnpm:
tiny-engine采用 pnpm 作为包管理器,以充分利用其在 monorepo(多包仓库)项目中的高效依赖管理能力。# 如果尚未安装 pnpm,请运行以下命令 npm install -g pnpm
第一步:克隆 tiny-engine 源码
首先,将 tiny-engine 的官方仓库克隆到您的本地。
git clone https://github.com/opentiny/tiny-engine.git
cd tiny-engine
进入项目目录后,您会发现这是一个结构清晰的 monorepo 项目,所有功能模块(如编辑器核心、物料面板、协作服务等)都作为独立的子包存在于 packages/ 目录下。
2️⃣ 第二步:安装项目依赖
在项目根目录下,执行 pnpm install。pnpm 会智能地解析并安装所有子包的依赖,并建立它们之间的符号链接(symlinks)。
pnpm install
💡 为什么是 pnpm?
在 monorepo 架构中,pnpm 通过其独特的非扁平化node_modules结构和内容寻址存储,可以极大地节省磁盘空间,并避免“幻影依赖”问题,保证了开发环境的纯净与一致性。
3️⃣ 第三步:启动开发服务,见证奇迹!
一切准备就绪,现在只需运行 dev 命令,即可一键启动整个 tiny-engine 开发环境。
pnpm dev
这个命令背后发生了什么?
- 它会同时启动多个服务,包括:
- Vite 前端开发服务器: 负责构建和热更新您在浏览器中看到的编辑器界面。
- 协作后端服务器 (y-websocket): 一个轻量级的 WebSocket 服务器,负责接收、广播和持久化 Y.js 的协同数据。
- 终端会输出编辑器前端的访问地址,通常默认为
http://localhost:7007(请以您终端的实际输出为准)。
4️⃣ 第四步:开启你的“多人协作”剧本
现在,是时候扮演不同的协作者了!
-
打开第一个窗口: 在您的浏览器(推荐 Chrome)中打开上一步获取的地址,例如
http://localhost:7007。您会看到tiny-engine的低代码设计器界面。这就是我们的用户A。

-
打开第二个窗口: 打开一个新的浏览器隐身窗口,或者使用另一台连接到同一局域网的设备,再次访问相同的地址。这个窗口将扮演用户B。
-
开始实时协同!: 将两个窗口并排摆放,现在开始您的表演:
- 在用户A的画布上拖入一个按钮组件。观察用户B的画布,几乎在拖拽完成的瞬间,同样的按钮就会“凭空出现”在相同的位置。
- 在用户B的界面上,选中刚刚同步过来的按钮,修改它的“按钮内容”属性。观察用户A的界面,按钮的文本会实时地、逐字地发生变化。
- 在用户A的大纲树面板中,拖拽一个组件来改变其层级结构。观察用户B的大纲树,节点会立即移动到新的位置。
- 在任意一个窗口中,尝试同时操作。比如,用户A修改组件的颜色,用户B修改其边距。您会发现,由于 CRDT 的特性,所有的修改最终都会被正确合并,达到最终一致的状态,而不会产生冲突或覆盖。
进阶探索与调试技巧
如果您对背后的原理感到好奇,可以尝试以下操作来深入探索:
- 查看协同状态: 打开浏览器的开发者工具,进入 控制台,你会看到相应的协同状态数据
- 网络“时光机”: 在开发者工具的
Network标签页,筛选WS(WebSocket) 连接。您可以看到客户端与y-websocket服务器之间流动的二进制消息。尝试断开网络再重连,观察 Y.js 是如何利用 CRDT 的能力,在重连后自动同步所有离线期间的变更的。 - 扮演“上帝”: 在控制台中,您可以访问 Y.js 的
doc和awareness实例,尝试手动修改数据或广播自定义状态,来更深入地理解数据驱动的协同模型。
通过以上步骤,您已经成功在本地完整地体验了 tiny-engine 先进的多人协作能力。这不仅仅是一个功能演示,它背后融合了 CRDT (Y.js)、实时通信 (WebSocket)、元数据驱动和事件总线 等一系列现代前端工程化的最佳实践。
演示

(本项目为开源之夏活动贡献,欢迎大家体验并使用)
源码可参考:https://github.com/opentiny/tiny-engine/tree/ospp-2025/multiplayer-collaboration
关于 OpenTiny
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:https://opentiny.design
OpenTiny 代码仓库:https://github.com/opentiny
TinyVue 源码:https://github.com/opentiny/tiny-vue
TinyEngine 源码: https://github.com/opentiny/tiny-engine
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~
如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~
3375

被折叠的 条评论
为什么被折叠?



