TinyEngine 低代码实时协作技术解读 + 上手教程

编程达人挑战赛·第4期 10w+人浏览 346人参与

本文由周天意同学原创。

一般的多人协作业务需求一般是针对文档,表格或者是制图之类的,场景比较简单,协同操作的对象为文字或者图片,对象比较单一。
乍一看低代码的多人协作看似无从下手,因为低代码不仅涉及到页面 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>
}
  
// 根节点类型,即页面 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 的基础上扩展了页面级的属性,如 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 元数据

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

机制主要负责作用范围
事件总线(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 元数据:追踪节点属性变化

而对于节点属性(如 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.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️⃣ 第四步:开启你的“多人协作”剧本

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

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

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

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

    • 在用户A的画布上拖入一个按钮组件。观察用户B的画布,几乎在拖拽完成的瞬间,同样的按钮就会“凭空出现”在相同的位置。
    • 在用户B的界面上,选中刚刚同步过来的按钮,修改它的“按钮内容”属性。观察用户A的界面,按钮的文本会实时地、逐字地发生变化。
    • 在用户A的大纲树面板中,拖拽一个组件来改变其层级结构。观察用户B的大纲树,节点会立即移动到新的位置。
    • 在任意一个窗口中,尝试同时操作。比如,用户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 标签,一起参与开源贡献~

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值