博客 / 詳情

返回

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>
}
  
// 根節點類型,即頁面 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

  1. 打開第二個窗口: 打開一個新的瀏覽器隱身窗口,或者使用另一台連接到同一局域網的設備,再次訪問相同的地址。這個窗口將扮演用户B
  2. 開始實時協同!: 將兩個窗口並排擺放,現在開始您的表演:

    • 在用户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 標籤,一起參與開源貢獻\~

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.