تطبيقات Angular البطيئة تدمر تجربة المستخدم وتزيد معدل الارتداد. اكتشف التقنيات المجربة التي استخدمتها في مشاريع حقيقية لتحسين الأداء من 12 ثانية تحميل إلى أقل من ثانية، وكيف تعالج مشاكل الذاكرة والمعالج خلف الكواليس.
عندما يفتح المستخدم تطبيق Angular الخاص بك ويجد نفسه ينتظر 12 ثانية حتى يظهر المحتوى الأولي، فهذا ليس مجرد بطء — إنه كارثة. في أحد المشاريع التي عملت عليها مؤخراً، كان التطبيق يرسل 3.2 ميغابايت من جافا سكريبت للمتصفح، وكان الـ Time to Interactive يتجاوز 15 ثانية على شبكات 3G. المشكلة ليست في Angular نفسه، بل في كيفية استخدامنا له. الحقيقة هي أن معظم المطورين لا يفهمون كيف يعمل الـ Change Detection أو الـ Zone.js خلف الكواليس، مما يؤدي إلى تكرار حسابات لا داعي لها واستهلاك غير ضروري للمعالج والذاكرة.
في هذا المقال، لن أتحدث عن النظريات أو النصائح العامة. سأشارك معك التقنيات المجربة التي استخدمتها في شركات حقيقية لتحسين أداء تطبيقات Angular، بدءاً من تقليل حجم الحزمة الأولية إلى تحسين الـ Rendering Pipeline. سأريك كيف يمكن لتغيير بسيط في استراتيجية الـ Change Detection أن يخفض وقت التحميل بنسبة 60%، وكيف أن استخدام OnPush بشكل صحيح يمكن أن يقلل عدد الـ Change Detection Cycles من 200 إلى أقل من 10 في الثانية الواحدة.
السبب الرئيسي لبطء تطبيقات Angular ليس في الإطار نفسه، بل في كيفية تعامل المطورين مع الـ Event Loop و الـ Change Detection. Angular يستخدم نظاماً يعتمد على Zone.js لمراقبة التغييرات، وهذا النظام يعمل بشكل افتراضي في وضعية Default Change Detection، مما يعني أنه يقوم بتشغيل دورة كشف التغييرات بعد كل حدث صغير، سواء كان هذا الحدث هو نقرة زر أو استجابة من API. في التطبيقات الكبيرة، يمكن أن يؤدي هذا إلى مئات الدورات غير الضرورية في الثانية الواحدة، مما يستهلك موارد المعالج ويجعل التطبيق يشعر بالبطء حتى لو كان المحتوى قد تم تحميله بالفعل.
لنأخذ مثالاً واقعياً: في أحد المشاريع التي عملت عليها، كان لدينا جدول بيانات يحتوي على 500 صف، وكل صف يحتوي على 10 مكونات فرعية. عند استخدام Default Change Detection، كان كل تفاعل مع الجدول (مثل التمرير أو الفرز) يؤدي إلى إعادة حساب جميع المكونات الـ 5000، حتى لو كان التغيير يؤثر على صف واحد فقط. النتيجة؟ التطبيق كان يتجمد لمدة ثانية كاملة عند كل تفاعل. بعد تحليل الأداء باستخدام أدوات Chrome DevTools، اكتشفنا أن الـ Change Detection Cycles كانت تستهلك 80% من وقت المعالج، بينما كان الـ Rendering يستهلك 15% فقط. هذا يعني أننا كنا نضيع معظم موارد الجهاز في حسابات لا داعي لها.
Zone.js هو المكتبة التي تجعل Angular قادراً على اكتشاف التغييرات تلقائياً، لكنها أيضاً السبب الرئيسي وراء بطء التطبيقات إذا لم يتم استخدامها بحكمة. Zone.js يعمل عن طريق تعديل الـ Event Loop الأصلي للمتصفح، مما يسمح له باعتراض جميع الأحداث غير المتزامنة مثل setTimeout أو XMLHttpRequest. المشكلة هي أن Zone.js لا يعرف أي من هذه الأحداث مهم وأيها غير مهم، لذلك يقوم بتشغيل دورة كشف التغييرات بعد كل حدث، حتى لو كان هذا الحدث مجرد تحديث مؤقت للواجهة لا يحتاج إلى إعادة حساب.
في أحد المشاريع، كان لدينا مكون يعرض مؤقتاً للعد التنازلي. هذا المكون كان يستخدم setInterval لتحديث الوقت كل ثانية. مع Default Change Detection، كان هذا يعني أن Angular يقوم بتشغيل دورة كشف التغييرات كل ثانية، حتى لو لم يكن هناك أي تغيير حقيقي في البيانات. بعد تحليل الأداء، اكتشفنا أن هذا المكون وحده كان يسبب 60 دورة كشف تغييرات غير ضرورية في الدقيقة الواحدة. الحل؟ استخدام ChangeDetectionStrategy.OnPush وتحديث المكون يدوياً فقط عند الحاجة.
// مكون مؤقت مع OnPush لتجنب Change Detection غير الضروري
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
@Component({
selector: 'app-countdown',
template: `{{ minutes }}:{{ seconds }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CountdownComponent {
minutes: number = 5;
seconds: number = 0;
private intervalId: any;
constructor(private cdr: ChangeDetectorRef) {}
startCountdown() {
this.intervalId = setInterval(() => {
if (this.sec== 0) {
this.minutes--;
this.seconds = 59;
} else {
this.seconds--;
}
// تحديث يدوي فقط عند الحاجة
this.cdr.detectChanges();
}, 1000);
}
ngOnDestroy() {
clearInterval(this.intervalId);
}
}أول شيء يجب أن تفعله لتحسين أداء تطبيق Angular هو تقليل حجم الحزمة الأولية. كل كيلوبايت إضافي يعني وقت تحميل أطول، خاصة على الشبكات البطيئة. في أحد المشاريع التي عملت عليها، كان حجم الحزمة الأولية 4.5 ميغابايت، وكان الـ Time to Interactive يتجاوز 20 ثانية على شبكات 3G. بعد تطبيق تقنيات التحسين، تمكنا من تقليل الحجم إلى 800 كيلوبايت، مما خفض وقت التحميل إلى أقل من 3 ثوانٍ.
هناك عدة طرق لتقليل حجم الحزمة، وأولها هو استخدام Ivy Compiler بشكل صحيح. Ivy هو المحرك الجديد لـ Angular الذي يقلل حجم الحزمة بشكل كبير عن طريق إزالة الكود الميت وتحسين الـ Tree Shaking. لكن حتى مع Ivy، هناك الكثير مما يمكنك فعله. على سبيل المثال، استخدام مكتبات خارجية بحكمة، وتجنب استيراد كل شيء من مكتبة واحدة، واستخدام التحميل الكسول للمكونات الكبيرة.
التحميل الكسول هو أحد أقوى الأدوات لتحسين أداء Angular، لكنه غالباً ما يتم استخدامه بشكل خاطئ. الفكرة بسيطة: بدلاً من تحميل جميع مكونات التطبيق عند فتح الصفحة الرئيسية، قم بتحميل المكونات فقط عند الحاجة إليها. لكن المشكلة هي أن الكثير من المطورين يستخدمون التحميل الكسول للمكونات الصغيرة، مما يزيد من عدد الطلبات للمتصفح ويبطئ التطبيق بدلاً من تسريعه.
في أحد المشاريع، كان لدينا لوحة تحكم معقدة تحتوي على 10 مكونات مختلفة، كل مكون بحجم 200 كيلوبايت. بدلاً من تحميل جميع المكونات عند فتح الصفحة، استخدمنا التحميل الكسول للمكونات التي لا تظهر إلا بعد تفاعل المستخدم، مثل النماذج والنوافذ المنبثقة. النتيجة؟ تم تقليل حجم الحزمة الأولية من 2 ميغابايت إلى 600 كيلوبايت، مما خفض وقت التحميل بنسبة 70%. لكن الأهم من ذلك هو أننا قللنا عدد الطلبات الأولية من 15 إلى 5 فقط، مما حسن من تجربة المستخدم بشكل كبير.
// مثال على التحميل الكسول الصحيح في Angular
// في ملف التوجيهات (routing module)
const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
children: [
{
path: 'reports',
loadChildren: () => import('./reports/reports.module').then(m => m.ReportsModule)
},
{
path: 'settings',
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule)
}
]
}
];
// في ملف reports.module.ts
@NgModule({
declarations: [ReportsComponent, ReportDetailComponent],
imports: [CommonModule, RouterModule.forChild(reportsRoutes)]
})
export class ReportsModule {}أداة webpack-bundle-analyzer هي أفضل صديق لك عندما يتعلق الأمر بتحليل حجم الحزمة. هذه الأداة تولد تقريراً مرئياً يوضح بالضبط ما الذي يشغل مساحة في الحزمة الخاصة بك. في أحد المشاريع، اكتشفنا باستخدام هذه الأداة أن مكتبة moment.js كانت تستهلك 300 كيلوبايت من الحزمة، على الرغم من أننا كنا نستخدم فقط جزء صغير منها. استبدلناها بـ date-fns، التي تسمح باستيراد الوظائف الفردية فقط، مما قلل الحجم إلى 50 كيلوبايت فقط.
هناك أيضاً أداة source-map-explorer التي تساعدك على فهم كيف يتم تجميع الكود الخاص بك. في أحد المشاريع، اكتشفنا أن Angular كان يكرر بعض الكود في عدة أجزاء من الحزمة بسبب استخدامنا الخاطئ لـ providedIn: 'root' في الخدمات. بعد إصلاح هذا الأمر، تمكنا من تقليل حجم الحزمة بنسبة 15%.
# تثبيت أدوات تحليل الحزمة
npm install --save-dev webpack-bundle-analyzer source-map-explorer
# إضافة سكربتات في package.json
"scripts": {
"analyze": "ng build --prod --stats-json && webpack-bundle-analyzer dist/stats.json",
"explore": "source-map-explorer dist/{your-app-name}/main.*.js"
}
# تشغيل التحليل
npm run analyzeحتى لو كان تطبيقك خفيفاً وسريع التحميل، يمكن أن يشعر المستخدم بالبطء إذا كانت واجهة المستخدم لا تستجيب فوراً لتفاعلاته. السبب الرئيسي وراء هذا البطء هو الـ Rendering Pipeline غير المحسن. في Angular، كل دورة كشف تغييرات تؤدي إلى إعادة حساب الـ DOM، وإذا كان هذا الحساب معقداً، يمكن أن يؤدي إلى تجمد واجهة المستخدم.
في أحد المشاريع، كان لدينا قائمة تحتوي على 1000 عنصر، وكل عنصر يحتوي على عدة مكونات فرعية. عند التمرير السريع في القائمة، كان التطبيق يتجمد لمدة نصف ثانية بسبب إعادة حساب الـ DOM لجميع العناصر المرئية. الحل؟ استخدام تقنية الـ Virtual Scrolling، التي تقوم بعرض العناصر المرئية فقط وتعيد استخدام مكونات الـ DOM بدلاً من إعادة إنشائها من الصفر. النتيجة؟ تجربة تمرير سلسة حتى مع 10,000 عنصر في القائمة.
// استخدام Virtual Scrolling مع CDK في Angular
import { Component } from '@angular/core';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
@Component({
selector: 'app-virtual-scroll-list',
template: `
<cdk-virtual-scroll-viewport itemSize="50" class="list-container">
<div *cdkVirtualFor="let item of items" class="list-item">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [
`.list-container { height: 500px; }
.list-item { height: 50px; }`
]
})
export class VirtualScrollListComponent {
items = Array.from({length: 10000}).map((_, i) => ({ name: `Item #${i}` }));
}استخدام ChangeDetectionStrategy.OnPush هو أحد أقوى الأدوات لتحسين أداء Angular، لكنه يتطلب فهماً عميقاً لكيفية عمل الـ Change Detection. الفكرة الأساسية هي أن المكون لن يقوم بإعادة حساب الـ DOM إلا إذا تغيرت مدخلاته (Inputs) أو إذا تم تشغيل دورة كشف التغييرات يدوياً. هذا يعني أنك بحاجة إلى تصميم مكوناتك بحيث تكون مدخلاتها غير قابلة للتغيير (Immutable).
في أحد المشاريع، كان لدينا مكون يعرض بيانات من API. كان هذا المكون يستخدم Default Change Detection، مما يعني أنه كان يعيد حساب الـ DOM عند كل تحديث للبيانات، حتى لو كانت البيانات نفسها لم تتغير. بعد تحويل المكون إلى OnPush، قمنا بتحديث المدخلات فقط عند الحاجة باستخدام Spread Operator لإنشاء كائنات جديدة. النتيجة؟ عدد دورات كشف التغييرات انخفض من 150 إلى 5 في الثانية الواحدة، مما حسن من أداء التطبيق بشكل ملحوظ.
// مكون مع OnPush وتحديثات غير قابلة للتغيير
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
@Input() user: { name: string, email: string };
}
// في المكون الأب
this.users = [...this.users, newUser]; // تحديث غير قابل للتغيير
// بدلاً من: this.users.push(newUser);عندما تستخدم *ngFor لعرض قائمة من العناصر، يقوم Angular بإعادة إنشاء جميع عناصر الـ DOM عند كل تحديث للقائمة، حتى لو كان التغيير بسيطاً مثل إضافة عنصر جديد. هذا يمكن أن يؤدي إلى بطء شديد في القوائم الكبيرة. الحل؟ استخدام TrackBy لتحديد مفتاح فريد لكل عنصر، مما يسمح لـ Angular بإعادة استخدام عناصر الـ DOM الموجودة بدلاً من إعادة إنشائها.
في أحد المشاريع، كان لدينا قائمة تحتوي على 500 عنصر، وكان كل تحديث للقائمة يؤدي إلى إعادة إنشاء جميع عناصر الـ DOM، مما كان يسبب تجمد واجهة المستخدم لمدة ثانية. بعد إضافة TrackBy، تمكنا من تقليل وقت التحديث من 1000 مللي ثانية إلى 50 مللي ثانية فقط، لأن Angular كان يعيد استخدام عناصر الـ DOM الموجودة بدلاً من إعادة إنشائها.
// استخدام TrackBy مع *ngFor
import { Component } from '@angular/core';
@Component({
selector: 'app-product-list',
template: `
<div *ngFor="let product of products; trackBy: trackByProductId">
{{ product.name }} - {{ product.price }}
</div>
`
})
export class ProductListComponent {
products = [
{ id: 1, name: 'Product 1', price: 100 },
{ id: 2, name: 'Product 2', price: 200 }
];
trackByProductId(index: number, product: any): number {
return product.id; // مفتاح فريد لكل عنصر
}
}تسريب الذاكرة وتجميد المعالج هما من أسوأ أعداء أداء تطبيقات Angular. تسريب الذاكرة يحدث عندما تحتفظ بمراجع للكائنات التي لم تعد بحاجة إليها، مما يمنع الـ Garbage Collector من تحرير الذاكرة. تجميد المعالج يحدث عندما تقوم بعمليات حسابية ثقيلة في الـ Main Thread، مما يمنع المتصفح من تحديث واجهة المستخدم.
في أحد المشاريع، كان لدينا مشكلة تسريب ذاكرة في مكون يستخدم WebSocket لتلقي تحديثات البيانات في الوقت الفعلي. كان المكون يقوم بإنشاء اشتراك جديد عند كل تهيئة دون إلغاء الاشتراك عند التدمير، مما كان يؤدي إلى تراكم الاشتراكات القديمة واستهلاك الذاكرة بشكل متزايد. بعد إصلاح هذا الأمر باستخدام ngOnDestroy لإلغاء الاشتراكات، تمكنا من تقليل استهلاك الذاكرة بنسبة 40%.
// تجنب تسريب الذاكرة بإلغاء الاشتراكات
import { Component, OnInit, OnDestroy } from '@angular/core';
import { WebSocketService } from './web-socket.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-realtime-data',
template: `{{ data }}`
})
export class RealtimeDataComponent implements OnInit, OnDestroy {
data: string;
private subscription: Subscription;
constructor(private webSocketService: WebSocketService) {}
ngOnInit() {
this.subscription = this.webSocketService.getData().subscribe(data => {
this.data = data;
});
}
ngOnDestroy() {
this.subscription.unsubscribe(); // إلغاء الاشتراك لتجنب تسريب الذاكرة
}
}العمليات الحسابية الثقيلة مثل معالجة الصور أو تحليل البيانات الكبيرة يجب ألا تتم في الـ Main Thread، لأنها ستجمد واجهة المستخدم. الحل؟ استخدام Web Workers لتشغيل هذه العمليات في الخلفية. في أحد المشاريع، كان لدينا مكون يقوم بمعالجة صور كبيرة على العميل، وكان هذا يسبب تجمد واجهة المستخدم لمدة 5 ثوانٍ عند كل تحميل صورة. بعد نقل عملية المعالجة إلى Web Worker، أصبحت واجهة المستخدم تستجيب فوراً، حتى أثناء معالجة الصور.
Web Workers يسمحون لك بتشغيل جافا سكريبت في خيط منفصل عن الـ Main Thread، مما يمنع تجميد واجهة المستخدم. لكن استخدامهم يتطلب بعض الحذر، لأنهم لا يستطيعون الوصول إلى الـ DOM مباشرة ويجب التواصل معهم عبر الرسائل. في المثال التالي، سنرى كيف يمكن استخدام Web Worker لمعالجة بيانات كبيرة بدون تجميد واجهة المستخدم.
// main.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-data-processor',
template: `
<button (click)="processData()">معالجة البيانات</button>
<div *ngIf="isProcessing">جاري المعالجة...</div>
<div>{{ result }}</div>
`
})
export class DataProcessorComponent {
isProcessing = false;
result: string;
processData() {
this.isProcessing = true;
const worker = new Worker('./data.worker', { type: 'module' });
worker. ({ data }) => {
this.result = data;
this.isProcessing = false;
};
worker.postMessage({ data: Array.from({length: 1000000}, (_, i) => i) });
}
}// data.worker.ts
addEventListener('message', ({ data }) => {
const result = data.data.reduce((acc, val) => acc + val, 0);
postMessage(`النتيجة: ${result}`);
});إذا كنت تستخدم setTimeout أو setInterval للرسوم المتحركة، فأنت ترتكب خطأ كبيراً. هذه الطرق لا تأخذ في الاعتبار حالة المعالج أو معدل تحديث الشاشة، مما يمكن أن يؤدي إلى تحديثات غير سلسة أو استهلاك غير ضروري للمعالج. بدلاً من ذلك، استخدم requestAnimationFrame، الذي يقوم بتشغيل الرسوم المتحركة في الوقت المناسب مع معدل تحديث الشاشة (عادة 60 مرة في الثانية).
في أحد المشاريع، كان لدينا مكون يعرض رسماً متحركاً معقداً، وكان يستخدم setInterval لتحديث الرسم كل 16 مللي ثانية (لتحقيق 60 إطار في الثانية). المشكلة هي أن setInterval لا يهتم بما إذا كان المعالج مشغولاً أم لا، مما كان يؤدي إلى تحديثات غير سلسة واستهلاك زائد للمعالج. بعد استبداله بـ requestAnimationFrame، أصبحت الرسوم المتحركة سلسة واستهلكت موارد أقل بكثير.
// استخدام requestAnimationFrame للرسوم المتحركة
import { Component, ElementRef, OnInit, OnDestroy } from '@angular/core';
@Component({
selector: 'app-animation',
template: `<canvas #canvas></canvas>`
})
export class AnimationComponent implements OnInit, OnDestroy {
private animationId: number;
private ctx: CanvasRenderingContext2D;
constructor(private el: ElementRef) {}
ngOnInit() {
const canvas = this.el.nativeElement.querySelector('canvas');
this.ctx = canvas.getContext('2d');
this.animate();
}
animate() {
// تحديث الرسم هنا
this.ctx.clearRect(0, 0, 500, 500);
this.ctx.fillStyle = 'blue';
this.ctx.fillRect(10, 10, 50, 50);
// طلب الإطار التالي
this.animati requestAnimationFrame(() => this.animate());
}
ngOnDestroy() {
cancelAnimationFrame(this.animationId); // إلغاء الرسوم المتحركة
}
}بعد أكثر من عشر سنوات في تطوير تطبيقات Angular، تعلمت أن تحسين الأداء ليس مجرد مجموعة من النصائح، بل هو أسلوب تفكير. يجب أن تفكر دائماً في كيفية تقليل العمل الذي يقوم به المتصفح، سواء كان ذلك عن طريق تقليل حجم الحزمة، أو تقليل عدد دورات كشف التغييرات، أو تجنب تسريب الذاكرة. إليك أهم النصائح التي أستخدمها في كل مشروع:
أخيراً، تذكر أن تحسين الأداء هو عملية مستمرة. لا تنتظر حتى يصبح التطبيق بطيئاً لتفكر في الأداء. ابدأ بالتفكير فيه منذ اليوم الأول، وقم بقياس الأداء بانتظام. في أحد المشاريع الأخيرة، قمنا بتحسين أداء التطبيق بنسبة 80% فقط عن طريق تطبيق هذه التقنيات، دون الحاجة إلى إعادة كتابة أي جزء كبير من الكود. الأداء الجيد ليس صدفة، بل هو نتيجة للتفكير المستمر في كيفية جعل التطبيق يعمل بكفاءة أكبر.