تطبيق Angular بطيء؟ اكتشف كيف تحول تطبيقاتك من متلكئة إلى سريعة كالبرق باستخدام تقنيات مجربة من أرض المعركة، مع شرح عميق لما يحدث خلف الكواليس في الذاكرة والمعالج.
في أحد المشاريع الكبيرة لشركة اتصالات، كان لدينا تطبيق Angular لإدارة حسابات المشتركين. عند فتح صفحة العملاء، كان المتصفح يتجمد لثانيتين كاملتين قبل أن يظهر أي شيء. المأساة؟ كنا نرسل 3.2 ميجابايت من جافا سكريبت فقط لتحميل الصفحة الرئيسية. المشكلة لم تكن في حجم الكود فقط، بل في الطريقة التي كان Angular يعالجه بها. الـ Event Loop كان يتوقف تماماً أثناء تحميل وتحليل الـ Bundles، والـ Change Detection كان يشتغل كالوحش كل مرة يلمس المستخدم أي شيء. هذا ليس مجرد بطء، بل هو تدمير لتجربة المستخدم وتحويل التطبيق إلى شيء غير صالح للاستخدام في بيئات الإنتاج الحقيقية.
الحقيقة التي لا يريد الكثيرون الاعتراف بها هي أن Angular ليس بطيئاً بطبيعته، لكن الطريقة التي نكتب بها التطبيقات غالباً ما تكون كارثية للأداء. المشكلة الأكبر ليست في Framework نفسه، بل في أننا نتعامل معه وكأننا نكتب تطبيق PHP في التسعينات - نضع كل شيء في Component واحد، نستخدم الـ Two-Way Binding بدون تفكير، ونملأ الـ Templates بكود منطقي معقد. في هذا المقال، سأريك كيف تحولنا من تطبيق يستغرق 4.7 ثانية للتحميل الكامل إلى تطبيق يظهر المحتوى في أقل من 500 مللي ثانية، مع الحفاظ على نفس الوظائف بالضبط.
عندما نتحدث عن أداء Angular، أول شيء يجب أن نفكر فيه هو حجم الـ Bundle النهائي. لكن الأرقام وحدها لا تكفي - يجب أن نفهم ماذا يحدث خلف الكواليس. عندما يرسل المتصفح 3 ميجابايت من جافا سكريبت، فهو لا يقوم فقط بتنزيل الملف، بل عليه تحليل الكود (Parsing)، تجميعه (Compilation)، ثم تنفيذه. هذه العملية تستهلك موارد المعالج والذاكرة بشكل كبير، خاصة على الأجهزة الضعيفة أو الهواتف المحمولة. في أحد المشاريع، اكتشفنا أن 60% من وقت التحميل كان يذهب لتحليل الكود فقط، وليس لتنفيذه!
الحل ليس مجرد تقليل حجم الكود، بل هو فهم كيف يعمل Angular تحت الغطاء. مثلاً، عند استخدام Ivy Compiler (الذي أصبح افتراضياً منذ Angular 8)، يتم تحويل التemplates إلى تعليمات تنفيذية مباشرة بدلاً من الاعتماد على الـ View Engine القديم. هذا يعني أن الكود الناتج يكون أصغر وأكثر كفاءة، لكن هذا لا يعني أننا نستطيع تجاهل حجم الـ Bundle. إليك ما فعلناه بالضبط:
// قبل: استيراد كامل لمكتبة RxJS
import * as rxjs from 'rxjs';
// بعد: استيراد محدد لوظائف محددة فقط
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Subject } from 'rxjs';
// في package.json
{
"sideEffects": [
"**/*.css",
"**/*.scss"
]
}
// في angular.json
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}عندما يرسل المتصفح ملف جافا سكريبت بحجم 3 ميجابايت، يبدأ الـ JavaScript Engine في تحليله. هذه العملية تسمى Parsing، وهي تستهلك وقتاً ومعالجاً. بعد ذلك، يأتي مرحلة الـ Compilation حيث يتم تحويل الكود إلى تعليمات منخفضة المستوى يمكن للمعالج فهمها. وأخيراً، يأتي التنفيذ الفعلي للكود. المشكلة أن هذه العمليات كلها تحدث في الـ Main Thread، مما يعني أنها توقف كل شيء آخر في الصفحة. في مشروعنا، اكتشفنا أن هذه العملية كانت تستغرق 2.3 ثانية على جهاز متوسط، بينما كانت تستغرق أكثر من 5 ثوانٍ على هاتف ذكي قديم.
الـ Change Detection في Angular هو سلاح ذو حدين. من ناحية، هو ما يجعل التطبيق تفاعلياً ويحدث الواجهة تلقائياً عند تغيير البيانات. من ناحية أخرى، هو أحد أكبر أسباب البطء في التطبيقات الكبيرة. المشكلة الأساسية هي أن Angular، افتراضياً، يشغل الـ Change Detection على كل مكون في التطبيق عند حدوث أي حدث - سواء كان ضغط زر، أو طلب HTTP، أو حتى Timer. في تطبيق متوسط الحجم، هذا يعني أن الـ Change Detection قد يشتغل مئات المرات في الثانية، حتى لو لم يتغير شيء فعلياً في البيانات.
في أحد المشاريع، كان لدينا جدول يحتوي على 500 صف، وكل صف يحتوي على 10 مكونات فرعية. عند تغيير قيمة واحدة في الجدول، كان Angular يشغل الـ Change Detection على كل المكونات الـ 5000! النتيجة؟ تجمد الصفحة لمدة ثانية كاملة عند كل تغيير بسيط. الحل لم يكن في إلغاء الـ Change Detection بالكامل، بل في التحكم فيه بذكاء:
@Component({
selector: 'app-data-table',
templateUrl: './data-table.component.html',
changeDetection: ChangeDetectionStrategy.OnPush // <-- المفتاح هنا
})
export class DataTableComponent {
@Input() data: DataItem[];
// بدلاً من استخدام Two-Way Binding، نستخدم Input و Output بشكل صريح
@Output() dataChange = new EventEmitter<DataItem[]>();
updateItem(item: DataItem) {
// منطق التحديث
this.dataChange.emit([...this.data]); // <-- Immuntable update
}
}
// في الـ Template
<div *ngFor="let item of data; trackBy: trackById">
<!-- المحتوى -->
</div>
// في الـ Component
trackById(index: number, item: DataItem): number {
return item.id; // <-- مفتاح فريد لكل عنصر
}السر هنا هو استخدام ChangeDetectionStrategy.OnPush. هذا يعني أن Angular لن يشغل الـ Change Detection على هذا المكون إلا في حالتين محددتين: عندما يتغير الـ Input بشكل مباشر (ونحن نضمن ذلك باستخدام التحديثات Immutable)، أو عندما نشغل الـ Change Detection يدوياً باستخدام ChangeDetectorRef. بالإضافة إلى ذلك، استخدمنا trackBy في الـ ngFor لمنع إعادة إنشاء الـ DOM Elements عند كل تغيير، مما قلل وقت التحديث من 1200 مللي ثانية إلى 80 مللي ثانية فقط.
عندما تستخدم ChangeDetectionStrategy.Default، يقوم Angular بإنشاء ما يسمى بـ ChangeDetector لكل مكون. هذا الـ ChangeDetector هو كائن صغير مسؤول عن مقارنة القيم القديمة بالجديدة وتحديث الـ DOM إذا لزم الأمر. المشكلة أن هذه العملية تحدث بشكل تسلسلي على كل مكونات التطبيق، من الجذر إلى الأوراق. في تطبيق كبير، قد يكون لديك آلاف من هذه الـ ChangeDetectors، وكل واحد منهم يستهلك وقتاً ومعالجاً. عندما تستخدم OnPush، فإنك تخبر Angular أن هذا المكون لا يحتاج إلى فحص إلا في حالات محددة، مما يقلل عدد الـ ChangeDetectors النشطين بشكل كبير.
الـ Lazy Loading ليس مجرد تقسيم التطبيق إلى موديولات. في معظم التطبيقات، نرى المطورين ينشئون موديولاً واحداً لكل صفحة، ثم يستخدمون loadChildren في الـ Routing. هذا جيد، لكنه ليس كافياً. المشكلة أن حتى هذه الموديولات قد تكون كبيرة وتحتوي على مكونات ومكتبات لا تحتاجها الصفحة فوراً. في مشروعنا، كان لدينا موديول لإدارة المستخدمين بحجم 1.2 ميجابايت، على الرغم من أننا كنا نحتاج فقط إلى جزء صغير منه عند تحميل الصفحة لأول مرة.
الحل كان في تطبيق ما أسميه "الـ Lazy Loading المتدرج". بدلاً من تحميل الموديول بالكامل، قسمنا الصفحة إلى أجزاء صغيرة يتم تحميلها فقط عند الحاجة. مثلاً، في صفحة المستخدم، قسمنا المحتوى إلى:
const routes: Routes = [
{
path: 'users',
component: UsersPageComponent,
children: [
{
path: '',
loadChildren: () => import('./users-core/users-core.module').then(m => m.UsersCoreModule)
},
{
path: 'profile',
loadChildren: () => import('./users-profile/users-profile.module').then(m => m.UsersProfileModule)
},
{
path: 'settings',
loadChildren: () => import('./users-settings/users-settings.module').then(m => m.UsersSettingsModule),
canLoad: [AuthGuard] // <-- تحميل مشروط
}
]
}
];
// في المستخدمين الذين يحتاجون إلى تحميل سريع جداً، استخدمنا Preloading Strategy
@NgModule({
imports: [RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules // أو استراتيجية مخصصة
})],
exports: [RouterModule]
})
export class AppRoutingModule { }لكننا لم نتوقف عند هذا الحد. استخدمنا أيضاً الـ Preloading Strategy لتحميل بعض الموديولات في الخلفية بعد تحميل الصفحة الرئيسية. مثلاً، إذا كان المستخدم غالباً ما ينتقل إلى صفحة التقارير بعد تسجيل الدخول، فنحن نبدأ في تحميل موديول التقارير في الخلفية بينما المستخدم يتفاعل مع الصفحة الرئيسية. هذا جعل الانتقال إلى صفحة التقارير يبدو فورياً، على الرغم من أن الموديول بحجم 800 كيلوبايت.
عندما تستخدم الـ Lazy Loading بشكل صحيح، فإنك لا تقلل فقط حجم الـ Initial Bundle، بل تقلل أيضاً من استخدام الذاكرة. كل موديول يتم تحميله يضيف إلى الـ Memory Footprint للتطبيق. في تطبيقنا قبل التحسين، كان الـ Memory Usage يصل إلى 350 ميجابايت بعد فتح عدة صفحات. بعد تطبيق الـ Lazy Loading المتدرج، انخفض هذا الرقم إلى 120 ميجابايت فقط، لأن الموديولات التي لم نكن نستخدمها لم تكن تُحمل إلى الذاكرة أصلاً. بالإضافة إلى ذلك، قللنا من عدد الـ Observables و الـ Subscriptions النشطة في الذاكرة، مما قلل من احتمالية الـ Memory Leaks.
الـ Rendering هو المكان الذي يظهر فيه البطء للمستخدم بشكل مباشر. عندما تضغط على زر ولا يحدث شيء لمدة ثانية، أو عندما تنتقل بين الصفحات وتتجمد الواجهة، فهذا غالباً بسبب مشاكل في الـ Rendering. في Angular، معظم مشاكل الـ Rendering تأتي من ثلاث مصادر رئيسية: الـ Change Detection غير المتحكم فيه، والـ DOM Manipulation المفرط، واستخدام مكونات ثقيلة بدون تفكير.
في أحد المشاريع، كان لدينا جدول بيانات يحتوي على 1000 صف، وكل صف يحتوي على 15 عموداً. عند محاولة التمرير في الجدول، كان الـ FPS ينخفض إلى 10 فقط، مما يجعل التجربة مؤلمة. الحل لم يكن في إلغاء الجدول بالكامل، بل في تطبيق تقنيات تحسين متقدمة:
@Component({
selector: 'app-virtual-table',
template: `
<cdk-virtual-scroll-viewport itemSize="50" class="viewport">
<div *cdkVirtualFor="let item of data; let i = index"
class="row"
[style.transform]="'translateY(' + (i * 50) + 'px)'">
{{item.name}} - {{item.value}}
</div>
</cdk-virtual-scroll-viewport>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VirtualTableComponent {
@Input() data: DataItem[] = [];
}
// باستخدام RxJS للتحكم في التحديثات
this.data$ = this.dataService.getData().pipe(
debounceTime(300),
distinctUntilChanged(),
map(data => data.slice(0, 1000)) // الحد الأقصى للبيانات
);لكن التحسين الحقيقي جاء من فهم كيف يعمل الـ Rendering في المتصفح. كل مرة نحدث فيها الـ DOM، يقوم المتصفح بإعادة حساب الـ Layout، و الـ Paint، و الـ Composite. هذه العمليات الثلاث تستهلك وقتاً ومعالجاً، خاصة إذا كنا نحدث عناصر كثيرة في نفس الوقت. باستخدام Virtual Scrolling، قللنا عدد العناصر التي يتم تحديثها في كل مرة من 1000 إلى 20 فقط، مما جعل الـ Rendering أسرع بعشرات المرات.
عندما تقوم بتحسين الـ Rendering، فإنك لا تساعد فقط الـ CPU، بل تساعد أيضاً الـ GPU. في المثال السابق، عندما كان لدينا 1000 عنصر DOM، كان المتصفح يقوم بعملية تسمى "Layout Thrashing"، حيث يقوم بإعادة حساب الـ Layout مرات عديدة في الثانية. هذا يسبب ضغطاً كبيراً على الـ GPU، خاصة إذا كانت العناصر تحتوي على تأثيرات مثل الظلال أو التدرجات اللونية. باستخدام Virtual Scrolling، قللنا من عدد العناصر التي تحتاج إلى إعادة رسم، مما سمح للـ GPU بالتركيز على العناصر المرئية فقط. النتيجة كانت زيادة في الـ FPS من 10 إلى 60، مما جعل التجربة سلسة كالحرير.
الـ Memory Leaks هي القاتل الصامت لتطبيقات Angular. في معظم الأحيان، لا تلاحظها حتى يصبح التطبيق بطيئاً جداً أو يتوقف عن الاستجابة. المشكلة الأكبر هي أن هذه الـ Leaks غالباً ما تأتي من ممارسات تبدو بريئة، مثل الـ Subscriptions التي لا يتم إلغاءها، أو الـ Event Listeners التي تبقى نشطة، أو حتى الـ Cached Data التي لا يتم تنظيفها أبداً.
في أحد المشاريع، كان لدينا تطبيق لإدارة المهام يحتوي على ميزة البحث الفوري. كل مرة يكتب المستخدم حرفاً، كنا نرسل طلب HTTP للبحث في قاعدة البيانات. المشكلة؟ كنا لا نلغي الـ Subscriptions السابقة، مما أدى إلى تراكم مئات الـ Subscriptions في الذاكرة. بعد ساعة من الاستخدام، كان التطبيق يستهلك أكثر من 1 جيجابايت من الذاكرة! الحل لم يكن معقداً، لكنه يتطلب فهم عميق لكيفية عمل RxJS و Angular معاً:
@Component({
selector: 'app-search',
template: `
<input (input)="onSearch($event)">
<div *ngFor="let result of results$">
{{result.name}}
</div>
`
})
export class SearchComponent implements OnDestroy {
private destroy$ = new Subject<void>();
results$: Observable<SearchResult[]>;
constructor(private searchService: SearchService) {}
onSearch(event: Event) {
const query = (event.target as HTMLInputElement).value;
this.results$ = this.searchService.search(query).pipe(
takeUntil(this.destroy$) // <-- إلغاء الـ Subscription عند تدمير المكون
);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
// في الخدمة
search(query: string): Observable<SearchResult[]> {
return this.http.get<SearchResult[]>(`/api/search?q=${query}`).pipe(
shareReplay(1) // <-- منع إرسال طلبات متعددة لنفس الاستعلام
);
}السر هنا هو استخدام takeUntil مع Subject خاص بالمكون. عندما يتم تدمير المكون، نرسل إشارة عبر هذا الـ Subject، مما يؤدي إلى إلغاء جميع الـ Subscriptions المرتبطة به. بالإضافة إلى ذلك، استخدمنا shareReplay لمنع إرسال طلبات HTTP متعددة لنفس الاستعلام، مما قلل من الضغط على السيرفر وتحسين أداء التطبيق بشكل كبير.
عندما يحدث الـ Memory Leak في تطبيق Angular، فإنك تحتجز مراجع لكائنات لم تعد بحاجة إليها. هذه الكائنات تبقى في الذاكرة ولا يمكن لـ Garbage Collector التخلص منها. في المثال السابق، كل مرة كنا ننشئ Subscription جديدة بدون إلغاء القديمة، كنا نحتفظ بمرجع للـ Observable والـ Observer في الذاكرة. مع مرور الوقت، تتراكم هذه المراجع، مما يؤدي إلى زيادة استخدام الذاكرة بشكل مستمر. في أسوأ الحالات، قد يؤدي هذا إلى توقف المتصفح بالكامل بسبب نفاد الذاكرة. الحل هو دائماً التأكد من إلغاء الـ Subscriptions عند عدم الحاجة إليها، واستخدام أدوات مثل Chrome DevTools لمراقبة استخدام الذاكرة وتحديد الـ Leaks قبل أن تصبح مشكلة كبيرة.
في عالم التطبيقات الحديثة، لا يعتمد الأداء فقط على الكود الذي تكتبه، بل على كيفية تفاعلك مع السيرفر. الـ HTTP Requests هي واحدة من أكبر مصادر البطء في أي تطبيق، خاصة إذا كنت تتعامل مع شبكات بطيئة أو سيرفرات بعيدة. في أحد المشاريع، كان لدينا تطبيق يحتاج إلى تحميل 15 طلب HTTP مختلف عند بدء التشغيل. حتى مع شبكة سريعة، كان هذا يستغرق 3-4 ثوانٍ، مما يجعل التطبيق يبدو بطيئاً للغاية.
الحل لم يكن في تقليل عدد الطلبات فقط، بل في جعل التطبيق يشعر بأنه أسرع حتى قبل اكتمال التحميل. إليك ما فعلناه بالضبط:
@Injectable()
export class CachingInterceptor implements HttpInterceptor {
private cache = new Map<string, { response: HttpResponse<any>, expires: number }>();
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.method !== 'GET') {
return next.handle(req);
}
const cachedResp this.cache.get(req.urlWithParams);
if (cachedResponse && cachedResponse.expires > Date.now()) {
return of(cachedResponse.response.clone());
}
return next.handle(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
this.cache.set(req.urlWithParams, {
response: event.clone(),
expires: Date.now() + 5 * 60 * 1000 // 5 دقائق
});
}
})
);
}
}
// في الـ Service
getData(): Observable<Data[]> {
return this.http.get<Data[]>('/api/data').pipe(
shareReplay(1), // <-- مشاركة الـ Subscription بين المتصلين
catchError(error => {
// العودة إلى البيانات المخزنة مؤقتاً في حالة الخطأ
return of(this.localCache.get('data') || []);
})
);
}لكن التحسين الحقيقي جاء من فهم كيف يعمل الـ Perceived Performance. بدلاً من جعل المستخدم ينتظر حتى تكتمل جميع الطلبات، قمنا بتحميل البيانات الأساسية أولاً وعرضناها للمستخدم، ثم قمنا بتحميل الباقي في الخلفية. مثلاً، في تطبيق التجارة الإلكترونية، قمنا بتحميل قائمة المنتجات أولاً، ثم قمنا بتحميل الصور والتفاصيل الإضافية بعد ذلك. هذا جعل التطبيق يشعر بأنه أسرع بكثير، على الرغم من أن الوقت الكلي للتحميل لم يتغير كثيراً.
عندما تقوم بتحسين الـ HTTP Requests، فإنك تقلل من عدد الـ Round Trips بين المتصفح والسيرفر، مما يقلل من تأثير الـ Latency. في المثال السابق، عندما كنا نرسل 15 طلباً مختلفاً، كان كل طلب يستغرق 100-200 مللي ثانية فقط بسبب الـ Latency، حتى لو كانت البيانات صغيرة. باستخدام الـ Caching و الـ Interceptors، قللنا من عدد الطلبات إلى 3 فقط، مما قلل من الوقت الكلي للـ Network من 2.5 ثانية إلى 500 مللي ثانية. بالإضافة إلى ذلك، قللنا من الضغط على السيرفر، مما سمح له بالتعامل مع المزيد من المستخدمين في نفس الوقت.
التحسين بدون قياس هو مجرد تخمين. في معظم المشاريع، نرى المطورين يحاولون تحسين الأداء بناءً على شعورهم الشخصي أو بناءً على ملاحظات المستخدمين. المشكلة أن هذا النهج غالباً ما يؤدي إلى إضاعة الوقت في تحسين أجزاء من التطبيق لا تؤثر كثيراً على الأداء الفعلي. في مشروعنا، استخدمنا أدوات مراقبة متقدمة لتحديد بالضبط أين كانت تكمن المشاكل:
// قياس الأداء باستخدام Web Vitals
import { getLCP, getFID, getCLS } from 'web-vitals';
getLCP(console.log);
getFID(console.log);
getCLS(console.log);
// مراقبة الأداء في بيئة الإنتاج
if (environment.production) {
Sentry.init({
dsn: 'YOUR_DSN',
integrations: [new Sentry.Integrations.BrowserTracing()],
tracesSampleRate: 1.0,
beforeSend(event) {
// فلترة الأخطاء غير المهمة
if (event.exception && event.exception.values) {
event.exception.values = event.exception.values.filter(e => {
return !e.value.includes('ResizeObserver');
});
}
return event;
}
});
}لكن الأدوات وحدها ليست كافية. يجب أن تعرف كيف تقرأ البيانات التي تقدمها لك. مثلاً، في أحد التقارير، اكتشفنا أن الـ Largest Contentful Paint كان يحدث بعد 3.2 ثانية، لكن الـ First Contentful Paint كان يحدث بعد 1.8 ثانية فقط. هذا يعني أن المستخدم كان يرى شيئاً على الشاشة بسرعة، لكن المحتوى الرئيسي كان يظهر متأخراً. الحل كان في تحسين تحميل الصور وتأجيل تحميل المحتوى غير الضروري، مما قلل الـ LCP إلى 1.5 ثانية فقط.
عندما تفتح الـ Performance Tab في Chrome DevTools، سترى خطاً زمنياً يوضح كل ما حدث في الصفحة خلال فترة التسجيل. الأجزاء المهمة التي يجب التركيز عليها هي:
في مثالنا، اكتشفنا أن معظم الوقت كان يذهب في الـ Scripting، خاصة في جزء يسمى "Angular Change Detection". هذا قادنا إلى التركيز على تحسين الـ Change Detection باستخدام OnPush و Virtual Scrolling، مما قلل وقت الـ Scripting من 1200 مللي ثانية إلى 300 مللي ثانية فقط.
بعد سنوات من العمل على تحسين أداء تطبيقات Angular، هذه هي النصائح التي أعتبرها لا تقدر بثمن:
في النهاية، تحسين أداء Angular ليس مجرد تطبيق مجموعة من التقنيات، بل هو تغيير في طريقة تفكيرك في بناء التطبيقات. بدلاً من التركيز فقط على كتابة الكود، يجب أن تفكر في كيفية تفاعل هذا الكود مع المتصفح والذاكرة والشبكة. عندما تبدأ في التفكير بهذه الطريقة، ستجد أن الأداء يتحسن بشكل كبير، ليس فقط في أرقام الـ Benchmark، بل في تجربة المستخدم الحقيقية.
الخطوة التالية؟ افتح تطبيق Angular الخاص بك الآن، وشغل Chrome DevTools، وسجل أداء الصفحة الرئيسية. ثم ابدأ في تطبيق التقنيات التي تعلمتها هنا، وقس النتائج. ستندهش من الفرق الذي يمكن أن تحدثه بضعة تغييرات ذكية.