天天看點

Yjs + quill:快速實作支援協同編輯的富文本編輯器

作者:前端西瓜哥

大家好,我是前端西瓜哥,這次來看看 Yjs 如何幫助我們實作協同編輯能力的。

Y.js 是一個支援 協同編輯 的開源庫。隻要我們将自己的資料轉換為 Y.js 提供的 Y.Array、Y.Map 類型,Y.js 就會自動幫我們做資料的一緻性處理和同步。

一緻性問題

協同編輯一個很棘手的問題是:多個使用者同時編輯産生的沖突要怎麼處理,如何保證一緻性?

比如兩個使用者同時往一個文本的末尾加上不同的字元,最終誰的字元在前,誰的字元在後?

Yjs + quill:快速實作支援協同編輯的富文本編輯器

目前業界有兩種方案,一個是 OT (Operational transformation)算法,是比較主流的一種解法。流行的開源解決方案是 ShareDB。

它的核心在于 Transform(轉換):服務端接收兩個用戶端的對同一版本資料的原子操作行為,轉換出它們各自要做的不同操作,然後傳遞給各個用戶端并應用,最終讓它們的内容是一緻的。

Yjs + quill:快速實作支援協同編輯的富文本編輯器

我之前寫過一篇介紹 OT 算法的文章,講的會更詳細一些,可以去看看:

《協同編輯中使用的 OT 算法是什麼?》

另一種是 CRDT(Conflict-free Replicated Data Type),中文就是 “無沖突複制資料類型”,主要被應用在分布式系統中,即可以不需要中心化伺服器。流行的開源方案是 Yjs。

但 CRDT 需要傳輸更多的資料,有不小的記憶體和性能開銷,且相比 OT 被提出地更晚,學術研究相對較少,是以一開始算不上是主流。

然而随着 Yjs 的出現并做了不少性能優化,CRDT 方案也逐漸流行了起來,越來越多新的協同工具選擇使用 Yjs 來作為資料一緻性的解決方案。

Yjs 是基于操作的 CRDT,其原理簡單來說,就是記錄所有使用者的操作,這些操作會拼接到一個雙向連結清單中,并通過通用的算法保證确定的順序,最後所有用戶端都能得到相同的一條連結清單,最後得到的資料自然也是一緻的。

Yjs + Quill:打造協同工具

我們來寫個 demo 感受一下 Yjs 的強大之處。

先用 vite 搭個普通的不帶架構的腳手架,這裡我用的 pnpm,其他包管理工具也行。

pnpm create vite
           

項目名為 yjs-quill-demo,選擇 Vanilla(不用架構的意思),然後選擇 JavaScript(如果你熟悉 TS,也可以選 TS)

接着是進入檔案夾,安裝依賴,并運作。

cd yjs-quill-demo
pnpm install
pnpm run dev
           

打開浏覽器輸入控制台輸出的連結,可以看到:

Yjs + quill:快速實作支援協同編輯的富文本編輯器

下面我們來安裝依賴。

首先是開源編輯器 quill 和它的插件 quill-cursors。這個插件可以展示一些其他使用者的光标的狀态

pnpm add quill quill-cursors
           

将 mian.js 檔案原來的内容删除,加上下面内容:

import Quill from 'quill';
import QuillCursors from 'quill-cursors';
import 'quill/dist/quill.snow.css'; // 使用了 snow 主題色
// 使用 cursors 插件
Quill.register('modules/cursors', QuillCursors);
const quill = new Quill(document.querySelector('#app'), {
  modules: {
    cursors: true,
    toolbar: [
      [{ header: [1, 2, false] }],
      ['bold', 'italic', 'underline'],
      ['image', 'code-block'],
    ],
    history: {
      userOnly: true, // 使用者自己實作曆史記錄
    },
  },
  placeholder: '前端西瓜哥...',
  theme: 'snow',
});
           

效果:

Yjs + quill:快速實作支援協同編輯的富文本編輯器

下面我們就要引入 Yjs,給 quill 加上協同編輯功能。

Yjs 官方提供了 y-quill 庫,通過它可以将 quill 資料模型和 Yjs 資料模型進行綁定。

pnpm add yjs y-quill
           

追加 Yjs 相關邏輯:

import * as Y from 'yjs';
import { QuillBinding } from 'y-quill';
// ...
const ydoc = new Y.Doc(); // y 文檔對象,儲存需要共享的資料
const ytext = ydoc.getText('quill'); // 建立名為 quill 的 Text 對象
const binding = new QuillBinding(ytext, quill); // 資料模型綁定
           

ok,接下來就是要接上服務端,實作資料傳輸了。服務的提供者,Yjs 稱為 provider,大概可以翻譯為 “供應者” 的意思。

Yjs 官方提供了幾種 Provider:WebRTC、WebSocket、Dat。

這裡我們用比較常見的 WebSocket。

pnpm add y-websocket
           

代碼:

import { WebsocketProvider } from 'y-websocket';
// ...
// 連接配接到 websocket 服務端
const provider = new WebsocketProvider('wss://demos.yjs.dev', 'quill-demo-room', ydoc);
// 資料模型綁定,再額外綁上了光标對象
const binding = new QuillBinding(ytext, quill, provider.awareness); 
           

這裡的伺服器用的是 Yjs 提供的 demo 體驗用的伺服器,因為一些喜聞樂見的原因,可能會連不上這個伺服器。

然後你會發現,如果在同一浏覽器打開兩個 tab,沒連上服務也能做協同編輯。這是因為 Yjs 會優先通過浏覽器的同 host 共享狀态的方式進行通信,然後才是網絡通信。是以最好是打開兩個不同的浏覽器做調試。

我們驗證一下。

Yjs + quill:快速實作支援協同編輯的富文本編輯器

左邊兩個 tab 頁來自同一個浏覽器,右邊則是另一個浏覽器。

當修改被我限速為 1 KB/s 的 tab 的編輯器内容時,來自同一浏覽器的另一個 tab 頁立刻發生了變更(證明通信走的是本地),而另一個浏覽器的 tab 則慢得多(說明走的網絡通訊)。

我們也可以自己在本地起一個伺服器,做法是:

HOST=localhost PORT=1234 npx y-websocket
           

對應着要改一下用戶端代碼中 ws 服務的位址:

const provider = new WebsocketProvider('ws://localhost:1234', 'quill-demo-room', ydoc);
           

完整代碼

import Quill from 'quill';
import QuillCursors from 'quill-cursors';
import 'quill/dist/quill.snow.css'; // 使用了 snow 主題色
import * as Y from 'yjs';
import { QuillBinding } from 'y-quill';
import { WebsocketProvider } from 'y-websocket';
// 使用 cursors 插件
Quill.register('modules/cursors', QuillCursors);
const quill = new Quill(document.querySelector('#app'), {
  modules: {
    cursors: true,
    toolbar: [
      [{ header: [1, 2, false] }],
      ['bold', 'italic', 'underline'],
      ['image', 'code-block'],
    ],
    history: {
      userOnly: true, // 使用者自己實作曆史記錄
    },
  },
  placeholder: '前端西瓜哥...',
  theme: 'snow',
});
const ydoc = new Y.Doc(); // y 文檔對象,儲存需要共享的資料
const ytext = ydoc.getText('quill'); // 建立名為 quill 的 Text 對象
// 連接配接到 websocket 服務端
const provider = new WebsocketProvider('wss://demos.yjs.dev', 'quill-demo-room', ydoc); 
// 資料模型綁定,再綁上光标對象
const binding = new QuillBinding(ytext, quill, provider.awareness); 
           

結尾

因為用了很多 Yjs 提供的子產品化的包,其實我們并沒有接觸到太多的實作細節,尤其是将資料綁定到 Yjs 提供的類型資料的實作。隻能說是簡單體驗了 Yjs 配合 quill 實作協同編輯的效果。

我是前端西瓜哥,歡迎關注我,學習更多前端知識。

繼續閱讀