Yjsを理解する:リアルタイム共同編集をシンプルに実現する
Yjs、CRDT(競合のない複製データ型)、YATAアルゴリズム、および共同編集アプリケーションの構築方法について分かりやすく解説します。
Googleドキュメント、Figma、Notionのようなリアルタイム共同編集アプリケーションの構築は、ウェブ開発において最も難易度の高いエンジニアリング課題の一つでした。ネットワーク遅延や切断への対応、同じ位置を複数人が同時に編集した際の競合の解決には、非常に複雑なロジックが必要でした。
現在では、Yjs のようなライブラリのおかげで、この開発は飛躍的に簡単になりました。Yjsとは何か、その内部メカニズム、そしてなぜこれが現代の共同編集アプリのデファクトスタンダードとなっているのかを解説します。
Yjsとは? (ELI5)#
あなたと友人が、1冊のノートに同時に物語を書いていると想像してください。
従来の共同編集システム(Googleドキュメントなどで使われる OT:操作変換)では、二人の間に立って調整する 「先生(中央サーバー)」 が必要です。あなたが文字を書くとき、まず先生に見せなければなりません。先生は「どちらが先に書いたか」を判断し、書く位置を調整して、最終的な結果を二人に伝えます。もし先生が席を外すと(サーバーがダウンすると)、文字を書くことはできなくなります。
Yjs が採用している CRDT(競合のない複製データ型) は、先生がいなくても機能します:
- あなたが書くすべての文字には、「目に見えないユニークなステッカー(ID)」 が貼られ、「隣の文字の情報」(例:「この文字は文字Xの直後に置く」)が記録されます。
- あなたと友人は、それぞれ別の部屋にいても(オフライン状態でも)、自由に書き進めることができます。
- 後でノートを持ち寄ったとき、ステッカーと隣の文字の情報を照らし合わせるだけで、ページをマージできます。マージのルールは数学的かつ決定論的であるため、衝突(コンフリクト)を起こすことなく、二人のノートは自動的に完全に同じ内容に同期されます!
コアアーキテクチャとワークフロー#
Yjsはネットワークに依存しないデータモデルとして機能します。ローカルドキュメントの状態(Y.Doc)を管理し、変更内容をバイナリデータに変換して、WebSocketやWebRTCなどの任意のプロトコルを通じて送信します。
sequenceDiagram
participant A as Client A
participant M as ネットワーク WebSocket
participant B as Client B
Note over A: ydoc.getMap().set('x', 1)
A->>M: encodeStateAsUpdate (バイナリ)
Note over B: ydoc.getMap().set('y', 2)
B->>M: encodeStateAsUpdate (バイナリ)
M->>B: Client A の更新を配信
Note over B: Y.applyUpdate() - 状態を自動マージ
M->>A: Client B の更新を配信
Note over A: Y.applyUpdate() - 状態を自動マージ
1. 共有データ型(Shared Types)#
Yjsは、通常のJavaScriptオブジェクトのように扱え、自動的に同期される共有データ型を提供します:
Y.Text:テキストエディタの共同編集用。Y.Array:共有リスト用。Y.Map:共有のキー・値ストア用。Y.XmlFragment:リッチテキスト構造用。
2. 接続プロバイダー(Connection Providers)#
Yjsはネットワークプロトコルを問いません。プロバイダーを接続することで、任意の通信を行えます:
y-websocket:標準的なサーバー・クライアント間の同期。y-webrtc:バックエンドを介さない、ブラウザ同士のP2P同期。y-p2p/y-matrix:分散型の代替プロトコル。
3. データベースプロバイダー(Database Providers)#
タブを閉じたときにデータが失われないよう、履歴やドキュメントを保存します:
y-indexeddb:オフライン編集をサポートするためのブラウザ内保存。y-leveldb/y-redis:サーバー側の永続化データベース用アダプター。
Yjsの仕組み(YATAアルゴリズム)#
Yjsは YATA(Yet Another Transformation Approach) アルゴリズムをベースにしています。以下の方法で、最終的な一貫性(コンバージェンス)と編集意図の保持を実現しています:
- 不変のユニークID: 挿入されるすべての要素(文字など)は、固有のクライアントIDとローカル処理カウンターからなる一意のIDを持ちます。
- 起点アンカー(Origin Anchors): すべての文字は、挿入された瞬間の左隣の文字(
origin)と右隣の文字(originRight)を記憶します。周囲のテキストが変わっても、文字は元の文脈に正しくアンカーされます。 - ランレングス圧縮(RLE): 要素ごとにメタデータを持つことによるメモリの肥大化を防ぐため、Yjsは同じクライアントからの連続する挿入操作を単一の「構造体」にグループ化します。これにより、極めて高速かつメモリ効率の高いCRDTを実現しています。
クイックコードサンプル#
Yjsを使ってマップや配列を同期する簡単な例です:
import * as Y from 'yjs';
// 1. ローカルとリモートのドキュメントを作成
const ydocLocal = new Y.Doc();
const ydocRemote = new Y.Doc();
// 2. 共有データ型を定義
const ymapLocal = ydocLocal.getMap('shared-meta');
const ymapRemote = ydocRemote.getMap('shared-meta');
// 3. 同時に変更を加える
ymapLocal.set('theme', 'dark');
ymapRemote.set('fontSize', '16px');
// 4. ネットワーク同期をシミュレート
const updateFromLocal = Y.encodeStateAsUpdate(ydocLocal);
const updateFromRemote = Y.encodeStateAsUpdate(ydocRemote);
Y.applyUpdate(ydocRemote, updateFromLocal);
Y.applyUpdate(ydocLocal, updateFromRemote);
// 両方のドキュメントが同じマージされたデータを保持
console.log(ymapLocal.toJSON());
// 出力: { theme: 'dark', fontSize: '16px' }javascript