إذا تجاوزت مرحلة كتابة السكريبتات البسيطة في جافاسكريبت، فلا بد أنك اصطدمت بحقيقة أن لغة جافاسكريبت هي لغة أحادية الخيط أو ما يُعرف بـ Single-threaded. هذا يعني ببساطة أن محرك جافاسكريبت ينفذ تعليمة برمجية واحدة في كل مرة وبشكل متسلسل. ولكن ماذا يحدث عندما تطلب بيانات من خادم خارجي عبر الشبكة، أو تحاول قراءة ملف ضخم، وهي عمليات قد تستغرق ثواني معدودة؟ هل تتوقف الصفحة بأكملها عن الاستجابة وتتجمد؟ هنا يأتي دور البرمجة غير المتزامنة أو ما يُعرف بـ Asynchronous Programming.
في الدروس السابقة، تعاملت مع الكود المتزامن الذي يُنفذ خطوة بخطوة بالترتيب. في هذا الدرس، سننتقل إلى المستوى المتوسط لنفهم كيف تتعامل جافاسكريبت مع المهام التي تأخذ وقتًا طويلاً دون أن تعطل تدفق التطبيق، وتحديدًا سنغوص في مفهومي الدوال الاسترجاعية (Callbacks) والوعود (Promises)، وكيف نتجنب المشاكل الشهيرة المرتبطة بهما.
لماذا نحتاج إلى البرمجة غير المتزامنة؟
تعمل جافاسكريبت داخل بيئات تشغيل مثل المتصفح أو Node.js، وغالبًا ما تحتاج إلى تنفيذ مهام لا نعرف متى ستنتهي بالضبط. يُطلق على هذه المهام اسم I/O Operations (عمليات الإدخال والإخراج). لو كان الكود متزامنًا بالكامل، لكان على المتصفح الانتظار حتى ينتهي تحميل صورة كبيرة أو استجابة قاعدة بيانات قبل أن يتمكن المستخدم من النقر على زر أو تحريك القائمة، وهو ما سيهدم تجربة المستخدم بالكامل.
لحل هذه المشكلة، تعتمد جافاسكريبت على حلقة الأحداث أو ما يُعرف بـ Event Loop، والتي تسمح بتأجيل تنفيذ المهام التي تستغرق وقتًا طويلاً إلى حين توفر نتائجها، مع الاستمرار في تنفيذ باقي الكود العادي في هذه الأثناء. الأدوات التاريخية والحديثة لإدارة هذا السلوك تبدأ من الـ Callbacks وتتطور وصولاً إلى الـ Promises.
الـ Callbacks: الأساس التاريخي والبوابة الأولى
الدالة الاسترجاعية أو الـ Callback هي ببساطة دالة يتم تمريرها كمعامل (Argument) إلى دالة أخرى، على أن يتم استدعاؤها لاحقًا بمجرد انتهاء المهمة غير المتزامنة. هذا هو الأسلوب الأقدم والأكثر أساسية للتعامل مع العمليات غير المتزامنة في جافاسكريبت.
لنأخذ مثالاً عمليًا يحاكي جلب بيانات مستخدم بشكل وهمي باستخدام دالة setTimeout لتمثيل التأخير الزمني للشبكة:
function fetchUserData(userId, callback) {
console.log("جارٍ جلب بيانات المستخدم...");
setTimeout(() => {
// بيانات وهمية يتم إرجاعها بعد ثانيتين
const user = { id: userId, name: "أحمد محمد", role: "مطور واجهات" };
callback(user);
}, 2000);
}
// استدعاء الدالة وتمرير دالة استرجاعية كمعامل
fetchUserData(42, function(userData) {
console.log("تم استلام البيانات بنجاح:");
console.log(userData);
});
console.log("هذا السطر سيتم طباعته قبل بيانات المستخدم!");
إذا قمت بتشغيل هذا الكود، ستلاحظ أن عبارة "هذا السطر سيتم طباعته قبل بيانات المستخدم!" تظهر في وحدة التحكم أولاً، على الرغم من أنها مكتوبة بعد استدعاء دالة fetchUserData. السبب هو أن دالة setTimeout حجزت المهمة في الخلفية وتركت خيط التنفيذ يتابع عمله، وعندما مرت الثواني الاثنتان، تم تنفيذ الـ Callback وطباعة البيانات.
مشكلة الـ Callbacks: ما يُعرف بـ Callback Hell
على الرغم من بساطة الـ Callbacks في المهام البسيطة، إلا أنها تصبح كابوسًا حقيقيًا عندما تضطر إلى تنفيذ عدة عمليات غير متزامنة تعتمد إحداها على نتيجة الأخرى. تخيل أنك تريد جلب المستخدم، ثم جلب منشوراته بناءً على معرفه، ثم جلب التعليقات على المنشور الأول. سيؤدي هذا إلى تداخل الدوال ببعضها بالشكل الهرمي التالي:
getUser(1, function(user) {
getPosts(user.id, function(posts) {
getComments(posts[0].id, function(comments) {
console.log(comments);
// المزيد من التداخل هنا...
}, function(error) {
console.log(error);
});
}, function(error) {
console.log(error);
});
}, function(error) {
console.log(error);
});
هذا الشكل المعماري يُعرف بـ Callback Hell أو جحيم الـ Callbacks، وهو يجعل الكود صعبة قراءته، وصعب الصيانة، ومعقدًا جداً في إدارة الأخطاء (Error Handling)، حيث تضطر لتكرار منطق معالجة الأخطاء في كل مستوى.
الـ Promises: الحل المنهجي المنظم
لحل مشاكل الـ Callbacks، تم تقديم الـ Promises (الوعود) في إصدار ECMAScript 2015 (ES6). الوعد في جافاسكريبت هو كائن يمثل القيمة المحتملة لعملية غير متزامنة لم تنتهِ بعد، ولكنه سيوفر نتيجة (إما نجاحًا أو فشلًا) في المستقبل.
يمر كائن الـ Promise بثلاث حالات أساسية:
- Pending (قيد الانتظار): الحالة الابتدائية، لم تتمOperation بعد.
- Fulfilled (متحقق / نجاح): تمت العملية بنجاح وتوفرت القيمة المطلوبة.
- Rejected (مرفوض / فشل): حدث خطأ أدى إلى فشل العملية.
كيف نقوم بإنشاء وعد والاستفادة منه؟ دعنا نحول مثال جلب بيانات المستخدم السابق ليستخدم نظام الـ Promises بدلاً من الـ Callbacks:
function fetchUserDataPromise(userId) {
return new Promise((resolve, reject) => {
console.log("جارٍ جلب بيانات المستخدم عبر الوعد...");
setTimeout(() => {
const success = true; // جرب جعلها false لاختبار الخطأ
if (success) {
const user = { id: userId, name: "سارة أحمد", role: "مهندسة برمجيات" };
resolve(user); // نجاح الوعد وإرجاع البيانات
} else {
reject("فشل الاتصال بالخادم ولم يتم العثور على المستخدم."); // فشل الوعد وإرجاع رسالة خطأ
}
}, 2000);
});
}
// استهلاك الوعد باستخدام then و catch
fetchUserDataPromise(10)
.then(user => {
console.log("تم استلام المستخدم:", user);
return user.id; // إرجاع قيمة لتمريرها للخطوة التالية
})
.then(userId => {
console.log("معرف المستخدم الذي تم معالجته هو:", userId);
})
.catch(error => {
console.error("حدث خطأ:", error);
})
.finally(() => {
console.log("انتهت عملية الجلب بغض النظر عن النتيجة.");
});
شرح هيكل الـ Promise وتوابع التعامل معه:
عند إنشاء Promise، نقوم بتمرير دالة تحتوي على معاملين رئيسيين هما resolve و reject. إذا سارت الأمور بشكل جيد ننادي resolve(value)، وإذا حدثت مشكلة ننادي reject(error).
أما عند استهلاك الوعد، فنستخدم:
- .then(): تُنفذ عندما يتحقق الوعد بنجاح (Fulfilled)، وتستقبل القيمة المُرجعة من resolve. الأجمل في الـ .then أنها يمكن أن تُرجع وعدًا جديدًا أو قيمة، مما يسمح بعمل تسلسل نظيف (Chaining) وتجنب تداخل الـ Callbacks.
- .catch(): تلتقط أي خطأ يحدث في أي مرحلة من مراحل التسلسل (إذا حدث reject في أي Promise سابق)، مما مركزية إدارة الأخطاء في مكان واحد.
- .finally(): دالة اختيارية تُنفذ في كلتا الحالتين (سواء نجح الوعد أو فشل)، وغالبًا ما تُستخدم لإغلاق مؤشرات التحميل (Loaders) أو تنظيف الموارد.
المقارنة العملية: Callback مقابل Promise
لنلقِ نظرة سريعة على الفرق الجوهري في قراءة الكود. مع الـ Callbacks، كنا نتحرك أفقيًا نحو الداخل مع كل عملية جديدة (Pyramid of Doom)، بينما مع الـ Promises، نتحرك رأسيًا إلى الأسفل عبر سلسلة من الدوال المنظمة (.then().then().catch())، وهو ما يُعرف بـ Flat Structure.
تخيل تسلسل عمليات جلب البيانات باستخدام الـ Promises:
getUser(1)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(error => console.error("حدث خطأ في إحدى المراحل:", error));
لاحظ كيف أصبح الكود مقروءًا، منطقيًا، وسهل التتبع مقارنة بالنسخة المعقدة للـ Callbacks.
أخطاء شائعة يقع فيها المطورون المتوسطون
عند التعامل مع الـ Promises، يقع العديد من المطورين في بعض الفخاخ الشائعة التي تؤدي إلى سلوكيات غير متوقعة في التطبيق:
1. نسيان إرجاع (Return) الوعد داخل الـ .then
إذا قمت بفتح كتلة برمجية جديدة داخل .then ولم تقم بكتابة كلمة return قبل استدعاء الوعد التالي، فلن ينتظر تسلسل الكود انتهاء تلك العملية، بل سيتم تخطيها أو إرجاع وعد غير مكتمل.
// خطأ شائع: نسيان return
getUser(1)
.then(user => {
getPosts(user.id); // لن ينتظر النظام انتهاء هذه الدالة
})
.then(posts => {
console.log(posts); // ستكون النتيجة undefined أو خطأ
});
2. تجاهل استخدام catch
ترك الونش بدون كتلة .catch يعني أن أي خطأ غير متوقع سيؤدي إلى ظهور تحذير مزعج في وحدة التحكم (Unhandled Promise Rejection) وقد يوقف تنفيذ أجزاء حرجة من التطبيق في بعض البيئات.
خلاصة الدرس
البرمجة غير المتزامنة هي عصب تطبيقات الويب الحديثة، والـ Callbacks كانت هي البداية التاريخية لفهم كيف تدار المهام المؤجلة في جافاسكريبت. ورغم أن الـ Callbacks أدت الغرض، إلا أنها تسببت في مشاكل تنظيمية مثل تداخل الكود وصعوبة قراءته. جاءت الـ Promises لتقدم نموذجًا أنظف وأكثر قابلية للصيانة يعتمد على حالات واضحة وتسلسل منطقي يعالج الأخطاء بمركزية عبر .catch.
هذا الفهم العميق للـ Callbacks والـ Promises يضعك على عتبة الانتقال للخطوة التالية في جافاسكريبت الحديثة، حيث سنرى كيف يمكن جعل الكود غير المتزامن يبدو تمامًا كالكود المتزامن باستخدام تعبيرات async/await في الدرس القادم.