Développement Web

معمارية Angular المعياريّة: تنظيم تطبيق كبير

4 min de lecture

تطبيق Angular قد يبلغ مئات المكوّنات، عشرات الخدمات، وعدّة فرق تساهم بالتوازي. بدون معمارية واضحة، يصبح الكود متاهة حيث كلّ تعديل يلامس عشرة مواضع. هذا الدليل يرسم المبادئ التي تجعل تطبيق Angular كبيراً قابلاً للصيانة على المدى الطويل، مع الأنماط الحديثة standalone.

للسياق الأوسع → Angular للمؤسسات: دليل عملي.


المحتويات

  1. ثلاث عائلات من الكود
  2. بنية feature-first
  3. Lazy loading بحسب الـ route
  4. التواصل بين الـ features
  5. الخدمات: أين تُعرَّف
  6. المكتبات المشتركة والـ monorepo
  7. اصطلاحات الفريق
  8. الهجرة التدريجيّة من الأسلوب القديم
  9. أسئلة شائعة

1. ثلاث عائلات من الكود

تطبيق Angular منظَّم جيّداً يُميّز ثلاث عائلات من الكود:

Core: خدمات singletons عامّة، interceptors، guards، نماذج بيانات مشتركة. تُهيَّأ مرّة واحدة عند الإقلاع. أمثلة: AuthService، ApiService، NotificationService، ErrorHandlerService.

Shared: مكوّنات، توجيهات، pipes قابلة لإعادة الاستخدام بلا منطق أعمال خاصّ. أزرار، نماذج عامّة، modals، badges، formatters. قابلة للاستيراد من قِبَل كلّ الـ features.

Features: وحدات أعمال وظيفيّة. feature لـ « العملاء »، أخرى لـ « الطلبات »، ثالثة لـ « الفوترة ». كلّ واحدة تحوي مكوّناتها وخدماتها وroutesها وtypesها — مكتفية بنفسها قدر الإمكان.

هذا الفصل، رغم كونه مفاهيمياً، يوجّه بقوّة بنية المجلّدات والتبعيّات المسموح بها بين أجزاء الكود. أيّ feature لا يجب أن تعتمد على feature أخرى مباشرة (لكن يمكنها الاعتماد على core وshared).


2. بنية feature-first

src/
├── app/
│   ├── core/
│   │   ├── auth/
│   │   │   ├── auth.service.ts
│   │   │   ├── auth.guard.ts
│   │   │   └── auth.interceptor.ts
│   │   ├── api/
│   │   │   └── api.service.ts
│   │   └── error/
│   ├── shared/
│   │   ├── ui/
│   │   │   ├── button/
│   │   │   ├── modal/
│   │   │   └── card/
│   │   ├── pipes/
│   │   └── directives/
│   ├── features/
│   │   ├── clients/
│   │   │   ├── pages/
│   │   │   │   ├── clients-list.component.ts
│   │   │   │   └── client-detail.component.ts
│   │   │   ├── components/
│   │   │   │   ├── client-card.component.ts
│   │   │   │   └── client-form.component.ts
│   │   │   ├── services/
│   │   │   │   └── client.service.ts
│   │   │   ├── models/
│   │   │   │   └── client.model.ts
│   │   │   └── clients.routes.ts
│   │   ├── orders/
│   │   ├── invoices/
│   │   └── dashboard/
│   ├── app.component.ts
│   ├── app.config.ts
│   └── app.routes.ts
├── assets/
├── environments/
└── styles.scss

هذه البنية توجّه التنظيم: كلّ feature مجلّد مكتفٍ بذاته، أسهل في التصفّح والإسناد لمطوّر مخصّص. إذا كبرت feature، يمكن تفكيكها إلى sub-features.

ملفّات index

// features/clients/index.ts
export { ClientsListComponent } from "./pages/clients-list.component";
export { ClientService } from "./services/client.service";
export type { Client } from "./models/client.model";

يسمح بالاستيراد البسيط: import { Client } from "@features/clients". الـ index يُعرّف الواجهة العامّة للـ feature ويُخفي التفاصيل الداخليّة.

Path aliases في tsconfig

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@core/*": ["app/core/*"],
      "@shared/*": ["app/shared/*"],
      "@features/*": ["app/features/*"]
    }
  }
}

imports أكثر قابلية للقراءة من المسارات النسبيّة العميقة.


3. Lazy loading بحسب الـ route

مع المكوّنات standalone، الـ lazy loading طبيعيّ. كلّ feature لها routes خاصّة تُحمَّل عند الطلب.

// app.routes.ts
import { Routes } from "@angular/router";

export const routes: Routes = [
  {
    path: "",
    loadComponent: () => import("@features/dashboard/dashboard.component")
      .then(m => m.DashboardComponent),
  },
  {
    path: "clients",
    loadChildren: () => import("@features/clients/clients.routes")
      .then(m => m.clientsRoutes),
  },
  {
    path: "orders",
    loadChildren: () => import("@features/orders/orders.routes")
      .then(m => m.ordersRoutes),
  },
  { path: "**", redirectTo: "" },
];

// features/clients/clients.routes.ts
export const clientsRoutes: Routes = [
  {
    path: "",
    loadComponent: () => import("./pages/clients-list.component")
      .then(m => m.ClientsListComponent),
  },
  {
    path: ":id",
    loadComponent: () => import("./pages/client-detail.component")
      .then(m => m.ClientDetailComponent),
    resolve: { client: clientResolver },
  },
];

المنافع

  • Bundle ابتدائي مُخفَّض: فقط كود الصفحة الأولى يُحمَّل
  • Builds بالتوازي: الـ bundlers الحديثة (esbuild) تُجمّع الـ chunks باستقلال
  • تنقّل سريع: الـ chunks اللاحقة تُحمَّل عند الطلب
  • Cache المتصفّح فعّال: chunk لم يتغيّر يبقى في الـ cache حتى بعد التحديث

التحميل المسبق الذكيّ

لاستباق الـ routes المرجَّح زيارتها:

import { provideRouter, withPreloading, PreloadAllModules } from "@angular/router";

// يحمّل كلّ شيء مسبقاً بعد الإقلاع
provideRouter(routes, withPreloading(PreloadAllModules));

// استراتيجيّة مخصّصة
class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>) {
    return route.data?.["preload"] ? load() : of(null);
  }
}

تتيح وَسم بعض الـ routes كأولويّة. مفيد عند معرفة أنماط التنقّل (مثلاً الـ dashboard يقود غالباً إلى /clients).


4. التواصل بين الـ features

الـ feature يجب أن لا تستورد مكوّناً من feature أخرى. كيف نجعلها تتواصل إذاً؟

عبر خدمات مشتركة (core)

// core/notifications/notification.service.ts
@Injectable({ providedIn: "root" })
export class NotificationService {
  private messages = signal<Message[]>([]);
  list = this.messages.asReadonly();

  notify(msg: string) {
    this.messages.update(list => [...list, { id: crypto.randomUUID(), text: msg }]);
  }
}

أيّ feature يمكنها حقن هذه الخدمة والإشعار. الـ toaster العام (في app.component) يقرأ الرسائل ويعرضها.

عبر الـ routing مع params

لتمرير سياق بين صفحات: استعمل params، query params، أو state الـ Router.

this.router.navigate(["/orders/new"], {
  queryParams: { clientId: id },
});

// في الصفحة الهدف
clientId = inject(ActivatedRoute).snapshot.queryParamMap.get("clientId");

عبر store عامّ للحالات المعقّدة

لحالات مشتركة فعلاً (مستخدم متّصل، سلّة، تفضيلات واجهة): خدمة core تكشف signals. NgRx لمعماريات أكثر رسميّة، لكنّه ليس ضرورياً لمعظم الحالات.

Anti-pattern: استيراد مباشر بين features

// خاطئ: feature A تستورد مكوّناً من feature B
import { OrderCard } from "@features/orders/components/order-card.component";

// إذا كان OrderCard قابلاً للإعادة فعلاً، انقله إلى shared

كسر هذه القاعدة يخلق كرة ثلج: feature A تعتمد على B، B تعتمد على C، وفجأة أيّ تعديل في C يكسر A. الفصل ضروري.


5. الخدمات: أين تُعرَّف

ثلاثة مستويات scope للخدمات في Angular.

providedIn: "root" — singleton عامّ

@Injectable({ providedIn: "root" })
export class ApiService { ... }

نسخة واحدة لكامل التطبيق. لخدمات core المستعملة في كلّ مكان: auth، api، logger، notifications.

Provided في route lazy

{
  path: "clients",
  loadChildren: () => import("./clients.routes").then(m => m.clientsRoutes),
  providers: [
    ClientService,  // مُنشَأ فقط لهذه الـ feature
  ],
}

الخدمة لا توجد إلّا طالما المستخدم داخل الـ feature. حين ينتقل، النسخة تُدمَّر. مناسب لخدمات تحمل حالة خاصّة بالـ feature.

Provided على مستوى المكوّن

@Component({
  ...,
  providers: [LocalStateService],
})
export class WizardComponent { ... }

نسخة لكلّ مكوّن. لحالات محليّة جدّاً (wizards متعدّدة الخطوات، نماذج معقّدة).

توصية عمليّة

  • خدمات core: providedIn: "root"
  • خدمات feature: provided في الـ route الجذر للـ feature (lazy)
  • خدمات محليّة للمكوّن: providers في المكوّن

هذا الانضباط يتجنّب تسرّب الحالة بين features ويوضّح دورة الحياة.


6. المكتبات المشتركة والـ monorepo

لشركة صغيرة أو متوسطة بعدّة تطبيقات Angular تتشارك كوداً مشتركاً (design system، خدمات API، types)، monorepo Nx هو الحلّ الحديث.

npx create-nx-workspace ma-pme --preset=angular-monorepo

البنية:

ma-pme/
├── apps/
│   ├── admin/
│   ├── client-portal/
│   └── public-site/
└── libs/
    ├── shared/
    │   ├── ui/         # design system
    │   ├── data-access/  # خدمات API مشتركة
    │   └── util/
    └── domain/
        ├── clients/
        └── orders/

المنافع

  • كود مشترك بدون نشر: لا packages npm للنشر
  • إعادة هيكلة atomic: تغيير يمسّ عدّة تطبيقات في commit واحد
  • Build تزايديّ: Nx يُعيد بناء ما تغيّر فقط
  • تحقّق من التبعيّات: فرض قواعد بين libs (مثلاً lib UI لا تعتمد أبداً على lib feature)

متى يكون الـ monorepo مهمّاً؟

  • تطبيقان فأكثر بكود مشترك
  • تطبيق واحد مع عدّة librairies داخليّة مُصدَّرة
  • فرق متعدّدة تحتاج تشارُك كود

لتطبيق Angular وحيد: لا حاجة لـ monorepo، بنية project كلاسيكيّة تكفي.


7. اصطلاحات الفريق

معمارية جيّدة على الورق لا تصمد إن لم يطبّق الفريق الاصطلاحات.

اصطلاحات التسمية

  • المكوّنات: clients-list.component.tsClientsListComponent
  • الخدمات: client.service.tsClientService
  • Pipes: highlight.pipe.tsHighlightPipe
  • التوجيهات: auto-focus.directive.tsAutoFocusDirective
  • Guards: auth.guard.tsauthGuard (دالّة)
  • Resolvers: client.resolver.tsclientResolver (دالّة)

Imports: قواعد للفرض

  • لا import من @features/A داخل @features/B
  • لا import من @features/* داخل @core أو @shared
  • لا import من @shared داخل @core

مع Nx، قواعد التبعيّات تُفرَض تلقائياً (@nx/enforce-module-boundaries). بدون Nx، ESLint مع eslint-plugin-import يُمكنه الإشارة إلى الانتهاكات.

اصطلاحات PR

  • Feature واحدة = PR واحد قدر الإمكان
  • مكوّنات مُركَّزة (< 250 سطر مثاليّاً)
  • اختبارات unit للخدمات الحرجة
  • لا منطق أعمال في المكوّنات (فوّض للخدمات)

8. الهجرة التدريجيّة من الأسلوب القديم

تطبيق Angular بـ NgModules يمكن نقله تدريجيّاً إلى standalone components.

الخطوة 1: standalone components

ng generate @angular/core:standalone

الأداة الرسميّة تنقل المكوّنات واحداً واحداً. اختر الخيار « Convert all components, directives and pipes to standalone ».

الخطوة 2: استبدال NgModule بـ routes

NgModules للـ routing تصبح ملفّات routes:

// قبل
@NgModule({
  declarations: [ClientsListComponent, ClientDetailComponent],
  imports: [CommonModule, RouterModule.forChild(routes)],
})
export class ClientsModule {}

// بعد
export const clientsRoutes: Routes = [
  { path: "", component: ClientsListComponent },
  { path: ":id", component: ClientDetailComponent },
];

الخطوة 3: التحديث التدريجيّ

  • نقل @Input() إلى input() تباعاً مع التعديلات
  • تحويل BehaviorSubject إلى signals
  • استبدال *ngIf / *ngFor بـ @if / @for
  • اعتماد دوال guards/resolvers الحديثة بدلاً من classes

لا تسعَ لتحديث كلّ شيء دفعة واحدة. كلّ feature تُمَسّ لسبب أعمال تصبح فرصة للتحديث، دون عرقلة التسليمات.

راجع أيضاً → تحسين أداء Angular للتحسينات التي تنبثق من هذا التحديث.


9. أسئلة شائعة

كم مكوّن في feature قبل التقسيم؟

إشاريّاً: إذا تجاوزت feature 30 إلى 50 مكوّناً، تستفيد غالباً من التقسيم إلى sub-features. لا قاعدة مطلقة. الإشارة العمليّة: إذا لم يعد ثلاثة مطوّرين قادرين على العمل بالتوازي على الـ feature دون عرقلة بعضهم، فهي ضخمة جدّاً.

هل يحتاج كلّ feature ملف index.ts؟

مفيد لكشف الواجهة العامّة للـ feature وإخفاء الداخليّ. لكن انتبه: على مشاريع كبيرة، الـ barrels المُصمَّمة سيّئاً تُبطئ الـ build (Vite و esbuild يحلّان الـ barrel عند كلّ build). اكتفِ بالبساطة أو لا تُضِفه.

Lazy loading منهجيّ أم للـ features الكبيرة فقط؟

منهجيّ لكلّ الـ features. لـ feature صغيرة جدّاً، الـ lazy loading يُضيف round-trip بلا فائدة، لكن هذه الكلفة مهمَلة. الانسجام يَسبق التحسين الهامشيّ.

Nx مقابل Angular CLI للانطلاق؟

لتطبيق واحد: Angular CLI يكفي. لـ تطبيقين فأكثر بكود مشترك أو طموح نموّ: Nx يستحقّ الاستثمار الأوّليّ. الهجرة ممكنة لاحقاً لكن أكثر كلفة من الانطلاق مباشرة بـ Nx.

كيف نشارك types بين Angular وbackend Node.js؟

إمّا في lib مشتركة (monorepo Nx)، أو بنشر paquet npm داخلي. الخيار الأوّل أبسط لشركة صغيرة أو متوسطة. للـ backend Node.js بدون monorepo: نسخ ولصق types مقبول إن كانت قليلة وقليلة التغيير.

هل يجب أن تختفي كلّ NgModules القديمة؟

على المدى الطويل نعم، كلّ المشاريع الجديدة standalone. للمشاريع القائمة، الهجرة التدريجيّة عندما تكون مربحة. لا ضغط مُلحّ، NgModules تبقى مدعومة.

هل نحتاج store عامّاً مثل NgRx لتطبيق كبير؟

ليس إلزاميّاً. الـ signals في خدمات core تغطّي كثيراً من الحالات بكود أقلّ. NgRx يبقى مهمّاً إذا أتقن الفريق Redux وكانت أدوات NgRx (devtools، الـ actions القابلة للتتبّع) ذات قيمة.


مقالات مرتبطة (سلسلة Angular)

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

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité