Yjs + Quill:快速實現(xiàn)支持協(xié)同編輯的富文本編輯器
大家好,我是前端西瓜哥,這次來看看 Yjs 如何幫助我們實現(xiàn)協(xié)同編輯能力的。
Y.js 是一個支持 協(xié)同編輯 的開源庫。只要我們將自己的數(shù)據(jù)轉(zhuǎn)換為 Y.js 提供的 Y.Array
、Y.Map
類型,Y.js 就會自動幫我們做數(shù)據(jù)的一致性處理和同步。
一致性問題
協(xié)同編輯一個很棘手的問題是:多個用戶同時編輯產(chǎn)生的沖突要怎么處理,如何保證一致性?
比如兩個用戶同時往一個文本的末尾加上不同的字符,最終誰的字符在前,誰的字符在后?
目前業(yè)界有兩種方案,一個是 OT (Operational transformation)算法,是比較主流的一種解法。流行的開源解決方案是 ShareDB。
它的核心在于 Transform(轉(zhuǎn)換):服務(wù)端接收兩個客戶端的對同一版本數(shù)據(jù)的原子操作行為,轉(zhuǎn)換出它們各自要做的不同操作,然后傳遞給各個客戶端并應(yīng)用,最終讓它們的內(nèi)容是一致的。
另一種是 CRDT(Conflict-free Replicated Data Type),中文就是 “無沖突復(fù)制數(shù)據(jù)類型”,主要被應(yīng)用在分布式系統(tǒng)中,即可以不需要中心化服務(wù)器。流行的開源方案是 Yjs。
但 CRDT 需要傳輸更多的數(shù)據(jù),有不小的內(nèi)存和性能開銷,且相比 OT 被提出地更晚,學(xué)術(shù)研究相對較少,所以一開始算不上是主流。
然而隨著 Yjs 的出現(xiàn)并做了不少性能優(yōu)化,CRDT 方案也逐漸流行了起來,越來越多新的協(xié)同工具選擇使用 Yjs 來作為數(shù)據(jù)一致性的解決方案。
Yjs 是基于操作的 CRDT,其原理簡單來說,就是記錄所有用戶的操作,這些操作會拼接到一個雙向鏈表中,并通過通用的算法保證確定的順序,最后所有客戶端都能得到相同的一條鏈表,最后得到的數(shù)據(jù)自然也是一致的。
Yjs + Quill:打造協(xié)同工具
我們來寫個 demo 感受一下 Yjs 的強大之處。
先用 vite 搭個普通的不帶框架的腳手架,這里我用的 pnpm,其他包管理工具也行。
pnpm create vite
項目名為 yjs-quill-demo,選擇 Vanilla(不用框架的意思),然后選擇 JavaScript(如果你熟悉 TS,也可以選 TS)
接著是進入文件夾,安裝依賴,并運行。
cd yjs-quill-demo
pnpm install
pnpm run dev
打開瀏覽器輸入控制臺輸出的鏈接,可以看到:
下面我們來安裝依賴。
首先是開源編輯器 quill 和它的插件 quill-cursors。這個插件可以展示一些其他用戶的光標(biāo)的狀態(tài)。
pnpm add quill quill-cursors
將 mian.js 文件原來的內(nèi)容刪除,加上下面內(nèi)容:
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, // 用戶自己實現(xiàn)歷史記錄
},
},
placeholder: '前端西瓜哥...',
theme: 'snow',
});
效果:
下面我們就要引入 Yjs,給 quill 加上協(xié)同編輯功能。
Yjs 官方提供了 y-quill 庫,通過它可以將 quill 數(shù)據(jù)模型和 Yjs 數(shù)據(jù)模型進行綁定。
pnpm add yjs y-quill
追加 Yjs 相關(guān)邏輯:
import * as Y from 'yjs';
import { QuillBinding } from 'y-quill';
// ...
const ydoc = new Y.Doc(); // y 文檔對象,保存需要共享的數(shù)據(jù)
const ytext = ydoc.getText('quill'); // 創(chuàng)建名為 quill 的 Text 對象
const binding = new QuillBinding(ytext, quill); // 數(shù)據(jù)模型綁定
ok,接下來就是要接上服務(wù)端,實現(xiàn)數(shù)據(jù)傳輸了。服務(wù)的提供者,Yjs 稱為 provider,大概可以翻譯為 “供應(yīng)者” 的意思。
Yjs 官方提供了幾種 Provider:WebRTC、WebSocket、Dat。
這里我們用比較常見的 WebSocket。
pnpm add y-websocket
代碼:
import { WebsocketProvider } from 'y-websocket';
// ...
// 連接到 websocket 服務(wù)端
const provider = new WebsocketProvider('wss://demos.yjs.dev', 'quill-demo-room', ydoc);
// 數(shù)據(jù)模型綁定,再額外綁上了光標(biāo)對象
const binding = new QuillBinding(ytext, quill, provider.awareness);
這里的服務(wù)器用的是 Yjs 提供的 demo 體驗用的服務(wù)器,因為一些喜聞樂見的原因,可能會連不上這個服務(wù)器。
然后你會發(fā)現(xiàn),如果在同一瀏覽器打開兩個 tab,沒連上服務(wù)也能做協(xié)同編輯。這是因為 Yjs 會優(yōu)先通過瀏覽器的同 host 共享狀態(tài)的方式進行通信,然后才是網(wǎng)絡(luò)通信。所以最好是打開兩個不同的瀏覽器做調(diào)試。
我們驗證一下。
左邊兩個 tab 頁來自同一個瀏覽器,右邊則是另一個瀏覽器。
當(dāng)修改被我限速為 1 KB/s 的 tab 的編輯器內(nèi)容時,來自同一瀏覽器的另一個 tab 頁立刻發(fā)生了變更(證明通信走的是本地),而另一個瀏覽器的 tab 則慢得多(說明走的網(wǎng)絡(luò)通訊)。
我們也可以自己在本地起一個服務(wù)器,做法是:
HOST=localhost PORT=1234 npx y-websocket
對應(yīng)著要改一下客戶端代碼中 ws 服務(wù)的地址:
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, // 用戶自己實現(xiàn)歷史記錄
},
},
placeholder: '前端西瓜哥...',
theme: 'snow',
});
const ydoc = new Y.Doc(); // y 文檔對象,保存需要共享的數(shù)據(jù)
const ytext = ydoc.getText('quill'); // 創(chuàng)建名為 quill 的 Text 對象
// 連接到 websocket 服務(wù)端
const provider = new WebsocketProvider('wss://demos.yjs.dev', 'quill-demo-room', ydoc);
// 數(shù)據(jù)模型綁定,再綁上光標(biāo)對象
const binding = new QuillBinding(ytext, quill, provider.awareness);
結(jié)尾
因為用了很多 Yjs 提供的模塊化的包,其實我們并沒有接觸到太多的實現(xiàn)細(xì)節(jié),尤其是將數(shù)據(jù)綁定到 Yjs 提供的類型數(shù)據(jù)的實現(xiàn)。只能說是簡單體驗了 Yjs 配合 quill 實現(xiàn)協(xié)同編輯的效果。