はじめに
Google DocsやFigma、Notionを使っていて「この共同編集機能、自分のアプリにも欲しいな」と思ったことはないですか。
正直なところ、リアルタイム共同編集って実装のハードルが高いイメージがありました。コンフリクト処理とか、オフライン対応とか、考えることが多すぎて。でもYjsを使ってみたら、想像以上にシンプルに実装できて驚きました。
Yjsは現在GitHubで20,700スター以上を獲得していて、113人のコントリビューターが開発に参加。最終コミットも数日前という活発なプロジェクトです。バンドルサイズは26.6kB(minified + gzipped)で、この手のライブラリとしてはかなり軽量な部類。
30代になって思うのは、「難しそう」で避けてきた技術も、実際に触ってみると案外いけるということ。Yjsはまさにそのパターンでした。
とはいえ、説明を読むよりも実際に動かしてみる方が早いと思います。ブラウザ内で2つのY.Docインスタンスを同期させ、疑似的なリアルタイム共同編集を再現したサンプルを用意したので、先に触ってみたい方はこちらからどうぞ。
Yjsとは
YjsはCRDT(Conflict-free Replicated Data Type)を実装したJavaScriptライブラリです。
CRDTって何かというと、簡単に言えば「複数の人が同時に編集しても、自動的にいい感じにマージしてくれるデータ構造」のこと。分散システムで一貫性を保つための仕組みで、学術的な背景がしっかりしています。
Yjsは「業界最速のCRDT実装」を謳っていて、実際にベンチマークでも優秀な成績を残しています。MITライセンスなので商用利用も問題なし。
特徴・メリット
1. ネットワーク非依存
これ、意外と重要なポイントなんですよ。Yjsは特定のネットワーク技術に依存していません。
WebSocket、WebRTC、独自プロトコル、何でも使える。つまり、P2Pでもサーバー経由でも、好きな構成で構築できる。インフラの自由度が高いのはありがたい。
2. オフライン編集対応
個人的には、この機能が一番刺さりました。
ネットワークが切れても編集を続けられて、再接続時に自動でマージ。出先でWi-Fiが不安定な時とか、地下鉄で作業してる時とか、オフライン対応があると安心感が違う。
3. 豊富なエディタ統合
ProseMirror、Quill、CodeMirror、Monaco、Slate、Tiptapなど、主要なリッチテキストエディタとの統合がすでに用意されています。
「車輪の再発明」をしなくていいのは時短になる。既存のエディタライブラリと組み合わせて、サクッと共同編集機能を追加できます。
4. 共有カーソル対応
複数人で編集してると「今誰がどこを編集してるか」が見えると便利ですよね。Yjsはカーソル位置の共有もサポートしていて、各エディタバインディングでリアルタイムにカーソルを表示できます。
5. Undo/Redo機能
Y.UndoManagerを使えば、Undo/Redo機能も簡単に実装できます。共同編集でも「自分の変更だけを戻す」みたいな制御が可能。
インストール方法
基本パッケージ
npm install yjs
プロバイダーの追加
通信方法に応じてプロバイダーを追加します。WebSocketを使う場合:
npm install y-websocket
WebRTCでP2P通信したい場合:
npm install y-webrtc
ブラウザのIndexedDBに永続化したい場合:
npm install y-indexeddb
エディタバインディング
使いたいエディタに応じて追加。例えばQuillの場合:
npm install y-quill quill
ProseMirrorやTiptapの場合:
npm install y-prosemirror
Yjsのサンプルを動かす
ここでは、サーバーもWebSocketも使わずに、Yjsが「CRDTとしてどう同期するか」をブラウザだけで体感できるサンプルを用意しました。ページの中に2つのY.Docインスタンス(docAとdocB)を作り、片方のupdateイベントで生成された差分を、もう片方にY.applyUpdateで適用する、という構成です。実際のアプリではy-websocketのようなプロバイダーがこのやり取りをネットワーク越しに行いますが、仕組み自体はこれとまったく同じです。
サンプルの中心部分だけを抜き出すとこうなります。Y.Textを使ったテキストの共有と、Y.applyUpdateによるドキュメント間同期の流れがわかります。
import * as Y from 'yjs'
// 2つの独立したYドキュメント(別クライアントを模擬)
const docA = new Y.Doc()
const docB = new Y.Doc()
// updateイベントで生成された差分をお互いに適用するだけで同期する
docA.on('update', update => Y.applyUpdate(docB, update))
docB.on('update', update => Y.applyUpdate(docA, update))
// 同じ名前で取得したY.Textは自動的に同期対象になる
const textA = docA.getText('shared')
const textB = docB.getText('shared')
textA.insert(0, 'Hello')
console.log(textB.toString()) // 'Hello' ← docBにも反映される
実際に動かせるサンプルが以下です。左右どちらのテキストエリアに入力しても、Y.TextのCRDT演算を通じてもう片方にリアルタイムで反映されます。試しに両方の欄に同時に文字を入力してみてください。どちらかの入力で上書きされることなく、両方の変更がマージされるのが確認できるはずです。
このサンプルではdocA.on('update', ...)とdocB.on('update', ...)の2行が同期の要です。どちらか一方をコメントアウトすると、片方向にしか変更が伝わらなくなるのがわかるはずです。また、docA.getText('shared')とdocB.getText('shared')の'shared'という名前を片方だけ別の文字列に変えてみると、参照するY.Textが別物として扱われ、同期が起きなくなります。この「同じ名前で取得すれば同期される」という挙動が、Yjsの共有データ型の基本ルールです。
基本的な使い方
ドキュメントの作成
まずは基本中の基本、Yドキュメントを作成してデータを操作するところから。
import * as Y from 'yjs'
// Yドキュメントを作成
const ydoc = new Y.Doc()
// 共有Map型を取得
const ymap = ydoc.getMap('shared-data')
ymap.set('name', '田中')
ymap.set('age', 35)
console.log(ymap.get('name')) // '田中'
getMap、getArray、getTextなどで共有データ型を取得できます。同じ名前で取得すれば、異なるクライアント間で同期されるデータ構造になります。
変更の監視
データの変更を監視したい場合はobserveを使います。
const yarray = ydoc.getArray('my-list')
yarray.observe(event => {
console.log('配列が変更されました')
event.changes.delta.forEach(change => {
if (change.insert) {
console.log('追加:', change.insert)
}
if (change.delete) {
console.log('削除:', change.delete, '件')
}
})
})
// これで上のobserveが発火する
yarray.insert(0, ['りんご', 'バナナ'])
WebSocketで同期
実際にリアルタイム同期を実現するには、プロバイダーを接続します。
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const ydoc = new Y.Doc()
// WebSocketサーバーに接続
const provider = new WebsocketProvider(
'wss://your-server.com',
'room-name',
ydoc
)
// 接続状態の監視
provider.on('status', event => {
console.log('接続状態:', event.status) // 'connected' or 'disconnected'
})
// あとは通常通りドキュメントを操作するだけ
const ytext = ydoc.getText('editor')
ytext.insert(0, 'Hello, World!')
これだけで、同じroom-nameに接続している全クライアント間でデータが同期されます。
Quillエディタとの統合
リッチテキストエディタと組み合わせる例です。
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { QuillBinding } from 'y-quill'
import Quill from 'quill'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider('wss://your-server.com', 'doc-room', ydoc)
const ytext = ydoc.getText('quill-content')
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }]
]
}
})
// バインディングを作成するだけで同期が有効に
const binding = new QuillBinding(ytext, quill, provider.awareness)
provider.awarenessを渡すことで、他のユーザーのカーソル位置も表示されるようになります。
Undo/Redo機能の追加
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const ytext = ydoc.getText('editor')
// UndoManagerを作成
const undoManager = new Y.UndoManager(ytext)
// 操作
ytext.insert(0, 'Hello')
ytext.insert(5, ' World')
// Undo
undoManager.undo() // 'Hello' に戻る
// Redo
undoManager.redo() // 'Hello World' に戻る
実践的なユースケース
1. ドキュメント共同編集アプリ
Google Docsライクなアプリを作るなら、YjsとTiptapの組み合わせが王道。Tiptapはy-prosemirrorバインディングを使えばすぐに共同編集対応できます。
2. リアルタイムホワイトボード
FigmaやMiroのようなツール。座標データをY.Mapで管理して、図形の追加・移動・削除を同期させる構成。
const shapes = ydoc.getMap('shapes')
// 図形を追加
shapes.set('shape-1', {
type: 'rectangle',
x: 100,
y: 200,
width: 50,
height: 50,
color: '#ff0000'
})
Yjsサンプル: Y.Mapで図形データをリアルタイム共有する
Y.Mapは、こうした「座標や色といった構造化データをそのまま共有する」用途に向いています。次のサンプルでは、Y.Mapに持たせた図形のx座標とcolorを2つのY.Doc間で同期し、片方のスライダーやカラーピッカーを操作すると、もう片方の四角形にも即座に反映される様子を確認できます。
要点だけ抜き出すとこうなります。shape.set()でMapの値を更新し、shape.observe()で変更を検知して再描画するという流れです。
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const shape = ydoc.getMap('shape')
shape.set('x', 40)
shape.set('color', '#4f46e5')
// Y.Mapの変更を検知して再描画する
shape.observe(() => {
render(shape.get('x'), shape.get('color'))
})
shape.set('x', 120) // 上のobserveが再度発火する
実際に触れるサンプルはこちらです。左右どちらのパネルでスライダーやカラーピッカーを動かしても、Y.Mapを介してもう片方の四角形が同じ位置・同じ色に追従します。
Y.Textが文字列の一部だけを差分同期するのに対し、Y.Mapはset()したキー単位で同期される点がポイントです。試しにshapeA.set('color', ...)の行をコメントアウトすると、位置は同期されても色だけは片方のパネルに反映されなくなり、Y.Mapがキーごとに独立して値を持つことが確認できます。
3. コードエディタ
CodeMirrorやMonacoとの統合で、VSCode Live Shareのような機能を実装可能。y-codemirrorやy-monacoを使えば、シンタックスハイライト付きの共同コーディング環境が作れます。
4. タスク管理アプリ
Y.Arrayでタスクリストを管理、Y.Mapでタスクの詳細を保持。チームで同時にタスクを追加・編集しても競合しない。
Yjsサンプル: Y.ArrayとY.UndoManagerで自分のタスクだけ取り消す
タスク管理アプリで地味に困るのが「Undoを押したら、自分ではなくチームメイトが追加したタスクまで消えてしまった」というケースです。YjsのY.UndoManagerはtrackedOriginsというオプションを持っていて、特定のオリジン(変更の発生源)でラップした操作だけをUndo対象にできます。次のサンプルではY.Arrayでタスクリストを共有しつつ、各ユーザーのUndoボタンが自分の追加したタスクだけを取り消す様子を確認できます。
要点はこの部分です。ydoc.transact()の第2引数にオリジン(ここでは'local')を渡し、Y.UndoManagerのtrackedOriginsに同じ値を指定することで、そのオリジン由来の変更だけが追跡対象になります。
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const todos = ydoc.getArray('todos')
// 'local'というオリジンでラップした変更だけを追跡するUndoManager
const undoManager = new Y.UndoManager(todos, {
trackedOrigins: new Set(['local'])
})
ydoc.transact(() => {
todos.push(['牛乳を買う'])
}, 'local')
undoManager.undo() // 'local'由来の変更だけを取り消す
実際に動かせるサンプルはこちらです。ユーザーA・ユーザーBそれぞれがタスクを追加でき、リストはY.Arrayを通じて両方のパネルに反映されます。それぞれの「Undo」ボタンを押すと、自分が追加したタスクだけが消え、相手が追加したタスクは残ることを確認してみてください。
ポイントはY.applyUpdate(docB, update, 'remote')で、相手から届いた変更に明示的に'remote'というオリジンを付けている部分です。これにより、undoB(trackedOrigins: ['local'])は自分がpushしたタスクしか追跡しません。もしY.applyUpdateの第3引数を省略したり'local'に揃えてしまうと、相手のタスクまでUndoで消えてしまうので、trackedOriginsとオリジン指定がセットで効いていることが体感できます。
5. プレゼンテーションツール
スライドデータをYjsで管理して、発表者と視聴者でリアルタイム同期。「現在のページ」もawarenessで共有すれば、参加者全員が同じページを見られる。
プロバイダーの選択
用途に応じてプロバイダーを使い分けると良いです。
| プロバイダー | 用途 | 特徴 |
|---|---|---|
| y-websocket | 一般的なWebアプリ | サーバー経由、安定性重視 |
| y-webrtc | P2Pアプリ | サーバー不要、低レイテンシ |
| y-indexeddb | オフライン対応 | ブラウザに永続化 |
| Hocuspocus | 本格運用 | 認証・永続化・スケーリング対応 |
| Liveblocks | マネージドサービス | インフラ管理不要 |
| PartyKit | エッジコンピューティング | グローバル分散 |
個人的には、プロトタイプにはy-websocket + y-indexeddb、本番環境ではHocuspocusかLiveblocksをおすすめします。
まとめ
Yjsを使えば、リアルタイム共同編集機能を思ったより簡単に実装できます。
正直なところ、CRDTという概念を聞いた時は「難しそう」と身構えていたんですよ。でも実際にYjsを触ってみると、抽象化がしっかりしていて、内部の複雑さを意識せずに使える。これ、ライブラリとしてはかなり重要なことだと思います。
コスパ的に見ても、26.6kBのバンドルサイズでこれだけの機能が使えるのは優秀。主要なエディタとの統合も揃っているので、「とりあえず共同編集を試したい」という段階から、本格的なプロダクション環境まで対応できます。
Google DocsやFigmaのような体験を自分のアプリに組み込みたいなら、Yjs一択ですね。
興味がある方は、まずは公式ドキュメント(https://docs.yjs.dev)を眺めてみてください。Getting Startedがよくできていて、動くものがすぐ作れます。
