اكتشف كيف تغير Angular Signals قواعد اللعبة في إدارة الحالة، مع شرح تقني عميق وأمثلة عملية تكشف ما يحدث خلف الكواليس في الذاكرة والمعالج، وتجنب الفخاخ التي يقع فيها حتى المطورون المحترفون.
تخيل أنك تعمل على لوحة تحكم معقدة في Angular، وتحتاج إلى تحديث جزء صغير من واجهة المستخدم بناءً على تغيير في البيانات. بدلاً من إعادة بناء الـ Component بالكامل أو الاعتماد على RxJS مع تعقيداته، تأتي Angular Signals لتقدم لك حلاً أنيقاً وفعّالاً: تحديثات دقيقة تعتمد على تتبع الاعتمادات (dependency tracking) دون الحاجة إلى إعادة بناء DOM بالكامل. هذه ليست مجرد ميزة جديدة، بل هي تحول جذري في كيفية تعامل Angular مع إدارة الحالة، وهي تشبه إلى حد كبير ما قدمته React مع Hooks، لكنها مبنية على أسس مختلفة تماماً.
في هذا المقال، سنغوص عميقاً في Angular Signals، لنفهم كيف تعمل خلف الكواليس، وكيف يمكن استخدامها بشكل عملي في مشاريع حقيقية، وما هي الفخاخ التي يجب تجنبها. سنبدأ بتشريح آلية عمل Signals، ثم ننتقل إلى أمثلة عملية معقدة قليلاً، ونناقش كيف يمكن أن تؤثر هذه الميزة على أداء التطبيقات الكبيرة، خاصة تلك التي تعتمد على تحديثات متكررة للبيانات مثل لوحات التحكم والتطبيقات المالية.
Angular Signals هي نظام تفاعلي جديد لإدارة الحالة في Angular، تم تقديمه كجزء من Angular 16 وما بعده. الفكرة الأساسية وراء Signals هي توفير طريقة بسيطة وفعالة لتتبع التغيرات في البيانات وتحديث واجهة المستخدم بناءً على هذه التغيرات دون الحاجة إلى إعادة بناء الـ Component بالكامل. هذا يختلف تماماً عن النهج التقليدي في Angular الذي يعتمد على Zone.js وChange Detection، والذي يمكن أن يكون مكلفاً من حيث الأداء في التطبيقات الكبيرة.
لكن لماذا ظهرت Signals الآن؟ الحقيقة هي أن مجتمع Angular كان يبحث عن حلول أكثر فعالية لإدارة الحالة، خاصة مع تزايد تعقيد التطبيقات الحديثة. RxJS، على الرغم من قوته، يمكن أن يكون معقداً ويصعب فهمه للمطورين الجدد، كما أنه قد يؤدي إلى مشاكل في الأداء إذا لم يتم استخدامه بعناية. من ناحية أخرى، كانت الحلول البديلة مثل NgRx أو Akita تتطلب الكثير من الكود الإضافي وتضيف تعقيداً غير ضروري للمشاريع الصغيرة والمتوسطة. هنا تأتي Signals لتقدم حلاً وسطاً: بسيطاً بما يكفي للمشاريع الصغيرة، وقوياً بما يكفي للمشاريع الكبيرة.
لفهم كيف تعمل Signals، يجب أن نفهم أولاً مفهوم الـ Reactive Programming. في جوهره، الـ Reactive Programming يعتمد على فكرة أن البيانات تتغير بمرور الوقت، وأن النظام يجب أن يتفاعل مع هذه التغيرات. في Angular Signals، يتم تحقيق ذلك من خلال ثلاثة مكونات رئيسية: signal()، computed()، و effect().
عندما تقوم بإنشاء signal باستخدام الدالة signal()، فإنك تقوم بإنشاء متغير تفاعلي يمكن تتبعه. مثلاً، إذا قمت بإنشاء signal باسم count، فإن أي تغيير على قيمة count سيؤدي إلى تحديث جميع الأماكن التي تعتمد على هذه القيمة. هذا يتم بشكل تلقائي دون الحاجة إلى كتابة كود إضافي. أما computed()، فهي دالة تستخدم لإنشاء قيم مشتقة من signals أخرى، وهي تقوم بتحديث قيمتها تلقائياً عندما تتغير أي من الـ signals التي تعتمد عليها. وأخيراً، effect() هي دالة تستخدم لتنفيذ تأثيرات جانبية بناءً على تغيرات الـ signals، مثل تحديث DOM أو إرسال طلبات HTTP.
// مثال بسيط يوضح كيفية استخدام signal و computed و effect
import { signal, computed, effect } from '@angular/core';
// إنشاء signal
const count = signal(0);
// إنشاء computed value تعتمد على signal
const doubleCount = computed(() => count() * 2);
// إنشاء effect لتنفيذ تأثير جانبي
const disposeEffect = effect(() => {
console.log(`The count is: ${count()}`);
console.log(`The double count is: ${doubleCount()}`);
});
// تغيير قيمة signal
count.set(5); // سيؤدي إلى تحديث doubleCount وتنفيذ effect
// التخلص من effect عند عدم الحاجة إليه
disposeEffect();ما يحدث خلف الكواليس هنا هو أن Angular يقوم بإنشاء شبكة من الاعتمادات (dependency graph) بين الـ signals والقيم المشتقة منها. عندما تتغير قيمة signal، يقوم Angular بتحديث جميع القيم المشتقة منها وتنفيذ الـ effects المرتبطة بها. هذا يتم بشكل فعال جداً، حيث لا يتم تحديث سوى الأجزاء التي تعتمد على القيمة المتغيرة، دون الحاجة إلى إعادة بناء الـ Component بالكامل. هذه الآلية تشبه إلى حد كبير ما يحدث في مكتبات مثل Svelte أو Vue، لكنها مدمجة داخل إطار عمل Angular نفسه.
عندما نتحدث عن أداء Angular Signals، يجب أن نفهم أولاً كيف يتعامل Angular مع تحديثات واجهة المستخدم. في النهج التقليدي، يعتمد Angular على Zone.js لتحديد متى يجب تشغيل Change Detection. هذا يعني أن أي حدث غير متزامن (مثل النقر على زر أو استجابة من طلب HTTP) سيؤدي إلى تشغيل Change Detection على مستوى التطبيق بأكمله، مما قد يكون مكلفاً من حيث الأداء، خاصة في التطبيقات الكبيرة التي تحتوي على العديد من الـ Components.
مع Signals، يتغير هذا النهج تماماً. بدلاً من الاعتماد على Zone.js، تعتمد Signals على تتبع الاعتمادات (dependency tracking) لتحديث واجهة المستخدم. هذا يعني أن Angular سيقوم فقط بتحديث الأجزاء التي تعتمد على الـ signals التي تغيرت، دون الحاجة إلى إعادة بناء الـ Component بالكامل. على سبيل المثال، إذا كان لديك component يحتوي على عدة signals، وتغيرت قيمة واحدة منها فقط، فإن Angular سيقوم بتحديث الجزء المتعلق بهذه القيمة فقط، وليس الـ Component بأكمله. هذا يؤدي إلى تحسين كبير في الأداء، خاصة في التطبيقات التي تحتوي على تحديثات متكررة للبيانات.
من حيث الذاكرة، فإن Signals تستخدم كمية أقل من الذاكرة مقارنة بالحلول التقليدية. في النهج التقليدي، قد تحتاج إلى إنشاء العديد من الـ Observables أو الـ Subjects في RxJS، والتي يمكن أن تستهلك ذاكرة كبيرة إذا لم يتم إدارتها بعناية. أما مع Signals، فإن كل signal هو مجرد متغير تفاعلي بسيط، ولا يتطلب إنشاء كائنات معقدة مثل Observables. بالإضافة إلى ذلك، فإن الـ computed values يتم حسابها فقط عند الحاجة، مما يقلل من استهلاك الذاكرة والمعالج.
// مثال يوضح كيفية تحسين الأداء باستخدام Signals
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-dashboard',
template: `
<div>
<p>Total: {{ total() }}</p>
<p>Tax: {{ tax() }}</p>
<p>Grand Total: {{ grandTotal() }}</p>
<button (click)="increment()">Increment</button>
</div>
`,
})
export class DashboardComponent {
// استخدام signals بدلاً من متغيرات عادية
private items = signal<number[]>([]);
private taxRate = signal(0.1);
// computed values تعتمد على signals
total = computed(() => this.items().reduce((sum, item) => sum + item, 0));
tax = computed(() => this.total() * this.taxRate());
grandTotal = computed(() => this.total() + this.tax());
increment() {
// تغيير قيمة signal يؤدي إلى تحديث computed values تلقائياً
this.items.update(items => [...items, 1]);
}
}
// في هذا المثال، عند النقر على الزر، سيتم تحديث total و tax و grandTotal تلقائياً
// دون الحاجة إلى إعادة بناء الـ Component بالكامل، مما يحسن الأداء بشكل كبير.الآن بعد أن فهمنا كيف تعمل Signals خلف الكواليس، دعونا نتحدث عن متى وكيف يجب استخدامها في المشاريع الحقيقية. من تجربتي، أن Signals هي الخيار الأمثل في الحالات التالية:
لكن هذا لا يعني أن Signals هي الحل الأمثل لكل الحالات. في بعض السيناريوهات، قد تظل RxJS هي الخيار الأفضل، خاصة عندما تحتاج إلى التعامل مع تدفقات البيانات المعقدة أو عندما تحتاج إلى دمج عدة مصادر بيانات معاً. على سبيل المثال، إذا كنت تعمل على تطبيق يتطلب معالجة بيانات في الوقت الفعلي من مصادر متعددة، مثل تطبيقات المراسلة أو الألعاب، فقد يكون من الأفضل استخدام RxJS مع Signals بدلاً من الاعتماد على Signals وحدها.
لنفترض أنك تعمل على بناء لوحة تحكم مالية تعرض ملخصاً للمبيعات والإيرادات والمصروفات. باستخدام Signals، يمكنك إدارة الحالة بسهولة وتحسين أداء التطبيق بشكل كبير. إليك مثال عملي يوضح كيفية القيام بذلك:
@Component({
selector: 'app-financial-dashboard',
template: `
<div>
<h2>Financial Dashboard</h2>
<div>
<p>Total Sales: {{ totalSales() | currency }}</p>
<p>Total Revenue: {{ totalRevenue() | currency }}</p>
<p>Total Expenses: {{ totalExpenses() | currency }}</p>
<p>Net Profit: {{ netProfit() | currency }}</p>
</div>
<button (click)="addSale()">Add Sale</button>
<button (click)="addExpense()">Add Expense</button>
</div>
`,
})
export class FinancialDashboardComponent {
private sales = signal<number[]>([]);
private expenses = signal<number[]>([]);
totalSales = computed(() => this.sales().reduce((sum, sale) => sum + sale, 0));
totalExpenses = computed(() => this.expenses().reduce((sum, expense) => sum + expense, 0));
totalRevenue = computed(() => this.totalSales() * 0.7); // 70% من المبيعات هو الإيرادات
netProfit = computed(() => this.totalRevenue() - this.totalExpenses());
addSale() {
this.sales.update(sales => [...sales, Math.floor(Math.random() * 1000) + 100]);
}
addExpense() {
this.expenses.update(expenses => [...expenses, Math.floor(Math.random() * 500) + 50]);
}
}
// في هذا المثال، عند النقر على الزرين، سيتم تحديث جميع القيم المشتقة تلقائياً
// دون الحاجة إلى إعادة بناء الـ Component بالكامل، مما يحسن الأداء بشكل كبير.على الرغم من أن Angular Signals تقدم حلاً قوياً لإدارة الحالة، إلا أنها ليست خالية من المشاكل. هناك العديد من الفخاخ التي يمكن أن يقع فيها المطورون، خاصة أولئك الذين اعتادوا على العمل مع RxJS أو الحلول التقليدية في Angular. دعونا نستعرض بعض هذه الفخاخ وكيفية تجنبها.
Effects في Signals تشبه إلى حد كبير الـ side effects في RxJS، لكنها تعمل بشكل مختلف قليلاً. المشكلة الرئيسية مع Effects هي أنها يمكن أن تؤدي إلى تحديثات غير ضرورية أو حتى حلقات لا نهائية إذا لم يتم استخدامها بعناية. على سبيل المثال، إذا قمت بإنشاء effect يقوم بتحديث signal بناءً على تغيير في signal آخر، فقد ينتهي بك الأمر إلى حلقة لا نهائية من التحديثات.
// مثال على حلقة لا نهائية باستخدام effect
const count = signal(0);
const doubleCount = computed(() => count() * 2);
effect(() => {
console.log(`Count changed to: ${count()}`);
// هذا سيؤدي إلى حلقة لا نهائية
count.set(count() + 1);
});
// الحل: تجنب تحديث signals داخل effects إلا إذا كان ذلك ضرورياً جداً
// وبدلاً من ذلك، استخدم computed values أو قم بتحديث الـ signals خارج الـ effectعندما تقوم بإنشاء effect باستخدام الدالة effect()، فإنها تُرجع دالة يمكن استخدامها للتخلص من الـ effect عند عدم الحاجة إليه. إذا لم تقم بالتخلص من الـ effect، فقد يؤدي ذلك إلى تسرب في الذاكرة (memory leak)، خاصة في التطبيقات التي تحتوي على العديد من الـ Components التي يتم إنشاؤها وتدميرها بشكل متكرر.
@Component({
selector: 'app-example',
template: `<button (click)="toggleEffect()">Toggle Effect</button>`,
})
export class ExampleComponent implements OnDestroy {
private disposeEffect: (() => void) | null = null;
toggleEffect() {
if (this.disposeEffect) {
this.disposeEffect();
this.disposeEffect = null;
} else {
this.disposeEffect = effect(() => {
console.log('Effect is running');
});
}
}
ngOnDestroy() {
if (this.disposeEffect) {
this.disposeEffect();
}
}
}إحدى الفخاخ الشائعة هي محاولة استخدام Signals بنفس الطريقة التي تستخدم بها RxJS. على الرغم من أن كلاهما يتعاملان مع البيانات التفاعلية، إلا أنهما يعملان بطرق مختلفة تماماً. على سبيل المثال، في RxJS، يمكنك استخدام operators مثل map و filter و debounceTime لمعالجة البيانات، بينما في Signals، يتم ذلك باستخدام computed values. إذا حاولت استخدام RxJS operators مع Signals، فسوف تواجه مشاكل في الأداء والسلوك.
// مثال على الخلط بين Signals و RxJS (هذا غير صحيح)
import { signal } from '@angular/core';
import { map } from 'rxjs/operators';
const count = signal(0);
// هذا غير ممكن ولن يعمل
// const doubleCount = count.pipe(map(x => x * 2));
// الحل الصحيح: استخدام computed
const doubleCount = computed(() => count() * 2);مع ظهور Angular Signals، بدأ الكثير من المطورين يتساءلون عما إذا كانت Signals ستحل محل RxJS في المستقبل. الحقيقة هي أن Signals و RxJS يهدفان إلى حل مشكلات مختلفة، وعلى الرغم من أن Signals يمكن أن تقلل من الحاجة إلى RxJS في بعض الحالات، إلا أنها لن تحل محلها تماماً. RxJS لا تزال الخيار الأفضل للتعامل مع تدفقات البيانات المعقدة والتفاعلات غير المتزامنة، بينما Signals هي الخيار الأمثل لإدارة الحالة البسيطة والتفاعلية داخل الـ Components.
من تجربتي، أعتقد أن مستقبل Angular سيكون قائماً على مزيج من Signals و RxJS. يمكن استخدام Signals لإدارة الحالة داخل الـ Components وتحسين الأداء، بينما يمكن استخدام RxJS للتعامل مع التفاعلات المعقدة بين الـ Components أو مع الـ APIs الخارجية. هذا المزيج سيوفر للمطورين المرونة اللازمة لبناء تطبيقات قوية وفعالة دون الحاجة إلى الاعتماد على مكتبات خارجية معقدة.
في بعض الحالات، قد تحتاج إلى دمج Signals مع RxJS للحصول على أفضل النتائج. على سبيل المثال، إذا كنت تعمل على تطبيق يتطلب جلب بيانات من API وتحديث واجهة المستخدم بناءً على هذه البيانات، يمكنك استخدام RxJS لجلب البيانات، ثم تحويل الـ Observable إلى signal باستخدام الدالة toSignal التي تم تقديمها في Angular 16. هذا سيتيح لك الاستفادة من قوة RxJS في التعامل مع البيانات غير المتزامنة، والاستفادة من سهولة Signals في إدارة الحالة داخل الـ Component.
@Component({
selector: 'app-data-fetcher',
template: `
<div>
<p *ngIf="loading()">Loading...</p>
<p *ngIf="!loading()">Data: {{ data() }}</p>
</div>
`,
})
export class DataFetcherComponent implements OnInit {
private http = inject(HttpClient);
private dataService = inject(DataService);
// تحويل Observable إلى signal
data = toSignal(this.dataService.getData(), { initialValue: null });
loading = toSignal(this.dataService.loading$, { initialValue: true });
ngOnInit() {
this.dataService.fetchData();
}
}
// في هذا المثال، يتم استخدام RxJS لجلب البيانات من API، ثم تحويل الـ Observable إلى signal
// باستخدام toSignal، مما يتيح إدارة الحالة بسهولة داخل الـ Component.بعد استكشاف Angular Signals بعمق، إليك نصيحتي الذهبية لك: ابدأ باستخدام Signals لإدارة الحالة البسيطة داخل الـ Components، واستخدم RxJS فقط عندما تحتاج إلى التعامل مع تدفقات البيانات المعقدة أو التفاعلات غير المتزامنة. تجنب الاعتماد الزائد على Effects، وتأكد دائماً من التخلص منها عند عدم الحاجة إليها لتجنب تسرب الذاكرة. وإذا كنت تعمل على مشروع كبير، ففكر في استخدام مزيج من Signals و RxJS للحصول على أفضل النتائج من حيث الأداء والبساطة.
وأخيراً، لا تخف من تجربة Signals في مشاريعك الحالية. فهي ليست مجرد ميزة جديدة، بل هي تحول جذري في كيفية إدارة الحالة في Angular، وهي هنا لتبقى. ابدأ بمشاريع صغيرة، ثم انتقل تدريجياً إلى المشاريع الأكبر، وستلاحظ الفرق في الأداء وسهولة الصيانة. وإذا واجهتك أي مشاكل، تذكر أن المجتمع العربي والعالمي حول Angular ينمو بسرعة، وهناك الكثير من الموارد والدعم المتاح لمساعدتك على النجاح.