بناء مستودع أحادي جاهز للإنتاج باستخدام Turborepo و Next.js والحزم المشتركة

AI Bot
بواسطة AI Bot ·

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

مستودع واحد يحكمهم جميعًا. يُعدّ Turborepo نظام البناء عالي الأداء لمستودعات JavaScript و TypeScript الأحادية. في هذا الدليل التعليمي، ستبني مستودعًا أحاديًا بمستوى إنتاجي يتضمّن عدة تطبيقات Next.js ومكوّنات واجهة مستخدم مشتركة وإعدادات TypeScript مشتركة وتخزينًا مؤقتًا عن بُعد لتسريع بناء CI بشكل كبير.

ما ستتعلّمه

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

  • إعداد مستودع أحادي Turborepo باستخدام مساحات عمل pnpm
  • إنشاء عدة تطبيقات Next.js تتشارك الشيفرة البرمجية
  • بناء مكتبة مكوّنات واجهة مستخدم مشتركة باستخدام TypeScript
  • تهيئة حزمة إعدادات TypeScript مشتركة
  • إنشاء حزمة أدوات مساعدة مشتركة تُستخدم عبر التطبيقات
  • إعداد خطوط أنابيب Turborepo لتنسيق المهام بكفاءة
  • تفعيل التخزين المؤقت عن بُعد لبناء CI شبه فوري
  • نشر التطبيقات الفردية مع سير عمل CI/CD مناسب

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

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

  • Node.js 20+ مُثبّت (node --version)
  • pnpm 9+ مُثبّت (pnpm --version) — إن لم يكن موجودًا: npm install -g pnpm
  • خبرة في TypeScript (الأنواع، الأنواع العامة، دقة الوحدات)
  • إلمام بـ Next.js (App Router، Server Components)
  • محرر أكواد — يُنصح بـ VS Code أو Cursor

لماذا Turborepo؟

مع نمو المشاريع، ينتهي بك الأمر غالبًا بعدة تطبيقات (موقع تسويقي، لوحة تحكم، لوحة إدارة) وشيفرة مشتركة (مكوّنات واجهة المستخدم، أدوات مساعدة، إعدادات). أمامك خياران:

  1. مستودعات متعددة: مستودع منفصل لكل حزمة. يصبح النشر وإدارة الإصدارات ومزامنة التبعيات أمرًا مرهقًا.
  2. مستودع أحادي: جميع الشيفرات في مستودع واحد مع أدوات مشتركة. أسهل لمشاركة الشيفرة والتغييرات الذرية عبر الحزم وتوحيد CI/CD.

يجعل Turborepo المستودعات الأحادية عملية من خلال حل مشكلتها الأكبر: أداء البناء. يوفّر:

الميزةالفائدة
تجزئة المهاميعيد بناء ما تغيّر فقط
التنفيذ المتوازييشغّل المهام المستقلة بشكل متزامن
التخزين المؤقت عن بُعدمشاركة مخرجات البناء عبر الفريق وCI
تنسيق خط الأنابيبيحترم ترتيب التبعيات تلقائيًا
التبني التدريجييُضاف للمستودعات الحالية دون إعادة كتابة

مقارنةً بالبدائل مثل Nx، يتميّز Turborepo بأنه أخف وزنًا، ولا يحتاج إعدادات افتراضية، ومُصمَّم خصيصًا لمنظومة JavaScript.


الخطوة 1: إنشاء هيكل المستودع الأحادي

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

pnpm dlx create-turbo@latest my-monorepo

عند ظهور الخيارات:

  • اختر pnpm كمدير الحزم
  • اختر القالب الافتراضي

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

cd my-monorepo

سترى هذا الهيكل:

my-monorepo/
├── apps/
│   ├── web/          # Next.js app
│   └── docs/         # Next.js docs app
├── packages/
│   ├── ui/           # Shared UI components
│   ├── eslint-config/  # Shared ESLint config
│   └── typescript-config/  # Shared tsconfig
├── turbo.json        # Turborepo pipeline config
├── pnpm-workspace.yaml
└── package.json

دعنا نفهم كل جزء قبل تخصيصه.


الخطوة 2: فهم هيكل مساحة العمل

ملف package.json الجذري

يُعرّف ملف package.json الجذري السكريبتات على مستوى مساحة العمل وتبعيات التطوير:

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "format": "prettier --write \"**/*.{ts,tsx,md}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4"
}

لاحظ أن turbo run build لا يبني أي شيء بنفسه. بل ينسّق سكريبت build في كل حزمة مساحة عمل، مع احترام ترتيب التبعيات وتخزين النتائج مؤقتًا.

ملف pnpm-workspace.yaml

يُخبر هذا الملف pnpm بأماكن حزم مساحة العمل:

packages:
  - "apps/*"
  - "packages/*"

ملف turbo.json

هذا هو قلب Turborepo — إعداد خط الأنابيب:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

المفاهيم الأساسية:

  • dependsOn: ["^build"] — الرمز ^ يعني "شغّل هذه المهمة في التبعيات أولًا." إذا كان web يعتمد على ui، فسيعمل ui#build قبل web#build.
  • outputs — الملفات التي يجب على Turborepo تخزينها مؤقتًا. في عمليات التشغيل اللاحقة، إذا لم تتغيّر المدخلات، يُعيد Turborepo تشغيل المخرجات المخزّنة فورًا.
  • cache: false — خوادم التطوير يجب ألا تُخزَّن مؤقتًا أبدًا.
  • persistent: true — يُعلّم المهام طويلة التشغيل (مثل خوادم التطوير) التي لا تنتهي.

الخطوة 3: إنشاء مكتبة مكوّنات واجهة مستخدم مشتركة

يتضمّن القالب حزمة ui أساسية. دعنا نُحسّنها لتصبح مكتبة مكوّنات متكاملة.

إعداد الحزمة

عدّل ملف packages/ui/package.json:

{
  "name": "@repo/ui",
  "version": "0.0.1",
  "private": true,
  "exports": {
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx",
    "./input": "./src/input.tsx",
    "./badge": "./src/badge.tsx",
    "./dialog": "./src/dialog.tsx"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

حقل exports بالغ الأهمية — فهو يُعرّف الواجهة العامة لحزمتك. بدلاً من تصدير واحد شامل، يحصل كل مكوّن على نقطة دخول خاصة به. هذا يُمكّن حذف الشيفرة غير المستخدمة (tree-shaking) بحيث لا تُجمَّع المكوّنات غير المُستخدمة.

إنشاء مكوّن Button

أنشئ ملف packages/ui/src/button.tsx:

import { type ButtonHTMLAttributes, forwardRef } from "react";
 
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";
 
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  isLoading?: boolean;
}
 
const variantStyles: Record<ButtonVariant, string> = {
  primary:
    "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
  secondary:
    "bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500",
  outline:
    "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500",
  ghost:
    "text-gray-700 hover:bg-gray-100 focus:ring-gray-500",
  danger:
    "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
};
 
const sizeStyles: Record<ButtonSize, string> = {
  sm: "px-3 py-1.5 text-sm",
  md: "px-4 py-2 text-base",
  lg: "px-6 py-3 text-lg",
};
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = "primary", size = "md", isLoading, className = "", children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={`inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading && (
          <svg
            className="mr-2 h-4 w-4 animate-spin"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
          </svg>
        )}
        {children}
      </button>
    );
  }
);
 
Button.displayName = "Button";
 
export { Button, type ButtonProps };

إنشاء مكوّن Card

أنشئ ملف packages/ui/src/card.tsx:

import type { HTMLAttributes, ReactNode } from "react";
 
interface CardProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
}
 
function Card({ children, className = "", ...props }: CardProps) {
  return (
    <div
      className={`rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md ${className}`}
      {...props}
    >
      {children}
    </div>
  );
}
 
function CardHeader({ children, className = "", ...props }: CardProps) {
  return (
    <div className={`mb-4 ${className}`} {...props}>
      {children}
    </div>
  );
}
 
function CardTitle({ children, className = "", ...props }: CardProps) {
  return (
    <h3 className={`text-lg font-semibold text-gray-900 ${className}`} {...props}>
      {children}
    </h3>
  );
}
 
function CardContent({ children, className = "", ...props }: CardProps) {
  return (
    <div className={`text-gray-600 ${className}`} {...props}>
      {children}
    </div>
  );
}
 
export { Card, CardHeader, CardTitle, CardContent };

إنشاء مكوّن Input

أنشئ ملف packages/ui/src/input.tsx:

import { type InputHTMLAttributes, forwardRef } from "react";
 
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}
 
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className = "", id, ...props }, ref) => {
    const inputId = id || label?.toLowerCase().replace(/\s+/g, "-");
    return (
      <div className="space-y-1">
        {label && (
          <label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={inputId}
          className={`block w-full rounded-lg border px-3 py-2 text-gray-900 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 ${
            error
              ? "border-red-300 focus:border-red-500 focus:ring-red-500"
              : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
          } ${className}`}
          {...props}
        />
        {error && <p className="text-sm text-red-600">{error}</p>}
      </div>
    );
  }
);
 
Input.displayName = "Input";
 
export { Input, type InputProps };

إنشاء مكوّن Badge

أنشئ ملف packages/ui/src/badge.tsx:

import type { HTMLAttributes } from "react";
 
type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
 
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  variant?: BadgeVariant;
}
 
const variantStyles: Record<BadgeVariant, string> = {
  default: "bg-gray-100 text-gray-800",
  success: "bg-green-100 text-green-800",
  warning: "bg-yellow-100 text-yellow-800",
  error: "bg-red-100 text-red-800",
  info: "bg-blue-100 text-blue-800",
};
 
function Badge({ variant = "default", className = "", children, ...props }: BadgeProps) {
  return (
    <span
      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantStyles[variant]} ${className}`}
      {...props}
    >
      {children}
    </span>
  );
}
 
export { Badge, type BadgeProps };

الخطوة 4: إنشاء حزمة أدوات مساعدة مشتركة

المستودعات الأحادية الحقيقية تتشارك أكثر من مجرد مكوّنات. أنشئ حزمة أدوات مساعدة للمنطق المشترك.

تهيئة الحزمة

mkdir -p packages/utils/src

أنشئ ملف packages/utils/package.json:

{
  "name": "@repo/utils",
  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts",
    "./cn": "./src/cn.ts",
    "./format": "./src/format.ts",
    "./validators": "./src/validators.ts"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "typescript": "^5.7.3"
  }
}

إنشاء دوال الأدوات المساعدة

أنشئ ملف packages/utils/src/cn.ts — أداة دمج أسماء الأصناف الكلاسيكية:

type ClassValue = string | number | boolean | undefined | null | ClassValue[];
 
export function cn(...inputs: ClassValue[]): string {
  return inputs
    .flat(Infinity)
    .filter((x) => typeof x === "string" && x.length > 0)
    .join(" ");
}

أنشئ ملف packages/utils/src/format.ts:

export function formatCurrency(amount: number, currency = "USD", locale = "en-US"): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(amount);
}
 
export function formatDate(date: Date | string, locale = "en-US"): string {
  const d = typeof date === "string" ? new Date(date) : date;
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(d);
}
 
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

أنشئ ملف packages/utils/src/validators.ts:

export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
 
export function isNotEmpty(value: string): boolean {
  return value.trim().length > 0;
}
 
export function isWithinLength(value: string, min: number, max: number): boolean {
  const len = value.trim().length;
  return len >= min && len <= max;
}

أنشئ ملف packages/utils/src/index.ts:

export { cn } from "./cn";
export { formatCurrency, formatDate, slugify } from "./format";
export { isValidEmail, isNotEmpty, isWithinLength } from "./validators";

أضف ملف tsconfig.json في packages/utils/tsconfig.json:

{
  "extends": "@repo/typescript-config/base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

الخطوة 5: تهيئة تطبيق الويب لاستخدام الحزم المشتركة

الآن اربط الحزم المشتركة بتطبيق Next.js الخاص بك.

إضافة التبعيات

في ملف apps/web/package.json، أضف تبعيات مساحة العمل:

{
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*",
    "next": "^15.2.3",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

شغّل pnpm install من الجذر لربط كل شيء:

pnpm install

تهيئة Next.js للمستودع الأحادي

يحتاج Next.js لمعرفة الحزم خارج مجلده. عدّل ملف apps/web/next.config.ts:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@repo/ui", "@repo/utils"],
};
 
export default nextConfig;

خيار transpilePackages يُخبر Next.js بتجميع حزم مساحة العمل هذه عبر المُجمّع الخاص به، مما يتيح لك كتابة TypeScript مباشرةً دون خطوة بناء منفصلة.

استخدام المكوّنات المشتركة

الآن استخدم الحزم المشتركة في تطبيقك. عدّل ملف apps/web/app/page.tsx:

import { Button } from "@repo/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@repo/ui/card";
import { Input } from "@repo/ui/input";
import { Badge } from "@repo/ui/badge";
import { formatDate } from "@repo/utils/format";
 
export default function Home() {
  return (
    <main className="mx-auto max-w-4xl p-8">
      <div className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900">
          Turborepo Monorepo
        </h1>
        <p className="mt-2 text-lg text-gray-600">
          Built with shared packages — {formatDate(new Date())}
        </p>
      </div>
 
      <div className="grid gap-6 md:grid-cols-2">
        <Card>
          <CardHeader>
            <CardTitle>Shared UI Components</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="mb-4">
              These components come from <code>@repo/ui</code>:
            </p>
            <div className="flex flex-wrap gap-2">
              <Button variant="primary">Primary</Button>
              <Button variant="secondary">Secondary</Button>
              <Button variant="outline">Outline</Button>
              <Button variant="ghost">Ghost</Button>
            </div>
          </CardContent>
        </Card>
 
        <Card>
          <CardHeader>
            <CardTitle>Badges &amp; Inputs</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="mb-4 flex gap-2">
              <Badge variant="success">Active</Badge>
              <Badge variant="warning">Pending</Badge>
              <Badge variant="error">Failed</Badge>
              <Badge variant="info">Info</Badge>
            </div>
            <Input label="Email" placeholder="you@example.com" />
          </CardContent>
        </Card>
      </div>
    </main>
  );
}

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

pnpm dev --filter=web

العلم --filter=web يُخبر Turborepo بتشغيل مهمة dev لتطبيق web فقط (وتبعياته).


الخطوة 6: إضافة تطبيق ثانٍ (لوحة تحكم إدارية)

تظهر القوة الحقيقية للمستودعات الأحادية عندما يكون لديك عدة تطبيقات تتشارك الشيفرة.

إنشاء تطبيق الإدارة

cd apps
pnpm dlx create-next-app@latest admin --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

ربط التبعيات المشتركة

عدّل ملف apps/admin/package.json لإضافة تبعيات مساحة العمل:

{
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*",
    "next": "^15.2.3",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

حدّث ملف apps/admin/next.config.ts:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@repo/ui", "@repo/utils"],
};
 
export default nextConfig;

شغّل التثبيت من الجذر:

cd ..
pnpm install

بناء صفحة لوحة التحكم الإدارية

أنشئ ملف apps/admin/app/page.tsx:

import { Button } from "@repo/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@repo/ui/card";
import { Badge } from "@repo/ui/badge";
import { formatCurrency, formatDate } from "@repo/utils/format";
 
const stats = [
  { label: "Total Revenue", value: formatCurrency(48250), change: "+12.5%" },
  { label: "Active Users", value: "2,420", change: "+8.1%" },
  { label: "Orders Today", value: "145", change: "+3.2%" },
  { label: "Conversion Rate", value: "3.6%", change: "-0.4%" },
];
 
const recentOrders = [
  { id: "ORD-001", customer: "Alice Johnson", amount: 299, status: "completed" as const },
  { id: "ORD-002", customer: "Bob Smith", amount: 149, status: "pending" as const },
  { id: "ORD-003", customer: "Charlie Brown", amount: 499, status: "completed" as const },
  { id: "ORD-004", customer: "Diana Ross", amount: 89, status: "failed" as const },
];
 
const statusVariant = {
  completed: "success",
  pending: "warning",
  failed: "error",
} as const;
 
export default function AdminDashboard() {
  return (
    <main className="mx-auto max-w-6xl p-8">
      <div className="mb-8 flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
          <p className="text-gray-500">{formatDate(new Date())}</p>
        </div>
        <Button variant="primary">Export Report</Button>
      </div>
 
      <div className="mb-8 grid gap-4 md:grid-cols-4">
        {stats.map((stat) => (
          <Card key={stat.label}>
            <CardContent>
              <p className="text-sm text-gray-500">{stat.label}</p>
              <p className="mt-1 text-2xl font-bold">{stat.value}</p>
              <Badge variant={stat.change.startsWith("+") ? "success" : "error"}>
                {stat.change}
              </Badge>
            </CardContent>
          </Card>
        ))}
      </div>
 
      <Card>
        <CardHeader>
          <CardTitle>Recent Orders</CardTitle>
        </CardHeader>
        <CardContent>
          <table className="w-full">
            <thead>
              <tr className="border-b text-left text-sm text-gray-500">
                <th className="pb-3">Order ID</th>
                <th className="pb-3">Customer</th>
                <th className="pb-3">Amount</th>
                <th className="pb-3">Status</th>
              </tr>
            </thead>
            <tbody>
              {recentOrders.map((order) => (
                <tr key={order.id} className="border-b last:border-0">
                  <td className="py-3 font-mono text-sm">{order.id}</td>
                  <td className="py-3">{order.customer}</td>
                  <td className="py-3">{formatCurrency(order.amount)}</td>
                  <td className="py-3">
                    <Badge variant={statusVariant[order.status]}>{order.status}</Badge>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </CardContent>
      </Card>
    </main>
  );
}

الآن يتشارك كلا التطبيقين web و admin نفس مكوّنات واجهة المستخدم والأدوات المساعدة. غيّر مكوّنًا مرة واحدة، وسيتحدّث في كل مكان.


الخطوة 7: تهيئة خطوط أنابيب Turborepo

حدّث ملف turbo.json للتعامل مع جميع المهام بكفاءة:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}

أضف السكريبتات الجديدة إلى ملف package.json الجذري:

{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "dev:web": "turbo run dev --filter=web",
    "dev:admin": "turbo run dev --filter=admin",
    "lint": "turbo run lint",
    "type-check": "turbo run type-check",
    "test": "turbo run test",
    "clean": "turbo run clean && rm -rf node_modules .turbo"
  }
}

فهم تبعيات المهام

رسم التبعيات هو مفتاح كفاءة Turborepo:

build task flow:
  @repo/typescript-config (no build needed)
  → @repo/utils#build
  → @repo/ui#build
  → web#build (depends on ui + utils)
  → admin#build (depends on ui + utils)

يشغّل Turborepo المهمتين utils#build و ui#build بالتوازي (لأنهما مستقلتان)، ثم يشغّل web#build و admin#build بالتوازي بمجرد انتهاء تبعياتهما.


الخطوة 8: تفعيل التخزين المؤقت عن بُعد

التخزين المؤقت عن بُعد هو الميزة القاتلة في Turborepo. يتيح لك مشاركة مخرجات البناء بين جهازك المحلي وزملائك في الفريق وCI — بحيث لا يعيد أحد بناء ما بناه شخص آخر بالفعل.

ربط المستودع

npx turbo login
npx turbo link

هذا يربط مستودعك الأحادي بالتخزين المؤقت عن بُعد لـ Vercel (يتوفر مستوى مجاني). بعد الربط، سترى تأكيدًا:

✓ Linked to my-org/my-monorepo

كيف يعمل

  1. البناء الأول: يشغّل Turborepo المهمة، ويحسب تجزئة المدخلات (ملفات المصدر، التبعيات، متغيرات البيئة)، ويرفع المخرجات إلى التخزين المؤقت عن بُعد.
  2. البناء الثاني (أو CI، أو زميل في الفريق): يحسب Turborepo نفس التجزئة، ويجدها في التخزين المؤقت عن بُعد، ويُنزّل المخرجات — متخطيًا البناء الفعلي.
# First build — runs everything
pnpm build
# → web#build: cache miss, computing...
# → admin#build: cache miss, computing...
 
# Second build — instant replay
pnpm build
# → web#build: cache hit, replaying output
# → admin#build: cache hit, replaying output

بالنسبة للمستودعات الأحادية الكبيرة، يمكن أن يُقلّل هذا أوقات CI من 20 دقيقة إلى أقل من دقيقة واحدة.

التخزين المؤقت عن بُعد المُستضاف ذاتيًا

إذا كنت تفضّل عدم استخدام Vercel، يمكنك استضافة خادم التخزين المؤقت بنفسك. يدعم Turborepo أي تخزين متوافق مع S3:

// turbo.json
{
  "remoteCache": {
    "enabled": true,
    "signature": true
  }
}

استخدم متغيرات البيئة TURBO_API و TURBO_TOKEN و TURBO_TEAM لتهيئة نقطة نهاية مُستضافة ذاتيًا.


الخطوة 9: إعداد CI/CD باستخدام GitHub Actions

أنشئ ملف .github/workflows/ci.yml:

name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Lint
        run: pnpm lint
 
      - name: Type check
        run: pnpm type-check
 
      - name: Build
        run: pnpm build
 
      - name: Test
        run: pnpm test

نشر التطبيقات المتغيّرة فقط

يستطيع Turborepo اكتشاف التطبيقات التي تغيّرت ونشرها فقط. استخدم العلم --filter مع git diff:

  deploy-web:
    name: Deploy Web
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2
 
      - name: Check for changes
        id: changes
        run: |
          if npx turbo-ignore web; then
            echo "skip=true" >> $GITHUB_OUTPUT
          else
            echo "skip=false" >> $GITHUB_OUTPUT
          fi
 
      - name: Deploy web app
        if: steps.changes.outputs.skip == 'false'
        run: echo "Deploying web app..."
        # Add your deployment command here

يخرج أمر turbo-ignore برمز 0 إذا لم يتغيّر شيء في تلك الحزمة، مما يتيح لك تخطي عمليات النشر غير الضرورية.


الخطوة 10: أنماط متقدّمة

متغيرات البيئة في Turborepo

إذا كانت مخرجات البناء تعتمد على متغيرات البيئة، يجب إخبار Turborepo بها. وإلا، قد يقدّم بناءً مخزّنًا مؤقتًا استخدم قيم بيئة مختلفة.

// turbo.json
{
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "env": ["DATABASE_URL", "NEXT_PUBLIC_API_URL"],
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}
  • globalEnv — متغيرات تؤثّر على جميع المهام.
  • env — متغيرات تؤثّر على مهمة محددة. التغييرات في هذه المتغيرات تُبطل التخزين المؤقت.

تصفية المهام

صيغة التصفية في Turborepo قوية:

# Run build for web and its dependencies
pnpm build --filter=web...
 
# Run build for everything except docs
pnpm build --filter=!docs
 
# Run build for packages that changed since main
pnpm build --filter=...[main]
 
# Run build for web and admin only
pnpm build --filter=web --filter=admin

الحزم الداخلية مقابل المنشورة

حزمنا تحمل القيمة "private": true — أي أنها داخلية، تُستهلك فقط داخل المستودع الأحادي. للحزم المنشورة (npm)، ستُضيف:

{
  "name": "@myorg/ui",
  "version": "1.0.0",
  "private": false,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts"
  }
}

استخدم changesets لإدارة إصدارات الحزم المنشورة.


الخطوة 11: إعداد Tailwind CSS المشترك

عندما تستخدم عدة تطبيقات Tailwind، شارك الإعدادات للحفاظ على اتساق رموز التصميم.

أنشئ ملف packages/tailwind-config/package.json:

{
  "name": "@repo/tailwind-config",
  "version": "0.0.1",
  "private": true,
  "main": "./tailwind.config.ts",
  "exports": {
    ".": "./tailwind.config.ts"
  },
  "devDependencies": {
    "tailwindcss": "^4.0.0"
  }
}

أنشئ ملف packages/tailwind-config/tailwind.config.ts:

import type { Config } from "tailwindcss";
 
const config: Config = {
  content: [
    "./app/**/*.{ts,tsx}",
    "./src/**/*.{ts,tsx}",
    "../../packages/ui/src/**/*.{ts,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          100: "#dbeafe",
          200: "#bfdbfe",
          300: "#93c5fd",
          400: "#60a5fa",
          500: "#3b82f6",
          600: "#2563eb",
          700: "#1d4ed8",
          800: "#1e40af",
          900: "#1e3a8a",
          950: "#172554",
        },
      },
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
        mono: ["JetBrains Mono", "monospace"],
      },
    },
  },
  plugins: [],
};
 
export default config;

في كل تطبيق، وسّع الإعدادات المشتركة:

// apps/web/tailwind.config.ts
import sharedConfig from "@repo/tailwind-config";
import type { Config } from "tailwindcss";
 
const config: Config = {
  ...sharedConfig,
  content: [
    "./app/**/*.{ts,tsx}",
    "./src/**/*.{ts,tsx}",
    "../../packages/ui/src/**/*.{ts,tsx}",
  ],
};
 
export default config;

اختبار المستودع الأحادي

دعنا نتحقق من أن كل شيء يعمل من البداية إلى النهاية.

بناء جميع الحزم

pnpm build

يجب أن ترى Turborepo ينسّق البناء:

 Tasks:    4 successful, 4 total
Cached:    0 cached, 4 total
  Time:    12.4s

شغّله مرة أخرى

pnpm build

هذه المرة، كل شيء مخزّن مؤقتًا:

 Tasks:    4 successful, 4 total
Cached:    4 cached, 4 total
  Time:    0.3s  >>> FULL TURBO

رسالة "FULL TURBO" تعني أن كل مهمة قُدّمت من التخزين المؤقت — لم يُنجَز أي عمل.

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

# All apps
pnpm dev
 
# Just the web app
pnpm dev:web

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

أخطاء "Module not found"

إذا لم يتمكن تطبيق من العثور على حزمة مساحة عمل:

  1. تحقق من أن الحزمة مدرجة في تبعيات ملف package.json الخاص بالتطبيق
  2. شغّل pnpm install من الجذر
  3. تأكد من أن transpilePackages تتضمّن الحزمة في next.config.ts

التخزين المؤقت لا يعمل

إذا لم تُخزَّن عمليات البناء مؤقتًا:

  1. شغّل turbo run build --summarize لفحص تجزئة المهمة
  2. تحقق من أن outputs في turbo.json تتطابق مع مخرجات البناء الفعلية
  3. تأكد من الإعلان عن متغيرات البيئة في env أو globalEnv

أخطاء مسارات TypeScript

إذا أظهر محررك أخطاء أنواع لحزم مساحة العمل:

  1. أعد تشغيل خادم TypeScript (Cmd+Shift+P ثم "TypeScript: Restart TS Server")
  2. تأكد من أن كل حزمة لديها ملف tsconfig.json مناسب يمتد من الإعدادات المشتركة
  3. تحقق من أن exports في package.json تُشير إلى الملفات الصحيحة

ملخص هيكل المشروع

إليك الهيكل الكامل للمستودع الأحادي الذي بنيته:

my-monorepo/
├── apps/
│   ├── web/                    # Marketing site
│   │   ├── app/page.tsx       # Uses @repo/ui + @repo/utils
│   │   ├── next.config.ts     # transpilePackages
│   │   └── package.json       # workspace:* deps
│   └── admin/                  # Admin dashboard
│       ├── app/page.tsx       # Uses @repo/ui + @repo/utils
│       ├── next.config.ts
│       └── package.json
├── packages/
│   ├── ui/                     # Shared UI components
│   │   └── src/
│   │       ├── button.tsx
│   │       ├── card.tsx
│   │       ├── input.tsx
│   │       └── badge.tsx
│   ├── utils/                  # Shared utilities
│   │   └── src/
│   │       ├── cn.ts
│   │       ├── format.ts
│   │       └── validators.ts
│   ├── tailwind-config/        # Shared Tailwind theme
│   ├── typescript-config/      # Shared tsconfig
│   └── eslint-config/          # Shared ESLint rules
├── .github/workflows/ci.yml   # CI with remote caching
├── turbo.json                  # Pipeline config
├── pnpm-workspace.yaml
└── package.json

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

الآن بعد أن أصبح لديك مستودع أحادي يعمل، فكّر في:

  • إضافة Storybook لحزمة ui لتوثيق المكوّنات
  • إعداد Changesets إذا كنت تخطط لنشر حزم على npm
  • إضافة اختبارات E2E باستخدام Playwright في حزمة packages/e2e منفصلة
  • النشر على Vercel — لديها دعم أصلي لـ Turborepo مع اكتشاف تلقائي للمشاريع
  • إضافة حزمة قاعدة بيانات مشتركة باستخدام Drizzle ORM للوصول المتسق للمخطط

الخلاصة

لقد بنيت مستودعًا أحاديًا جاهزًا للإنتاج باستخدام Turborepo يتضمّن:

  • تطبيقان Next.js يتشاركان الشيفرة بسلاسة
  • مكتبة واجهة مستخدم مشتركة بمكوّنات قابلة لإعادة الاستخدام ومُحدّدة الأنواع
  • حزمة أدوات مساعدة مشتركة للمنطق المشترك
  • إعدادات TypeScript و Tailwind مشتركة
  • خطوط أنابيب بناء فعّالة مع التخزين المؤقت والتنفيذ المتوازي
  • سير عمل CI/CD مع النشر الانتقائي

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


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على تنسيق الوكلاء: الروتينات والتسليمات.

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

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

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

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