دليل تعلم جافاسكربت الجدولة: المهلة setTimeout والفترة setInterval في جافاسكربت


صفا الفليج

وأنت تكتب الشيفرة، ستقول في نفسك «أريد تشغيل هذه الدالة بعد قليل وليس الآن الآن. هذا ما نسمّيه "بجدولة الاستدعاءات" (scheduling a call).

إليك تابِعين اثنين لهذه الجدولة:

  • يتيح لك ‎setTimeout‎ تشغيل الدالة مرّة واحدة بعد فترة من الزمن.
  • يتيح لك ‎setInterval‎ تشغيل الدالة تكراريًا يبدأ ذلك بعد فترة من الزمن ويتكرّر كلّ فترة حسب تلك الفترة التي حدّدتها.

صحيح أنّ هذين التابِعين ليسا في مواصفة لغة جافاسكربت إلّا أنّ أغلب البيئات فيها مُجدوِل داخلي يقدّمهما لنا. وللدقّة، فكلّ المتصّفحات كما وNode.js تدعمهما.

تابع تحديد المهلة setTimeout

الصياغة:

let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

المُعاملات:

  • func|code: ما يجب تنفيذه أكان دالة أو سلسلة نصية فيها شيفرة. عادةً هي دالة ولكن كعادة الأسباب التاريخية (أيضًا) يمكن تمرير سلسلة نصية فيها شيفرة، ولكنّ ذلك ليس بالأمر المستحسن.
  • delay: التأخير قبل بدء التنفيذ بالمليثانية (1000 مليثانية = ثانية واحدة). مبدئيًا يساوي 0.
  • arg1‎, ‎arg2…: وُسطاء الدالة (ليست مدعومة في IE9-‎)

إليك هذه الشيفرة التي تستدعي ‎sayHi()‎ بعد ثانيةً واحدة:

function sayHi() {
  alert('Hello');
}

setTimeout(sayHi, 1000);

مع المُعاملات:

function sayHi(phrase, who) {
  alert( phrase + ', ' + who );
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

لو كان المُعامل الأول سلسلة نصية فستصنع جافاسكربت دالة منها.

أي أنّ هذا سيعمل:

setTimeout("alert('Hello')", 1000);

ولكن استعمال السلاسل النصية غير مستحسن. استعمل الدوال السهمية بدلًا عنها:

setTimeout(() => alert('Hello'), 1000);

مرّر الدالة لكن لا تشغّلها يُخطئ المبرمجون المبتدئون أحيانًا فيُضيفون أقواس ‎()‎ بعد الدالة:

// هذا خطأ!
setTimeout(sayHi(), 1000);

لن يعمل ذلك إذ يتوقّع ‎setTimeout‎ إشارة إلى الدالة، بينما هنا ‎sayHi()‎ يشغّل الدالة وناتج التنفيذ هو الذي يُمرّر إلى ‎setTimeout‎. في حالتنا ناتج ‎sayHi()‎ ليس معرّفًا ‎undefined‎ (إذ لا تُعيد الدالة شيئًا)، ويعني ذلك أنّ عملنا ذهب سدًى ولم نُجدول أي شيء.

الإلغاء باستعمال clearTimeout

نستلمُ حين نستدعي ‎setTimeout‎ «هويّةَ المؤقّت» ‎timerId‎ ويمكن استعمالها لإلغاء عملية التنفيذ.

صياغة الإلغاء:

let timerId = setTimeout(...);
clearTimeout(timerId);

في الشيفرة أسفله نُجدول الدالة ثمّ نُلغيها (غيّرنا الخطّة العبقرية)، بهذا لا يحدث شيء:

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // هويّة المؤقّت

clearTimeout(timerId);
alert(timerId); // ‫ذات الهويّة (لا تصير null بعد الإلغاء)

يمكن أن نرى من ناتج التابِع ‎alert‎ أنّ هويّة المؤقّت (في المتصفّحات) هي عدد. يمكن أن تكون في البيئات الأخرى أيّ شيء آخر. فمثلًا في Node.js نستلم كائن مؤقّت فيه توابِع أخرى.

نُعيد بأن ليس هناك مواصفة عالمية متّفق عليها لهذه التوابِع، فما من مشكلة في هذا.

يمكنك مراجعة مواصفة HTML5 للمؤقّتات (داخل المتصفّحات) في فصل المؤقّتات.

setInterval

صياغة التابِع ‎setInterval‎ هي ذات ‎setTimeout‎:

let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

ولكلّ المُعاملات ذات المعنى. ولكن على العكس من ‎setTimeout‎ فهذا التابِع يشغّل الدالة مرّة واحدة ثمّ أخرى وأخرى وأخرى تفصلها تلك الفترة المحدّدة.

يمكن أن نستدعي ‎clearInterval(timerId)‎ لنُوقف الاستدعاءات اللاحقة.

سيعرض المثال الآتي الرسالة كلّ ثانيتين اثنتين، وبعد خمس ثوان يتوقّف ناتجها:

// نكرّر التنفيذ بفترة تساوي ثانيتين
let timerId = setInterval(() => alert('tick'), 2000);

// وبعد خمس ثوان نُوقف الجدولة
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);

الوقت لا يتوقّف حين تظهر مُنبثقة ‎alert‎ تُواصل عقارب ساعة المؤقّت الداخلي (في أغلب المتصفّحات بما فيها كروم وفَيَرفُكس) بالمضيّ حتّى حين عرض ‎alert/confirm/prompt‎.

لذا متى ما شغّلت الشيفرة أعلاه ولم تصرف نافذة ‎alert‎ بسرعة، فسترى نافذة ‎alert‎ الثانية بعد ذلك مباشرةً، بذلك تكون الفترة الفعلية بين التنبيهين أقلّ من ثانيتين.

تداخل setTimeout

لو أردنا تشغيل أمر كلّ فترة، فهناك طريقتين اثنتين.

الأولى هي ‎setInterval‎. والثانية هي ‎setTimeout‎ متداخلة هكذا:

/** بدل كتابة:
let timerId = setInterval(() => alert('tick'), 2000);
*/

let timerId = setTimeout(function tick() {
  alert('tick');
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

تابِع ‎setTimeout‎ أعلاه يُجدول الاستدعاء التالي ليحدث بعد نهاية الأول (لاحظ ‎(*)‎).

كتابة توابِع ‎setTimeout‎ متداخلة يعطينا شيفرة مطواعة أكثر من ‎setInterval‎. بهذه الطريقة يمكن تغيير جدولة الاستدعاء التالي حسب ناتج الحالي.

فمثلًا علينا كتابة خدمة تُرسل طلب بيانات إلى الخادوم كلّ خمس ثوان، ولكن لو كان الخادوم مُثقلًا بالعمليات فيجب أن تزداد الفترة إلى 10 فَـ 20 فَـ 40 ثانية وهكذا…

إليك فكرة عن الشيفرة:

let delay = 5000;

let timerId = setTimeout(function request() {
  ...نُرسل الطلب...

  if (لو فشل الطلب لوجود ضغط على الخادوم) {
    // نزيد الفترة حتّى الطلب التالي
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

ولو كانت الدوال التي نُجدولها ثقيلة على المعالج فيمكن أن نقيس الزمن الذي أخذتها عملية التنفيذ الحالية ونؤجّل أو نقدّم الاستدعاء التالي.

يتيح لنا تداخل التوابِع ‎setTimeout‎ بضبط الفترة بين عمليات التنفيذ بدقّة أعلى ممّا تقدّمه ‎setInterval‎.

لنرى الفرق بين الشيفرتين أسفله. الأولى تستعمل ‎setInterval‎:

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

الثانية تستعمل ‎setTimeout‎ متداخلة:

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

سيُشغّل المُجدول الداخلي ‎func(i++)‎ كلّ 100 مليثانية حسب ‎setInterval‎:

setinterval-interval.png

هل لاحظت ذلك؟

التأخير الفعلي بين استدعاءات ‎func‎ التي ينفّذها ‎setInterval‎ أقل مما هي عليه في الشيفرة!

هذا طبيعي إذ أنّ الوقت الذي يأخذه تنفيذ ‎func‎ يستهلك بعضًا من تلك الفترة أيضًا.

يمكن أيضًا بأن يصير تنفيذ ‎func‎ أكبر ممّا توقعناه على حين غرّة ويأخذ وقتًا أطول من 100 مليثانية.

في هذه الحال ينتظر المحرّك انتهاء ‎func‎ ثمّ يرى المُجدول: لو انقضى الوقت يشغّل الدالة مباشرةً.

دومًا ما تأخذ الدالة وقتًا أطول من ‎delay‎ مليثانية في هذه الحالات الهامشية، إذ تجري الاستدعاءات واحدةً بعد الأخرى دون هوادة.

وإليك صورة ‎setTimeout‎ المتداخلة:

settimeout-interval.png

تضمن ‎setTimeout‎ المتداخلة لنا التأخير الثابت (100 مليثانية في حالتنا).

ذلك لأنّ الاستدعاء التالي لا يُجدول إلا بعد انتهاء السابق.

كنس المهملات وردود نداء التابِعين setInterval و setTimeout تُنشأ إشارة داخلية إلى الدالة (وتُحفظ في المُجدول) متى مرّرتها إلى إلى ‎setInterval/setTimeout‎، وهذا يمنع كنس الدالة على أنّها مهملات، حتّى لو لم تكن هناك إشارات إليها.

// ‫تبقى الدالة في الذاكرة حتّى يُستدعى `‎clearInterval‎`.
setTimeout(function() {...}, 100);

ولكن هناك تأثير جانبي لذلك كالعادة، فالدوال تُشير إلى بيئتها المُعجمية الخارجية. لذا طالما «تعيش»، تعيش معها المتغيرات الخارجية أيضًا، وهي أحيانًا كبيرة تأخذ ذاكرة أكبر من الدالة ذاتها. لذا، متى ما لم ترد تلك الدالة المُجدولة فالأفضل أن تُلغيها حتّى لو كانت صغيرة جدًا.

جدولة setTimeout بتأخير صفر

إليك الحالة الخاصة: ‎setTimeout(func, 0)‎ أو ‎setTimeout(func)‎.

يُجدول هذا التابِع ليحدث تنفيذ ‎func‎ بأسرع ما يمكن، إلّا أن المُجدول لن يشغّلها إلا بعد انتهاء السكربت الذي يعمل حاليًا.

أي أنّ الدالة تُجدول لأن تعمل «مباشرةً بعد» السكربت الحالي.

فمثلًا تكتب هذه الشيفرة "Hello" ثم مباشرة "World":

setTimeout(() => alert("World"));

alert("Hello");

يعني السطر الأوّل «ضع الاستدعاء في التقويم بعد 0 مليثانية»، إلّا أنّ المُجدول لا «يفحص تقويمه» إلّا بعد انتهاء السكربت الحالي، بهذا تصير ‎"Hello"‎ أولًا وبعدها تأتي ‎"World"‎.

كما أنّ هناك استعمالات متقدّمة خصّيصًا للمتصفّحات للمهلة بالتأخير صفر هذه، وسنشرحها في الفصل «حلقة الأحداث: المهام على المستويين الجُسيمي والذرّي».

في الواقع، فالتأخير الصفر هذا ليس صفرًا (في المتصفّحات) تحدّ المتصفّحات من التأخير بين تشغيل المؤقّتات المتداخلة. تقول مواصفة HTML5: «بعد المؤقّتات المتداخلة الخمسة الأولى، تُجبر الفترة لتكون أربع مليثوان على الأقل.».

لنرى ما يعني ذلك بهذا المثال أسفله. يُعيد استدعاء ‎setTimeout‎ جدولة نفسه بمدّة تأخير تساوي صفر، ويتذكّر كل استدعاء الوقت الفعلي بينه وبين آخر استدعاء في مصفوفة ‎times‎. ولكن، ما هي التأخيرات الفعلية؟ لنرى بأعيننا:

let start = Date.now();
let times = [];

setTimeout(function run() {
  times.push(Date.now() - start); // نحفظ التأخير من آخر استدعاء

  if (start + 100 < Date.now()) alert(times); // نعرض التأخيرات بعد 100 مليثانية
  else setTimeout(run); // وإلّا نُعيد الجدولة
});

// ‫إليك مثالًا عن الناتج:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100

تعمل المؤقّتات الأولى مباشرةً (كما تقول المواصفة)، وبعدها نرى ‎9, 15, 20, 24...‎. تلك الأربع مليثوان الإضافية هي التأخير المفروض بين الاستدعاءات.

حتّى مع ‎setInterval‎ بدل ‎setTimeout‎، ذات الأمر: تعمل الدالة ‎setInterval(f)‎ أوّل ‎f‎ مرّة بمدّة تأخير صفر، وبعدها تزيد أربع مليثوان لباقي الاستدعاءات.

سبب وجود هذا الحدّ هو من العصور الحجرية (متعوّدة دايمًا) وتعتمد شيفرات كثيرة على هذا السلوك.

بينما مع نسخة الخواديم من جافاسكربت فهذا الحدّ ليس له وجود، وهناك أيضًا طُرق أخرى لبدء المهام التزامنية مباشرةً، مثل setImmediate للغة Node.js، هذا قلنا بأنّ هذا يخصّ المتصفّحات فقط.

ملخص

  • يتيح لنا التابِعان ‎setTimeout(func, delay, ...args)‎ و‎setInterval(func, delay, ...args)‎ تشيل الدالة ‎func‎ مرّة أو كلّ فترة حسب كذا مليثانية (‎delay‎).
  • لإلغاء التنفيذ علينا استدعاء ‎clearTimeout/clearInterval‎ بالقيمة التي أعاداها ‎setTimeout/setInterval‎.
  • يُعدّ استدعاء الدوال ‎setTimeout‎ تداخليًا خيارًا أفضل من ‎setInterval‎ إذ يُتيح لنا ضبط الوقت بين كلّ عملية استدعاء بدقّة.
  • الجدولة بضبط التأخير على الصفر باستعمال ‎setTimeout(func, 0)‎ (كما واستعمال ‎setTimeout(func)‎) يكون حين نريدها «بأقصى سرعة ممكنة، متى انتهى السكربت الحالي».
  • يَحدّ المتصفّح من أدنى تأخير بعد استدعاء ‎setTimeout‎ أو ‎setInterval‎ المتداخل الخامس (أو أكثر) - يَحدّه إلى 4 مليثوان، وهذا لأسباب تاريخية

لاحظ بأنّ توابِع الجدولة لا تضمن التأخير كما هو حرفيًا.

فمثلًا يمكن أن تكون مؤقّتات المتصفّحات أبطأ لأسباب عديدة:

  • المعالج مُثقل بالعمليات.
  • لسان المتصفّح يعمل في الخلفية.
  • يعمل الحاسوب المحمول على البطارية.

يمكن لهذا كله رفع دقّة المؤقّت الدنيا (أي أدنى تأخير ممكن) لتصير 300 مليثانية أو حتى 1000 مليثانية حسب المتصفّح وإعدادات الأداء في نظام التشغيل.

تمارين

اكتب الناتج كل ثانية

الأهمية: 5

اكتب الدالة ‎printNumbers(from, to)‎ لتكتب عددًا كلّ ثانية بدءًا بِـ ‎from‎ وانتهاءً بِـ ‎to‎.

اصنع نسختين من الحل.

  1. واحدةً باستعمال ‎setInterval‎.
  2. واحدةً باستعمال ‎setTimeout‎ متداخلة.

الحل

باستعمال ‎setInterval‎:

function printNumbers(from, to) {
  let current = from;

  let timerId = setInterval(function() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }, 1000);
}

// ‫الاستعمال:
printNumbers(5, 10);

باستعمال ‎setTimeout‎ متداخلة:

function printNumbers(from, to) {
  let current = from;

  setTimeout(function go() {
    alert(current);
    if (current < to) {
      setTimeout(go, 1000);
    }
    current++;
  }, 1000);
}

// ‫الاستعمال:
printNumbers(5, 10);

لاحظ كلا الحلّين: هناك تأخير أولي قبل أول عملية كتابة إذ تُستدعى الدالة بعد ‎1000ms‎ في أوّل مرة.

لو أردت تشغيل الدالة مباشرةً فعليك كتابة استدعاء إضافي في سطر آخر هكذا:

function printNumbers(from, to) {
  let current = from;

  function go() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }

  go(); // هنا
  let timerId = setInterval(go, 1000);
}

printNumbers(5, 10);

ماذا سيعرض setTimeout؟

الأهمية: 5

جدول أحدهم في الشيفرة أسفله استدعاء ‎setTimeout‎، وثمّ كتب عملية حسابية ثقيلة لتعمل (وهي تأخذ أكثر من 100 مليثانية حتى تنتهي).

متى ستعمل الدالة المُجدولة؟

  1. بعد الحلقة؟
  2. قبل الحلقة؟
  3. في بداية الحلقة؟

ما ناتج ‎alert‎؟

let i = 0;

setTimeout(() => alert(i), 100); // ?

// عُدّ بأنّ الوقت اللازم لتنفيذ هذه الدالة يفوق 100 مليثانية
for(let j = 0; j < 100000000; j++) {
  i++; 
}

الحل

لن يُشغّل أيّ تابِع ‎setTimeout‎ إلا بعدما تنتهي الشيفرة الحالية.

ستكون قيمة ‎i‎ هي القيمة الأخيرة: ‎100000000‎.

let i = 0;

setTimeout(() => alert(i), 100); // 100000000

// عُدّ بأنّ الوقت اللازم لتنفيذ هذه الدالة يفوق 100 مليثانية
for(let j = 0; j < 100000000; j++) {
  i++; 
}

ترجمة -وبتصرف- للفصل Scheduling: setTimeout and setInterval من كتاب The JavaScript language





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن