الكتابات/tutorial/2026/06
Tutorial28 يونيو 2026·26 دقيقة

Waku: بناء تطبيق React Server Components باستخدام إطار العمل البسيط

تعلّم Waku، إطار عمل React البسيط من مبتكر Zustand وJotai. ابنِ مدونة كاملة تعمل باستخدام React Server Components، والتوجيه المبني على الملفات، والعرض الثابت والديناميكي، ودوال الخادم، والنشر بدون إعدادات.

أصبحت مكوّنات الخادم في React (React Server Components أو RSC) النموذج الذهني الافتراضي لتطوير React الحديث، لكن معظم أطر العمل تدفنها تحت طبقات من الاصطلاحات وقواعد التخزين المؤقت والإعدادات. يتّبع Waku النهج المعاكس. ابتكره Daishi Kato، مؤلف Zustand وJotai وValtio، وهو "إطار عمل React البسيط" — طبقة رفيعة مدعومة بـ Vite تكشف مكوّنات الخادم والتوجيه المبني على الملفات ودوال الخادم دون أي تعقيد زائد.

في هذا الدرس ستبني مدونة صغيرة لكن متكاملة باستخدام Waku: صفحات مقالات تُعرض من الخادم، وصفحة فهرس مُولّدة بشكل ثابت، ومكوّن تفاعلي على جانب العميل، ودالة خادم تتعامل مع إرسال نموذج. في النهاية ستفهم بدقة أين يقع الحد الفاصل بين الخادم والعميل، وكيف تتحكم في طريقة العرض لكل مسار على حدة.

لماذا Waku، ومتى تلجأ إليه

لا يحاول Waku أن يحلّ محلّ Next.js لكل فريق. إنه يشغل مكانة متعمَّدة: أصغر إطار عمل قابل للتطبيق ويمنحك مع ذلك نموذج برمجة مكوّنات الخادم في React بالكامل. ولهذا التموضع نتائج حقيقية.

  • مساحة المفاهيم صغيرة جدًا. هناك أساسًا أربعة أشياء لتتعلمها — اصطلاحات src/pages/، ومبدّل العرض getConfig، وتوجيها 'use client' و'use server'. وبمجرد أن تستوعبها، لا توجد طبقة تخزين مؤقت خفية أو سحر توجيه تصارعه.
  • إنه أصيل في Vite. يُبنى Waku على Vite وReact 19، لذا تبدو الإضافات ومتغيرات البيئة وتجربة التطوير مألوفة لأي شخص استخدم تطبيق Vite حديثًا. لا توجد تجريدات حزم مخصّصة تعترض طريقك.
  • العرض قرار صريح لكل مسار. بدلًا من استنتاج السلوك الثابت مقابل الديناميكي من طريقة كتابتك لجلب البيانات، يجعلك Waku تصرّح به في getConfig. هذه المقايضة — قليل من الشيفرة التكرارية مقابل غموض أقل بكثير — هي الاختيار المميِّز للإطار.

الجأ إلى Waku حين تريد تعلّم أو تعليم RSC دون تشتيت، أو حين تبني موقعًا قائمًا على المحتوى أو تطبيقًا مركّزًا، أو حين تبدو اصطلاحات إطار أثقل أكثر مما يحتاجه مشروعك. والجأ إلى شيء أكبر حين تحتاج إلى منظومة إضافات واسعة، أو خطوط معالجة لتحسين الصور، أو وسائط (middleware) جاهزة متضمَّنة من البداية.

المتطلبات المسبقة

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

  • Node.js 20+ مثبّت (يستهدف Waku إصدارات Node الحديثة ويستخدم Vite في الخلفية)
  • إلمام بأساسيات React 19: المكوّنات، والخصائص (props)، والـ hooks
  • فهم أساسي لمعنى مكوّنات الخادم (مكوّنات تُعرض على الخادم ولا تُرسل شيفرة JavaScript الخاصة بها إلى المتصفح أبدًا)
  • محرر شيفرة (يُفضّل VS Code) وطرفية (terminal)

لا تحتاج إلى خبرة سابقة في Next.js أو أي إطار عمل آخر. في الواقع، يُعدّ Waku طريقة ممتازة لتعلّم RSC تحديدًا لأنه يضيف القليل جدًا فوق React.

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

مدونة متعددة الصفحات اسمها Waku Notes تتضمّن:

  • صفحة رئيسية مُولّدة بشكل ثابت تعرض قائمة المقالات
  • صفحات مقالات فردية تُعرض ديناميكيًا عبر مسار يعتمد على الـ slug
  • تخطيط مشترك مع شريط تنقّل
  • مكوّن عميل لزر "إعجاب" تفاعلي
  • دالة خادم تسجّل تعليقًا دون الحاجة إلى مسار API منفصل

طبقة البيانات بأكملها مجرد مصفوفة في الذاكرة، حتى تركّز على إطار العمل بدلًا من قاعدة بيانات.

الخطوة 1: تهيئة المشروع

أنشئ مشروع Waku جديدًا باستخدام القالب الرسمي:

npm create waku@latest

ستطلب الأداة اسم المشروع وقالبًا. اختر القالب basic وسمِّ المشروع waku-notes. ثم ثبّت الحزم وشغّل خادم التطوير:

cd waku-notes
npm install
npm run dev

افتح http://localhost:3000. ينبغي أن ترى صفحة البداية الخاصة بـ Waku. لنلقِ نظرة على البنية التي أنشأها القالب:

waku-notes/
├── src/
│   └── pages/
│       ├── _root.tsx       # يخصّص <html> و<head> و<body>
│       ├── _layout.tsx     # يغلّف كل مسار (التنقّل، الأنماط، المزوّدات)
│       └── index.tsx       # الصفحة الرئيسية (المسار: /)
├── public/
├── package.json
└── vite.config.ts

الفكرة الأساسية: كل ما يوجد داخل src/pages/ هو مكوّن خادم افتراضيًا. لا حاجة لأي توجيه (directive). جلب البيانات والأسرار والوصول إلى قاعدة البيانات كلها تعيش هنا ولا تصل إلى المتصفح أبدًا.

الخطوة 2: فهم الحد الفاصل بين الخادم والعميل

افتح src/pages/index.tsx. لأنه لا يحتوي على توجيه 'use client' في الأعلى، فإنه يعمل على الخادم. هذا يعني أنه بإمكانك كتابة شيفرة غير متزامنة مباشرة داخل المكوّن:

// src/pages/index.tsx — مكوّن خادم
export default async function HomePage() {
  const now = new Date().toISOString();
  return (
    <main>
      <h1>Waku Notes</h1>
      <p>تم العرض على الخادم في {now}</p>
    </main>
  );
}

لا يوجد getServerSideProps، ولا loader، ولا واجهة بيانات خاصة. المكوّن نفسه هو جالب البيانات. أي شيء تنتظره بـ await هنا يحدث على الخادم، وفقط HTML الناتج وحمولة RSC المُسلسلة هي التي تصل إلى العميل.

نصيحة جديرة بالاستيعاب مبكرًا:

يمكن لمكوّنات الخادم أن تستورد وتعرض مكوّنات العميل، لكن لا يحدث العكس داخل الوحدة نفسها أبدًا. لا يستطيع مكوّن العميل أن يستقبل من مكوّن الخادم إلا خصائص قابلة للتسلسل (نصوص، أرقام، كائنات بسيطة، وأبناء معروضين من الخادم).

الخطوة 3: إنشاء طبقة البيانات

أنشئ وحدة بيانات صغيرة تتشاركها صفحتا الخادم. أنشئ الملف src/lib/posts.ts:

// src/lib/posts.ts
export type Post = {
  slug: string;
  title: string;
  excerpt: string;
  body: string;
  publishedAt: string;
};
 
const posts: Post[] = [
  {
    slug: "hello-waku",
    title: "مرحبًا بـ Waku",
    excerpt: "لماذا يهمّ إطار React البسيط في 2026.",
    body: "يبقي Waku مساحة السطح صغيرة لتظل مكوّنات الخادم واضحة وسهلة القراءة.",
    publishedAt: "2026-06-20",
  },
  {
    slug: "rendering-modes",
    title: "العرض الثابت مقابل الديناميكي",
    excerpt: "اختر وضع العرض المناسب لكل مسار.",
    body: "الصفحات ثابتة افتراضيًا؛ اختر الديناميكي عندما تحتاج بيانات خاصة بكل طلب.",
    publishedAt: "2026-06-24",
  },
];
 
export async function getAllPosts(): Promise<Post[]> {
  return posts;
}
 
export async function getPost(slug: string): Promise<Post | undefined> {
  return posts.find((p) => p.slug === slug);
}

في تطبيق حقيقي ستستعلم هذه الدوال من Postgres، أو تستدعي واجهة API، أو تقرأ ملفات MDX. تبقى التواقيع (signatures) كما هي لأن مكوّنات الخادم تكتفي بانتظارها عبر await.

الخطوة 4: بناء الصفحة الرئيسية الثابتة

استبدل محتوى src/pages/index.tsx بقائمة من المقالات. لاحظ الدالة المُصدّرة getConfig — هكذا يقرّر Waku كيفية عرض المسار:

// src/pages/index.tsx
import { Link } from "waku";
import { getAllPosts } from "../lib/posts";
 
export default async function HomePage() {
  const posts = await getAllPosts();
  return (
    <main>
      <h1>Waku Notes</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link to={`/blog/${post.slug}`}>{post.title}</Link>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}
 
export const getConfig = async () => {
  return {
    render: "static",
  } as const;
};

نقطتان جديرتان بالتأكيد:

  1. يُستورد Link من waku. ينفّذ التنقّل على جانب العميل دون إعادة تحميل الصفحة بالكامل، إذ يجلب حمولة RSC الخاصة بالمسار الوجهة.
  2. تُرجع getConfig القيمة render: 'static'. أثناء البناء، يعرض Waku هذه الصفحة مسبقًا كـ HTML خام. تصبح أصلًا (asset) ثابتًا يمكن استضافته في أي مكان — دون الحاجة إلى خادم لهذا المسار.

الصفحات ثابتة افتراضيًا، لذا يمكنك تقنيًا حذف getConfig هنا. لكن التصريح بها صراحةً يجعل النية واضحة وهو ممارسة جيدة في قاعدة شيفرة مشتركة.

الخطوة 5: إضافة تخطيط مشترك

افتح src/pages/_layout.tsx. يغلّف ملف _layout.tsx مساره وكل المسارات المتفرّعة عنه. التخطيط الجذري هو المكان المناسب للتنقّل العام والأنماط المشتركة:

// src/pages/_layout.tsx
import "../styles.css";
import { Link } from "waku";
import type { ReactNode } from "react";
 
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <div className="app">
      <header>
        <nav>
          <Link to="/">الرئيسية</Link>
          <Link to="/about">حول</Link>
        </nav>
      </header>
      <section>{children}</section>
      <footer>
        <p>بُني بـ Waku — إطار عمل React البسيط</p>
      </footer>
    </div>
  );
}

التخطيطات هي أيضًا مكوّنات خادم. تُعرض مرة واحدة على الخادم وتبقى مثبّتة عبر عمليات التنقّل على جانب العميل ضمن قسمها، لذا لا يومض الترويسة (header) ولا يُعاد تركيبها عند الانتقال بين الصفحات.

الخطوة 6: إنشاء مسار ديناميكي للمقالات

الآن الجزء المثير. أنشئ src/pages/blog/[slug].tsx. تجعل الأقواس المربّعة الـ slug مقطعًا ديناميكيًا، ويمرّر Waku قيمته كخاصية (prop):

// src/pages/blog/[slug].tsx
import { getAllPosts, getPost } from "../../lib/posts";
 
type PostPageProps = { slug: string };
 
export default async function PostPage({ slug }: PostPageProps) {
  const post = await getPost(slug);
  if (!post) {
    return <p>لم يُعثر على المقال.</p>;
  }
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.publishedAt}</time>
      <p>{post.body}</p>
    </article>
  );
}
 
export const getConfig = async () => {
  const posts = await getAllPosts();
  return {
    render: "static",
    staticPaths: posts.map((p) => p.slug),
  } as const;
};

هنا تُرجع getConfig القيمة render: 'static' بالإضافة إلى مصفوفة staticPaths. يستدعي Waku دالة بياناتك أثناء البناء، فيعلم بوجود مقالين، ويعرض مسبقًا /blog/hello-waku و/blog/rendering-modes كـ HTML ثابت. هذا يكافئ "توليد المواقع الثابتة بمسارات ديناميكية" في أطر أخرى، لكنه مُعبَّر عنه كدالة إعداد صغيرة موضوعة بجوار الصفحة.

أما إن أردت عرض الصفحة من جديد عند كل طلب — مثلًا عندما يأتي المحتوى من نظام إدارة محتوى يتغيّر باستمرار — فبدّل الوضع:

export const getConfig = async () => {
  return {
    render: "dynamic", // SSR: يعمل على الخادم لكل طلب
  } as const;
};

مع dynamic، تُسقط staticPaths تمامًا لأن المسارات تُحلّ وقت الطلب. هذا الاختيار على مستوى كل مسار هو ميزة Waku المريحة الأساسية: استراتيجية العرض قرار من سطر واحد، يُتخذ بجوار المكوّن الذي يحتاجه.

الخطوة 7: إضافة مكوّن عميل

حتى الآن كان كل شيء يُعرض من الخادم. التفاعلية — الحالة، والتأثيرات، ومعالجات الأحداث — تتطلب مكوّن عميل. أنشئ src/components/LikeButton.tsx وعلّمه بـ 'use client':

// src/components/LikeButton.tsx
"use client";
 
import { useState } from "react";
 
export function LikeButton({ initial = 0 }: { initial?: number }) {
  const [likes, setLikes] = useState(initial);
  return (
    <button onClick={() => setLikes((n) => n + 1)}>
{likes}
    </button>
  );
}

يحدّد توجيه 'use client' الحدّ الفاصل: تُحزَّم هذه الوحدة وتبعياتها وتُرسَل إلى المتصفح. كل ما هو فوقها في الشجرة يبقى على الخادم فقط.

الآن اعرضه من صفحة المقال (مكوّن الخادم). يمرّر مكوّن الخادم رقمًا بسيطًا كخاصية عبر الحدّ الفاصل:

// src/pages/blog/[slug].tsx (مقتطف)
import { LikeButton } from "../../components/LikeButton";
 
// ...داخل PostPage، بعد <p>{post.body}</p>:
<LikeButton initial={0} />

أعد تحميل صفحة مقال واضغط الزر — يتحدّث العدّاد فورًا في المتصفح، بينما يبقى المقال المحيط معروضًا من الخادم. لديك جزيرة ترطيب (hydration island) دقيقة وبسيطة.

الخطوة 8: التعامل مع نموذج عبر دالة خادم

يدعم Waku دوال الخادم (توجيه 'use server')، التي تتيح لمكوّن العميل استدعاء شيفرة جانب الخادم مباشرة — دون مسار API يدوي ودون شيفرة fetch تكرارية. أنشئ src/lib/comments.ts:

// src/lib/comments.ts
"use server";
 
const comments: { slug: string; text: string }[] = [];
 
export async function addComment(slug: string, text: string) {
  if (!text.trim()) {
    return { ok: false, error: "لا يمكن أن يكون التعليق فارغًا" };
  }
  comments.push({ slug, text });
  return { ok: true, count: comments.filter((c) => c.slug === slug).length };
}

توجيه 'use server' في أعلى الملف يعني أن كل دالة مُصدّرة تعمل على الخادم، حتى عند استدعائها من المتصفح. يُولّد Waku أنابيب الـ RPC نيابةً عنك. الآن استدعِها من مكوّن عميل:

// src/components/CommentBox.tsx
"use client";
 
import { useState } from "react";
import { addComment } from "../lib/comments";
 
export function CommentBox({ slug }: { slug: string }) {
  const [text, setText] = useState("");
  const [status, setStatus] = useState("");
 
  async function submit() {
    const result = await addComment(slug, text);
    if (result.ok) {
      setStatus(`تم الحفظ. إجمالي التعليقات: ${result.count}`);
      setText("");
    } else {
      setStatus(result.error ?? "حدث خطأ ما");
    }
  }
 
  return (
    <div>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={submit}>نشر التعليق</button>
      <p>{status}</p>
    </div>
  );
}

يبدو الاستيراد عاديًا تمامًا، لكن addComment لا تعمل في المتصفح أبدًا. تُسلسل المعطيات، وتُرسل إلى الخادم، وتُنفَّذ هناك، وتعود القيمة المُرجَعة — كل ذلك مع تحقّق كامل من الأنواع طرفًا لطرف لأنه TypeScript بسيط. ضع <CommentBox slug={post.slug} /> في صفحة المقال أسفل زر الإعجاب.

الخطوة 9: التوجيه البرمجي (اختياري، متقدّم)

يغطي التوجيه المبني على الملفات معظم التطبيقات، لكن Waku يكشف أيضًا واجهة منخفضة المستوى createPages للحالات التي تُولَّد فيها المسارات من البيانات أو حين تريد تحكّمًا كاملًا. أنشئ src/waku.server.tsx:

// src/waku.server.tsx
import { createPages } from "waku";
import adapter from "waku/adapters/default";
import { HomePage } from "./templates/home-page";
import { AboutPage } from "./templates/about-page";
 
const pages = createPages(async ({ createPage }) => [
  createPage({
    render: "static",
    path: "/",
    component: HomePage,
  }),
  createPage({
    render: "dynamic",
    path: "/about",
    component: AboutPage,
  }),
]);
 
export default adapter(pages);

تأخذ createPage نفس render وpath وcomponent التي تعرفها من التوجيه المبني على الملفات — لكنها تتيح لك بناء جدول المسارات برمجيًا. وهناك دوال مرافقة: createLayout للأغلفة، وcreateRoot لتخصيص هيكل المستند، وcreateApi لمعالجات الطلبات الخام. لا تلجأ إلى هذا إلا عندما تصبح اصطلاحات الملفات مقيِّدة؛ بالنسبة لمدونتنا، النهج المبني على الملفات أنظف.

الخطوة 10: البناء والنشر

أنتج بناء إنتاج:

npm run build

يعرض Waku مسبقًا كل مسار render: 'static' كـ HTML، ويحزّم جزر العميل، ويُعدّ مدخل خادم لأي مسارات render: 'dynamic'. عاينه محليًا:

npm run start

Waku محايد تجاه النشر ويوفّر مهايئات (adapters) للمنصّات الكبرى. لاستهداف Vercel مثلًا، حدّد بيئة النشر قبل البناء:

npm run build -- --with-vercel

تشمل الأهداف المدعومة Node.js وVercel وNetlify وCloudflare Workers وDeno Deploy وBun وAWS Lambda. يمكن حتى وضع مدونة ثابتة بالكامل (كل مسار render: 'static') على أي CDN أو تخزين كائنات، لأن ناتج البناء مجرد HTML وأصول.

ملاحظة نشر للمنطقة (MENA): إذا كان جمهورك في تونس أو الخليج أو منطقة الشرق الأوسط وشمال إفريقيا الأوسع، فضّل هدفًا على الحافة (Cloudflare Workers) أو منطقة قريبة من مستخدميك. المسارات الثابتة المُقدَّمة من CDN تتجاوز زمن الرحلة ذهابًا وإيابًا تمامًا، وهو ما يهمّ على اتصالات الجوّال غير المستقرّة.

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

تحقّق من أن البناء يتصرّف كما هو متوقّع:

  1. شغّل npm run build وأكّد أن الناتج يسرد HTML مُعروضًا مسبقًا لـ / و/blog/hello-waku و/blog/rendering-modes.
  2. شغّل npm run start، ثم عطّل JavaScript من أدوات مطوّري المتصفح وأعد تحميل صفحة مقال ثابتة — ينبغي أن يظهر المحتوى رغم ذلك، مما يثبت أنه عُرض من الخادم.
  3. أعد تفعيل JavaScript واضغط زر الإعجاب — ينبغي أن يتزايد العدّاد دون طلب شبكي، مما يثبت أن جزيرة العميل قد رُطِّبت.
  4. أرسل تعليقًا وأكّد تزايد العدد المُرجَع، مما يثبت أن دالة الخادم نُفِّذت عن بُعد.

استكشاف الأخطاء وإصلاحها

"لا يمكن استدعاء الـ Hooks إلا داخل مكوّن عميل." استخدمت useState أو useEffect أو معالج حدث في ملف بلا 'use server' وبلا 'use client'. أضف 'use client' إلى أعلى ملف ذلك المكوّن.

مكوّن عميل يعرض مكوّن خادم فيتعطّل. لا يمكن استيراد مكوّنات الخادم داخل مكوّنات العميل. بدلًا من ذلك، مرّر مكوّن الخادم كـ children من مكوّن خادم أعلى.

مسار staticPaths يُرجع 404. الـ slug المطلوب غير موجود في المصفوفة التي أرجعتها getConfig أثناء البناء. للمحتوى المتغيّر باستمرار، بدّل المسار إلى render: 'dynamic'.

دالة الخادم ترمي خطأ "لا يمكن استدعاؤها على الخادم أثناء العرض." يُقصد بدوال الخادم أن تُستدعى من معالجات أحداث مكوّن العميل، لا أثناء عرض مكوّن الخادم. للبيانات المطلوبة أثناء العرض، استدعِ وحدة بياناتك مباشرة بدلًا من ذلك.

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

  • استبدل المصفوفة في الذاكرة بقاعدة بيانات حقيقية. اقرنه مع دليل Drizzle ORM مع Next.js — أنماط طبقة البيانات تنتقل مباشرة إلى مكوّنات خادم Waku.
  • أضف البيانات الوصفية ووسوم SEO عبر _root.tsx لعناوين وبيانات Open Graph خاصة بكل مسار.
  • استكشف Slices في Waku للعرض الجزئي الدقيق لأجزاء واجهة المستخدم المشتركة.
  • قارن مقاربات أطر العمل الوصفية مع درس TanStack Start مقابل Next.js لترى أين يناسب إطار RSC البسيط حزمتك التقنية.

الخلاصة

يثبت Waku أن مكوّنات خادم React لا تتطلّب إطار عمل ثقيلًا. بمجلّد واحد من الاصطلاحات، ودالة getConfig مُصدّرة للعرض لكل مسار، وحدّي 'use client' و'use server'، وحفنة من مهايئات النشر، يصبح لديك كل ما يلزم لإطلاق تطبيق React سريع يعتمد الخادم أولًا. لقد بنيت مدونة بمسارات ثابتة وديناميكية، وجزيرة عميل مُرطَّبة، ودالة خادم — وفي كل خطوة بقي الحدّ الفاصل بين الخادم والعميل صريحًا وواضحًا. هذا الوضوح هو جوهر Waku: أبقِ الإطار بسيطًا ليبقى React هو البطل.