تطبيق Angular بطيء؟ اكتشف التقنيات الحقيقية التي تستخدمها الشركات الكبرى لتحويل التطبيقات البطيئة إلى سريعة، مع شرح عميق لكيفية عملها خلف الكواليس في الذاكرة والمعالج.
تطبيقك Angular يعمل ببطء؟ قبل أن تلوم الـ Framework نفسها، دعنا نتفق على حقيقة واحدة: ٩٠٪ من مشاكل الأداء في Angular ليست بسبب Angular نفسه، بل بسبب الطريقة التي نكتب بها الكود. في شركة كبيرة عملت معها، كان لدينا تطبيق Angular يستغرق ١٢ ثانية ليظهر الصفحة الرئيسية على أجهزة متوسطة المواصفات. بعد تطبيق التقنيات التي سأشرحها هنا، انخفض الوقت إلى أقل من ثانية ونصف. الفرق ليس في السحر، بل في فهم كيف يعمل Angular خلف الكواليس وكيف يمكن للـ Change Detection و الـ Zone.js أن يدمرا أداء التطبيق إذا لم نتعامل معهما بحذر.
المشكلة الأكبر التي يواجهها معظم المطورين هي أنهم يتعاملون مع Angular كصندوق أسود. يكتبون الكود، يعمل، وينسون. لكن الحقيقة هي أن كل سطر تكتبه يؤثر على كيفية إدارة Angular للذاكرة، وكيفية تحديث الـ DOM، وكيفية استجابة التطبيق لتفاعلات المستخدم. في هذا المقال، سأفكك لك التقنيات المجربة التي استخدمتها في مشاريع حقيقية لتحسين أداء Angular، مع شرح عميق لما يحدث خلف الكواليس في المعالج والذاكرة.
الـ Change Detection في Angular هو الآلية التي تراقب التغيرات في البيانات وتحدث الـ DOM بناءً عليها. المشكلة أن Angular يعمل بـ Default Change Detection Strategy التي تعيد فحص كل مكون في التطبيق عند أي حدث، سواء كان هذا الحدث متعلق بالمكون أم لا. تخيل أن لديك تطبيق به ٥٠٠ مكون، وكل مرة يضغط المستخدم على زر، Angular يعيد فحص الـ ٥٠٠ مكون. هذا جنون! في أحد المشاريع، كان لدينا تطبيق يحتوي على جدول بيانات كبير، وكل مرة يقوم المستخدم بالتصفية، كان التطبيق يتجمد لثوانٍ بسبب إعادة فحص كل المكونات.
الحل؟ استخدام OnPush Change Detection Strategy. هذه الاستراتيجية تجعل Angular يعيد فحص المكون فقط عندما يتغير الـ Input الخاص به أو عندما يحدث حدث داخل المكون نفسه. الفرق كبير جداً. في المثال السابق، بعد تحويل المكونات إلى OnPush، انخفض وقت الاستجابة من ٣ ثوانٍ إلى أقل من ٢٠٠ مللي ثانية. لكن هناك فخ هنا: إذا كنت تعتمد على تغييرات في الـ Object أو الـ Array من الخارج، فلن يعمل OnPush إلا إذا استخدمت Immutable Objects أو الـ ChangeDetectorRef بشكل يدوي.
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListComponent {
@Input() users: User[] = [];
// هذا لن يعمل مع OnPush لأنه تغيير داخلي في الـ Array
addUser(user: User) {
this.users.push(user); // ❌ Angular لن يلاحظ التغيير
}
// الحل: استخدم Immutable Objects
addUserCorrect(user: User) {
this.users = [...this.users, user]; // ✅ Angular سيلاحظ التغيير
}
}OnPush ليس حلاً سحرياً. يجب استخدامه بحذر. استخدمه عندما يكون المكون يعتمد فقط على الـ Inputs الخاصة به ولا يحتاج إلى مراقبة تغيرات خارجية بشكل مستمر. تجنب استخدامه في المكونات التي تعتمد على خدمات خارجية تتغير بشكل متكرر، مثل الـ Real-time Data، إلا إذا كنت مستعداً لإدارة الـ Change Detection يدوياً باستخدام ChangeDetectorRef. في أحد المشاريع، حاولنا استخدام OnPush في مكون يعرض بيانات من WebSocket، وكان النتيجة كارثة: البيانات لم تعد تظهر إلا بعد تحديث الصفحة. الحل كان استخدام ChangeDetectorRef.markForCheck() بعد كل تحديث للبيانات.
@Injectable()
export class DataService {
private dataSubject = new BehaviorSubject<User[]>([]);
data$ = this.dataSubject.asObservable();
updateData(users: User[]) {
this.dataSubject.next(users);
}
}
@Component({
selector: 'app-realtime-data',
templateUrl: './realtime-data.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RealtimeDataComponent {
constructor(private dataService: DataService, private cdr: ChangeDetectorRef) {
this.dataService.data$.subscribe(users => {
this.users = users;
this.cdr.markForCheck(); // ✅ إجبار Angular على إعادة الفحص
});
}
}Zone.js هو المكتبة التي تجعل Angular قادراً على اكتشاف الأحداث غير المتزامنة مثل الـ setTimeout، الـ Promises، والـ HTTP Requests. لكنها أيضاً السبب الرئيسي وراء إعادة تشغيل الـ Change Detection بشكل مفرط. كل مرة يحدث فيها حدث غير متزامن، Zone.js يقوم بتشغيل الـ Change Detection على كامل التطبيق. في تطبيق يحتوي على الكثير من الـ HTTP Requests أو الـ WebSocket Events، هذا يعني أن الـ Change Detection يعمل عشرات المرات في الثانية دون داعٍ.
الحل؟ الخروج من Zone.js عندما لا تحتاج إليه. يمكنك استخدام NgZone.runOutsideAngular لتنفيذ الكود خارج سياق Angular، مما يمنع تشغيل الـ Change Detection. في مشروع حقيقي، كان لدينا مكون يعرض رسوم بيانية باستخدام مكتبة خارجية (Chart.js)، وكل مرة يتم تحديث الرسم البياني، كان Zone.js يشغل الـ Change Detection على كامل التطبيق. بعد استخدام runOutsideAngular، انخفض استخدام المعالج من ٤٠٪ إلى أقل من ٥٪ عند تحديث الرسم البياني.
@Component({
selector: 'app-chart',
templateUrl: './chart.component.html'
})
export class ChartComponent implements AfterViewInit {
@ViewChild('chartCanvas') chartCanvas!: ElementRef;
constructor(private ngZone: NgZone) {}
ngAfterViewInit() {
this.ngZone.runOutsideAngular(() => {
// الكود هنا لن يشغل الـ Change Detection
const chart = new Chart(this.chartCanvas.nativeElement, {
type: 'line',
data: { /* ... */ }
});
// تحديث الرسم البياني دون تشغيل الـ Change Detection
setInterval(() => {
chart.update();
}, 1000);
});
}
}اخرج من Zone.js عندما تعمل مع مكتبات خارجية لا تحتاج إلى تحديث الـ DOM بشكل متزامن مع Angular، مثل الرسوم البيانية، الخرائط، أو أي مكتبة تعالج الـ UI بشكل مستقل. ابقَ داخل Zone.js عندما تحتاج إلى تحديث الـ DOM بناءً على أحداث غير متزامنة، مثل الـ HTTP Responses أو الـ User Events. الفخ هنا هو أنك إذا خرجت من Zone.js ونسيت أن تعود إليه عندما تحتاج إلى تحديث الـ UI، ستجد أن التطبيق لا يستجيب للتغيرات. في أحد المشاريع، خرجنا من Zone.js لنعمل مع مكتبة WebGL، لكننا نسينا أن نعود إليه عند الحاجة لتحديث زر في الـ UI، والنتيجة كانت أن الزر لم يتغير إلا بعد تحديث الصفحة.
الـ Lazy Loading هو أحد أقوى أدوات تحسين الأداء في Angular، لكنه غالباً ما يُستخدم بشكل خاطئ. الفكرة بسيطة: تحميل المكونات فقط عندما يحتاجها المستخدم. لكن المشكلة أن معظم المطورين يستخدمونه فقط على مستوى الـ Modules الكبيرة، مثل تحميل الـ Admin Module عندما يدخل المستخدم إلى لوحة التحكم. هذا جيد، لكنه ليس كافياً. في تطبيق كبير، حتى الـ Modules الصغيرة يمكن أن تحتوي على مكونات ثقيلة لا يحتاجها المستخدم فوراً.
الحل؟ استخدم الـ Lazy Loading على مستوى المكونات الفردية باستخدام الـ Component Lazy Loading. نعم، Angular يدعم ذلك! في مشروع حقيقي، كان لدينا مكون يحتوي على جدول بيانات كبير مع رسوم بيانية معقدة، وكان هذا المكون يُحمل مع الصفحة الرئيسية رغم أنه لا يظهر إلا بعد النقر على زر. بعد تحويله إلى مكون يتم تحميله بشكل كسول، انخفض حجم الـ Bundle الأولي من ٤ ميجابايت إلى ١.٨ ميجابايت، وزمن تحميل الصفحة من ٣.٥ ثانية إلى ١.٢ ثانية.
// في ملف التوجيهات (Routing)
{
path: 'dashboard',
component: DashboardComponent,
children: [
{
path: 'analytics',
loadChildren: () => import('./analytics/analytics.module').then(m => m.AnalyticsModule)
},
{
path: 'heavy-component',
loadComponent: () => import('./heavy/heavy.component').then(c => c.HeavyComponent)
// ✅ تحميل المكون بشكل كسول (Angular 14+)
}
]
}أول فخ هو تحميل المكونات الكسولة بشكل مبكر. بعض المطورين يضعون روابط إلى المكونات الكسولة في الـ Template قبل أن يحتاجها المستخدم، مما يؤدي إلى تحميلها فوراً عند فتح الصفحة. الفخ الثاني هو عدم استخدام الـ Preloading Strategy. Angular يدعم استراتيجيات مثل PreloadAllModules التي تقوم بتحميل الـ Modules الكسولة في الخلفية بعد تحميل الصفحة الرئيسية، مما يحسن تجربة المستخدم دون التأثير على زمن التحميل الأولي. في أحد المشاريع، استخدمنا PreloadAllModules مع استثناء لبعض الـ Modules الكبيرة، مما أدى إلى تحميل الصفحة الرئيسية بسرعة ثم تحميل باقي الـ Modules في الخلفية دون أن يشعر المستخدم بأي بطء.
@NgModule({
imports: [RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
// أو استخدم استراتيجية مخصصة
// preloadingStrategy: CustomPreloadingStrategy
})],
exports: [RouterModule]
})
export class AppRoutingModule {}
// استراتيجية مخصصة لتحميل بعض الـ Modules فقط
@Injectable({
providedIn: 'root'
})
export class CustomPreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
return route.data && route.data['preload'] ? load() : of(null);
}
}عندما تستخدم *ngFor لعرض قائمة من العناصر، Angular يقوم بإعادة إنشاء كل عنصر في الـ DOM عند أي تغيير في القائمة، حتى لو كان التغيير بسيطاً مثل إضافة عنصر جديد. هذا يؤدي إلى إعادة رسم كامل للقائمة، مما يؤثر على الأداء خاصة في القوائم الكبيرة. الحل؟ استخدام الـ trackBy. هذه الوظيفة تخبر Angular بكيفية تتبع العناصر في القائمة، مما يسمح له بإعادة استخدام العناصر الموجودة في الـ DOM بدلاً من إعادة إنشائها من الصفر.
في أحد المشاريع، كان لدينا جدول يحتوي على ١٠٠٠ صف، وكل مرة نقوم بالتصفية أو الترتيب، كان التطبيق يتجمد لثوانٍ. بعد إضافة trackBy، انخفض وقت التصفية من ٢.٥ ثانية إلى أقل من ١٠٠ مللي ثانية. الفرق كبير جداً، ومع ذلك، معظم المطورين لا يستخدمون trackBy لأنهم لا يعرفون كيف يعمل أو لأنهم يعتقدون أنه ليس ضرورياً للقوائم الصغيرة. لكن الحقيقة هي أن trackBy ليس فقط لتحسين الأداء، بل أيضاً لمنع إعادة رسم العناصر بشكل غير متوقع، مما قد يؤدي إلى فقدان حالة الـ UI مثل الـ Scroll Position أو الـ Focus.
@Component({
selector: 'app-user-table',
templateUrl: './user-table.component.html'
})
export class UserTableComponent {
users: User[] = [];
// بدون trackBy: Angular يعيد إنشاء كل عنصر عند أي تغيير
// مع trackBy: Angular يعيد استخدام العناصر الموجودة
trackByUserId(index: number, user: User): number {
return user.id; // ✅ استخدم معرف فريد لكل عنصر
}
}
<!-- في الـ Template -->
<tr *ngFor="let user of users; trackBy: trackByUserId">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>لا تحتاج إلى trackBy إذا كانت قائمتك صغيرة جداً (أقل من ٢٠ عنصر) ولا تتغير بشكل متكرر. أيضاً، إذا كانت قائمتك تحتوي على عناصر بسيطة جداً ولا تحتوي على حالة داخلية (مثل الـ Scroll أو الـ Focus)، فقد لا تحتاج إلى trackBy. لكن في رأيي، من الأفضل دائماً استخدام trackBy حتى في القوائم الصغيرة، لأنه يمنع مشاكل غير متوقعة في المستقبل عندما تكبر القائمة. الفخ هنا هو أنك إذا استخدمت trackBy بشكل خاطئ، مثل استخدام index بدلاً من معرف فريد، فقد يؤدي ذلك إلى سلوك غير متوقع عند إعادة ترتيب القائمة أو حذف عناصر منها.
عندما تجمع بين OnPush و الـ Observables، يمكنك تحقيق أداء مذهل، لكن هناك فخاخ يجب تجنبها. المشكلة أن OnPush لا يعمل بشكل جيد مع الـ Observables إذا لم تستخدمها بالطريقة الصحيحة. في أحد المشاريع، كان لدينا مكون يستخدم OnPush ويعرض بيانات من Observable، لكن البيانات لم تكن تظهر إلا بعد تحديث الصفحة. السبب؟ كنا نستخدم الـ async pipe بشكل خاطئ، وكنا ننسى أن الـ async pipe يقوم بإدارة الـ Subscription تلقائياً، مما يجعل OnPush يعمل بشكل صحيح.
الحل هو استخدام الـ async pipe مع OnPush. الـ async pipe يقوم بإدارة الـ Subscription تلقائياً، ويقوم أيضاً بتشغيل الـ Change Detection عند صدور قيمة جديدة من الـ Observable. هذا يجعل OnPush يعمل بشكل صحيح دون الحاجة إلى استخدام ChangeDetectorRef يدوياً. في المثال السابق، بعد تحويل الكود لاستخدام الـ async pipe، بدأت البيانات تظهر فوراً دون الحاجة إلى تحديث الصفحة. الفرق ليس فقط في الأداء، بل أيضاً في بساطة الكود.
@Component({
selector: 'app-user-profile',
templateUrl: './user-profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent {
user$: Observable<User>;
constructor(private userService: UserService) {
this.user$ = this.userService.getUser(); // ✅ Observable
}
}
<!-- في الـ Template -->
<div *ngIf="user$ | async as user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>أول فخ هو استخدام الـ async pipe أكثر من مرة لنفس الـ Observable في نفس الـ Template. هذا يؤدي إلى إنشاء أكثر من Subscription لنفس الـ Observable، مما يؤثر على الأداء ويسبب مشاكل في إدارة الـ Memory. الحل هو استخدام الـ *ngIf مع الـ as لتخزين النتيجة في متغير. الفخ الثاني هو نسيان أن الـ async pipe يقوم بإدارة الـ Subscription تلقائياً، مما قد يؤدي إلى الـ Memory Leaks إذا قمت بإدارة الـ Subscriptions يدوياً في نفس الوقت. في أحد المشاريع، كنا نستخدم الـ async pipe ونقوم أيضاً بإدارة الـ Subscriptions يدوياً في الـ ngOnDestroy، والنتيجة كانت أن الـ Subscriptions لم تُغلق بشكل صحيح، مما أدى إلى تسريب الذاكرة.
<!-- ❌ خطأ: إنشاء أكثر من Subscription لنفس الـ Observable -->
<div>
<h2>{{ (user$ | async)?.name }}</h2>
<p>{{ (user$ | async)?.email }}</p>
</div>
<!-- ✅ صحيح: استخدام *ngIf مع as -->
<div *ngIf="user$ | async as user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>الـ Web Workers هي أحد أقوى الأدوات لتحسين أداء تطبيقات Angular، لكنها غالباً ما تُهمل. الفكرة هي نقل العمليات الثقيلة إلى خيط منفصل (Thread) بحيث لا تؤثر على الـ Main Thread الذي يعالج الـ UI. في تطبيق يحتوي على عمليات حسابية معقدة أو معالجة بيانات كبيرة، استخدام الـ Web Workers يمكن أن يمنع تجمد الـ UI ويحسن تجربة المستخدم بشكل كبير. في أحد المشاريع، كان لدينا تطبيق يقوم بمعالجة صور كبيرة على الـ Client Side، وكان التطبيق يتجمد تماماً أثناء المعالجة. بعد نقل المعالجة إلى Web Worker، أصبح التطبيق يستجيب بشكل طبيعي أثناء المعالجة، وزمن المعالجة انخفض من ٨ ثوانٍ إلى أقل من ٣ ثوانٍ.
المشكلة أن معظم المطورين لا يعرفون كيف يستخدمون الـ Web Workers مع Angular، أو يعتقدون أنها معقدة جداً. لكن الحقيقة هي أن Angular يدعم الـ Web Workers بشكل جيد، ويمكنك استخدامها بسهولة باستخدام مكتبات مثل @angular/platform-webworker. الفخ هنا هو أنك إذا لم تدير الـ Web Workers بشكل صحيح، فقد تؤدي إلى مشاكل في الـ Memory أو إلى عدم تحديث الـ UI بشكل صحيح. في أحد المشاريع، كنا نستخدم Web Worker لمعالجة بيانات كبيرة، لكننا نسينا إغلاق الـ Worker بعد الانتهاء، مما أدى إلى تسريب الذاكرة.
// main.ts
import { bootstrapWorkerUi } from '@angular/platform-webworker';
// قم بتشغيل التطبيق باستخدام Web Worker
bootstrapWorkerUi('webworker.bundle.js');
// app.worker.ts
import { bootstrapWorker } from '@angular/platform-webworker';
import { AppModule } from './app/app.module';
// قم بتشغيل الـ Worker
bootstrapWorker(AppModule);
// في المكون
@Component({
selector: 'app-image-processor',
templateUrl: './image-processor.component.html'
})
export class ImageProcessorComponent {
processImage(image: File) {
const worker = new Worker('./image.worker', { type: 'module' });
worker.postMessage({ image });
worker. ({ data }) => {
console.log('تمت المعالجة:', data);
worker.terminate(); // ✅ أغلق الـ Worker بعد الانتهاء
};
}
}استخدم الـ Web Workers عندما يكون لديك عمليات تستغرق وقتاً طويلاً ولا تحتاج إلى الوصول المباشر إلى الـ DOM، مثل معالجة الصور، حساب البيانات الكبيرة، أو الـ Machine Learning على الـ Client Side. تجنب استخدامها للعمليات البسيطة التي تستغرق أقل من ١٠٠ مللي ثانية، لأن إنشاء الـ Worker نفسه يستغرق وقتاً. أيضاً، تجنب استخدامها إذا كنت بحاجة إلى تحديث الـ UI بشكل متكرر أثناء المعالجة، لأن التواصل بين الـ Worker والـ Main Thread يتم عبر الرسائل، مما قد يؤدي إلى بطء في التحديثات. في أحد المشاريع، حاولنا استخدام Web Worker لتحديث عداد في الـ UI كل ١٠ مللي ثانية، والنتيجة كانت أن الـ UI أصبح بطيئاً جداً بسبب كثرة الرسائل بين الـ Worker والـ Main Thread.
تحسين أداء Angular ليس عن استخدام حيلة واحدة، بل عن فهم كيف تعمل الأشياء خلف الكواليس واتخاذ قرارات ذكية بناءً على ذلك. ابدأ بتحليل تطبيقك باستخدام أدوات مثل Chrome DevTools و Angular DevTools لتحديد الأماكن التي تستهلك معظم الوقت والموارد. ثم طبق التقنيات التي شرحناها هنا بشكل تدريجي، وراقب تأثير كل تغيير على الأداء. في تجربتي، أكبر تحسن في الأداء يأتي من تطبيق OnPush مع الـ async pipe، واستخدام trackBy في القوائم الكبيرة، والخروج من Zone.js عند العمل مع مكتبات خارجية.
لا تنسَ أن الأداء ليس فقط عن السرعة، بل أيضاً عن تجربة المستخدم. تطبيق سريع ولكنه غير مستقر أو يصعب صيانته ليس حلاً جيداً. لذلك، دائماً قسّم تطبيقك إلى مكونات صغيرة وقابلة لإعادة الاستخدام، واستخدم الـ Lazy Loading لتقليل حجم الـ Bundle الأولي، واستخدم الـ Web Workers للعمليات الثقيلة. وأخيراً، لا تخف من تجربة أشياء جديدة وتحدي الافتراضات السائدة. في عالم البرمجة، دائماً هناك طريقة أفضل.
نصيحة أخيرة: إذا كنت تريد تحسين أداء تطبيق Angular بشكل حقيقي، ابدأ بقياس الأداء قبل وبعد كل تغيير. استخدم أدوات مثل Lighthouse و WebPageTest للحصول على أرقام دقيقة. بدون قياس، أنت فقط تخمن. ومع القياس، يمكنك اتخاذ قرارات مبنية على بيانات حقيقية وليس على آراء.