تطبيق Angular بطيء؟ اكتشف كيف تحول تطبيقاتك من معاناة المستخدمين إلى تجربة سلسة باستخدام هذه التقنيات المجربة التي استخدمتها في مشاريع حقيقية لتخفيض زمن التحميل بنسبة 70%.
عندما يفتح المستخدم تطبيق Angular الخاص بك ويجد نفسه ينتظر خمس ثوانٍ ليظهر زر واحد، فهذا ليس مجرد بطء عابر — إنه جريمة ضد تجربة المستخدم. الحقيقة الصادمة هي أن معظم تطبيقات Angular لا تعاني من نقص في الموارد، بل من سوء إدارة هذه الموارد. في عام 2023، أظهرت دراسة لشركة Google أن 53% من المستخدمين يغادرون موقعاً إذا استغرق تحميله أكثر من ثلاث ثوانٍ. تخيل أنك تخسر نصف عملائك المحتملين لأن تطبيقك لم يُحسن أداؤه بشكل صحيح. المشكلة ليست في Angular نفسه، بل في كيفية استخدامنا له. في هذا المقال، سأريك كيف تحول تطبيقك من بطيء إلى سريع باستخدام تقنيات مجربة في مشاريع حقيقية، وليس مجرد نظريات أكاديمية.
قبل أن نتعمق في الحلول، دعنا نفهم المشكلة من جذورها. عندما يكون تطبيق Angular بطيئاً، غالباً ما يكون السبب واحداً أو أكثر من هذه العوامل: تحميل وحدات JavaScript ضخمة، تحديثات DOM غير ضرورية، استدعاءات API متكررة، أو إدارة حالة غير فعالة. لكن أسوأ ما في الأمر هو أن هذه المشاكل لا تظهر بوضوح في بيئة التطوير المحلية، بل تظهر فقط في الإنتاج عندما يكون المستخدمون الحقيقيون على الخط. هذا هو السبب في أن تحسين الأداء يجب أن يكون جزءاً من عملية التطوير، وليس مجرد خطوة أخيرة قبل النشر.
التحميل الكسول (Lazy Loading) هو أول خطوة يجب اتخاذها لتحسين أداء تطبيق Angular. الفكرة بسيطة: بدلاً من تحميل جميع وحدات التطبيق عند بدء التشغيل، قم بتحميلها فقط عندما يحتاج المستخدم إليها. لكن الكثير من المطورين يستخدمون التحميل الكسول بشكل سطحي دون فهم تأثيره الحقيقي على الأداء. عندما تستخدم `loadChildren` في ملف التوجيهات، فإنك تخبر Angular بتحميل الوحدة فقط عند الوصول إلى مسار معين. لكن هل تعلم أن هذا يمكن أن يقلل حجم الحزمة الأولية بنسبة تصل إلى 60%؟ في مشروع قمت بالعمل عليه مؤخراً، قمنا بتطبيق التحميل الكسول بشكل صحيح، مما قلل زمن التحميل الأولي من 4.2 ثانية إلى 1.8 ثانية فقط.
لكن التحميل الكسول ليس مجرد إضافة `loadChildren` في ملف التوجيهات. هناك تفاصيل صغيرة يمكن أن تحدث فرقاً كبيراً. مثلاً، يجب عليك تجنب تحميل الوحدات المشتركة (Shared Modules) بشكل متكرر. بدلاً من ذلك، قم بإنشاء وحدة مشتركة أساسية تحتوي فقط على المكونات والخدمات التي تحتاجها معظم الوحدات، وقم بتحميلها مرة واحدة عند بدء التشغيل. أيضاً، استخدم `preloadAllModules` بحذر. على الرغم من أنه يمكن أن يحسن أداء التطبيق بعد التحميل الأولي، إلا أنه يزيد من استهلاك البيانات للمستخدمين الذين لا يزورون جميع المسارات.
// قبل التحسين: تحميل الوحدة بالكامل عند بدء التشغيل
// app-routing.module.ts
const routes: Routes = [
{ path: 'dashboard', component: DashboardComponent },
{ path: 'settings', component: SettingsComponent }
];
// بعد التحسين: التحميل الكسول للوحدات
const routes: Routes = [
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
},
{
path: 'settings',
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule)
}
];
// استخدم Preloading بحذر
@NgModule({
imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
exports: [RouterModule]
})
export class AppRoutingModule {}أحد أكبر أسباب بطء تطبيقات Angular هو تحديث DOM بشكل متكرر وغير ضروري. Angular يستخدم آلية تغيير الكشف (Change Detection) لمراقبة التغيرات في البيانات وتحديث DOM وفقاً لذلك. لكن المشكلة هي أن Angular يقوم بتشغيل تغيير الكشف على جميع المكونات في الشجرة بشكل افتراضي، حتى لو لم تتغير البيانات في بعض المكونات. هذا يمكن أن يؤدي إلى تحديثات DOM مكلفة، خاصة في التطبيقات الكبيرة التي تحتوي على مئات المكونات.
الحل هو التحكم في تغيير الكشف بشكل يدوي باستخدام `ChangeDetectionStrategy.OnPush`. عندما تستخدم هذه الاستراتيجية، فإن Angular لن يقوم بتشغيل تغيير الكشف على المكون إلا في حالتين: عندما يتغير مدخل الإدخال (`@Input`) أو عندما يتم تشغيل حدث من داخل المكون نفسه. هذا يمكن أن يقلل بشكل كبير من عدد تحديثات DOM غير الضرورية. في أحد المشاريع، قمنا بتطبيق `OnPush` على جميع المكونات، مما قلل زمن الاستجابة للتفاعلات من 300 مللي ثانية إلى 80 مللي ثانية فقط.
@Component({
selector: 'app-user-profile',
templateUrl: './user-profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent {
@Input() user: User;
// عندما يتغير المدخل، سيتم تشغيل تغيير الكشف
updateUser(newUser: User) {
this.user = newUser;
// لا حاجة لاستدعاء detectChanges() يدوياً هنا
}
// عند النقر على زر داخل المكون، سيتم تشغيل تغيير الكشف
onButtonClick() {
// منطق الحدث
}
}لكن استخدام `OnPush` ليس حلاً سحرياً. يجب عليك أيضاً تجنب استخدام الكائنات والمراجع القديمة في المدخلات. على سبيل المثال، إذا كان لديك كائن `user` كمدخل، وتريد تحديثه، يجب عليك إنشاء كائن جديد بدلاً من تعديل الكائن القديم. هذا لأن Angular يستخدم المقارنة المرجعية (`===`) لتحديد ما إذا كان المدخل قد تغير. إذا قمت بتعديل الكائن القديم، فلن يتعرف Angular على التغيير، ولن يقوم بتحديث DOM.
// ❌ خطأ: تعديل الكائن القديم
this.user.name = 'New Name';
// ✅ صحيح: إنشاء كائن جديد
this.user = { ...this.user, name: 'New Name' };
// أو باستخدام Immutable.js
this.user = Immutable.fromJS(this.user).set('name', 'New Name').toJS();على الرغم من فوائد `OnPush`، هناك حالات يجب فيها تجنبها. مثلاً، إذا كان لديك مكون يعتمد على خدمات أو بيانات خارجية لا تمر عبر المدخلات، فقد يكون من الصعب إدارة تغيير الكشف يدوياً. أيضاً، إذا كان لديك مكونات معقدة تحتوي على الكثير من الأحداث الداخلية، فقد يكون من الأسهل استخدام الاستراتيجية الافتراضية. لكن في معظم الحالات، يمكنك إعادة هيكلة الكود لاستخدام `OnPush` بشكل فعال.
حجم حزم JavaScript هو أحد أكبر العوامل التي تؤثر على أداء تطبيق Angular. كلما كانت الحزمة أكبر، كلما استغرق تحميلها وقتاً أطول، خاصة على الأجهزة ذات الاتصال البطيء أو المعالجات الضعيفة. في مشروع عملت عليه مؤخراً، كان حجم الحزمة الأولية 2.4 ميجابايت، مما أدى إلى زمن تحميل أولي يبلغ 6.7 ثانية على اتصال 3G. بعد تطبيق تقنيات التحسين، قلصنا الحجم إلى 800 كيلوبايت وزمن التحميل إلى 2.1 ثانية فقط.
أول خطوة في تحسين الحزم هي استخدام أدوات التحليل مثل `webpack-bundle-analyzer`. هذه الأداة تعطيك نظرة مفصلة على ما يحتويه كل ملف في الحزمة، وتساعدك على تحديد المكتبات الكبيرة أو الملفات المكررة. على سبيل المثال، قد تكتشف أنك تقوم بتضمين مكتبة كاملة مثل `lodash` بينما تحتاج فقط إلى بعض الوظائف منها. في هذه الحالة، يمكنك استخدام استيرادات محددة بدلاً من استيراد المكتبة بأكملها.
// ❌ خطأ: استيراد المكتبة بأكملها
import _ from 'lodash';
// ✅ صحيح: استيراد الوظائف المحددة فقط
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';خطوة أخرى مهمة هي تفعيل ضغط Brotli على السيرفر. Brotli هو خوارزمية ضغط توفر نسبة ضغط أفضل من Gzip بنسبة تصل إلى 20%. معظم السيرفرات الحديثة تدعم Brotli، ويمكنك تفعيله بسهولة. على سبيل المثال، في NGINX، يمكنك إضافة هذه الأسطر إلى ملف التكوين:
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;أيضاً، يجب عليك تفعيل تقسيم الكود (Code Splitting) باستخدام `import()` ديناميكي. هذا يسمح بتحميل أجزاء من الكود فقط عند الحاجة إليها، بدلاً من تحميل كل شيء في حزمة واحدة. في Angular، يمكنك تحقيق ذلك باستخدام التحميل الكسول كما ذكرنا سابقاً، أو باستخدام مكتبات مثل `ngx-quicklink` لتحسين تحميل الوحدات بشكل ذكي بناءً على سلوك المستخدم.
الصور والأصول الأخرى يمكن أن تضيف الكثير إلى حجم الحزمة. يجب عليك دائماً تحسين الصور باستخدام أدوات مثل `ImageOptim` أو `TinyPNG`. أيضاً، استخدم تنسيقات الصور الحديثة مثل WebP التي توفر جودة أفضل بحجم أقل. في أحد المشاريع، قمنا بتحويل جميع الصور من JPEG إلى WebP، مما قلل حجم الصور بنسبة 35% دون فقدان ملحوظ في الجودة.
أيضاً، استخدم التحميل الكسول للصور باستخدام سمة `loading="lazy"` في عناصر `<img>`. هذا يسمح بتحميل الصور فقط عندما تصبح قريبة من منطقة العرض، مما يقلل من كمية البيانات التي يتم تحميلها في البداية.
<img src="image.jpg" loading="lazy" alt="وصف الصورة">إدارة الحالة هي أحد أكبر التحديات في تطبيقات Angular الكبيرة. عندما يكون لديك حالة معقدة، يمكن أن تؤدي الحسابات المتكررة إلى بطء التطبيق. على سبيل المثال، إذا كان لديك قائمة تحتوي على مئات العناصر، وكان كل عنصر يحتاج إلى حساب قيمة بناءً على الحالة العامة، فإن إعادة حساب هذه القيم في كل مرة يتغير فيها شيء يمكن أن يكون مكلفاً جداً.
الحل هو استخدام تقنيات مثل Memoization لتجنب إعادة الحسابات غير الضرورية. Memoization هي تقنية تخزين نتائج الدوال المكلفة بحيث يمكن إعادة استخدامها بدلاً من إعادة حسابها في كل مرة. في Angular، يمكنك استخدام مكتبات مثل `ngrx-store-localstorage` أو كتابة دوال مخصصة باستخدام `memoize` من مكتبة `lodash`.
import { memoize } from 'lodash';
// دالة مكلفة للحساب
const calculateExpensiveValue = (items: Item[]): number => {
console.log('Calculating expensive value...');
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};
// تطبيق Memoization
const memoizedCalculate = memoize(calculateExpensiveValue);
// الآن سيتم حساب القيمة مرة واحدة فقط لكل مجموعة فريدة من المدخلات
console.log(memoizedCalculate(items)); // حساب
console.log(memoizedCalculate(items)); // استخدام النتيجة المخزنةأيضاً، يمكنك استخدام مكتبات إدارة الحالة مثل NgRx أو Akita لتجنب إعادة الحسابات غير الضرورية. هذه المكتبات تستخدم تقنيات مثل Selectors التي تسمح لك بتحديد أجزاء معينة من الحالة وإعادة حسابها فقط عند تغيير هذه الأجزاء. على سبيل المثال، في NgRx، يمكنك كتابة Selector يقوم بحساب القيمة فقط عندما تتغير البيانات ذات الصلة، وليس في كل مرة يتم فيها تشغيل تغيير الكشف.
import { createSelector } from '@ngrx/store';
// Selector لحساب القيمة الإجمالية
const selectItems = (state: AppState) => state.items;
const selectTotal = createSelector(
selectItems,
(items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// سيتم إعادة حساب القيمة فقط عند تغيير قائمة العناصرعلى الرغم من فوائد مكتبات إدارة الحالة، هناك حالات يمكن فيها تجنبها. مثلاً، إذا كان تطبيقك صغيراً ولا يحتوي على حالة معقدة، فقد يكون استخدام الخدمات العادية مع RxJS كافياً. أيضاً، إذا كان فريقك غير ملم بمفاهيم مثل Redux، فقد يؤدي استخدام NgRx إلى تعقيد الكود بدلاً من تبسيطه. في هذه الحالات، يمكنك استخدام مكتبات أخف مثل Akita أو حتى كتابة حلول مخصصة باستخدام RxJS.
استدعاءات API هي جزء لا يتجزأ من معظم تطبيقات Angular، لكنها يمكن أن تكون مصدراً رئيسياً للبطء. عندما يكون لديك استدعاءات متكررة أو بطيئة، يمكن أن يؤدي ذلك إلى تجميد واجهة المستخدم وجعل التطبيق يبدو بطيئاً. في أحد المشاريع، كان لدينا استدعاء API يستغرق 1.2 ثانية لإرجاع البيانات، وكان يتم تشغيله في كل مرة يقوم فيها المستخدم بتحديث الصفحة. بعد تطبيق بعض التحسينات، قلصنا زمن الاستجابة إلى 300 مللي ثانية فقط.
أول خطوة لتحسين استدعاءات API هي استخدام التخزين المؤقت (Caching). بدلاً من إجراء استدعاء API في كل مرة يحتاج فيها المستخدم إلى البيانات، يمكنك تخزين النتيجة مؤقتاً واستخدامها عند الحاجة. في Angular، يمكنك استخدام مكتبة مثل `ngx-cacheable` أو كتابة حل مخصص باستخدام RxJS. على سبيل المثال، يمكنك استخدام `shareReplay` لإنشاء تدفق يمكن إعادة استخدامه من قبل عدة مكونات دون إجراء استدعاء API متكرر.
import { shareReplay } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class DataService {
private dataCache$ = this.http.get<Data[]>('/api/data').pipe(
shareReplay(1) // تخزين النتيجة وإعادة استخدامها
);
getData(): Observable<Data[]> {
return this.dataCache$;
}
}خطوة أخرى مهمة هي استخدام Pagination و Infinite Scroll لتقليل كمية البيانات التي يتم تحميلها في كل مرة. بدلاً من تحميل جميع البيانات دفعة واحدة، قم بتحميلها على دفعات صغيرة. هذا لا يقلل فقط من زمن الاستجابة الأولي، بل يقلل أيضاً من استهلاك الذاكرة والمعالج. في Angular، يمكنك استخدام مكتبات مثل `ngx-infinite-scroll` لتنفيذ هذه الميزة بسهولة.
@Component({
selector: 'app-data-list',
template: `
<div
infiniteScroll
[infiniteScrollDistance]=