我是 Soi 股份有限公司的西浦。
上一篇文章「Figma 的同時編輯,為什麼會這麼順暢?從官方部落格讀懂受 CRDT 啟發的同步設計」是從一種違和感開始的:Figma 裡其他人正在移動的物件,會像是自己在操作一樣順暢地跟隨過來。讀了 Figma 官方部落格之後,我把其背後的同步與衝突解決機制整理成以下 4 點:
其中「因為使用的是到達伺服器的順序,所以不需要時間戳記」這一點,我在閱讀時其實半信半疑。雖然說明很合理,但總覺得不可能這麼單純。也可能只是自己以為理解了而已,所以我決定用 Node.js + WebSocket 做一個最小配置,親手跑起來確認看看。
這次實作的是 Figma 官方部落格中 2019 年的設計,為了便於理解而做了簡化。它與 Figma 實際的內部實作並不相同。
程式碼與展示在這裡。未包含驗證、持久化、以及多文件支援。
也有可直接在瀏覽器使用的 Web 版展示,不需要安裝。
因為是免費額度的託管,若一段時間沒有存取就會休眠。第一次啟動可能要等數十秒。
先給大家看最容易理解的結果。以下是 4 個機制之中的「樂觀更新與 flickering 防止」,在切換為 OFF 時的差異。雖然都是同樣在拖曳節點,差異卻很明顯。
OFF 時,物件會被拉回到比滑鼠稍慢的位置,因此看起來會卡頓。

ON 時,則會直接跟隨滑鼠,動作很順暢。

機制會在第 5 章說明。先從作為基礎的整體架構看起。
這是 4 個機制共通的前提。
不使用共識演算法,也不使用向量時鐘。因為伺服器只有一個,所以到達伺服器的順序就直接成為答案,不需要比較時間戳記。
伺服器端的實作就只有這樣。
server/document.js
applyPropertyUpdate(nodeId, key, value) {
const node = this.nodes.get(nodeId);
if (!node) return null;
node.properties[key] = value;
return node;
}
只是按到達順序覆寫而已。
我也測試了兩個用戶端幾乎同時修改同一個屬性時,是否會收斂到相同的值。
[client1] 樂觀套用:A.color = "#00ff00"
[client2] 樂觀套用:A.color = "#ff00ff"
[client1] 確定(不重新繪製):A.color = "#00ff00"
[client2] 忽略(仍有未確定的自己的更新):A.color = "#00ff00"
[client2] 確定(不重新繪製):A.color = "#ff00ff"
[client1] 收到並套用:A.color = "#ff00ff" (from 1af61d3b)
client1 的 A.color = #ff00ff
client2 的 A.color = #ff00ff
後送達伺服器的更新(這裡是 client2)最終會反映到兩個用戶端上。
client2 在自己的更新確定之前收到了 client1 的更新,但它將其忽略了。原因會在第 5 章說明。
在瀏覽器 UI 裡,必須要兩個人同時操作同一個地方才看得到收斂過程,所以我用日誌來確認。可以用 npm run scenario:lww 重現。
用戶端會在操作的瞬間先反映到本地。同時,也會把該操作的 opId 記為「等待確定」。
client/DemoClient.js
updateProperty(nodeId, key, value) {
const node = this.nodes.get(nodeId);
const opId = this.nextOpId();
node.properties[key] = value; // 立即反映(樂觀更新)
this.pendingOps.set(opId, { type: 'update-property', nodeId, key });
this.ws.send(JSON.stringify({ type: 'update-property', nodeId, key, value, opId }));
}
如果從伺服器收到的確定通知屬於自己的操作,因為值早已顯示在畫面上,所以不會重新繪製。這就是 flickering 防止。
[client1] 樂觀套用:B.x = 250
[client1] 確定(不重新繪製):B.x = 250
如果每次確定都重新繪製,值會先短暫倒退再回到正確值,畫面就會閃爍。
只有日誌很難看出差異,所以我在瀏覽器 UI 加了 ON/OFF 切換。關閉時,自己的操作一旦收到確定通知,就會以當下從伺服器收到的(稍舊的)值重新繪製。而在確定通知到達之前,滑鼠還會持續移動。因此,已確定的值會是「稍早之前的位置」,OFF 時就會被拉回那個位置。第 2 章的 GIF,就是切換這個開關錄下來的。
client/public/app.js
if (wasPending && flickerPreventionEnabled) {
// 已確定的自己的操作 → 不重新繪製(flickering 防止)
} else if (wasPending) {
// OFF:即使是自己的操作也強制重新繪製(刻意重現閃爍)
renderAll();
}
不需要兩個人,也不需要兩個分頁。只要一個人快速拖曳節點,就能看出差異。
我測量了從瀏覽器到伺服器的往返時間,本機大約 5ms,部署在 Render 的展示約 150ms(兩者都是中位數)。第 2 章的 GIF 是在 Render 上的展示錄製的。本機延遲較小,所以 ON 和 OFF 的差異不會像那麼大。
只靠「自己的操作確定時不重新繪製」還不夠。若在自己的更新確定前,其他人對同一個屬性的更新先到了,就會出現問題。來自伺服器的通知會依照伺服器處理的順序送達。如果在等待確定的期間收到其他人的更新,那代表它是比自己的更新更早被處理的。若直接套用到畫面上,之後自己的確定通知又不重新繪製,畫面就會停留在其他人的值上。
因此,只要同一個屬性上還有等待確定的更新,就會忽略其他人的更新。第 4 章的日誌裡,client2 的「忽略」就是這段處理。Figma 官方部落格也寫了相同的想法。當切換成 OFF 時,這個忽略也不會做。
先反映,不等回應;回應到了也不重畫。通信延遲之所以不會出現在畫面上,就是因為這種「不等待」的設計。
我把上一篇文章畫的 sequenceDiagram 直接動起來重現了。
伺服器端的判定如下。
server/document.js
reparent(nodeId, newParentId) {
if (newParentId === nodeId || this.isAncestor(nodeId, newParentId)) {
return null; // 會造成循環參照,因此拒絕
}
// ...
}
實際跑出來的日誌,和圖中的流程一致。
[client1] 樂觀套用:A 的父節點 → B
[client2] 樂觀套用:B 的父節點 → A
[server] reparent A → B(接受)
[client1] 確定(不重新繪製):A 的父節點 → B
[client2] 收到並套用:A 的父節點 → B
[server] reparent B → A(因循環參照而拒絕)
[client2] 拒絕(循環參照)→ 回復:B 的父節點已改回 root
屬性的衝突,是採用伺服器最後處理的值。另一方面,父子關係的變更,則只拒絕會導致循環參照的操作,並把本地狀態回復。沒有做複雜的協調,只靠這個簡單的取捨,就解決了循環問題。
上一篇文章提到,Figma 實際上不是用 64 位元浮點數,而是用字串保存任意精度的分數。這次我改用 BigInt,在需要時延長位數來計算中間值。
求中間值的核心是下面的 midpointDigits。插入在兩個 order 之間的值,會由呼叫它的 generateKeyBetween(a, b) 產生。完整原始碼在 shared/fractionalIndex.js。
shared/fractionalIndex.js
function midpointDigits(lowDigits, highDigits) {
let len = Math.max(lowDigits.length, highDigits.length, 1);
for (;;) {
const low = lowDigits.padEnd(len, '0');
const high = highDigits.padEnd(len, '0');
const sum = BigInt(low) + BigInt(high);
if (sum % 2n === 0n) {
const mid = (sum / 2n).toString().padStart(len, '0');
if (mid !== low && mid !== high) return trimTrailingZeros(mid);
}
len += 1; // 若精度不足,就增加 1 位數後重試
}
}
不管在同一對元素之間插入多少次,都能再放入新的值。
order 只保留小數點以下的位數,5 表示 0.5,745 表示 0.745。依照小到大排序,所以初始狀態是 A、B 的順序。
初始狀態:A(order=5), B(order=745)
移動後:B(order=25), A(order=5)
插入 C 後:B(order=25), C(order=375), A(order=5)
如果用浮點數做「取兩者之間的中間值」,每次往同一個空隙插入,空隙都會減半。以雙精度(64 位元)來說,持續在 0.5 和 1 之間插入,中間值在第 52 次就會和邊界值相同,之後就不能再插入了。若是字串延長位數的方式,位數不夠就再加一位即可,因此插入次數沒有上限。
不過,這個展示沒有處理同時插入的情況。如果兩個人同時在同一對元素之間插入,伺服器會產生兩個相同的 order(例如 generateKeyBetween('25','5') 呼叫兩次,兩者都會得到 375)。由於沒有定義相同值的排序順序,實際應用時需要加入以節點 ID 等方式決定平手的處理。
在簡化的範圍內,4 個機制都如預期運作。不過,真正做過之後,我發現有兩件事是只讀文章時不容易注意到的。
這是最重要的一點。上一篇我寫到「因為使用到達伺服器的順序,所以不需要時間戳記」,但實作後才發現,這其實完全建立在第 3 章的前提上,也就是「由 1 個伺服器程序決定 1 個文件的更新順序」。
如果有兩個地方在決定順序,會怎樣呢?假設 client1 連到伺服器 X,client2 連到伺服器 Y。同樣是對某個屬性的更新,在 X 與 Y 上都可能各自成為「先到達」的那個。因為「到達順序」不再唯一,就需要時刻或邏輯時鐘之類,用來決定順序的其他機制。
這也是為什麼大家會說時間戳記管理很麻煩;而我也因此理解,Figma 是透過把每個文件的順序決定點縮成一個,來避開這個麻煩。
聽到要檢查循環參照時我還有些緊張,但伺服器端只是用 isAncestor 往上追祖先的幾行而已。Fractional Indexing 也只要在不夠時把位數往前延伸一位。伺服器之所以能這麼簡單,是因為它已經把「到達順序就是答案」這件事定義好了。
相對地,費工夫的是用戶端。要把自己的操作記成「等待確定」的 pendingOps,以及在伺服器拒絕時做回復。透過第 5 章的開關對照 ON/OFF,我也體會到這些看起來不起眼的部分,正是畫面順暢的來源。光看日誌時沒發現的差異,一切換就看見了。
在 Soi,我們做委託開發時,常會遇到多人同時操作的畫面。這次的想法,我認為不只對 Figma,對這類畫面也很有用。
如果在管理後台的列表中想顯示「現在誰正在編輯哪一列」,就能使用第 9 章「顯示被選取的節點」的同一種思路。把「誰正在看哪裡」當成獨立於編輯內容之外的暫時資訊,就能在不動既有資料模型的情況下加進去。
對於狀態切換或勾選方塊這種常按的按鈕,第 5 章的樂觀更新與 pendingOps 組合就很好用。按下瞬間先改變外觀,等確定通知來了也不重新繪製,體感會比等待伺服器回應快很多。
與其把整套機制原封不動搬過去,不如只抽出思考方式來使用,這樣更實際。
除了文件的持久狀態(LWW、reparent、順序)之外,我也加入了暫時性的存在資訊同步。
這些都不是文件狀態,不會儲存在伺服器,只是轉送給其他用戶端而已。
server/index.js
function broadcastExcept(message, exceptWs) {
const payload = JSON.stringify(message);
for (const ws of wss.clients) {
if (ws.readyState === ws.OPEN && ws !== exceptWs) ws.send(payload);
}
}
// select-node / deselect-node 也和游標、區域選取一樣處理。
// 完全不會碰到 Document 類別。
case 'select-node': {
broadcastExcept({ type: 'select-node', nodeId: msg.nodeId, fromClientId: clientId }, ws);
break;
}
我認為,把持久資料與暫時存在資訊分開思考,是 Figma 之外的協作工具也很實用的想法。
因為想把學到的機制用在別的題材上,我就在同一個資源庫裡加了一個 Kanban 風格的看板「Agent Workspace」。AI 協作產生器會像人類一樣,以 1 個用戶端的身分參與。可以從一般展示畫面的工具列切換過去。
修改文件的處理,使用的是和人類相同的 Document 方法(reparent、move-node 等)。AI 的操作也會通過和人類一樣的驗證(例如拒絕循環參照等)。
即使是在卡片搬運途中,也會讓其他人的畫面看得見。不論是人類拖曳,還是 AI 移動,都會送出相同的 drag-start / drag-hover / drag-end 訊息。這同樣屬於游標或被選取節點那種存在資訊,所以伺服器只負責轉送,不會儲存。卡片本身的移動,則會在放下時,照舊透過 reparent 和 move-node 確定。

對 AI 的指示,可以用像「把『實作展示』完成」這樣的日文下達。公開的展示版本是不用 API 金鑰、以固定模式運作的 mock。若設定金鑰,也可以連接到 Anthropic API。使用方式已整理在資源庫的 README 中。
上一篇文章是從一種違和感開始的:Figma 裡其他人正在操作的物件,會像自己的操作一樣順暢地跟隨過來。我親自實作確認了其背後到底發生了什麼事。
實作後我明白了,之所以看起來順暢,是因為「不等待」;而即使發生衝突也不會崩壞,則是因為「做取捨」。
而且這些取捨之所以能只用幾行程式碼實現,也是因為「1 文件 = 1 伺服器」這個前提。我原本半信半疑的「不需要時間戳記」是真的,但前提是要把決定順序的地方限定為一個。
在協作看起來很順暢的背後,其實有隱藏通信延遲的技巧,以及在衝突發生時的取捨;而這兩者都是由比我原先想像更小的機制支撐起來的。或許前一篇比較的 Notion 與試算表之間的體感差異,也和這類設計有關。
技術部落格每週更新 1~2 篇,歡迎追蹤 Soi 以掌握最新文章!
原文出處:https://qiita.com/keishin_nishiura/items/b13d72afb085891e4fc9