本文由周天意同學原創。
一般的多人協作業務需求一般是針對文檔,表格或者是製圖之類的,場景比較簡單,協同操作的對象為文字或者圖片,對象比較單一。
乍一看低代碼的多人協作看似無從下手,因為低代碼不僅涉及到頁面 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 標籤,一起參與開源貢獻\~