תארו לעצמכם 50 אנליסטים שעורכים בו-זמנית דוח רבעוני ב-CRM, עם שינויים שנעלמים או מתנגשים. הכאב הזה נעלם כאשר מאמצים CRDT — אלגוריתם מתמטי המבטיח מיזוג דטרמיניסטי של שינויים. הבעיה היא ש-Google Docs לא משתלב במערכת שלכם, ובניית עורך שיתופי מאפס נתקלת במורכבות סנכרון. אנחנו פותרים זאת עם ספריות CRDT — Yjs או Liveblocks. השלמנו למעלה מ-50 פרויקטים עם Yjs במשך יותר מ-10 שנים, והשגנו סנכרון שינויים יעיל ושכפול ללא התנגשויות.
לאחרונה, סטארטאפ פינטק פנה אלינו עם צורך דומה: שילוב עורך שיתופי ב-CRM שלהם. בחרנו ב-Yjs — CRDT בקוד פתוח שמסנכרן שינויים בפחות מ-100 אלפיות שנייה. זה מאפשר סנכרון שינויים יעיל ושכפול ללא התנגשויות. התוצאה: אין תלות בספק, שליטה מלאה בנתונים, וחיסכון של עד 70% בעלויות בהשוואה לפתרונות מנוהלים. לדוגמה, שימוש ב-Yjs יכול להפחית את העלויות החודשיות שלכם בעד 95% בהשוואה ל-Liveblocks עבור 1000 משתמשים פעילים. Yjs זול עד פי 20 מ-Liveblocks עבור יישומים עם תעבורה גבוהה. למדו עוד על CRDT ב-ויקיפדיה.
בחירת הגישה: Yjs מול Liveblocks
| קריטריון | Yjs + Hocuspocus | Liveblocks |
|---|---|---|
| תשתית | השרת שלכם (WebSockets) | מנוהל, ללא תשתית |
| עלות עבור 1000 משתמשים פעילים | ~50$ לחודש (אירוח) | 1,000$ לחודש |
| גמישות | שליטה מלאה | מוגבל על ידי הספק |
| מורכבות | בינונית | נמוכה |
| שמירה | כל מסד נתונים (PostgreSQL, MongoDB) | Liveblocks Storage |
Yjs מנצח בגמישות וללא תלות בספק. אם אתם צריכים שליטה מלאה ויכולים לתמוך בשרת, זו הבחירה הטובה ביותר. Liveblocks מתאים להתחלה מהירה ללא דאגות DevOps.
למה לבחור ב-Yjs לעריכה שיתופית?
Yjs משתמש ב-CRDT — אלגוריתם המבטיח מיזוג דטרמיניסטי של שינויים ללא מתאם מרכזי. גם אם שני משתמשים עורכים במצב לא מקוון, התנגשויות נפתרות אוטומטית. מתחת למכסה המנוע יש ספריות לטקסט, מערכים ומפות.
לדוגמה, אחד הלקוחות שלנו (סטארטאפ פינטק) נזקק לעד 50 אנליסטים שעובדים בו-זמנית על דוח רבעוני. לאחר יישום Yjs עם שרת Hocuspocus, השגנו סנכרון יציב עם זמן השהיה של פחות מ-100 אלפיות שנייה. בדיקות אישרו תמיכה בעד 500 משתמשים בו-זמנית ועיבוד של 2000 שינויים בשנייה.
איך נפתרות התנגשויות?
CRDT אינו דורש נעילות. כל שינוי (פעולה) מקבל מזהה ייחודי המבוסס על חותמת זמן ומזהה צומת. בעת מיזוג, האלגוריתם מסדר את הפעולות כך שהתוצאה זהה אצל כל המשתתפים. טכנית, זה מיושם באמצעות מבנה לוגריתמי — מיזוג לוקח O(log n).
פתרון ארכיטקטוני: המקרה שלנו
צד השרת — Node.js עם Hocuspocus. אנו מחברים מסד נתונים (PostgreSQL) לשמירת מצבי Yjs. אימות באמצעות JWT, הרשאות ברמת המסמך.
לקוח — React עם עורך Tiptap. אנו משתמשים בהרחבות Collaboration ו-CollaborationCursor לסמני משתמשים אחרים. אווטרים של משתתפים מוצגים בזמן אמת באמצעות WebSocket.
קוד לדוגמה פרוס להלן. הקוד נבדק בסביבת ייצור.
// Сервер — Hocuspocus
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { Logger } from '@hocuspocus/extension-logger';
const server = Server.configure({
port: 1234,
extensions: [
new Logger(),
new Database({
fetch: async ({ documentName }) => {
const doc = await documentRepo.findByName(documentName);
return doc?.content ?? null;
},
store: async ({ documentName, state }) => {
await documentRepo.upsert(documentName, state);
}
})
],
async onAuthenticate({ token, documentName }) {
const payload = jwt.verify(token, process.env.JWT_SECRET);
const canAccess = await checkDocumentAccess(payload.sub, documentName);
if (!canAccess) throw new Error('Access denied');
return { userId: payload.sub };
}
});
server.listen();
// Клиент — React + Tiptap
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
function CollaborativeEditor({ documentId }) {
const doc = useMemo(() => new Y.Doc(), []);
const provider = useMemo(() => new HocuspocusProvider({
url: process.env.NEXT_PUBLIC_HOCUSPOCUS_URL,
name: `doc:${documentId}`,
document: doc,
token: getAuthToken(),
onStatus: ({ status }) => console.log('Provider status:', status)
}), [documentId, doc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc }),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.name,
color: generateUserColor(currentUser.id)
}
})
]
});
return (
<div className="editor-container">
<CollaboratorsAvatars provider={provider} />
<EditorContent editor={editor} />
</div>
);
}
function CollaboratorsAvatars({ provider }) {
const [users, setUsers] = useState([]);
useEffect(() => {
const awareness = provider.awareness;
const updateUsers = () => {
const states = Array.from(awareness.getStates().values());
setUsers(states.filter(s => s.user).map(s => s.user));
};
awareness.on('change', updateUsers);
updateUsers();
return () => awareness.off('change', updateUsers);
}, [provider]);
return (
<div className="collaborators">
{users.map(user => (
<Avatar key={user.id} name={user.name} color={user.color} title={`${user.name} сейчас редактирует`} />
))}
</div>
);
} פרטי יישום טכניים על Yjs
כדי להפחית עומס רשת, אנו משתמשים בעדכוני מצב מצטברים באמצעות // Сервер — Hocuspocus import { Server } from '@hocuspocus/server'; import { Database } from '@hocuspocus/extension-database'; import { Logger } from '@hocuspocus/extension-logger'; const server = Server.configure({ port: 1234, extensions: [ new Logger(), new Database({ fetch: async ({ documentName }) => { const doc = await documentRepo.findByName(documentName); return doc?.content ?? null; }, store: async ({ documentName, state }) => { await documentRepo.upsert(documentName, state); } }) ], async onAuthenticate({ token, documentName }) { const payload = jwt.verify(token, process.env.JWT_SECRET); const canAccess = await checkDocumentAccess(payload.sub, documentName); if (!canAccess) throw new Error('Access denied'); return { userId: payload.sub }; } }); server.listen(); . כאשר לקוח מתנתק, מאגר השינויים אינו אובד — בעת חיבור מחדש, השינויים נשלחים בקבוצה. דחיסת נתונים משתמשת ב-GZip סטנדרטי, ומפחיתה תעבורה ב-60%.
Liveblocks (מנוהל)
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';
import * as Y from 'yjs';
import { LiveblocksYjsProvider } from '@liveblocks/yjs';
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_KEY,
});
const { RoomProvider, useRoom } = createRoomContext(client);
function EditorPage({ documentId }) {
return (
<RoomProvider id={`document-${documentId}`} initialPresence={{}}>
<CollaborativeEditorWithLiveblocks />
</RoomProvider>
);
}
function CollaborativeEditorWithLiveblocks() {
const room = useRoom();
const doc = useMemo(() => new Y.Doc(), []);
useEffect(() => {
const provider = new LiveblocksYjsProvider(room, doc);
return () => provider.destroy();
}, [room, doc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc }),
],
});
return <EditorContent editor={editor} />;
} תהליך העבודה
- אנליטיקה — דיון במקרי שימוש, עומס, דרישות שמירה.
- עיצוב — בחירת המחסנית (Yjs/Liveblocks), עיצוב מודל נתונים, הגדרת זכויות גישה.
- יישום — הגדרת השרת, שילוב העורך, יישום סמנים, אווטרים, שמירה אוטומטית.
- בדיקות — בדיקות עומס המדמות 100+ משתמשים בו-זמנית.
- פריסה — פריסה על האירוח שלכם (Docker, Vercel, Cloudflare). מסירת תיעוד וגישה.
- תמיכה — אחריות ל-3 חודשים על באגים שנמצאו, אפשרות להארכה.
מה כלול בתוצאה
- עורך פונקציונלי עם עריכה שיתופית, סמנים ואווטרים.
- צד שרת עם שמירה במסד הנתונים שלכם.
- תיעוד על API והתאמת ממשק.
- הדרכת צוות על עבודה עם Yjs.
לוחות זמנים ועלות משוערים
| אפשרות | לוח זמנים | עלות משוערת (החל מ-) |
|---|---|---|
| שילוב Liveblocks (ללא שרת) | החל משבוע | 3,000$ |
| Yjs + Hocuspocus מאפס | החל מ-3 שבועות | 10,000$ |
טעויות נפוצות ביישום
- התעלמות מסנכרון מצב בעת ניתוק לקוח (אנו משתמשים ב-
// Клиент — React + Tiptap import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import * as Y from 'yjs'; import { HocuspocusProvider } from '@hocuspocus/provider'; function CollaborativeEditor({ documentId }) { const doc = useMemo(() => new Y.Doc(), []); const provider = useMemo(() => new HocuspocusProvider({ url: process.env.NEXT_PUBLIC_HOCUSPOCUS_URL, name: `doc:${documentId}`, document: doc, token: getAuthToken(), onStatus: ({ status }) => console.log('Provider status:', status) }), [documentId, doc]); const editor = useEditor({ extensions: [ StarterKit.configure({ history: false }), Collaboration.configure({ document: doc }), CollaborationCursor.configure({ provider, user: { name: currentUser.name, color: generateUserColor(currentUser.id) } }) ] }); return ( <div className="editor-container"> <CollaboratorsAvatars provider={provider} /> <EditorContent editor={editor} /> </div> ); } function CollaboratorsAvatars({ provider }) { const [users, setUsers] = useState([]); useEffect(() => { const awareness = provider.awareness; const updateUsers = () => { const states = Array.from(awareness.getStates().values()); setUsers(states.filter(s => s.user).map(s => s.user)); }; awareness.on('change', updateUsers); updateUsers(); return () => awareness.off('change', updateUsers); }, [provider]); return ( <div className="collaborators"> {users.map(user => ( <Avatar key={user.id} name={user.name} color={user.color} title={`${user.name} сейчас редактирует`} /> ))} </div> ); }עם פסקי זמן). - טעינה שגויה של מסמכים ישנים (אנו משתמשים בעדכונים מצטברים באמצעות
encodeStateAsUpdate). - הגבלת גודל מסמך — Yjs מתמודד עם עד 100 MB ללא בעיות, אך עדיף לשמור כ-blobs.
קבלו ייעוץ מהצוות שלנו — נעריך את התרחיש שלכם ונציע את הפתרון האופטימלי. צרו קשר כדי להזמין עריכה שיתופית בזמן אמת עוד היום.







