COSCUP - 一起來開發一個 notion 吧,多人即時共編筆記分享

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

一些能夠多人協作的平台 ex. Google 文件、HackMd、Notion

圖: 一些能夠多人協作的平台,如 Google 文件、HackMd、Notion


步驟 4: 版本控制

版本控制

圖: 版本控制概念


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

如果今天只有一個 branch 如果有任何對文件的修改,都可以創建一個 comment

圖: 單 branch 下的修改以 comment 形式處理


步驟 6: 每個 commit 都相當於是一個能夠隨時回退的版本

每個 commit 都相當於是一個能夠隨時回退的版本

圖: Commit 作為可回退版本


步驟 7: 透過 commit 你也能看到一個專案的發展史

透過 commit 你也能看到一個專案的發展史

圖: Commit 記錄專案發展史


步驟 8: 或者你同事的偷懶史

或者你同事的偷懶史

圖: Commit 暴露同事的「偷懶史」


步驟 9: 多人開發

多人開發

圖: 多人開發情境


步驟 10: 將大家辛苦工作的結果合併回去

將大家辛苦工作的結果合併回去

圖: 合併多人工作結果


步驟 11: 那當然,很常就會發生衝突 conflict

那當然,很常就會發生衝突 conflict

圖: 合併時常見的衝突問題


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

偶爾也有那種感覺休假半年才來上班的同事 將半年前的 branch 進行合併的魔幻操作

圖: 陳年 branch 合併的魔幻衝突


步驟 13: OT 與 CRDT

OT 與 CRDT

圖: OT 與 CRDT 介紹


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

來用一個簡單的例子講解 OT 的概念 假設有兩個人同時在編輯一串文字

圖: OT 概念簡單例子 - 兩人同時編輯文字


步驟 15: OT 的做法就是同步操作 也就是將用戶的操作廣播出去

OT 的做法就是同步操作 也就是將用戶的操作廣播出去

圖: OT 透過廣播操作實現同步


步驟 16: 當然,因為網路延遲 這個傳播會有時間差

當然,因為網路延遲 這個傳播會有時間差

圖: 網路延遲導致的時間差


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

當其他人收到這個廣播時 便會自動去套用這個操作 好讓兩個人的畫面一致

圖: 接收廣播後套用操作,確保一致


步驟 18: 但換個會衝突的例子

但換個會衝突的例子

圖: 衝突例子


步驟 19: 但兩個人的操作非常接近

但兩個人的操作非常接近

圖: 操作位置接近的衝突


步驟 20: 這時因為網路延遲的關西

這時因為網路延遲的關西

圖: 延遲導致資料不一致


步驟 21: 會導致兩邊的數據不一致

會導致兩邊的數據不一致

圖: 最終資料不一致問題


步驟 22: 所以我們會在廣播時附上時間戳

所以我們會在廣播時附上時間戳

圖: 使用時間戳解決順序問題


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

這邊就要來講到 OT 當中的 T Transformation 透過時間戳的關西,轉換操作 使兩者最後結果一致

圖: OT 的 Transformation (T) 透過時間戳轉換操作


步驟 24: 再來講講 CRDT 與 OT 不同,CRTD 主要是關注於資料上

再來講講 CRDT 與 OT 不同,CRTD 主要是關注於資料上

圖: CRDT 聚焦資料而非操作


步驟 25: 他會

他會

圖: CRDT 特性說明 (續)


步驟 26: CRDT

CRDT

圖: CRDT 特性


步驟 27: CRDT

CRDT

圖: CRDT 特性 (續)


步驟 28: CRDT

CRDT

圖: CRDT 特性 (續)


步驟 29: CRDT

CRDT

圖: CRDT 特性 (續)


步驟 30: CRDT

CRDT

圖: CRDT 特性 (續)


步驟 31: CRDT 選擇理由

CRDT 選擇理由

圖: 選擇 CRDT 的理由


步驟 32:

圖: CRDT 相關說明


步驟 33: Demo

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 分享

圖: Ziphus 相關分享


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