写点什么

TinyEngine 低代码多人实时协作“原理”+“实操”全攻略解读

作者:OpenTiny社区
  • 2025-11-27
    广东
  • 本文字数:11226 字

    阅读完需:约 37 分钟

TinyEngine低代码多人实时协作“原理”+“实操”全攻略解读

本文由周天意同学原创。


一般的多人协作业务需求一般是针对文档,表格或者是制图之类的,场景比较简单,协同操作的对象为文字或者图片,对象比较单一。乍一看低代码的多人协作看似无从下手,因为低代码不仅涉及到页面 canvas 中一些文字属性的同步,还涉及到组件拖拽,样式,绑定事件,高级属性,甚至是代码协同编辑的编辑与同步。那我们是如何在低代码这个场景下实现多人协同编辑的呢。

TinyEngine 低代码引擎多人协同技术详解

CRDT

我们首先来介绍一下实现低代码编辑的协同编辑的底层逻辑 —— CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)是一种允许并发修改、自动合并且永不冲突的数据结构。即使多个用户同时编辑同一份文档、表格或图形,系统也能在之后自动合并出一致的结果,不需要“锁”或“人工解决冲突”

一个例子

假设你有一个协作文本编辑器有两个用户:A 插入“Hello ”B 插入“World!”


在普通系统中,如果两个操作几乎同时发生,可能导致冲突(比如:谁的改动算数?)。但在 CRDT 模型下,每个操作都是可合并的:系统会基于操作的逻辑时间或唯一标识符自动确定合并顺序;最终所有节点都会收敛到相同的状态,比如 "Hello World!"。

CRDT 的两种主要类型

  1. State-based(状态型 CRDT)每个节点维护完整的状态副本,并定期将状态合并:local_state = merge(local_state, remote_state)

  2. 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>}  // 根节点类型,即页面 Schemaexport 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 的基础上扩展了页面级的属性,如 statemethodslifeCycles 等。

从数据结构到协同对象

在使用 CRDT(这里是 Yjs 进行实时协作时,我们的“协作单元”就是上述的这类数据结构。换句话说,Yjs 需要在内部维护一份与 RootNode 对应的共享状态副本。


然而,Yjs 并不能直接理解复杂的 TypeScript 对象结构,我们需要将其转化为 Yjs 能够识别和同步的类型系统。例如:


  • 普通对象 → Y.Map

  • 数组 → Y.Array

  • 字符串、数字、布尔值 → Y.Text / 基本类型

  • 嵌套结构(如 children)则需要递归地转化为嵌套的 Y 类型。


因此,我们的第一步工作是:


根据已有的 NodeRootNode 数据结构,将其映射为等价的 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(即 RootNodeNode 的结构树)。换句话说:


Yjs 负责维护协作的共享状态,但页面的实际渲染与交互仍是基于本地内存中的 Schema。


因此,我们必须建立一套监听机制,让 Yjs 的变更能够驱动 Schema 与视图的更新,形成如下的完整同步链路:


Yjs 数据变化 → 更新本地 Schema → 触发渲染引擎刷新视图
复制代码


非常好 👍,你这里实际上引出了多人协同中最关键的一个设计点——“操作意图层”和“数据层”的解耦”。你的思路已经非常正确:用事件总线处理结构性变更(如节点插入/删除),用 meta 元数据追踪属性变更。下面我帮你把这一节内容完整、系统地扩写成技术博客风格,同时保留你的原始语义与工程感。👇

实现思路:Yjs observe 机制

Yjs 为我们提供了非常强大的变更监听机制:


  • observe:监听单个 Y.MapY.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 元数据

为了解决这一问题,我们在架构中引入了两个关键机制:


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 元数据:追踪节点属性变化

而对于节点属性(如 propsstyleloopcondition 等)而言,我们并不需要同步操作意图,只需同步最终结果即可。因此我们在每个节点的 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 是整个反向同步机制的关键函数,它的主要职责是:


  1. 确定 Yjs 结构中目标位置通过 parent.id 获取共享文档中对应的 Y.MapY.Array,找到应插入的目标节点。

  2. 构造 Yjs 节点对象将本地的 Node 数据结构序列化为对应的 Yjs 类型(Y.Map),并递归地将 propschildren 等字段映射为 Yjs 可操作的数据结构。

  3. 执行事务性插入使用 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):通过 observeobserveDeep 监听 Yjs 的数据变更,当远端协作者修改文档时,本地自动更新 Schema,从而触发界面刷新。

  • 反向同步(Schema → Yjs):通过 Vue Hook 捕获本地用户操作(如插入、删除、修改节点等),再调用封装的 useRealtimeCollab() 方法,将变更同步回 Yjs 文档。

  • 事件总线与 Meta 元数据:用于解决单纯数据变更中无法还原操作意图的问题。事件总线负责节点级别的创建与删除同步,而 Meta 则用于监听属性与状态的更改。


最终,我们构建出了一条完整的数据同步链路:


Yjs 改动 → Schema 更新 → 视图刷新Schema 改动 → Yjs 更新 → 远端同步
复制代码


这条链路确保了多人协同环境下的数据一致性与实时响应能力,让每一个编辑动作都能即时地被所有协作者感知与呈现。它既保证了操作的语义化,也为后续的冲突解决与版本管理打下了坚实的基础。

实操上手:

接下来,我们将引导您在本地环境中,仅需几条命令,就能启动一个功能完备的协同设计画布,并见证实时同步的“魔法”。

预备工作:你的开发环境

在开始之前,请确保您的本地环境满足以下条件,这是保证顺利运行的基础:


  • Node.js: 版本需 ≥ 16。我们推荐使用 nvmfnm 等工具来管理 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.gitcd 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️⃣ 第四步:开启你的“多人协作”剧本

现在,是时候扮演不同的协作者了!


  1. 打开第一个窗口: 在您的浏览器(推荐 Chrome)中打开上一步获取的地址,例如 http://localhost:7007。您会看到 tiny-engine 的低代码设计器界面。这就是我们的用户 A


  1. 打开第二个窗口: 打开一个新的浏览器隐身窗口,或者使用另一台连接到同一局域网的设备,再次访问相同的地址。这个窗口将扮演用户 B

  2. 开始实时协同!: 将两个窗口并排摆放,现在开始您的表演:

  3. 在用户 A 的画布上拖入一个按钮组件。观察用户 B 的画布,几乎在拖拽完成的瞬间,同样的按钮就会“凭空出现”在相同的位置。

  4. 在用户 B 的界面上,选中刚刚同步过来的按钮,修改它的“按钮内容”属性。观察用户 A 的界面,按钮的文本会实时地、逐字地发生变化。

  5. 在用户 A 的大纲树面板中,拖拽一个组件来改变其层级结构。观察用户 B 的大纲树,节点会立即移动到新的位置。

  6. 在任意一个窗口中,尝试同时操作。比如,用户 A 修改组件的颜色,用户 B 修改其边距。您会发现,由于 CRDT 的特性,所有的修改最终都会被正确合并,达到最终一致的状态,而不会产生冲突或覆盖。

进阶探索与调试技巧

如果您对背后的原理感到好奇,可以尝试以下操作来深入探索:


  • 查看协同状态: 打开浏览器的开发者工具,进入 控制台,你会看到相应的协同状态数据

  • 网络“时光机”: 在开发者工具的 Network 标签页,筛选 WS (WebSocket) 连接。您可以看到客户端与 y-websocket 服务器之间流动的二进制消息。尝试断开网络再重连,观察 Y.js 是如何利用 CRDT 的能力,在重连后自动同步所有离线期间的变更的。

  • 扮演“上帝”: 在控制台中,您可以访问 Y.js 的 docawareness 实例,尝试手动修改数据或广播自定义状态,来更深入地理解数据驱动的协同模型。


通过以上步骤,您已经成功在本地完整地体验了 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 标签,一起参与开源贡献~

发布于: 刚刚阅读数: 4
用户头像

OpenTiny 企业级web前端开发解决方案 2023-06-06 加入

官网:opentiny.design 我们是华为云的 OpenTiny 开源社区,会定期为大家分享一些团队内部成员的技术文章或华为云社区优质博文,涉及领域主要涵盖了前端、后台的技术等。

评论

发布
暂无评论
TinyEngine低代码多人实时协作“原理”+“实操”全攻略解读_开源_OpenTiny社区_InfoQ写作社区