بناء تطبيق ويب متكامل باستخدام Deno 2 وإطار عمل Fresh

AI Bot
بواسطة AI Bot ·

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

ما ستبنيه

في هذا الدرس، ستبني تطبيق إدارة مهام متكامل باستخدام Deno 2 وإطار عمل Fresh. بنهاية الدرس، سيكون لديك تطبيق يعمل بالكامل مع:

  • صفحات معروضة من الخادم بدون JavaScript افتراضياً
  • جزر تفاعلية (Islands) للمكونات الديناميكية
  • مسارات API لعمليات CRUD
  • Deno KV لتخزين البيانات بشكل دائم
  • TypeScript في كل مكان — بدون أي إعدادات

الوقت المطلوب: 45-60 دقيقة


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

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

  1. Deno 2 مثبّت — شغّل deno --version للتحقق (يجب أن يكون 2.x+)
  2. معرفة أساسية بـ TypeScript/JavaScript
  3. إلمام بـ HTML و CSS
  4. محرر أكواد (يُنصح بـ VS Code مع إضافة Deno)

إذا لم يكن Deno مثبّتاً:

# macOS / Linux
curl -fsSL https://deno.land/install.sh | sh
 
# Windows (PowerShell)
irm https://deno.land/install.ps1 | iex

لماذا Deno 2 + Fresh؟

ما هو Deno 2؟

Deno 2 هو بيئة تشغيل JavaScript و TypeScript من الجيل التالي، أنشأها Ryan Dahl — المبتكر الأصلي لـ Node.js. يُصلح العديد من عيوب تصميم Node مع إضافة:

  • دعم TypeScript مدمج — لا حاجة لملف tsconfig.json أو خطوة بناء
  • آمن افتراضياً — أذونات صريحة للملفات والشبكة والبيئة
  • توافق مع npm — استخدم أي حزمة npm مع محدد npm:
  • أدوات مدمجة — منسّق، مدقق، مشغل اختبارات، ومقياس أداء
  • Deno KV — قاعدة بيانات مفتاح-قيمة مدمجة

ما هو Fresh؟

Fresh هو أشهر إطار عمل ويب متكامل لـ Deno. يتميز بـ:

  • بنية الجزر (Islands) — يُرسل JavaScript فقط للمكونات التفاعلية
  • بدون خطوة بناء — الكود يعمل مباشرة، مما يتيح نشراً فورياً
  • عرض من جانب الخادم — الصفحات تُعرض مسبقاً على الخادم لتحميل سريع
  • توجيه قائم على الملفات — المسارات تتطابق مع مسارات الملفات
  • Preact تحت الغطاء — مكتبة واجهة مستخدم خفيفة متوافقة مع React

الخطوة 1: إنشاء مشروع Fresh جديد

افتح الطرفية وأنشئ مشروع Fresh جديد:

deno run -A -r https://fresh.deno.dev my-task-manager

عند السؤال:

  • هل تريد استخدام مكتبة تنسيق؟ ← اختر Tailwind CSS
  • هل تريد استخدام VS Code؟ ← اختر Yes (إذا كنت تستخدم VS Code)

انتقل إلى المشروع:

cd my-task-manager

شغّل خادم التطوير للتحقق:

deno task dev

افتح http://localhost:8000 في المتصفح. يجب أن ترى صفحة ترحيب Fresh.

بنية المشروع

هذا ما أنشأه Fresh:

my-task-manager/
├── components/       # مكونات واجهة مشتركة
├── islands/          # مكونات تفاعلية على جانب العميل
├── routes/           # مسارات قائمة على الملفات ونقاط API
│   ├── _app.tsx      # غلاف التطبيق (التخطيط)
│   ├── index.tsx     # الصفحة الرئيسية
│   └── api/          # مسارات API
├── static/           # ملفات ثابتة (CSS، صور)
├── deno.json         # إعدادات Deno
├── dev.ts            # نقطة دخول التطوير
├── main.ts           # نقطة دخول الإنتاج
└── fresh.gen.ts      # ملف manifest مُولّد تلقائياً

مفهوم أساسي: الملفات في routes/ تُعرض من الخادم افتراضياً. الملفات في islands/ تُحيّى (hydrated) على العميل مع JavaScript. هذه هي بنية الجزر — فقط الأجزاء التفاعلية ترسل JS إلى المتصفح.


الخطوة 2: تعريف نموذج بيانات المهام

أنشئ ملفاً جديداً لأنواع المهام وعمليات البيانات.

أنشئ utils/db.ts:

// utils/db.ts
 
export interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}
 
// فتح قاعدة بيانات Deno KV
const kv = await Deno.openKv();
 
export async function getAllTasks(): Promise<Task[]> {
  const tasks: Task[] = [];
  const entries = kv.list<Task>({ prefix: ["tasks"] });
 
  for await (const entry of entries) {
    tasks.push(entry.value);
  }
 
  // ترتيب حسب تاريخ الإنشاء، الأحدث أولاً
  return tasks.sort(
    (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  );
}
 
export async function getTask(id: string): Promise<Task | null> {
  const entry = await kv.get<Task>(["tasks", id]);
  return entry.value;
}
 
export async function createTask(
  title: string,
  description: string
): Promise<Task> {
  const id = crypto.randomUUID();
  const now = new Date().toISOString();
 
  const task: Task = {
    id,
    title,
    description,
    completed: false,
    createdAt: now,
    updatedAt: now,
  };
 
  await kv.set(["tasks", id], task);
  return task;
}
 
export async function updateTask(
  id: string,
  updates: Partial<Pick<Task, "title" | "description" | "completed">>
): Promise<Task | null> {
  const existing = await getTask(id);
  if (!existing) return null;
 
  const updated: Task = {
    ...existing,
    ...updates,
    updatedAt: new Date().toISOString(),
  };
 
  await kv.set(["tasks", id], updated);
  return updated;
}
 
export async function deleteTask(id: string): Promise<boolean> {
  const existing = await getTask(id);
  if (!existing) return false;
 
  await kv.delete(["tasks", id]);
  return true;
}

لماذا Deno KV؟ إنها مخزن مفتاح-قيمة مدمج لا يحتاج أي إعداد. البيانات تبقى محفوظة بعد إعادة تشغيل الخادم محلياً، وعند النشر على Deno Deploy تصبح قاعدة بيانات موزعة عالمياً.


الخطوة 3: بناء مسارات API

يستخدم Fresh التوجيه القائم على الملفات لنقاط API أيضاً. أنشئ نقاط RESTful لإدارة المهام.

قائمة وإنشاء المهام

أنشئ routes/api/tasks.ts:

// routes/api/tasks.ts
import { Handlers } from "$fresh/server.ts";
import { createTask, getAllTasks } from "../../utils/db.ts";
 
export const handler: Handlers = {
  // GET /api/tasks — عرض جميع المهام
  async GET(_req, _ctx) {
    const tasks = await getAllTasks();
    return new Response(JSON.stringify(tasks), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // POST /api/tasks — إنشاء مهمة جديدة
  async POST(req, _ctx) {
    const body = await req.json();
    const { title, description } = body;
 
    if (!title || typeof title !== "string") {
      return new Response(
        JSON.stringify({ error: "العنوان مطلوب" }),
        { status: 400, headers: { "Content-Type": "application/json" } }
      );
    }
 
    const task = await createTask(title, description || "");
    return new Response(JSON.stringify(task), {
      status: 201,
      headers: { "Content-Type": "application/json" },
    });
  },
};

تحديث وحذف مهمة واحدة

أنشئ routes/api/tasks/[id].ts:

// routes/api/tasks/[id].ts
import { Handlers } from "$fresh/server.ts";
import { deleteTask, getTask, updateTask } from "../../../utils/db.ts";
 
export const handler: Handlers = {
  // GET /api/tasks/:id
  async GET(_req, ctx) {
    const task = await getTask(ctx.params.id);
    if (!task) {
      return new Response(
        JSON.stringify({ error: "المهمة غير موجودة" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
    return new Response(JSON.stringify(task), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // PATCH /api/tasks/:id
  async PATCH(req, ctx) {
    const body = await req.json();
    const task = await updateTask(ctx.params.id, body);
 
    if (!task) {
      return new Response(
        JSON.stringify({ error: "المهمة غير موجودة" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
 
    return new Response(JSON.stringify(task), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // DELETE /api/tasks/:id
  async DELETE(_req, ctx) {
    const success = await deleteTask(ctx.params.id);
 
    if (!success) {
      return new Response(
        JSON.stringify({ error: "المهمة غير موجودة" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
 
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

الخطوة 4: إنشاء جزيرة قائمة المهام

الجزر هي طريقة Fresh لإضافة التفاعلية. فقط مكونات الجزر ترسل JavaScript إلى المتصفح — كل شيء آخر يبقى HTML ثابتاً.

أنشئ islands/TaskList.tsx:

// islands/TaskList.tsx
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
 
interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
}
 
export default function TaskList() {
  const tasks = useSignal<Task[]>([]);
  const newTitle = useSignal("");
  const newDescription = useSignal("");
  const loading = useSignal(true);
  const error = useSignal("");
 
  // جلب المهام عند التحميل
  useEffect(() => {
    fetchTasks();
  }, []);
 
  async function fetchTasks() {
    loading.value = true;
    try {
      const res = await fetch("/api/tasks");
      tasks.value = await res.json();
    } catch (e) {
      error.value = "فشل تحميل المهام";
    } finally {
      loading.value = false;
    }
  }
 
  async function addTask(e: Event) {
    e.preventDefault();
    if (!newTitle.value.trim()) return;
 
    try {
      const res = await fetch("/api/tasks", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          title: newTitle.value,
          description: newDescription.value,
        }),
      });
 
      if (res.ok) {
        newTitle.value = "";
        newDescription.value = "";
        await fetchTasks();
      }
    } catch (e) {
      error.value = "فشل إضافة المهمة";
    }
  }
 
  async function toggleTask(id: string, completed: boolean) {
    try {
      await fetch(`/api/tasks/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !completed }),
      });
      await fetchTasks();
    } catch (e) {
      error.value = "فشل تحديث المهمة";
    }
  }
 
  async function removeTask(id: string) {
    try {
      await fetch(`/api/tasks/${id}`, { method: "DELETE" });
      await fetchTasks();
    } catch (e) {
      error.value = "فشل حذف المهمة";
    }
  }
 
  return (
    <div class="max-w-2xl mx-auto p-4">
      {/* نموذج إضافة مهمة */}
      <form onSubmit={addTask} class="mb-8 bg-white rounded-lg shadow p-6">
        <h2 class="text-xl font-bold mb-4 text-gray-800">إضافة مهمة جديدة</h2>
 
        <input
          type="text"
          placeholder="عنوان المهمة..."
          value={newTitle.value}
          onInput={(e) => newTitle.value = (e.target as HTMLInputElement).value}
          class="w-full p-3 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          required
        />
 
        <textarea
          placeholder="الوصف (اختياري)..."
          value={newDescription.value}
          onInput={(e) => newDescription.value = (e.target as HTMLTextAreaElement).value}
          class="w-full p-3 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          rows={2}
        />
 
        <button
          type="submit"
          class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
        >
          إضافة المهمة
        </button>
      </form>
 
      {/* رسالة الخطأ */}
      {error.value && (
        <div class="bg-red-100 text-red-700 p-3 rounded-lg mb-4">
          {error.value}
        </div>
      )}
 
      {/* حالة التحميل */}
      {loading.value && (
        <p class="text-center text-gray-500">جاري تحميل المهام...</p>
      )}
 
      {/* قائمة المهام */}
      {!loading.value && tasks.value.length === 0 && (
        <p class="text-center text-gray-500 py-8">
          لا توجد مهام بعد. أضف مهمتك الأولى!
        </p>
      )}
 
      <div class="space-y-3">
        {tasks.value.map((task) => (
          <div
            key={task.id}
            class={`bg-white rounded-lg shadow p-4 flex items-start gap-3 transition-opacity ${
              task.completed ? "opacity-60" : ""
            }`}
          >
            <button
              onClick={() => toggleTask(task.id, task.completed)}
              class={`mt-1 w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center ${
                task.completed
                  ? "bg-green-500 border-green-500 text-white"
                  : "border-gray-300 hover:border-blue-500"
              }`}
            >
              {task.completed && "✓"}
            </button>
 
            <div class="flex-1 min-w-0">
              <h3
                class={`font-semibold text-gray-800 ${
                  task.completed ? "line-through" : ""
                }`}
              >
                {task.title}
              </h3>
              {task.description && (
                <p class="text-gray-500 text-sm mt-1">{task.description}</p>
              )}
              <p class="text-gray-400 text-xs mt-1">
                {new Date(task.createdAt).toLocaleDateString("ar")}
              </p>
            </div>
 
            <button
              onClick={() => removeTask(task.id)}
              class="text-red-400 hover:text-red-600 flex-shrink-0 text-lg"
              title="حذف المهمة"
            >
              ×
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

نقاط مهمة:

  • useSignal من Preact Signals توفر حالة تفاعلية — أكثر أداءً من useState
  • هذا المكون يعيش في islands/ لذا يُرسل JavaScript إلى العميل
  • كل شيء آخر في الصفحة يبقى HTML ثابتاً

الخطوة 5: إنشاء الصفحة الرئيسية

الآن اربط الجزيرة بصفحة معروضة من الخادم.

استبدل routes/index.tsx:

// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getAllTasks } from "../utils/db.ts";
import TaskList from "../islands/TaskList.tsx";
 
interface PageData {
  totalTasks: number;
  completedTasks: number;
}
 
export const handler: Handlers<PageData> = {
  async GET(_req, ctx) {
    const tasks = await getAllTasks();
    return ctx.render({
      totalTasks: tasks.length,
      completedTasks: tasks.filter((t) => t.completed).length,
    });
  },
};
 
export default function Home({ data }: PageProps<PageData>) {
  const { totalTasks, completedTasks } = data;
  const progress = totalTasks > 0
    ? Math.round((completedTasks / totalTasks) * 100)
    : 0;
 
  return (
    <>
      <Head>
        <title>مدير المهام — مبني بـ Deno 2 و Fresh</title>
      </Head>
 
      <div class="min-h-screen bg-gray-100">
        {/* الرأس */}
        <header class="bg-white shadow-sm">
          <div class="max-w-2xl mx-auto px-4 py-6">
            <h1 class="text-3xl font-bold text-gray-900">
              📋 مدير المهام
            </h1>
            <p class="text-gray-500 mt-1">
              مبني بـ Deno 2 و Fresh — معروض من الخادم مع جزر تفاعلية
            </p>
          </div>
        </header>
 
        {/* شريط الإحصائيات — معروض من الخادم، بدون JS */}
        <div class="max-w-2xl mx-auto px-4 mt-6">
          <div class="bg-white rounded-lg shadow p-4 flex items-center justify-between">
            <div class="flex gap-6 text-sm">
              <span class="text-gray-600">
                الإجمالي: <strong class="text-gray-900">{totalTasks}</strong>
              </span>
              <span class="text-gray-600">
                مكتملة: <strong class="text-green-600">{completedTasks}</strong>
              </span>
              <span class="text-gray-600">
                متبقية:{" "}
                <strong class="text-blue-600">
                  {totalTasks - completedTasks}
                </strong>
              </span>
            </div>
            <div class="flex items-center gap-2">
              <div class="w-24 bg-gray-200 rounded-full h-2">
                <div
                  class="bg-green-500 h-2 rounded-full transition-all"
                  style={{ width: `${progress}%` }}
                />
              </div>
              <span class="text-xs text-gray-500">{progress}%</span>
            </div>
          </div>
        </div>
 
        {/* المحتوى الرئيسي */}
        <main class="py-6">
          <TaskList />
        </main>
 
        {/* التذييل */}
        <footer class="text-center py-6 text-gray-400 text-sm">
          <p>
            مدعوم بـ{" "}
            <a href="https://deno.com" class="text-blue-500 hover:underline">
              Deno 2
            </a>{" "}
            و{" "}
            <a href="https://fresh.deno.dev" class="text-blue-500 hover:underline">
              Fresh
            </a>
          </p>
        </footer>
      </div>
    </>
  );
}

الخطوة 6: إضافة وسيط للتسجيل

يدعم Fresh الوسائط (Middleware) للوظائف المشتركة. لنضف تسجيل الطلبات.

أنشئ routes/_middleware.ts:

// routes/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
 
export async function handler(req: Request, ctx: FreshContext) {
  const start = Date.now();
  const url = new URL(req.url);
 
  // معالجة الطلب
  const resp = await ctx.next();
 
  const duration = Date.now() - start;
  const status = resp.status;
 
  console.log(
    `${req.method} ${url.pathname} — ${status} (${duration}ms)`
  );
 
  return resp;
}

الخطوة 7: التشغيل والاختبار

شغّل خادم التطوير:

deno task dev

افتح http://localhost:8000 واختبر:

  1. إضافة مهمة — املأ العنوان والوصف، اضغط "إضافة المهمة"
  2. تبديل الإكمال — اضغط على مربع الاختيار بجانب المهمة
  3. حذف مهمة — اضغط زر ×
  4. تحديث الصفحة — المهام تبقى محفوظة بفضل Deno KV

اختبار API مباشرة

# عرض المهام
curl http://localhost:8000/api/tasks
 
# إنشاء مهمة
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "تعلم Deno 2", "description": "إكمال درس Fresh"}'
 
# تبديل حالة مهمة (استبدل TASK_ID)
curl -X PATCH http://localhost:8000/api/tasks/TASK_ID \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'
 
# حذف مهمة
curl -X DELETE http://localhost:8000/api/tasks/TASK_ID

الخطوة 8: النشر على Deno Deploy

Deno Deploy هي منصة الاستضافة الطبيعية لتطبيقات Fresh. النشر شبه فوري.

الخيار أ: عبر تكامل GitHub

  1. ارفع مشروعك إلى GitHub
  2. اذهب إلى dash.deno.com
  3. أنشئ مشروعاً جديداً
  4. اربط مستودع GitHub الخاص بك
  5. عيّن نقطة الدخول إلى main.ts
  6. انشر — يستغرق ثوانٍ فقط

الخيار ب: عبر سطر الأوامر

# تثبيت deployctl
deno install -Arf jsr:@deno/deployctl
 
# النشر
deployctl deploy --project=my-task-manager --entrypoint=main.ts

Deno KV على Deploy: عند النشر على Deno Deploy، يتم توزيع بيانات KV عالمياً تلقائياً — بدون إعداد قاعدة بيانات أو سلاسل اتصال.


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

أخطاء "تم رفض الإذن"

Deno آمن افتراضياً. إذا رأيت أخطاء أذونات، تأكد من استخدام علامات --allow-* أو تشغيل مع -A أثناء التطوير:

deno run -A main.ts

"الوحدة غير موجودة" لحزم npm

استخدم محدد npm: في الاستيراد:

import express from "npm:express@4";

بيانات Deno KV لا تُحفظ

افتراضياً، تُخزّن بيانات KV في ملف محلي. تأكد من وجود أذونات كتابة للمجلد. لمسار مخصص:

const kv = await Deno.openKv("./data/kv.db");

ما تعلمته

في هذا الدرس، بنيت تطبيقاً متكاملاً وتعلمت:

  • بنية مشروع Fresh — المسارات، الجزر، المكونات، والأدوات المساعدة
  • بنية الجزر — إرسال JavaScript فقط حيث يُحتاج
  • العرض من جانب الخادم — صفحات معروضة على الخادم لتحميل فوري
  • مسارات API — نقاط RESTful مع توجيه قائم على الملفات
  • Deno KV — تخزين بيانات دائم بدون إعدادات
  • الوسائط — وظائف مشتركة مثل التسجيل
  • النشر — الدفع إلى الإنتاج مع Deno Deploy

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

  • إضافة المصادقة — استخدم Deno KV OAuth لتسجيل الدخول عبر GitHub/Google
  • إضافة فئات المهام — وسّع نموذج البيانات بعلامات وفلاتر
  • إضافة تحديثات فورية — استخدم Server-Sent Events (SSE) لمزامنة المهام مباشرة
  • استكشاف إضافات Fresh — تحقق من fresh.deno.dev/docs/concepts/plugins
  • اقرأ وثائق Deno — استكشف docs.deno.com لميزات بيئة التشغيل

الخلاصة

يقدم Deno 2 و Fresh نهجاً بسيطاً ومنعشاً لتطوير الويب المتكامل. بنية الجزر تضمن أن تطبيقاتك سريعة افتراضياً، TypeScript يعمل بدون إعدادات، و Deno KV يلغي الحاجة لإعداد قاعدة بيانات خارجية. إذا سئمت من سلاسل أدوات البناء المعقدة والأُطر الثقيلة، فإن Fresh يستحق التجربة الجدية لمشروعك القادم.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على دمج ALLaM-7B-Instruct-preview مع Ollama.

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

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

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

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