دليل Liveblocks 2.0 لعام 2026: تعاون فوري مع Next.js 15

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

كان التعاون الفوري في السابق مشروعاً يستغرق ستة أشهر: إنشاء عنقود WebSocket، واختيار CRDT، وكتابة بروتوكول الحضور، وتصميم استراتيجية لحل التعارضات، والدعاء بأن يعيد العميل الاتصال بسلاسة عند إغلاق المستخدم لجهازه. Liveblocks 2.0 يلخّص كل ذلك في خدمة مُدارة مع hooks من الدرجة الأولى لـReact. تضع المزود (provider) في شجرة Next.js، تستدعي useOthers() أو useStorage()، فيكتسب تطبيقك تجربة تعاونية بأسلوب Figma مجاناً.

في هذا الدليل، ستبني تطبيق لوحة تعاونية من الصفر باستخدام Liveblocks 2.0 وNext.js 15. في النهاية سيرى عدة مستخدمين مؤشرات بعضهم البعض، ويتشاركون الحضور المباشر، ويتركون تعليقات متسلسلة على اللوحة، ويحررون قائمة مشتركة من الملاحظات اللاصقة تبقى متسقة حتى لو انقطع أحدهم في منتصف التعديل.

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20 أو أحدث مثبت
  • حساب Liveblocks مجاني (سجّل عبر liveblocks.io)
  • إلمام بـReact وTypeScript وApp Router في Next.js
  • فهم أساسي لأنماط المصادقة
  • محرر كود (يُفضل VS Code)

ما الذي ستبنيه

في نهاية الدليل، سيكون لديك:

  1. تطبيق Next.js 15 مع مزود Liveblocks مرتبط بـApp Router
  2. مؤشرات حية تتبع المستخدمين الآخرين بشكل فوري
  3. صور حضور تعرض من هو متصل حالياً
  4. لوحة مشتركة من الملاحظات اللاصقة متزامنة عبر Liveblocks Storage
  5. تعليقات متسلسلة مرتبطة بعناصر اللوحة
  6. مصادقة قائمة على الرموز (token) بحيث ينضم المستخدمون المسجلون فقط
  7. نشر جاهز للإنتاج على Vercel

الخطوة 1: إعداد المشروع

أنشئ تطبيق Next.js 15 جديداً مع TypeScript وTailwind. استخدم App Router، فإن hooks الجديدة في Liveblocks 2.0 وعناصر التعليقات تتوقع React Server Components.

npx create-next-app@latest liveblocks-canvas \
  --typescript --tailwind --app --eslint \
  --src-dir --import-alias "@/*"
cd liveblocks-canvas

ثبّت حزم Liveblocks المطلوبة. التقسيم مقصود: @liveblocks/client هو القلب المستقل عن أي إطار، و@liveblocks/react يقدّم الـhooks، و@liveblocks/react-ui يقدّم مكونات جاهزة للتعليقات والإشعارات.

npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui
npm install -D @liveblocks/node

@liveblocks/node هو ما سيشغّل نقطة المصادقة من جهة الخادم في الخطوة 4.

الخطوة 2: تكوين مشروع Liveblocks

افتح لوحة Liveblocks وأنشئ مشروعاً جديداً. سجّل ثلاث قيم من صفحة مفاتيح API:

  1. المفتاح العام — آمن للاستخدام في المتصفح؛ مفيد للنماذج الأولية
  2. المفتاح السري — للخادم فقط؛ يُستخدم لإصدار رموز الوصول للغرف
  3. معرّف المشروع — يظهر في رابط المشروع

أضفها إلى .env.local:

LIVEBLOCKS_SECRET_KEY=sk_dev_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_xxxxxxxxxxxxxxxxxxxxx

لا تضع المفتاح السري في Git أبداً. المفتاح العام مقبول للتطوير، لكن تطبيقات الإنتاج يجب أن تستخدم دائماً المفتاح السري مع نقطة مصادقة قائمة على الرموز، وهو ما سنبنيه قريباً.

الخطوة 3: تعريف أنواع Liveblocks

يعتمد Liveblocks 2.0 بشكل كبير على TypeScript. تعلن عن شكل الحضور والتخزين وبيانات المستخدم وبيانات المحادثات مرة واحدة في نوع عام، فتصبح كل الـhooks لاحقاً مكتوبة بالكامل. أنشئ src/liveblocks.config.ts:

import { LiveList, LiveObject } from "@liveblocks/client";
 
export type StickyNote = LiveObject<{
  id: string;
  x: number;
  y: number;
  text: string;
  color: "yellow" | "pink" | "blue" | "green";
  authorId: string;
}>;
 
declare global {
  interface Liveblocks {
    Presence: {
      cursor: { x: number; y: number } | null;
      selectedNoteId: string | null;
    };
    Storage: {
      notes: LiveList<StickyNote>;
    };
    UserMeta: {
      id: string;
      info: {
        name: string;
        avatar: string;
        color: string;
      };
    };
    ThreadMetadata: {
      noteId: string;
    };
  }
}

نقطتان جديرتان بالملاحظة:

  • Presence حالة عابرة تُبَث للمستخدمين الآخرين في الغرفة (موضع المؤشر، التحديد الحالي). تختفي لحظة انقطاع الاتصال.
  • Storage حالة دائمة مشتركة تُخزَّن على خوادم Liveblocks. LiveList وLiveObject بنى بيانات خالية من التعارض تدمج التعديلات المتزامنة تلقائياً.

الخطوة 4: بناء نقطة المصادقة

تطبيقات الإنتاج لا يجب أن تكشف المفتاح العام مباشرة. بدلاً من ذلك، أنشئ مساراً على الخادم يصادق المستخدم بجلسته الحالية، ثم يطلب من Liveblocks رمز وصول مرتبطاً بالغرفة. أنشئ src/app/api/liveblocks-auth/route.ts:

import { Liveblocks } from "@liveblocks/node";
import { NextRequest, NextResponse } from "next/server";
 
const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});
 
export async function POST(request: NextRequest) {
  const user = await getCurrentUser(request);
  if (!user) {
    return new NextResponse("Unauthorized", { status: 401 });
  }
 
  const { room } = await request.json();
 
  const session = liveblocks.prepareSession(user.id, {
    userInfo: {
      name: user.name,
      avatar: user.avatar,
      color: pickColor(user.id),
    },
  });
 
  session.allow(room, session.FULL_ACCESS);
 
  const { status, body } = await session.authorize();
  return new NextResponse(body, { status });
}
 
async function getCurrentUser(request: NextRequest) {
  return {
    id: "user-" + Math.random().toString(36).slice(2, 10),
    name: "Demo User",
    avatar: "/avatars/default.png",
  };
}
 
function pickColor(userId: string) {
  const colors = ["#6366f1", "#ec4899", "#10b981", "#f59e0b", "#06b6d4"];
  const index = userId.charCodeAt(userId.length - 1) % colors.length;
  return colors[index];
}

استبدل getCurrentUser بمنطق الجلسة الفعلي لديك سواء Auth.js أو Clerk أو Better Auth أو ما تستخدمه. استدعاء session.allow() يمنح حق الوصول لغرفة محددة بصلاحية FULL_ACCESS. استخدم READ_ACCESS للمشاهدين فقط مثل الضيوف.

الخطوة 5: تركيب مزود الغرفة

أنشئ src/components/Room.tsx ليلف الصفحات التي تحتاج ميزات تعاونية. المزود يدير دورة حياة WebSocket ومنطق إعادة الاتصال وبث الحضور بدلاً عنك.

"use client";
 
import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";
import { LiveList } from "@liveblocks/client";
 
export function Room({
  children,
  roomId,
}: {
  children: ReactNode;
  roomId: string;
}) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider
        id={roomId}
        initialPresence={{ cursor: null, selectedNoteId: null }}
        initialStorage={{ notes: new LiveList([]) }}
      >
        <ClientSideSuspense fallback={<CanvasSkeleton />}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
 
function CanvasSkeleton() {
  return (
    <div className="flex h-screen items-center justify-center text-zinc-500">
      جاري الاتصال بالغرفة...
    </div>
  );
}

تفاصيل مهمة:

  • ClientSideSuspense هو الطريقة الموصى بها لحجب المحتوى الذي يعتمد على Storage. ينتظر اللقطة الأولى قبل العرض.
  • initialPresence وinitialStorage يعملان فقط لأول مستخدم في الغرفة. الباقون يستلمون حالة الغرفة الموجودة.
  • مسار الاستيراد suspense يفعّل تكامل React Suspense؛ النسخة بدون suspense تُعيد قيماً اختيارية تحتاج فحصاً للقيم الفارغة.

الخطوة 6: عرض المؤشرات الحية

المؤشرات الحية هي اللمسة المميزة في Figma. وهي بسيطة جداً في Liveblocks 2.0 لأن بيانات المؤشر تعيش في Presence، تُبَث بسرعة 60 إطاراً في الثانية، وتُجمع تلقائياً عند انقطاع المستخدم.

أنشئ src/components/LiveCursors.tsx:

"use client";
 
import { useMyPresence, useOthers } from "@liveblocks/react/suspense";
import { useEffect } from "react";
 
export function LiveCursors() {
  const [, updateMyPresence] = useMyPresence();
  const others = useOthers();
 
  useEffect(() => {
    function onPointerMove(event: PointerEvent) {
      updateMyPresence({
        cursor: { x: event.clientX, y: event.clientY },
      });
    }
    function onPointerLeave() {
      updateMyPresence({ cursor: null });
    }
 
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerleave", onPointerLeave);
    return () => {
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerleave", onPointerLeave);
    };
  }, [updateMyPresence]);
 
  return (
    <>
      {others.map(({ connectionId, presence, info }) => {
        if (!presence.cursor) return null;
        return (
          <Cursor
            key={connectionId}
            x={presence.cursor.x}
            y={presence.cursor.y}
            color={info?.color ?? "#6366f1"}
            name={info?.name ?? "Anonymous"}
          />
        );
      })}
    </>
  );
}
 
function Cursor({
  x,
  y,
  color,
  name,
}: {
  x: number;
  y: number;
  color: string;
  name: string;
}) {
  return (
    <div
      className="pointer-events-none fixed left-0 top-0 z-50 transition-transform duration-75"
      style={{ transform: `translate(${x}px, ${y}px)` }}
    >
      <svg width="20" height="20" viewBox="0 0 20 20" fill={color}>
        <path d="M3 3l14 5-6 2-2 6z" />
      </svg>
      <span
        className="ml-3 rounded-md px-2 py-0.5 text-xs font-medium text-white shadow-md"
        style={{ background: color }}
      >
        {name}
      </span>
    </div>
  );
}

useOthers() يُعيد مصفوفة بكل المستخدمين الآخرين المتصلين مع حضورهم وبياناتهم الوصفية. يعيد العرض فقط عند تغيّر الجزء المعني، فإضافة المؤشرات شبه مجانية حتى مع عشرات المتعاونين.

الخطوة 7: إضافة صور الحضور

كومة من الصور العائمة تخبر المستخدمين بمن هو في الغرفة دون إجبارهم على ملاحقة المؤشرات.

"use client";
 
import { useOthers, useSelf } from "@liveblocks/react/suspense";
 
export function ActiveCollaborators() {
  const others = useOthers();
  const self = useSelf();
 
  return (
    <div className="fixed right-4 top-4 flex items-center gap-2">
      <span className="text-sm text-zinc-500">
        {others.length + 1} متصل
      </span>
      <div className="flex -space-x-2">
        {self ? (
          <Avatar
            name={self.info?.name ?? "أنت"}
            color={self.info?.color ?? "#6366f1"}
            isSelf
          />
        ) : null}
        {others.slice(0, 4).map(({ connectionId, info }) => (
          <Avatar
            key={connectionId}
            name={info?.name ?? "Anon"}
            color={info?.color ?? "#10b981"}
          />
        ))}
        {others.length > 4 ? (
          <span className="ml-2 text-xs text-zinc-500">
            +{others.length - 4}
          </span>
        ) : null}
      </div>
    </div>
  );
}
 
function Avatar({
  name,
  color,
  isSelf,
}: {
  name: string;
  color: string;
  isSelf?: boolean;
}) {
  return (
    <div
      className="flex h-9 w-9 items-center justify-center rounded-full border-2 border-white text-sm font-semibold text-white shadow"
      style={{ background: color, outline: isSelf ? "2px solid #111" : "none" }}
      title={name}
    >
      {name.charAt(0).toUpperCase()}
    </div>
  );
}

useSelf() يُعيد المستخدم الحالي مع بياناته الصادرة بالرمز. ادمجه مع useOthers() لعرض القائمة الكاملة.

الخطوة 8: مزامنة لوحة الملاحظات اللاصقة

والآن الجزء الأهم: الحالة المشتركة. سنخزّن مصفوفة من الملاحظات اللاصقة في Liveblocks Storage بحيث يرى كل مستخدم نفس اللوحة، ويحدث حلّ التعارض تلقائياً.

"use client";
 
import {
  useMutation,
  useStorage,
  useSelf,
} from "@liveblocks/react/suspense";
import { LiveObject } from "@liveblocks/client";
import { nanoid } from "nanoid";
 
export function StickyCanvas() {
  const notes = useStorage((root) => root.notes);
  const self = useSelf();
 
  const addNote = useMutation(({ storage }, x: number, y: number) => {
    const note = new LiveObject({
      id: nanoid(),
      x,
      y,
      text: "ملاحظة جديدة",
      color: "yellow" as const,
      authorId: self?.id ?? "anonymous",
    });
    storage.get("notes").push(note);
  }, [self?.id]);
 
  const updateNote = useMutation(
    ({ storage }, id: string, patch: Partial<{ text: string; x: number; y: number }>) => {
      const list = storage.get("notes");
      for (let index = 0; index < list.length; index++) {
        const note = list.get(index);
        if (note?.get("id") === id) {
          note.update(patch);
          return;
        }
      }
    },
    [],
  );
 
  function onCanvasDoubleClick(event: React.MouseEvent) {
    addNote(event.clientX, event.clientY);
  }
 
  return (
    <div
      onDoubleClick={onCanvasDoubleClick}
      className="relative h-screen w-full bg-zinc-50"
    >
      {notes.map((note) => (
        <StickyNote
          key={note.id}
          note={note}
          onChange={(text) => updateNote(note.id, { text })}
        />
      ))}
      <p className="absolute bottom-4 left-4 text-sm text-zinc-400">
        نقرة مزدوجة في أي مكان لإضافة ملاحظة
      </p>
    </div>
  );
}

ثلاث ملاحظات جديرة بالتأمل:

  • useStorage يقبل دالة اختيار، ولا يعيد العرض إلا عند تغيّر الشريحة المختارة، فتعديل ملاحظة لا يعيد عرض الباقي.
  • useMutation هو الطريقة الآمنة الوحيدة للكتابة في Storage. يسجّل Liveblocks العملية ويبثها للأقران ويدمج الكتابات المتزامنة بحتمية.
  • LiveObject وLiveList يستخدمان خوارزمية شبيهة بـYjs، فالتعديلات المتزامنة لا تُكتب فوق بعضها بصمت.

محرر الملاحظة بأبسط صورة:

function StickyNote({
  note,
  onChange,
}: {
  note: { id: string; x: number; y: number; text: string; color: string };
  onChange: (text: string) => void;
}) {
  return (
    <div
      className="absolute w-48 rounded-md p-3 shadow-lg"
      style={{
        left: note.x,
        top: note.y,
        background: "#fef08a",
      }}
    >
      <textarea
        defaultValue={note.text}
        onBlur={(event) => onChange(event.target.value)}
        className="h-24 w-full resize-none bg-transparent text-sm outline-none"
      />
    </div>
  );
}

الخطوة 9: إضافة التعليقات المتسلسلة

يقدّم Liveblocks 2.0 وحدة تعليقات تتعامل مع التسلسل، والتفاعلات، وإيصالات القراءة جاهزة للاستخدام. اربط محادثة بكل ملاحظة لاصقة عبر تعيين noteId في ThreadMetadata.

"use client";
 
import {
  Composer,
  Thread,
} from "@liveblocks/react-ui";
import { useThreads } from "@liveblocks/react/suspense";
 
export function NoteComments({ noteId }: { noteId: string }) {
  const { threads } = useThreads({
    query: { metadata: { noteId } },
  });
 
  return (
    <div className="space-y-4">
      {threads.map((thread) => (
        <Thread key={thread.id} thread={thread} />
      ))}
      <Composer
        metadata={{ noteId }}
        placeholder="أضف تعليقاً..."
      />
    </div>
  );
}

استورد الأنماط الافتراضية في الـlayout الجذري مرة واحدة:

import "@liveblocks/react-ui/styles.css";

مكونا Thread وComposer يعرضان واجهة متاحة للجميع بالكامل، تدعم الوضع الداكن. يتعاملان مع التحديثات التفاؤلية، والإكمال التلقائي للإشارات، وتحويل Markdown نيابة عنك.

الخطوة 10: ربط كل شيء معاً

أخيراً، الصفحة. مرّر معرف غرفة فريد لكل لوحة بحيث تبقى الوثائق المختلفة معزولة.

import { Room } from "@/components/Room";
import { LiveCursors } from "@/components/LiveCursors";
import { ActiveCollaborators } from "@/components/ActiveCollaborators";
import { StickyCanvas } from "@/components/StickyCanvas";
 
export default function BoardPage({
  params,
}: {
  params: { boardId: string };
}) {
  return (
    <Room roomId={`board-${params.boardId}`}>
      <ActiveCollaborators />
      <StickyCanvas />
      <LiveCursors />
    </Room>
  );
}

شغّل خادم التطوير، افتح نفس رابط اللوحة في نافذتين، وشاهد المؤشرات تتبع بعضها فورياً.

npm run dev

الخطوة 11: التحسين للإنتاج

ثلاثة مفاتيح يجب ضبطها قبل النشر:

اضبط معدل تحديثات الحضور. Liveblocks يضبط بث المؤشرات على السلك تلقائياً، لكن يمكنك تقليل العمل على العميل بتمرير throttle: 16 إلى LiveblocksProvider لـ60 إطاراً، أو throttle: 50 لـ20 إطاراً إذا لم تكن الدقة حرجة.

استخدم Status لمعرفة حالة الاتصال. useStatus() يُعيد حالة الاتصال ("connecting"، "connected"، "reconnecting"، "disconnected"). أعرض شريطاً عند تحوّلها إلى "reconnecting" ليعرف المستخدمون أن عليهم الانتظار قبل التعديلات الكبيرة.

حدّد مهلة خمول للغرفة. في لوحة Liveblocks، أعد إعداد المشروع لقطع الاتصال تلقائياً بعد فترة من الخمول. هذا يبقي عدد الاتصالات النشطة الشهري متوقعاً.

الخطوة 12: النشر على Vercel

Liveblocks يعمل بسلاسة مع Vercel. ادفع الكود إلى GitHub، استورد المستودع إلى Vercel، وأضف متغيرَي بيئة:

  • LIVEBLOCKS_SECRET_KEY (مفتاح الإنتاج من لوحة Liveblocks)
  • NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY (إن كنت لا تزال تستخدم المفتاح العام)

هذا كل شيء. Liveblocks يعمل على بنيته التحتية الموزعة عالمياً، فلا حاجة لتجهيز Redis، أو إدارة عنقود WebSocket، أو القلق من البدء البارد على serverless.

اختبار التنفيذ

افتح الرابط المنشور في متصفحَين مختلفَين (أو نافذة عادية ونافذة تصفح خاص). يجب أن ترى:

  • المؤشرَين يتتبعان بعضهما بسلاسة فورية
  • الصورتَين تظهران في الكومة أعلى اليمين
  • الملاحظات اللاصقة تظهر فوراً على الشاشتَين عندما ينقر مستخدم نقرة مزدوجة
  • محادثات التعليقات المرتبطة بالملاحظات تتزامن بين النافذتَين
  • إعادة اتصال سلسة عندما تفعّل وضع الطيران لحظات

استكشاف الأخطاء

"Cannot read property of undefined" داخل حدود Suspense. نسيت تغليف المكوّن بـClientSideSuspense أو استوردت من @liveblocks/react بدل @liveblocks/react/suspense.

المؤشرات لا تظهر. تحقق أن مكون المؤشر مرسوم فوق اللوحة في z-index، وأن useMyPresence().cursor يجري تحديثه. خطأ شائع نسيان تعيين cursor إلى null عند pointerleave، فتظل المؤشرات القديمة عالقة.

عمليات Storage تفشل بصمت. العمليات تعمل فقط داخل useMutation. استدعاء storage.get(...).push(...) خارج هذا الـhook لا يفعل شيئاً لأن Liveblocks يحتاج السياق المعاملاتي للبث.

عالق على "جاري الاتصال بالغرفة..." نقطة المصادقة تعيد الشكل الخطأ. تأكد أن جسم الاستجابة هو الجسم الخام الصادر من session.authorize() وأن رمز الحالة منقول.

الخطوات التالية

  • أضف تكامل Yjs لتشغيل محرر نص غني تعاوني عبر @liveblocks/yjs
  • خزّن الملاحظات اللاصقة في قاعدة بياناتك الخاصة عبر Liveblocks Storage REST API وWebhooks
  • أضف الإشعارات بـ@liveblocks/react-ui لتنبيه المستخدمين بالإشارات
  • اجمعها مع TanStack Query v5 لحالة هجينة بين العميل والخادم
  • ادمجها مع Better Auth لمصادقة جاهزة للإنتاج

الخاتمة

يحوّل Liveblocks 2.0 التعاون متعدد المستخدمين إلى ميزة تشحنها في عصر، لا في ربع سنة. بالاعتماد على Presence للحالة العابرة، وStorage للحالة الدائمة الخالية من التعارض، ووحدات التعليقات لتجربة التعاون، تحصل على تجربة Figma دون تشغيل أي من الأجزاء الصعبة. ابدأ بسطح تعاوني واحد في تطبيقك، عادةً لوحة تحكم أو لوحة تخطيط أو محرر مستند، ودع مستخدميك يخبرونك بما تجعله متعدد المستخدمين بعد ذلك.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على بناء وكيل ذكاء اصطناعي مستقل باستخدام Agentic RAG و Next.js.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

بناء تطبيق متكامل باستخدام Appwrite Cloud و Next.js 15

تعلّم كيفية بناء تطبيق ويب متكامل باستخدام Appwrite Cloud كخدمة خلفية و Next.js 15 مع App Router. يغطي هذا الدليل المصادقة وقواعد البيانات وتخزين الملفات والميزات الفورية.

30 د قراءة·