COSCUP - 一起來開發一個 notion 吧,多人即時共編筆記分享
📅 發布時間: 2024-08-02 🏷️ 標籤: CRDT, OT, Yjs, Notion, 多人即時共編
如何開發一個共編筆記的開發技術,淺談 CRDT、OT 的概念,分享當前生態系與資源。著重在多人即時共編時如何做到不衝突資料變更,最終開發一個可以部署的超簡單版本 notion 筆記。
💻 多人即時共編筆記開發
🎯 學習目標:理解版本控制(如 Git)的衝突問題,以及多人即時協作的挑戰掌握 OT(Operational Transformation)和 CRDT(Conflict-free Replicated Data Type)的核心概念使用 Yjs 與 y-websocket 快速建置 Node.js 後端與 React 前端,实现簡單的多人即時文字共編
步驟 1: 講者介紹

圖: 講者介紹
步驟 2: 如何開發一個多人即時共編筆記

圖: 如何開發一個多人即時共編筆記
步驟 3: 一些能夠多人協作的平台 ex. Google 文件、HackMd、Notion

圖: 一些能夠多人協作的平台,如 Google 文件、HackMd、Notion
步驟 4: 版本控制

圖: 版本控制概念
步驟 5: 如果今天只有一個 branch 如果有任何對文件的修改,都可以創建一個 comment

圖: 單 branch 下的修改以 comment 形式處理
步驟 6: 每個 commit 都相當於是一個能夠隨時回退的版本

圖: Commit 作為可回退版本
步驟 7: 透過 commit 你也能看到一個專案的發展史

圖: Commit 記錄專案發展史
步驟 8: 或者你同事的偷懶史

圖: Commit 暴露同事的「偷懶史」
步驟 9: 多人開發

圖: 多人開發情境
步驟 10: 將大家辛苦工作的結果合併回去

圖: 合併多人工作結果
步驟 11: 那當然,很常就會發生衝突 conflict

圖: 合併時常見的衝突問題
步驟 12: 偶爾也有那種感覺休假半年才來上班的同事 將半年前的 branch 進行合併的魔幻操作

圖: 陳年 branch 合併的魔幻衝突
步驟 13: OT 與 CRDT

圖: OT 與 CRDT 介紹
步驟 14: 來用一個簡單的例子講解 OT 的概念 假設有兩個人同時在編輯一串文字

圖: OT 概念簡單例子 - 兩人同時編輯文字
步驟 15: OT 的做法就是同步操作 也就是將用戶的操作廣播出去

圖: OT 透過廣播操作實現同步
步驟 16: 當然,因為網路延遲 這個傳播會有時間差

圖: 網路延遲導致的時間差
步驟 17: 當其他人收到這個廣播時 便會自動去套用這個操作 好讓兩個人的畫面一致

圖: 接收廣播後套用操作,確保一致
步驟 18: 但換個會衝突的例子

圖: 衝突例子
步驟 19: 但兩個人的操作非常接近

圖: 操作位置接近的衝突
步驟 20: 這時因為網路延遲的關西

圖: 延遲導致資料不一致
步驟 21: 會導致兩邊的數據不一致

圖: 最終資料不一致問題
步驟 22: 所以我們會在廣播時附上時間戳

圖: 使用時間戳解決順序問題
步驟 23: 這邊就要來講到 OT 當中的 T Transformation 透過時間戳的關西,轉換操作 使兩者最後結果一致

圖: OT 的 Transformation (T) 透過時間戳轉換操作
步驟 24: 再來講講 CRDT 與 OT 不同,CRTD 主要是關注於資料上

圖: CRDT 聚焦資料而非操作
步驟 25: 他會

圖: CRDT 特性說明 (續)
步驟 26: CRDT

圖: CRDT 特性
步驟 27: CRDT

圖: CRDT 特性 (續)
步驟 28: CRDT

圖: CRDT 特性 (續)
步驟 29: CRDT

圖: CRDT 特性 (續)
步驟 30: CRDT

圖: CRDT 特性 (續)
步驟 31: CRDT 選擇理由

圖: 選擇 CRDT 的理由
步驟 32:

圖: CRDT 相關說明
步驟 33: Demo

圖: 實際 Demo 展示
步驟 34: 首先,設定 Node.js 伺服器,安裝所需的套件:
npm init -y
npm install ws y-websocket yjs步驟 35: 創建一個 server.js 文件
(無程式碼變更,建立檔案準備後端伺服器)
步驟 36: 先引入 yjs
import * as Y from "yjs"; // 📝 引入 Yjs 庫,這是基於 CRDT 的協作編輯核心庫,用來創建和管理共享資料結構,自動解決衝突步驟 37: 創建一個 Doc 物件 這個是 yjs 的核心資料結構之一 他會自己追蹤其他用戶的更改、自己同步資料
import * as Y from "yjs";
const doc = new Y.Doc(); // 📝 創建 Y.Doc 共享文檔物件,這是 Yjs 的核心,用來儲存所有協作資料,內建變更追蹤和同步機制,確保多客戶端一致性步驟 38: 我們可以在透過 y-websocket 讓 Doc 透過 websocket 取得來自其他客戶的更新資訊
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket"; // 📝 [新增] 引入 y-websocket,提供 WebSocket 協議,讓多客戶端即時同步 Y.Doc 變更
import ws from "ws"; // 📝 [新增] 引入 ws 庫,作為 Node.js 環境下的 WebSocket polyfill,確保伺服器端連線穩定
const doc = new Y.Doc();
const wsProvider = new WebsocketProvider( // 📝 [新增] 建立 WebSocket 提供者,連接伺服器並綁定 doc,允許多用戶在同一房間即時同步變更
"ws://localhost:1234", // 📝 [新增] 指定 WebSocket 伺服器地址,作為同步端點
"my-roomname", // 📝 [新增] 指定房間名稱,同名稱用戶共享相同 doc
doc, // 📝 [新增] 綁定共享文檔
{ WebSocketPolyfill: ws } // 📝 [新增] 配置 polyfill,解決 Node.js 瀏覽器 API 相容性問題
); // 📝 啟動提供者,開始監聽和廣播變更步驟 39: 再來是前端的部分 直接創建一個新的 react 專案
npx create-react-app frontend
cd frontend
npm install yjs y-websocket步驟 40: 在 App.js 裏頭
import React from "react";
function App() {
return (
<div className="App">
</div>
);
}
export default App;步驟 41: 一樣在前端,一模的創建一個 Doc 並且創建 websocket 連線 基本上此時,前端的 doc 已經會與後端自動同步了
// ... (前面的 import React 保持不變)
import React, { useEffect } from "react"; // 📝 [修改] 新增 useEffect,用來在組件生命週期中初始化同步邏輯
import * as Y from "yjs"; // 📝 [新增] 引入 Yjs,前端與後端共享相同 CRDT 資料結構
import { WebsocketProvider } from "y-websocket"; // 📝 [新增] 引入 y-websocket,提供前端 WebSocket 連線
function App() {
useEffect(() => { // 📝 [新增] useEffect 鉤子,組件掛載時執行同步初始化
const ydoc = new Y.Doc(); // 📝 [新增] 前端創建獨立 Y.Doc,但透過 provider 與後端同步,CRDT 確保最終一致
const provider = new WebsocketProvider( // 📝 [新增] 前端 WebSocket 提供者,連接到後端房間,自動處理雙向同步
"ws://localhost:1234", // 📝 [新增] 連接到後端伺服器
"my-roomname", // 📝 [新增] 加入相同房間,實現多人共享
ydoc // 📝 [新增] 綁定前端 doc
); // 📝 啟動連線,此時 doc 已可與其他客戶端自動同步變更
return () => { // 📝 [新增] 清理函式,組件卸載時斷開連線,避免資源洩漏和不必要同步
provider.disconnect(); // 📝 [新增] 明確斷開 WebSocket,釋放連線
}; // 📝 結束清理
}, []); // 📝 空依賴陣列,僅執行一次
// ... (return JSX 保持不變)
}步驟 42: 創建一個輸入框 並透過 ref 好方便我們操作他
// ... (前面的 imports 和 useEffect 保持不變)
const textareaRef = useRef(null); // 📝 [新增] 使用 useRef 獲取 textarea DOM 引用,便於直接操作值、位置和事件,避免 React 狀態同步延遲
return (
<div className="App">
<textarea // 📝 [新增] 新增 textarea 作為編輯區域
ref={textareaRef} // 📝 [新增] 綁定 ref,允許 JavaScript 直接存取 DOM
style={{ width: "100%", height: "90vh" }} // 📝 [新增] 設定全寬高,模擬全螢幕編輯器
></textarea> // 📝 完成輸入元件
</div>
);步驟 43: Doc 有點類似 Dictionary 可以放置很多不同的資料在裏頭
// ... (前 useEffect 保持不變)
useEffect(() => {
// ...
const provider = new WebsocketProvider(/* ... */);
const yText = ydoc.getText("textarea"); // 📝 [新增] 從 doc 獲取名為 "textarea" 的 Y.Text 共享文字物件,這是 CRDT 資料類型,支援無衝突的插入/刪除操作,並自動合併變更
// ... (return 清理保持不變)
});步驟 44: 我們將輸入框一旦輸入文字 則直接把這串字插入進 yText 中 yText 會處理好自動同步問題
// ... (前部分保持不變,將 handleInput 移到 useEffect 內)
function handleInput(event) { // 📝 [新增] 自訂輸入處理函式,攔截瀏覽器原生 input 事件,將本地操作轉換為 Yjs 操作,實現即時同步
if (event.inputType === "insertText") { // 📝 [新增] 處理文字插入事件
const data = event.data || ""; // 📝 [新增] 獲取插入文字
const position = textareaRef.current.selectionStart - 1; // 📝 [新增] 計算插入位置(減1修正光標偏移),確保精準定位
yText.insert(position, data); // 📝 [新增] 在 yText 指定位置插入,Yjs CRDT 自動處理衝突並廣播給其他客戶端
textareaRef.current.value = yText.toString(); // 📝 [新增] 更新本地 textarea 值,反映即時變更
} // 📝 結束 insertText 處理
} // 📝 結束 handleInput
textareaRef.current.addEventListener("input", handleInput); // 📝 [新增] 綁定輸入事件監聽器,捕捉所有輸入動作
return () => {
// ...
textareaRef.current.removeEventListener("input", handleInput); // 📝 [新增] 清理事件監聽器,避免記憶體洩漏
};步驟 45: 這邊 yjs 提供一個監聽的接口 當檢測到 yText 被遠端同步更新後 會自動更新輸入框
// ... (前 useEffect 保持不變)
const yText = ydoc.getText("textarea");
yText.observe(() => { // 📝 [新增] 訂閱 yText 變化事件,當遠端或其他本地變更同步時觸發,確保 UI 即時更新
const textarea = textareaRef.current; // 📝 [新增] 獲取 textarea 引用
const currentValue = textarea.value; // 📝 [新增] 記錄目前本地值
const newValue = yText.toString(); // 📝 [新增] 獲取 yText 最新同步值
if (currentValue !== newValue) { // 📝 [新增] 僅當值不同時更新,避免無限迴圈
textarea.value = newValue; // 📝 [新增] 更新 textarea,同步遠端變更到 UI
} // 📝 結束條件更新
}); // 📝 啟動觀察器,實現雙向同步步驟 46: 這邊加上刪除、換行功能
// ... (前 handleInput 保持不變)
function handleInput(event) {
if (event.inputType === "insertText") {
// ... (保持不變)
} else if (event.inputType === "insertLineBreak") { // 📝 [新增] 處理換行事件
const position = textareaRef.current.selectionStart; // 📝 [新增] 獲取當前光標位置
yText.insert(position, "\n"); // 📝 [新增] 在位置插入換行符,CRDT 確保跨客戶端一致
textareaRef.current.value = yText.toString(); // 📝 [新增] 更新本地值
} else if (event.inputType === "deleteContentBackward") { // 📝 [新增] 處理後退刪除(Backspace)
const position = textareaRef.current.selectionStart; // 📝 [新增] 獲取刪除位置
yText.delete(position, 1); // 📝 [新增] 刪除前一字符,Yjs 自動處理位置調整和同步
textareaRef.current.value = yText.toString(); // 📝 [新增] 更新本地值
} // 📝 結束額外事件處理
}步驟 47: 解決光標沒有自動更新
// ... (前 yText.observe 保持不變,僅修改內部)
yText.observe(() => {
const textarea = textareaRef.current;
const cursorPosition = textarea.selectionStart; // 📝 [新增] 在更新前記錄光標位置,避免同步時光標跳動,提供流暢使用者體驗
// ... (currentValue, newValue, if 檢查保持不變)
if (currentValue !== newValue) {
textarea.value = newValue;
textarea.setSelectionRange(cursorPosition, cursorPosition); // 📝 [新增] 更新值後恢復原光標位置,維持編輯連續性
}
});步驟 48: 編輯器套件分享

圖: 編輯器套件分享
步驟 49: 編輯器套件分享

圖: 編輯器套件分享 (續)
步驟 50: 編輯器套件分享

圖: 編輯器套件分享 (續)
步驟 51: Ziphus 分享

圖: Ziphus 相關分享
💡 總結: 透過 Yjs 的 CRDT 實現,我們避開了 OT 的複雜轉換,直接操作共享資料結構,即時無衝突共編。完整程式可在本地運行node server.js啟動後端,npm start前端,即可多開瀏覽器測試多人同步!
