لماذا يتصرف كود JavaScript الخاص بك مثل هاتف تالف؟
تخيل: تكتب حلقة بسيطة، وتضيف معالجات الأحداث للأزرار، وفجأة - بام! - تظهر جميع الأزرار نفس الرقم. مألوف؟ إذا سبق لك أن واجهت الإغلاقات في حلقة JavaScript، فأنت تعرف هذه اللحظة من التنافر المعرفي. أنت متأكد من أن الكود صحيح، لكن المتصفح يصر بعناد: "لا يا صديقي، لقد أخطأت في شيء ما".
على Stack Overflow، جمع هذا السؤال مئات الآلاف من المشاهدات. ولا عجب: التقاط متغيرات الحلقة في الإغلاق هو أحد أكثر الفخاخ شيوعًا للمبتدئين وحتى المطورين ذوي الخبرة. دعنا نفهم لماذا يحدث هذا، والأهم من ذلك، كيفية إصلاحه مع مراعاة الأداء.
المشكلة الكلاسيكية: var وعدم التزامن
ألق نظرة على هذا الكود البريء:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); // ماذا سيطبع؟ }, 100);}إذا أجبت بـ "0, 1, 2, 3, 4" - تهانينا، أنت متفائل. الواقع قاسٍ: ستظهر وحدة التحكم 5, 5, 5, 5, 5. لماذا؟ لأن var له نطاق دالة وليس نطاق كتلة. في الوقت الذي يتم فيه تنفيذ setTimeout، تكون الحلقة قد انتهت بالفعل، وستكون قيمة i تساوي 5. جميع الإغلاقات الخمسة تشير إلى نفس المتغير.
هذا مثال كلاسيكي على مشكلة الإغلاقات في حلقة JavaScript. والحل واضح: نحتاج إلى إنشاء نطاق جديد لكل تكرار.
الحل رقم 1: IIFE - الحل القديم الجيد
قبل ظهور ES6، كان المطورون يستخدمون الدوال المنفذة فورًا (IIFE):
for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(j); }, 100); })(i);}هنا j هي نسخة من i في لحظة استدعاء IIFE. كل إغلاق يلتقط قيمته الخاصة. هل يعمل؟ نعم. هل هو جميل؟ ليس كثيرًا. من وجهة نظر تحسين الإغلاقات، هذا ينشئ دالة إضافية في كل تكرار، مما قد يؤثر على الذاكرة إذا كانت الحلقة ضخمة.
الحل رقم 2: let - المنقذ من ES6
مع ظهور let، تم حل المشكلة بأناقة:
for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); // 0, 1, 2, 3, 4 }, 100);}let ينشئ ربطًا جديدًا لكل تكرار للحلقة. هذا سلوك مدمج، مثبت في مواصفات ECMAScript. كل إغلاق يحصل على نسخته الخاصة من i. الأداء؟ ممتاز - يقوم المحرك بتحسين ذلك على مستوى التجميع.
ومع ذلك، هناك فارق بسيط: إذا كنت تستخدم let داخل الحلقة ولكن خارج كتلة for، فقد يختلف السلوك. احتفظ دائمًا بـ let داخل رأس الحلقة.
الحل رقم 3: bind - النهج الوظيفي
تسمح طريقة bind بتثبيت الوسائط:
for (var i = 0; i < 5; i++) { setTimeout(function(j) { console.log(j); }.bind(null, i), 100);}هنا، bind ينشئ دالة جديدة مع وسيطة محددة مسبقًا. هذا يعمل، لكنه ينشئ دالة إضافية لكل تكرار. من حيث الأداء، bind أبطأ قليلاً من IIFE بسبب الفحوصات الداخلية.
الحل رقم 4: forEach وطرق المصفوفات الأخرى
أحيانًا أفضل طريقة لتجنب المشكلة هي عدم استخدام for:
[0, 1, 2, 3, 4].forEach(function(i) { setTimeout(function() { console.log(i); }, 100);});forEach ينشئ نطاقًا جديدًا لكل تكرار من خلال callback. هذا لا يحل المشكلة فحسب