١٥ ديسمبر ٢٠٢١

المزخرفات decorators‌ والتمرير forwarding: التابعان call وapply

تقدّم لنا لغة جافا سكريبت مرونة عالية غير مسبوقة في التعامل مع الدوال، إذ يمكننا تمريرها أو استعمالها على أنّها كائنات. والآن سنرى كيف نُمرّر الاستدعاءات بينها وكيف نُزخرِفها.

خبيئة من خلف الستار

لنقل بأنّ أمامنا الدالة الثقيلة على المعالج ‎slow(x)‎ بينما نتائجها مستقرة، أي لنقل بأنّنا لو مرّرنا ذات ‎x‎، فسنجد ذات النتيجة دومًا.

لو استدعينا هذه الدالة مرارًا وتكرارًا، فالأفضل لو خبّئنا (أي تذكّرنا) ناتجها لئلا يذهب الوقت سدًى لإجراء ذات الحسابات.

ولكن، بدل إضافة هذه الميزة في دالة ‎slow()‎ نفسها، سنُنشئ دالة غالِفة تُضيف ميزة الخبيئة هذه. سنرى أسفله مدى فوائد هذا الأمر.

إليك الشيفرة أولًا، وبعدها الشرح:

function slow(x) {
  // هنا مهمّة ثقيلة تُهلك المعالج
  alert(`‎Called with ${x}‎`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) {    // لو وجدنا هذا المفتاح في الخبيئة
      return cache.get(x); // نقرأ النتيجة منها
    }

    let result = func(x);  // وإلّا نستدعي الدالة

    cache.set(x, result);  // ثمّ نُخبّئ (نتذكّر) ناتجها
    return result;
  };
}

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1) is cached and the result returned
alert( "Again: " + slow(1) ); // slow(1) result returned from cache

alert( slow(2) ); // slow(2) is cached and the result returned
alert( "Again: " + slow(2) ); // slow(2) result returned from cache

في الشيفرة أعلاه، ندعو ‎cachingDecorator‎ بالمُزخرِف (decorator): وهي دالة خاصّة تأخذ دالة أخرى مُعاملًا وتعدّل على سلوكها.

الفكرة هنا هي استدعاء ‎cachingDecorator‎ لأيّ دالة أردنا، وستُعيد لنا غِلاف الخبيئة ذاك. الفكرة هذه رائعة إذ يمكن أن نكون أمام مئات من الدوال التي يمكن أن تستغلّ هذه الميزة، وكلّ ما علينا فعله هو إضافة ‎cachingDecorator‎ عليها.

كما وأنّا نحافظ على الشيفرة أبسط بفصل ميزة الخبيئة عن مهمّة الدالة الفعلية.

ناتج ‎cachingDecorator(func)‎ هو «غِلاف» يُعيد الدالة ‎function(x)‎ التي «تُغلّف» استدعاء ‎func(x)‎ داخل شيفرة الخبيئة:

[decorator-makecaching-wrapper.png]

الشيفرات الخارجية لا ترى أيّ تغيير على دالة ‎slow‎ المُغلّفة. ما فعلناه هو تعزيز سلوكها بميزة الخبيئة.

إذًا نُلخّص: ثمّة فوائد عدّة لاستعمال ‎cachingDecorator‎ منفصلًا بدل تعديل شيفرة الدالة ‎slow‎ نفسها:

  • إعادة استعمال ‎cachingDecorator‎، فنُضيفه على دوال أخرى.
  • فصل شيفرة الخبيئة فلا تزيد من تعقيد دالة ‎slow‎ نفسها (هذا لو كانت معقّدة).
  • إمكانية إضافة أكثر من مُزخرف عند الحاجة (سنرى ذلك لاحقًا).

استعمال ‎func.call‎ لأخذ السياق

لا ينفع مُزخرِف الخبيئة الذي شرحناه مع توابِع الكائنات.

فمثلًا في الشيفرة أسفله، سيتوقّف عمل ‎worker.slow()‎ بعد هذه الزخرفة:

// ‫هيًا نُضف ميزة الخبيئة إلى worker.slow
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // أمامنا مهمّة ثقيلة على المعالج هنا
    alert("Called with " + x);
    return x * this.someMethod(); // (*)
  }
};

// نفس الشيفرة أعلاه
function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func(x); // (**)
    cache.set(x, result);
    return result;
  };
}

alert( worker.slow(1) ); // التابِع الأصلي يعمل كما ينبغي

worker.slow = cachingDecorator(worker.slow); // وقت الخبيئة

alert( worker.slow(2) ); // ‫لاااا! خطأ: تعذّرت قراءة الخاصية 'someMethod' في undefined

مكان الخطأ هو السطر ‎(*)‎ الذي يحاول الوصول إلى ‎this.someMethod‎ ويفشل فشلًا ذريعًا. هل تعرف السبب؟

السبب هو أنّ الغِلاف يستدعي الدالة الأصلية هكذا ‎func(x)‎ في السطر ‎(**)‎. وحين نستدعيها هكذا تستلم الدالة ‎this = undefined‎.

سنرى ما يشبه هذا الخطأ لو شغّلنا هذه الشيفرة:

let func = worker.slow;
func(2);

إذًا… يُمرّر الغِلاف الاستدعاء إلى التابِع الأصلي دون السياق ‎this‎، بهذا يحصل الخطأ.

وقت الإصلاح.

ثمّة تابِع دوال مضمّن في اللغة باسم func.call(context, …args) يتيح لنا استدعاء الدالة

صياغته هي:

func.call(context, arg1, arg2, ...)

يُشغّل التابِع الدالةَ ‎func‎ بعد تمرير المُعامل الأول (وهو ‎this‎) وثمّ مُعاملاتها.

للتبسيط، هذين الاستدعاءين لا يفرقان بشيء في التنفيذ:

func(1, 2, 3);
func.call(obj, 1, 2, 3)

فكلاهما يستدعي ‎func‎ بالمُعاملات ‎1‎ و‎2‎ و‎3‎. الفرق الوحيد هو أنّ ‎func.call‎ تضبط قيمة ‎this‎ على ‎obj‎ علاوةً على ذلك.

لنأخذ مثالًا. في الشيفرة أسفله نستدعي ‎sayHi‎ بسياق كائنات أخرى: يُشغّل ‎sayHi.call(user)‎ الدالةَ ‎sayHi‎ ويُمرّر ‎this=user‎، ثمّ في السطر التالي يضبط ‎this=admin‎:

function sayHi() {
  alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// نستعمل call لنمرّر مختلف الكائنات على أنّها this:
sayHi.call( user ); // this = John
sayHi.call( admin ); // this = Admin

وهنا نستدعي ‎call‎ لتستدعي ‎say‎ بالسياق والعبارة المُمرّرتين:

function say(phrase) {
  alert(this.name + ': ' + phrase);
}

let user = { name: "John" };

// ‫الكائن user يصير this وتصير Hello المُعامل الأول
say.call( user, "Hello" ); // John: Hello

في حالتنا نحن، يمكن استعمال ‎call‎ في الغِلاف ليُمرّر السياق إلى الدالة الأصلية:

let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    alert("Called with " + x);
    return x * this.someMethod(); // (*)
  }
};

function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func.call(this, x); // ‫هكذا نُمرّر «this» كما ينبغي
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // والآن نُضيف الخبيئة

alert( worker.slow(2) ); // يعمل
alert( worker.slow(2) ); // ‫يعمل ولا يستدعي التابِع الأصلي (إذ القيمة مُخبّأة)

الآن يعمل كلّ شيء كما نريد.

لنوضّح الأمر أكثر، لنرى بالتفصيل المملّ تمريرات ‎this‎ من هنا إلى هناك:

  1. بعد الزخرفة، يصير ‎worker.slow‎ الغِلاف ‎function (x) { ... }‎.
  2. لذا حين نُنفّذ ‎worker.slow(2)‎، يأخذ الغِلاف القيمةَ ‎2‎ وسيطًا ويضبط ‎this=worker‎ (وهو الكائن قبل النقطة).
  3. في الغِلاف (باعتبار أنّ النتيجة لم تُخبّأ بعد)، تُمرّر ‎func.call(this, x)‎ قيمة ‎this‎ الحالية (وهي ‎worker‎) مع المُعامل الحالي (‎2‎) – كلّه إلى التابِع الأصلي.

استعمال أكثر من وسيط داخل func.apply

الآن صار وقت تعميم ‎cachingDecorator‎ على العالم. كنّا إلى هنا نستعملها مع الدوال التي تأخذ مُعاملًا واحدًا فقط.

وماذا لو أردنا تخبئة التابِع ‎worker.slow‎ الذي يأخذ أكثر من مُعامل؟

let worker = {
  slow(min, max) {
    return min + max; // نُعدّها عملية تستنزف المعالج
  }
};

// علينا تذكّر الاستدعاءات بنفس المُعامل هنا
worker.slow = cachingDecorator(worker.slow);

كنّا سابقًا نستعمل ‎cache.set(x, result)‎ (حين تعاملنا مع المُعامل الوحيد ‎x‎) لنحفظ الناتج، ونستعمل ‎cache.get(x)‎ لنجلب الناتج. أمّا الآن فعلينا تذكّر ناتج مجموعة مُعاملات ‎(min,max)‎. الخارطة ‎Map‎ لا تأخذ المفاتيح إلّا بقيمة واحدة.

ثمّة أمامنا أكثر من حلّ:

  1. كتابة بنية بيانات جديدة تشبه الخرائط (أو استعمال واحدة من طرف ثالث) يمكن استعمالها لأكثر من أمر وتسمح لنا بتخزين أكثر من مفتاح.
  2. استعمال الخرائط المتداخلة: تصير ‎cache.set(min)‎ خارطة تُخزّن الزوجين ‎(max, result)‎. ويمكن أن نأخذ الناتج ‎result‎ باستعمال ‎cache.get(min).get(max)‎.
  3. دمج القيمتين في واحدة. في حالتنا هذه يمكن استعمال السلسلة النصية ‎"min,max"‎ لتكون مفتاح ‎Map‎. ويمكن أن نقدّم للمُزخرِف دالة عنونة Hashing يمكنها صناعة قيمة من أكثر من قيمة، فيصير الأمر أسهل.

أغلب التطبيقات العملية تَعدّ الحل الثالث كافيًا، ولهذا سنستعمله هنا.

علينا أيضًا استبدال التابِع ‎func.call(this, x)‎ بالتابِع ‎func.call(this, ...arguments)‎ كي نُمرّر كلّ المُعاملات إلى استدعاء الدالة المُغلّفة لا الأولى فقط.

رحّب بالمُزخرف ‎cachingDecorator‎ الجديد، أكثر قوة وأناقة:

let worker = {
  slow(min, max) {
    alert(`‎Called with ${min},${max}‎`);
    return min + max;
  }
};

function cachingDecorator(func, hash) {
  let cache = new Map();
  return function() {
    let key = hash(arguments); // (*)
    if (cache.has(key)) {
      return cache.get(key);
    }

    let result = func.call(this, ...arguments); // (**)

    cache.set(key, result);
    return result;
  };
}

function hash(args) {
  return args[0] + ',' + args[1];
}

worker.slow = cachingDecorator(worker.slow, hash);

alert( worker.slow(3, 5) ); // يعمل
alert( "Again " + worker.slow(3, 5) ); // ‫نفس الناتج (خبّأناه)

الآن صار يعمل مهما كان عدد المُعاملات (ولكن علينا تعديل دالة العنونة لتسمح هي أيضًا بالمُعاملات أيًا كان عددها. سنشره أسفله إحدى الطرائق الجميلة لإنجاز هذه المهمة).

أمامنا تعديلان اثنان:

  • في السطر‎(*)‎، نستدعي ‎hash‎ لتصنع مفتاحًا واحدًا من ‎arguments‎. نستعمل هنا دالة «دمج» بسيطة تحوّل المُعاملان ‎(3, 5)‎ إلى المفتاح ‎"3,5"‎. لو كانت الحالة لديك أكثر تعقيدًا، فتحتاج إلى دوال عنونة أخرى.
  • ثمّ يستعمل ‎(**)‎ التابِع ‎func.call(this, ...arguments)‎ لتمرير السياق وكلّ المُعاملات التي استلمها الغِلاف (وليس الأول فقط) – كله إلى الدالة الأصلية.

يمكننا بدل استعمال ‎func.call(this, ...arguments)‎ استغلال ‎func.apply(this, arguments)‎.

صياغة هذا التابِع المبني في اللغة func.apply هي:

func.apply(context, args)

يُشغّل التابِع الدالةَ ‎func‎ بضبط ‎this=context‎ واستعمال الكائن الشبيه بالمصفوفات ‎args‎ قائمةً بالمُعطيات للدالة.

الفارق الوحيد بين ‎call‎ و‎apply‎ هي أنّ الأوّل يتوقّع قائمة بالمُعطيات بينما الثاني يأخذ كائنًا شبيهًا بالمصفوفات يحويها.

أي أنّ الاستدعاءين الآتين متساويين تقريبًا:

func.call(context, ...args); // نمرّر الكائن قائمةً بمُعامل التوزيع
func.apply(context, args);   // ‫نفس الفكرة باستعمال apply

ولكن هناك فرق بسيط واحد:

  • يُتيح لنا مُعامل التوزيع ‎...‎ تمرير المُتعدَّد ‎args‎ قائمةً إلى ‎call‎.
  • لا يقبل ‎apply‎ إلّا مُعامل ‎args‎ شبيه بالمصفوفات.

أي أنّ هذين الاستدعاءين يُكمّلان بعضهما البعض. لو توقّعنا وصول مُتعدَّد فنستعمل ‎call‎، ولو توقّعنا شبيهًا بالمصفوفات نستعمل ‎apply‎.

أمّا الكائنات المُتعدَّدة والشبيهة بالمصفوفات (مثل المصفوفات الحقيقية)، فيمكننا نظريًا استعمال أيّ من الاثنين، إلّا أنّ ‎apply‎ سيكون أسرع غالبًا إذ أنّ مُعظم محرّكات جافا سكريبت تحسّن أدائه داخليًا أكثر من ‎call‎.

يُدى تمرير كافة المُعاملات (مع السياق) من دالة إلى أخرى بتمرير الاستدعاء.

إليك أبسط صوره:

let wrapper = function() {
  return func.apply(this, arguments);
};

حين تستدعي أيّ شيفرة خارجية ‎wrapper‎ محال أن تفرّق بين استدعائها واستدعاء الدالة الأصلية ‎func‎.

استعارة التوابِع

أمّا الآن لنحسّن دالة العنونة قليلًا:

function hash(args) {
  return args[0] + ',' + args[1];
}

لا تعمل الدالة حاليًا إلّا على مُعاملين اثنين، وسيكون رائعًا لو أمكن أن ندمج أيّ عدد من ‎args‎.

أوّل حلّ نفكّر به هو استعمال التابِع arr.join:

function hash(args) {
  return args.join();
}

ولكن… للأسف فهذا لن ينفع، إذ نستدعي ‎hash(arguments)‎ بتمرير كائن المُعاملات ‎arguments‎ المُتعدَّد والشبيه بالمصفوفات… إلّا أنّه ليس بمصفوفة حقيقية.

بذلك استدعاء ‎join‎ سيفشل كما نرى أسفله:

function hash() {
  alert( arguments.join() ); // ‫خطأ: arguments.join ليست بدالة
}

hash(1, 2);

مع ذلك فما زال هناك طريقة سهلة لضمّ عناصر المصفوفة:

function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

ندعو هذه الخدعة باستعارة التوابِع.

فيها نأخذ (أي نستعير) تابِع الضمّ من المصفوفات العادية (‎[].join‎) ونستعمل ‎[].join.call‎ لتشغيله داخل سياق ‎arguments‎.

ولكن، لمَ تعمل أصلًا؟

هذا بسبب بساطة الخوارزمية الداخلية للتابِع الأصيل ‎arr.join(glue)‎ في اللغة.

أقتبس -بتصرّف خفيف جدًا- من مواصفات اللغة:

  1. لمّا أنّ ‎glue‎ هو المُعامل الأول، ولو لم تكن هناك مُعاملات فهو ‎","‎.
  2. لمّا أنّ ‎result‎ هي سلسلة نصية فارغة.
  3. أضِف ‎this[0]‎ إلى نهاية ‎result‎. أضِف ‎glue‎ و‎this[1]‎.
  4. أضِف ‎glue‎ و‎this[2]‎.
  5. …كرّر حتّى يتنهي ضمّ العناصر الـ ‎this.length‎.
  6. أعِد ‎result‎.

إذًا فهو يأخذ ‎this‎ ويضمّ ‎this[0]‎ ثمّ ‎this[1]‎ وهكذا معًا. كتب المطوّرون التابِع بهذه الطريقة عمدًا ليسمح أن تكون ‎this‎ أيّ شبيه بالمصفوفات (ليست مصادفة إذ تتبع كثير من التوابِع هذه الممارسة). لهذا يعمل التابِع حين يكون ‎this=arguments‎.

المُزخرِفات وخاصيات الدوال

استبدال الدوال أو التوابِع بأخرى مُزخرفة هو أمر آمن عادةً، ولكن باستثناء صغير: لو احتوت الدالة الأًلية على خاصيات (مثل ‎func.calledCount‎) فلن تقدّمها الدالة المُزخرفة، إذ أنّها غِلاف على الدالة الأصلية. علينا بذلك أن نحذر في هذه الحالة.

نأخذ المثال أعلاه مثالًا، لو احتوت الدالة ‎slow‎ أيّ خاصيات فلن يحتوي الغِلاف ‎cachingDecorator(slow)‎ عليها.

يمكن أن تقدّم لنا بعض المُزخرِفات خاصيات خاصة بها. فمثلًا يمكن أن يعدّ المُزخرِف كم مرّة عملت الدالة وكم من وقت أخذ ذلك، وتقدّم لنا خاصيات لنرى هذه لمعلومات.

توجد طريقة لإنشاء مُزخرِفات تحتفظ بميزة الوصول إلى خاصيات الدوال، ولكنّها تطلب استعمال الكائن الوسيط ‎Proxy‎ لتغليف الدوال. سنشرح هذا الكائن لاحقًا في قسم «تغليف الدوال: ‎apply‎».

ملخص

تُعدّ المُزخرِفات أغلفة حول الدوال فتعدّل سلوكها، بينما المهمة الأساس مرهونة بالدالة نفسها.

يمكن عدّ المُزخرِفات «مزايا» نُضيفها على الدالة، فنُضيف واحدة أو أكثر، ودون تغيير أيّ سطر في الشيفرة!

رأينا التوابِع الآتية لنعرف كيفية إعداد المُزخرِف ‎cachingDecorator‎:

  • func.call(context, arg1, arg2…) – يستدعي ‎func‎ حسب السياق والمُعاملات الممرّرة.
  • func.apply(context, args) – يستدعي ‎func‎ حيث يُمرّر ‎context‎ بصفته ‎this‎ والكائن الشبيه بالمصفوفات ‎args‎ في قائمة المُعاملات.

عادةً ما نكتب تمرير الاستدعاءات باستعمال ‎apply‎:

let wrapper = function() {
  return original.apply(this, arguments);
};

كما رأينا مثالًا عن استعارة التوابِع حيث أخذنا تابِعًا من كائن واستدعيناه ‎call‎ في سياق كائن آخر غيره. يشيع بين المطوّرين أخذ توابِع المصفوفات وتطبيقها على المُعاملات ‎arguments‎. لو أردت بديلًا لذلك فاستعمل كائن المُعاملات البقية إذ هو مصفوفة حقيقية.

ستجد في رحلتك المحفوفة بالمخاطر مُزخرِفات عديدة. حاوِل التمرّس عليها بحلّ تمارين هذا الفصل.

تمارين

مُزخرِف تجسّس

الأهمية: 5

أنشِئ المُزخرِف ‎spy(func)‎ ليُعيد غِلافًا يحفظ كلّ استدعاءات تلك الدالة في خاصية ‎calls‎ داخله.

احفظ كلّ استدعاء على أنّه مصفوفة من الوُسطاء.

مثال:

function work(a, b) {
  alert( a + b ); // ‫ليست work إلّا دالة أو تابِعًا لسنا نعرف أصله
}

work = spy(work); // (*)

work(1, 2); // 3
work(4, 5); // 9

for (let args of work.calls) {
  alert( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}

ملاحظة: نستفيد من هذا المُزخرِف أحيانًا لاختبار الوحدات. يمكن عدّ ‎sinon.spy‎ في المكتبة Sinon.JS صورةً متقدّمةً عنه.

الحل

سيُخزّن الغِلاف الذي أعادته spy(f)‎ كلّ الوُسطاء، بعدها يستعمل f.apply لتمرير الاستدعاء. ……

مُزخرِف تأخير

الأهمية: 5

أنشِئ المُزخرف ‎delay(f, ms)‎ ليُؤخّر كلّ استدعاء من ‎f‎ بمقدار ‎ms‎ مليثانية.

مثال:

function f(x) {
  alert(x);
}

// أنشِئ الغِلافات
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("test"); // ‫يعرض «test» بعد 1000 مليثانية
f1500("test"); // ‫يعرض «test» بعد 1500 مليثانية

أي أنّ المُزخرِف ‎delay(f, ms)‎ يُعيد نسخة عن ‎f‎ «تأجّلت ‎ms‎».

الدالة ‎f‎ في الشيفرة أعلاه تقبل وسيطًا واحدًا، ولكن على الحل الذي ستكتبه تمرير كلّ الوُسطاء والسياق ‎this‎ كذلك.

الحل

function delay(f, ms) {

  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };

}

let f1000 = delay(alert, 1000);

f1000("test"); // ‫يعرض test بعد 1000 مليثانية

لاحظ بأنّا استعملنا الدالة السهمية هنا. كما نعلم فالدوال السهمية لا تملك لا ‎this‎ ولا ‎arguments‎، لذا يأخذ ‎f.apply(this, arguments)‎ كِلا ‎this‎ و‎arguments‎ من الغِلاف.

لو مرّرنا دالة عادية فسيستدعيها ‎setTimeout‎ بدون المُعاملات ويضبط ‎this=window‎ (باعتبار أنّا في بيئة المتصفّح).

مع ذلك يمكننا تمرير قيمة ‎this‎ الصحيحة باستعمال متغيّر وسيط ولكنّ ذلك سيكون تعبًا لا داعٍ له:

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // خزّنه في متغير وسيط
    setTimeout(function() {
      f.apply(savedThis, args); // استعمل الوسيط هنا
    }, ms);
  };

}

مُزخرف إزالة ارتداد

اصنع المُزخرِف ‎debounce(f, ms)‎ ليُعيد غِلافًا يُمرّر الاستدعاء إلى ‎f‎ مرّة واحدة كلّ ‎ms‎ مليثانية.

بعبارة أخرى: حين ندعو الدالة «بأنّ ارتدادها أُزيل» Debounce فهي تضمن لنا بأنّ الاستدعاءات التي ستحدث في أقلّ من ‎ms‎ مليثانية بعد الاستدعاء السابق – ستُهمل.

مثال:

let f = debounce(alert, 1000);

f(1); // يعمل مباشرةً
f(2); // يُهمل

setTimeout( () => f(3), 100); // ‫يُهمل ( لم تمرّ إلّا 100 ملي ثانية )
setTimeout( () => f(4), 1100); // يعمل
setTimeout( () => f(5), 1500); // ‫يُهمل (لم تمرّ الـ 1000 مليثانية من آخر تشغيل)

عمليًا في الشيفرات، نستعمل ‎debounce‎ للدوال التي تستلم أو تُحدّث شيئًا ما نعرف مسبقًا بأنّ لا شيء جديد سيحدث له في هذه الفترة القصيرة، فالأفضل أن نُهمله ولا نُهدر الموارد.

الحل

function debounce(f, ms) {

  let isCooldown = false;

  return function() {
    if (isCooldown) return;

    f.apply(this, arguments);

    isCooldown = true;

    setTimeout(() => isCooldown = false, ms);
  };

}

استدعاء ‎debounce‎ يُعيد غِلافًا. ثمّة حالتين اثنتين لهذا الغِلاف:

  • ‎isCooldown = false‎ – يمكن أن تعمل الدالة.
  • ‎isCooldown = true‎ – ننتظر انتهاء المهلة.

في أوّل استدعاء يكون ‎isCooldown‎ بقيمة ‎false‎ فيعمل الاستدعاء، وتتغيّر الحالة إلى ‎true‎.

نُهمل أيّ استدعاء آخر طالما ‎isCooldown‎ صحيحة.

بعدها يعكس ‎setTimeout‎ الحالة إلى ‎false‎ بعد مرور فترة التأجيل.

مُزخرِف خنق

الأهمية: 5

أنشِئ مُزخرِف «الخنق/throttle» ‏‎throttle(f, ms)‎ ليُعيد غِلافًا يُمرّر الاستدعاء إلى ‎f‎ مرّة كلّ ‎ms‎ مليثانية. والاستدعاءات التي تحدث في فترة «الراحة» تُهمل.

الفرق بين هذه وبين ‎debounce‎ هي أنّه لو كان الاستدعاء المُهمل هو آخر الاستدعاءات أثناء فترة الراحة، فسيعمل متى انتهت تلك الفترة.

لنطالع هذا التطبيق من الحياة العملية لنعرف أهمية هذا الشيء الغريب العجيب وما أساسه أصلًا.

لنقل مثلًا أنّا نريد تعقّب تحرّك الفأرة.

يمكن أن نضبط دالة (في المتصفّح) لتعمل كلّما تحرّكت الفأرة وتأخذ مكان المؤشّر أثناء هذه الحركة. لو كنت تستعمل الفأرة فعادةً ما تعمل الدالة هذه بسرعة (ربما تكون 100 مرّة في الثانية، أي كلّ 10 مليثوان).

نريد تحديث بعض المعلومات في صفحة الوِب أثناء حركة المؤشّر.

…ولكن تحديث الدالة ‎update()‎ عملية ثقيلة ولا تنفع لكلّ حركة فأرة صغيرة. كما وليس منطقيًا أصلًا التحديث أكثر من مرّة كلّ 100 مليثانية.

لذا نُغلّف الدالة في مُزخرف: نستعمل ‎throttle(update, 100)‎ على أنّها دالة التشغيل كلّما تحرّكت الفأرة بدلًا من الدالة ‎update()‎ الأصلية. سيُستدعى المُزخرِف كثيرًا صحيح، ولكنّها لن يمرّر الاستدعاءات هذه إلى ‎update()‎ إلّا مرّة كلّ 100 مليثانية.

هكذا سيظهر للمستخدم:

  1. في أوّل تحريك للفأرة، تُمرّر نسختنا المُزخرفة من الدالة الاستدعاء مباشرةً إلى ‎update‎، وهذا مهمّ إذ يرى المستخدم كيف تفاعلت الصفحة مباشرةً مع تحريكه الفأرة.
  2. ثمّ يُحرّك المستخدم الفأرة أكثر، ولا يحدث شيء طالما لم تمرّ ‎100ms‎. نسختنا المُزخرفة الرائعة تُهمل تلك الاستدعاءات.
  3. بعد نهاية ‎100ms‎ يعمل آخر استدعاء ‎update‎ حاملًا الإحداثيات الأخيرة.
  4. وأخيرًا تتوقّف الفأرة عن الحراك. تنتظر الدالة المُزخرفة حتى تمضي ‎100ms‎ وثمّ تشغّل ‎update‎ حاملةً آخر الإحداثيات. وهكذا نُعالج آخر حركة للفأرة، وهذا مهم

مثال عن الشيفرة:

function f(a) {
  console.log(a);
}

// تمرّر f1000 الاستدعاءات إلى f مرّة كلّ 1000 مليثانية كحدّ أقصى
let f1000 = throttle(f, 1000);

f1000(1); // تعرض 1
f1000(2); // (مخنوقة، لم تمض 1000 مليثانية بعد)
f1000(3); // (مخنوقة، لم تمض 1000 مليثانية بعد)

// ‫حين تمضي 1000 مليثانية...
// ‫...تطبع 3، إذ القيمة 2 الوسطية أُهملت

ملاحظة: يجب تمرير المُعاملات والسياق ‎this‎ المُمرّرة إلى ‎f1000‎- تمريرها إلى ‎f‎ الأصلية.

الحل

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }

    func.apply(this, arguments); // (1)

    isThrottled = true;

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

يُعيد استدعاء ‎throttle(func, ms)‎ الغِلاف ‎wrapper‎.

  1. أثناء الاستدعاء الأول، يُشغّل ‎wrapper‎ ببساطة الدالة ‎func‎ ويضبط حالة الراحة (‎isThrottled = true‎).
  2. في هذه الحالة نحفظ كلّ الاستدعاءات في ‎savedArgs/savedThis‎. لاحظ بأنّ السياق والوُسطاء مهمّان ويجب حفظهما كلاهما، فنحتاجهما معًا لنُعيد ذلك الاستدعاء كما كان ونستدعيه حقًا.
  3. بعد مرور ‎ms‎ مليثانية، يعمل ‎setTimeout‎، بهذا تُزال حالة الراحة (‎isThrottled = false‎) ولو كانت هناك استدعاءات مُهملة، نُنفّذ ‎wrapper‎ بآخر ما حفظنا من وُسطاء وسياق.

لا نشغّل في الخطوة الثالثة ‎func‎ بل ‎wrapper‎ إذ نريد تنفيذ ‎func‎ إضافةً إلى دخول حالة الراحة ثانيةً وضبط المؤقّت لتصفيرها.

ترجمة -وبتصرف- للفصل Decorators and forwarding, call/apply من كتاب The JavaScript language

مهمه

الأهمية: 5

Create a decorator spy(func) that should return a wrapper that saves all calls to function in its calls property.

Every call is saved as an array of arguments.

For instance:

function work(a, b) {
  alert( a + b ); // work is an arbitrary function or method
}

work = spy(work);

work(1, 2); // 3
work(4, 5); // 9

for (let args of work.calls) {
  alert( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}

P.S. That decorator is sometimes useful for unit-testing. Its advanced form is sinon.spy in Sinon.JS library.

افتح sandbox بالإختبارات.

The wrapper returned by spy(f) should store all arguments and then use f.apply to forward the call.

function spy(func) {

  function wrapper(...args) {
    // using ...args instead of arguments to store "real" array in wrapper.calls
    wrapper.calls.push(args);
    return func.apply(this, args);
  }

  wrapper.calls = [];

  return wrapper;
}

افتح الحل الإختبارات في sandbox.

الأهمية: 5

Create a decorator delay(f, ms) that delays each call of f by ms milliseconds.

For instance:

function f(x) {
  alert(x);
}

// create wrappers
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("test"); // shows "test" after 1000ms
f1500("test"); // shows "test" after 1500ms

In other words, delay(f, ms) returns a “delayed by ms” variant of f.

In the code above, f is a function of a single argument, but your solution should pass all arguments and the context this.

افتح sandbox بالإختبارات.

The solution:

function delay(f, ms) {

  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };

}

let f1000 = delay(alert, 1000);

f1000("test"); // shows "test" after 1000ms

Please note how an arrow function is used here. As we know, arrow functions do not have own this and arguments, so f.apply(this, arguments) takes this and arguments from the wrapper.

If we pass a regular function, setTimeout would call it without arguments and this=window (assuming we’re in the browser).

We still can pass the right this by using an intermediate variable, but that’s a little bit more cumbersome:

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // store this into an intermediate variable
    setTimeout(function() {
      f.apply(savedThis, args); // use it here
    }, ms);
  };

}

افتح الحل الإختبارات في sandbox.

الأهمية: 5

The result of debounce(f, ms) decorator is a wrapper that suspends calls to f until there’s ms milliseconds of inactivity (no calls, “cooldown period”), then invokes f once with the latest arguments.

In other words, debounce is like a secretary that accepts “phone calls”, and waits until there’s ms milliseconds of being quiet. And only then it transfers the latest call information to “the boss” (calls the actual f).

For instance, we had a function f and replaced it with f = debounce(f, 1000).

Then if the wrapped function is called at 0ms, 200ms and 500ms, and then there are no calls, then the actual f will be only called once, at 1500ms. That is: after the cooldown period of 1000ms from the last call.

…And it will get the arguments of the very last call, other calls are ignored.

Here’s the code for it (uses the debounce decorator from the Lodash library):

let f = _.debounce(alert, 1000);

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// debounced function waits 1000ms after the last call and then runs: alert("c")

Now a practical example. Let’s say, the user types something, and we’d like to send a request to the server when the input is finished.

There’s no point in sending the request for every character typed. Instead we’d like to wait, and then process the whole result.

In a web-browser, we can setup an event handler – a function that’s called on every change of an input field. Normally, an event handler is called very often, for every typed key. But if we debounce it by 1000ms, then it will be only called once, after 1000ms after the last input.

In this live example, the handler puts the result into a box below, try it:

See? The second input calls the debounced function, so its content is processed after 1000ms from the last input.

So, debounce is a great way to process a sequence of events: be it a sequence of key presses, mouse movements or something else.

It waits the given time after the last call, and then runs its function, that can process the result.

The task is to implement debounce decorator.

Hint: that’s just a few lines if you think about it :)

افتح sandbox بالإختبارات.

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

A call to debounce returns a wrapper. When called, it schedules the original function call after given ms and cancels the previous such timeout.

افتح الحل الإختبارات في sandbox.

الأهمية: 5

Create a “throttling” decorator throttle(f, ms) – that returns a wrapper.

When it’s called multiple times, it passes the call to f at maximum once per ms milliseconds.

The difference with debounce is that it’s completely different decorator:

  • debounce runs the function once after the “cooldown” period. Good for processing the final result.
  • throttle runs it not more often than given ms time. Good for regular updates that shouldn’t be very often.

In other words, throttle is like a secretary that accepts phone calls, but bothers the boss (calls the actual f) not more often than once per ms milliseconds.

Let’s check the real-life application to better understand that requirement and to see where it comes from.

For instance, we want to track mouse movements.

In a browser we can setup a function to run at every mouse movement and get the pointer location as it moves. During an active mouse usage, this function usually runs very frequently, can be something like 100 times per second (every 10 ms). We’d like to update some information on the web-page when the pointer moves.

…But updating function update() is too heavy to do it on every micro-movement. There is also no sense in updating more often than once per 100ms.

So we’ll wrap it into the decorator: use throttle(update, 100) as the function to run on each mouse move instead of the original update(). The decorator will be called often, but forward the call to update() at maximum once per 100ms.

Visually, it will look like this:

  1. For the first mouse movement the decorated variant immediately passes the call to update. That’s important, the user sees our reaction to their move immediately.
  2. Then as the mouse moves on, until 100ms nothing happens. The decorated variant ignores calls.
  3. At the end of 100ms – one more update happens with the last coordinates.
  4. Then, finally, the mouse stops somewhere. The decorated variant waits until 100ms expire and then runs update with last coordinates. So, quite important, the final mouse coordinates are processed.

A code example:

function f(a) {
  console.log(a);
}

// f1000 passes calls to f at maximum once per 1000 ms
let f1000 = throttle(f, 1000);

f1000(1); // shows 1
f1000(2); // (throttling, 1000ms not out yet)
f1000(3); // (throttling, 1000ms not out yet)

// when 1000 ms time out...
// ...outputs 3, intermediate value 2 was ignored

P.S. Arguments and the context this passed to f1000 should be passed to the original f.

افتح sandbox بالإختبارات.

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }

    func.apply(this, arguments); // (1)

    isThrottled = true;

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

A call to throttle(func, ms) returns wrapper.

  1. During the first call, the wrapper just runs func and sets the cooldown state (isThrottled = true).
  2. In this state all calls are memorized in savedArgs/savedThis. Please note that both the context and the arguments are equally important and should be memorized. We need them simultaneously to reproduce the call.
  3. After ms milliseconds pass, setTimeout triggers. The cooldown state is removed (isThrottled = false) and, if we had ignored calls, wrapper is executed with the last memorized arguments and context.

The 3rd step runs not func, but wrapper, because we not only need to execute func, but once again enter the cooldown state and setup the timeout to reset it.

افتح الحل الإختبارات في sandbox.

خريطة الدورة التعليمية