6 червня 2023 р.

Декоратори та переадресація виклику, call/apply

JavaScript дає виняткову гнучкість при роботі з функціями. Вони можуть бути передані в інші функції, використані як об’єкти, і тепер ми побачимо, як перенаправляти виклики між ними і декорувати їх.

Прозоре кешування

Скажімо, у нас є функція slow(x), яка виконує ресурсоємкі операції, але її результати стабільні. Іншими словами, для того ж x вона завжди повертає той же результат.

Якщо функція часто викликається, ми можемо кешувати (запам’ятати) її результати, щоб не витрачати час на перерахування.

Але замість того, щоб додати цю функціональність в slow() ми створимо функцію-обгортку, що додає кешування. Як ми побачимо, існує багато переваг такого підходу.

Ось код та пояснення:

function slow(x) {
  // тут можуть бути важкі ресурсозатратні операції
  alert(`Викликана з ${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);  // в іншому випадку викликати func

    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 – це декоратор: спеціальна функція, яка бере іншу функцію і змінює її поведінку.

Ідея полягає в тому, що ми можемо викликати cachingDecorator для будь-якої функції, і вона поверне обгортку з кешуванням. Це чудово тому, що ми можемо мати багато функцій, які могли б використовувати таку особливість, і все, що нам потрібно зробити, це примінити до них cachingDecorator.

Розділяючи кешування та код основної функції, ми також зберігаємо простоту основного коду.

Результат cachingDecorator(func) являє собою “обгортку”: function(x), що “обгортає” виклик func(x) в логіку кешування:

З зовнішнього коду, загорнута 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) ); // Ой! Помилка: Cannot read property 'someMethod' of undefined (*)

Помилка виникає в рядку (*), що намагається отримати доступ до this.someMethod та завершається з помилкою. Чи можете ви зрозуміти, чому?

Причиною є те, що обгортка викликає оригінальну функцію, як func(x) у рядку (**). І, коли ця функція викликається так, вона отримує this = undefined.

Ми спостерігаємо подібну ситуацію, коли намагаємося запустити:

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

Отже, обгортка передає виклик до оригінального методу, але без контексту this. Звідси помилка.

Давайте це виправимо.

Існує спеціальний вбудований метод функції func.call(context, …args), що дозволяє викликати функцію явно задаючи їй this.

Синтаксис:

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: "Іван" };
let admin = { name: "Адмін" };

// використовуйте call, щоб передати різні об’єкти як "this"
sayHi.call( user ); // Іван
sayHi.call( admin ); // Адмін

І тут ми використовуємо call, щоб викликати say з даним контекстом і фразою:

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

let user = { name: "Іван" };

// користувач стає this, і "Привіт" стає першим аргументом
say.call( user, "Привіт" ); // Іван: Привіт

У нашому випадку ми можемо використовувати call у обгортці, щоб передати контекст до початкової функції:

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

  slow(x) {
    alert("Викликана з " + 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);

Раніше, для одного аргументу x, ми могли просто cache.set(x, result), щоб зберегти результат та cache.get(x), щоб отримати його. Але тепер нам потрібно пам’ятати результат для комбінації аргументів (min,max). Вбудований Map приймає лише одне значення як ключ.

Є багато можливих рішень:

  1. Впровадити нову (або скористайтеся сторонньою) map-подібну структуру даних, яка більш універсальна і дозволяє використовувати декілька ключів.
  2. Використати вкладені коллекції: cache.set(min) буде Map, що зберігає пару (max, result). Таким чином, ми можемо отримати result як cache.get(min).get(max).
  3. З’єднати два значення в одне. У нашому конкретному випадку ми можемо просто використовувати рядок "min,max" як ключ Map. Для гнучкості ми можемо забезпечити функція хешування для декоратора, що знає, як зробити одне значення з багатьох.

Для багатьох практичних застосунків, 3-й варіант досить хороший, тому ми будемо дотримуватися його.

Також ми повинні передати не просто x, але всі аргументи в func.call. Давайте пригадаємо, що в function() ми можемо отримати псевдо-масив її аргументів, як arguments, тому func.call(this, x) слід замінити на func.call(this, ...arguments).

Ось більш потужний cachingDecorator:

let worker = {
  slow(min, max) {
    alert(`Викликана з ${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( "Знову " + worker.slow(3, 5) ); // те ж саме значення (з кешу)

Тепер він працює з будь-якою кількістю аргументів (хоча хеш-функція також повинна бути пристосована, щоб використовувати будь-яку кількість аргументів. Цікавий спосіб зробити це буде наведено нижче).

Є дві зміни:

  • В рядку (*) викликається hash, щоб створити одну ключ з arguments. Тут ми використовуємо просту функцію “приєднання”, яка перетворює аргументи (3, 5) у ключ "3,5". Більш складні випадки можуть потребувати інших функцій хешування.
  • Потім (**) використовує func.call(this, ...arguments), щоб передати як контекст, так і всі аргументи (а не тільки перший) обгортки до оригінальної функції.

func.apply

Замість func.call(this, ...arguments) ми могли б використовувати func.apply(this, arguments).

Синтаксис вбудованого методу func.apply:

func.apply(context, args)

Він запускає func, встановлюючи this = context і використовує псевдо-масив args як список аргументів.

Єдина різниця синтаксису між call та apply в тому, що call очікує список аргументів, в той час як apply приймає псевдо-масив з ними.

Отже, ці два виклики майже еквівалентні:

func.call(context, ...args);
func.apply(context, args);

Вони виконують той самий виклик func з даним контекстом та аргументами.

Є тільки тонка різниця щодо args:

  • Оператор розширення ... дозволяє передати ітерований args як список до call.
  • apply приймає лише псевдо-масив args.

… і для об’єктів, які є як ітерованими, так і псевдо-масивами, а так само як і справжніми масивами, ми можемо використовувати будь-який з цих методів, але apply, мабуть, буде швидше, тому що більшість рушіїв JavaScript внутрішньо оптимізують його краще.

Передача всіх аргументів разом з контекстом до іншої функції називається переадресація виклику.

Це найпростіша її форма:

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.
  4. Додати glue і this[1].
  5. Додати glue і this[2].
  6. …Робити це до тих пір, поки this.length елементів буде склеїно.
  7. Повернути result.

Отже, технічно цей метод приймає this і з’єднує this[0], this[1] … і т.д. разом. Він навмисно написаний таким чином, що дозволяє будь-який псевдо-масив this (не випадково, багато методів дотримуються цієї практики). Ось чому це також працює з this=arguments.

Декоратори та функціональні властивості

Як правило, можна безпечно замінити функцію або метод декоратором, за винятком однієї дрібниці. Якщо оригінальна функція мала властивості в собі, як наприклад func.calledCount або щось інше, то декорований об’єкт не буде надавати доступ до них. Тому що це обгортка. Тому потрібно бути обережним, якщо використовує її.

Наприклад, у прикладі вище, якщо slow функція мала будь-які властивості в собі, то cachingDecorator(slow) – це обгортка без них.

Деякі декоратори можуть забезпечити свої власні властивості.Наприклад, декоратор може підрахувати, скільки разів функція була викликана, і скільки часу це зайняло, і виставляти цю інформацію за допомогою властивостей обгортки.

Існує спосіб створити декоратори, які зберігають доступ до властивостей функцій, але це вимагає використання спеціального об’єкту Proxy, щоб обернути функцію. Ми обговоримо це пізніше у статті Proxy та Reflect.

Підсумки

Декоратор – це обгортка навколо функції, яка змінює її поведінку. Основна робота все ще виконується за допомогою функції.

Декоратори можна розглядати як “особливості” або “аспекти”, які можна додати до функції. Ми можемо додати один або додати багато декораторів. І все це, не змінюючи коду оригінальної функції!

Для реалізації 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. Альтернативою є використання об’єкта, який є справжнім масивом, за допомогою rest оператору.

На практиці є багато декораторів для різних задач. Перевірте, наскільки добре ви засвоїли їх, вирішивши завдання цієї глави.

Завдання

важливість: 5

Створіть декоратор spy(func), який повинен повернути обгортку, яка зберігає всі виклики функції у властивості calls.

Кожен виклик зберігається як масив аргументів.

For instance:

function work(a, b) {
  alert( a + b ); // працює як довільна функція або метод
}

work = spy(work);

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

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

P.S. Цей декоратор іноді корисний для unit-тестування. Його просунута форма – sinon.spy у бібліотеці Sinon.JS.

Відкрити пісочницю з тестами.

Обгортка, що повертається за допомогою spy(f), повинна зберігати всі аргументи, а потім використовувати f.apply, щоб переадресувати виклик.

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;
}

Відкрити рішення із тестами в пісочниці.

важливість: 5

Створіть декоратор delay(f, ms), яка затримує кожен виклик f на ms мілісекунд.

Наприклад:

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

// створюємо обгортки
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("тест"); // показує "test" після 1000 мс
f1500("тест"); // показує "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("тест"); // показує "тест" після 1000 мс

Зверніть увагу, як тут використовується стрілочна функція. Як відомо, стрілочні функції не мають власних this та arguments, тому f.apply(this, arguments) бере this та arguments з обгортки.

Якщо ми передамо звичайну функцію, setTimeout буде викликати її без аргументів, а this=window (припускаючи, що ми знаходимося в браузері).

Ми все ще можемо передати право this за допомогою проміжної змінної, але це трохи більше громіздко:

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // зберігаємо this в проміжну змінну
    setTimeout(function() {
      f.apply(savedThis, args); // використовуємо її тут
    }, ms);
  };

}

Відкрити рішення із тестами в пісочниці.

важливість: 5

Результат debounce(f, ms) декоратору – це обгортка, що призупиняє виклики до f, поки не пройде ms мілісекунд бездіяльності (без викликів, “cooldown period”), а потім викликає f один раз з останніми аргументами.

Іншими словами, debounce – це як секретар, який приймає “телефонні дзвінки”, і чекає, поки не закінчаться ms мілісекунди тиші. І лише тоді він передає останню інформацію про виклик до “боса” (викликає фактичну f).

Наприклад, у нас була функція f і замінили її на f = debounce(f, 1000).

Тоді, якщо загорнута функція викликається при 0 мс, 200 мс та 500 мс, а потім викликів немає, то фактична f буде викликатися лише один раз, при 1500 мс. Тобто: після закінчення періоду 1000 мс від останнього виклику.

…І вона отримає аргументи самого останнього виклику, а інші виклики будуть ігноруватися.

Ось код для цього (використовує декоратор debounce з Lodash library):

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

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// повернута з debounced функція чекає 1000 мс після останнього виклику, а потім запускає: alert("c")

Тепер практичний приклад. Скажімо, користувач друкує щось, і ми хотіли б надіслати запит на сервер, коли ввід закінчиться.

Немає сенсу надсилати запит на кожен набраний символ. Замість цього ми хотіли б почекати, а потім обробляти весь результат.

У веббраузері ми можемо налаштувати обробник подій – функцію, яка викликається при кожній зміні поля введення.Зазвичай, обробник подій викликається дуже часто, для кожного друкованого символу. Але якщо ми використаємо debounce на 1000 мс, то він буде викликатися лише один раз, через 1000 мс після останнього введення.

У цьому реальному прикладі обробник ставить результат у поле нижче, спробуйте це:

Бачите? Другий ввід викликає функцію, що була повернута з debounce, тому цей вміст обробляється через 1000 мс з останнього введення.

Отже, debounce – це чудовий спосіб обробки послідовності подій: будь то послідовність натискання клавіш, рухів миші або щось інше.

Він чекає певного часу після останнього дзвінка, а потім запускає свою функцію, яка може обробити результат.

Завдання полягає в тому, щоб реалізувати декоратор debounce.

Підказка: Це лише кілька рядків, якщо ви думаєте про це :)

Відкрити пісочницю з тестами.

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

Виклик debounce повертає обгортку. Коли він викликається, він відкладає виклик оригінальної функції після даного ms і скасовує попередній подібний тайм-аут.

Відкрити рішення із тестами в пісочниці.

важливість: 5

Створіть “дросельний” декоратор throttle(f, ms) – що повертає обгортку.

Коли він викликається кілька разів, він передає виклик до f максимум один раз на ms мілісекунд.

Різниця з debounce полягає в тому, що це зовсім інший декоратор:

  • debounce запускає функцію один раз після періоду “спокою”. Це добре для обробки кінцевого результату.
  • throttle запускає функцію не частіше, ніж дано ms часу. Це добре для регулярних оновлень, які не повинні бути дуже часто.

Іншими словами, throttle – це як секретар, який приймає телефонні дзвінки, але турбує боса (викликає фактичну f) не частіше, ніж один раз на ms мілісекунд.

Перевірмо застосунок з реального життя, щоб краще зрозуміти ці вимогу та побачити, звідки вони походять.

Наприклад, ми хочемо відстежувати рухи миші.

У браузері ми можемо налаштувати запускати функцію при кожному русі миші та отримати місце курсору та те, як він рухається. Під час активного використання миші ця функція зазвичай працює дуже часто, може бути щось на зразок 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 ms time out ...
// ...вивід 3, проміжне значення 2 було проігноровано

P.S. Аргументи та контекст this передані в f1000 повинні бути передані оригінальній f.

Відкрити пісочницю з тестами.

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

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

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

    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 виконується з останніми запам’ятовуваними аргументами та контекстом.

3-й крок запускає не func, а wrapper, тому що ми не тільки повинні виконувати func, але й ще раз вводити стан відпочинку та налаштовувати тайм-аут, щоб скинути його.

Відкрити рішення із тестами в пісочниці.

Навчальна карта