16 липня 2023 р.

Proxy та Reflect

Об’єкт Proxy обгортає інший об’єкт і перехоплює операції, такі як читання/запис властивостей та інші, за бажанням обробляючи їх самостійно, або прозоро дозволяючи об’єкту обробляти їх.

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

Proxy

Синтаксис:

let proxy = new Proxy(target, handler)
  • target – об’єкт для обгортання, може бути будь-чим, включаючи функції.
  • handler – конфігурація проксі: об’єкт з “пастками” (“traps”), методами, які перехоплюють операції, наприклад, пастка get – для зчитування властивості target, пастка set – для запису властивості в target і так далі.

Для операцій над proxy, якщо в handler є відповідна пастка, то вона спрацьовує, і проксі має шанс обробити її, інакше операція виконується над target.

Як початковий приклад, створімо проксі без пасток:

let target = {};
let proxy = new Proxy(target, {}); // порожній handler

proxy.test = 5; // записуємо в проксі (1)
alert(target.test); // 5, властивість з’явилася у target!

alert(proxy.test); // 5, ми також можемо зчитати її з проксі (2)

for(let key in proxy) alert(key); // test, ітерація працює (3)

Оскільки пасток немає, усі операції на proxy перенаправляються до target.

  1. Операція запису proxy.test= встановлює значення для target.
  2. Операція зчитування proxy.test повертає значення з target.
  3. Ітерація по proxy повертає значення з target.

Як бачимо, без пасток proxy є прозорою обгорткою навколо target.

Proxy – це особливий “екзотичний об’єкт”. Він не має своїх властивостей. З порожнім handler він прозоро перенаправляє операції до target.

Щоб активувати більше можливостей, додаймо пастки.

Що саме ми можемо ними перехопити?

Для більшості операцій над об’єктами в специфікації JavaScript є так званий “внутрішній метод”, який на найнижчому рівні описує, як його виконувати. Наприклад, [[Get]], внутрішній метод для зчитування властивості, [[Set]], внутрішній метод для запису властивості тощо. Ці методи використовуються лише в специфікації, ми не можемо називати їх безпосередньо по імені.

Пастки проксі перехоплюють виклики цих методів. Вони перераховані в специфікації Proxy і в таблиці нижче.

Для кожного внутрішнього методу в цій таблиці є пастка: ім’я методу, яке ми можемо додати до параметра handler нового проксі, щоб перехопити операцію:

Внутрішній Метод Метод Пастки Викликається, коли…
[[Get]] get зчитування значення
[[Set]] set запис значення
[[HasProperty]] has оператор in
[[Delete]] deleteProperty оператор delete
[[Call]] apply виклик функції
[[Construct]] construct оператор new
[[GetPrototypeOf]] getPrototypeOf Object.getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf Object.setPrototypeOf
[[IsExtensible]] isExtensible Object.isExtensible
[[PreventExtensions]] preventExtensions Object.preventExtensions
[[DefineOwnProperty]] defineProperty Object.defineProperty, Object.defineProperties
[[GetOwnProperty]] getOwnPropertyDescriptor Object.getOwnPropertyDescriptor, for..in, Object.keys/values/entries
[[OwnPropertyKeys]] ownKeys Object.getOwnPropertyNames, Object.getOwnPropertySymbols, for..in, Object.keys/values/entries
Інваріанти

JavaScript встановлює деякі інваріанти – умови, які повинні виконуватися внутрішніми методами та пастками.

Більшість із них стосуються значень, що повертаються:

  • [[Set]] має повертати true, якщо значення було записано успішно, інакше false.
  • [[Delete]] має повертати true, якщо значення було успішно видалено, інакше false.
  • …і так далі, ми побачимо більше у прикладах нижче.

Є й інші інваріанти, наприклад:

  • [[GetPrototypeOf]], застосований до об’єкта проксі, має повертати те саме значення, що й [[GetPrototypeOf]], застосоване до цільового об’єкта проксі. Іншими словами, зчитування прототипу проксі завжди має повертати прототип цільового об’єкта.

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

Інваріанти забезпечують правильну та послідовну поведінку функцій мови. Повний список інваріантів знаходиться в специфікації. Ви, мабуть, не порушите їх, якщо не робитимете щось дивне.

Подивімося, як це працює на практичних прикладах.

Типове значення із пасткою “get”

Найпоширеніші пастки призначені для зчитування/запису властивостей.

Щоб перехопити зчитування, handler повинен мати метод get(target, property, receiver).

Він запускається, коли властивість зчитується, з такими аргументами:

  • target – це цільовий об’єкт, який передається як перший аргумент до new Proxy,
  • property – назва властивості,
  • receiver – якщо цільова властивість є геттером, тоді receiver є об’єктом, який буде використовуватися як this у його виклику. Зазвичай це сам об’єкт proxy (або об’єкт, який успадковується від нього, якщо ми успадковуємо від проксі). Наразі цей аргумент нам не потрібен, тому детальніше роз’яснимо його пізніше.

Використаймо get для реалізації значень за замовчуванням для об’єкта.

Ми створимо числовий масив, який повертає 0 для неіснуючих значень.

Зазвичай, коли хтось намагається отримати неіснуючий елемент масиву, він отримує значення undefined, але ми обернемо звичайний масив у проксі, який перехоплює зчитування і повертає 0, якщо такої властивості немає:

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // типове значення
    }
  }
});

alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (немає такого елемента)

Як ми бачимо, це досить легко зробити за допомогою пастки get.

Ми можемо використовувати Proxy для реалізації будь-якої логіки для “типових” значень.

Уявіть, що у нас є словник із фразами та їх перекладами:

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined

Прямо зараз, якщо фрази немає, зчитування з dictionary повертає значення undefined. Але на практиці, як правило, краще залишити фразу неперекладеною, ніж undefined. Тож давайте змусимо його повертати неперекладену фразу в цьому випадку замість undefined.

Щоб досягти цього, ми обгорнемо dictionary у проксі, який перехоплює операції зчитування:

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

dictionary = new Proxy(dictionary, {
  get(target, phrase) { // перехоплює зчитування властивості з dictionary
    if (phrase in target) { // якщо ми маємо таку в словнику
      return target[phrase]; // повертаємо переклад
    } else {
      // інакше повертаємо неперекладену фразу
      return phrase;
    }
  }
});

// Знайдіть у словнику довільні фрази!
// У гіршому випадку вони будуть не перекладені.
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (немає перекладу)
Будь ласка, зверніть увагу:

Зверніть увагу, як проксі перезаписує змінну:

dictionary = new Proxy(dictionary, ...);

Проксі повинен повністю замінити цільовий об’єкт скрізь. Ніхто ніколи не повинен посилатися на цільовий об’єкт після того, як він був проксійований. Інакше легко заплутатися.

Валідація з пасткою “set”.

Скажімо, нам потрібен масив виключно для чисел. Якщо додано значення іншого типу, має бути помилка.

Пастка set запускається, коли властивість записується.

set(target, property, value, receiver):

  • target – це цільовий об’єкт, який передається як перший аргумент до new Proxy,
  • property – назва властивості,
  • value – значення властивості,
  • receiver – аналогічно пастці get, має значення лише для властивостей сеттера.

Пастка set повинна повертати true, якщо налаштування є успішними, і false в іншому випадку (викликає TypeError).

Використаймо його для перевірки нових значень:

let numbers = [];

numbers = new Proxy(numbers, { // (*)
  set(target, prop, val) { // для перехоплення запису властивості
    if (typeof val == 'number') {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  }
});

numbers.push(1); // додано успішно
numbers.push(2); // додано успішно
alert("Довжина: " + numbers.length); // 2

numbers.push("test"); // TypeError ('set' на проксі повернула false)

alert("Цей рядок ніколи не буде досягнуто (помилка в рядку вище)");

Зверніть увагу: вбудований функціонал масивів все ще працює! Значення додаються за допомогою push. Властивість length автоматично збільшується, коли додаються значення. Наш проксі нічого не порушує.

Нам не потрібно перевизначати методи масиву, що додають значення, такі як push та unshift тощо, щоб додати туди перевірки, оскільки всередині вони використовують операцію [[Set]], яку перехоплює проксі.

Отже, код чистий і лаконічний.

Не забувайте повернути true

Як було сказано вище, існують інваріанти, яких слід дотримуватися.

Для set повинно повернутися true у випадку успішного запису.

Якщо ми забудемо це зробити або повернемо будь-яке помилкове значення, операція призведе до TypeError.

Перебір за допомогою “ownKeys” і “getOwnPropertyDescriptor”

Object.keys, цикл for..in та більшість інших методів, які перебирають властивості об’єкта, використовують внутрішній метод [[OwnPropertyKeys]] (перехоплюється пасткою ownKeys), щоб отримати список властивостей.

Такі методи відрізняються в деталях:

  • Object.getOwnPropertyNames(obj) повертає несимвольні ключі.
  • Object.getOwnPropertySymbols(obj) повертає символьні ключі.
  • Object.keys/values() повертає несимвольні ключі/значення з прапором enumerable (прапори властивостей були пояснені в статті Прапори та дескриптори властивостей).
  • for..in перебирає ключі без символів з прапором enumerable, а також ключі прототипів.

…Але всі вони починаються з цього списку.

У наведеному нижче прикладі ми використовуємо пастку ownKeys, щоб зробити цикл for..in над user, а також Object.keys і Object.values, щоб пропустити властивості, які починаються з символу підкреслення _:

let user = {
  name: "Іван",
  age: 30,
  _password: "***"
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "ownKeys" виключив _password
for(let key in user) alert(key); // name, потім: age

// аналогічний ефект для цих методів:
alert( Object.keys(user) ); // name,age
alert( Object.values(user) ); // Іван,30

Поки що це працює.

Хоча, якщо ми повернемо ключ, якого не існує в об’єкті, Object.keys не виведе його в списку:

let user = { };

user = new Proxy(user, {
  ownKeys(target) {
    return ['a', 'b', 'c'];
  }
});

alert( Object.keys(user) ); // <пусто>

Чому? Причина проста: Object.keys повертає лише властивості з прапором enumerable. Щоб перевірити це, він викликає внутрішній метод [[GetOwnProperty]] для кожної властивості, щоб отримати її дескриптор. І тут, оскільки властивості немає, її дескриптор порожній, немає прапора enumerable, тому вона пропускається.

Щоб Object.keys повертав властивість, нам потрібно, щоб вона існувала в об’єкті з прапором enumerable, або ми можемо перехоплювати виклики [[GetOwnProperty]] (це робить пастка getOwnPropertyDescriptor) , і повертати дескриптор із enumerable: true.

Ось приклад цього:

let user = { };

user = new Proxy(user, {
  ownKeys(target) { // викликається один раз, щоб отримати список властивостей
    return ['a', 'b', 'c'];
  },

  getOwnPropertyDescriptor(target, prop) { // викликається для кожного значення
    return {
      enumerable: true,
      configurable: true
      /* ...інші прапори, ймовірне "значення:..." */
    };
  }

});

alert( Object.keys(user) ); // a, b, c

Зауважимо ще раз: нам потрібно перехоплювати [[GetOwnProperty]] лише тоді, коли властивість відсутня в об’єкті.

Захищені властивості з “deleteProperty” та іншими пастками

Існує поширена домовленість, що властивості та методи з префіксом підкреслення _ є внутрішніми. До них не слід звертатися ззовні об’єкта.

Але технічно це можливо:

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

alert(user._password); // secret

Використаймо проксі, щоб запобігти будь-якому доступу до властивостей, які починаються з _.

Нам знадобляться пастки:

  • get, щоб прокидати помилку під час читання такої властивості,
  • set, щоб прокидати помилку під час запису,
  • deleteProperty, щоб прокидати помилку під час видалення,
  • ownKeys для виключення властивостей, що починаються з _, із for..in та методів, таких як Object.keys.

Ось код:

let user = {
  name: "John",
  _password: "***"
};

user = new Proxy(user, {
  get(target, prop) {
    if (prop.startsWith('_')) {
      throw new Error("Доступ заборонено");
    }
    let value = target[prop];
    return (typeof value === 'function') ? value.bind(target) : value; // (*)
  },
  set(target, prop, val) { // для перехоплення запису властивості
    if (prop.startsWith('_')) {
      throw new Error("Доступ заборонено");
    } else {
      target[prop] = val;
      return true;
    }
  },
  deleteProperty(target, prop) { // для перехоплення видалення властивості
    if (prop.startsWith('_')) {
      throw new Error("Доступ заборонено");
    } else {
      delete target[prop];
      return true;
    }
  },
  ownKeys(target) { // для перехоплення перебору властивостей
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "get" не дозволяє прочитати _password
try {
  alert(user._password); // Error: Доступ заборонено
} catch(e) { alert(e.message); }

// "set" не дозволяє записати _password
try {
  user._password = "test"; // Error: Доступ заборонено
} catch(e) { alert(e.message); }

// "deleteProperty" не дозволяє видалити _password
try {
  delete user._password; // Error: Доступ заборонено
} catch(e) { alert(e.message); }

// "ownKeys" виключає _password з перебору
for(let key in user) alert(key); // name

Будь ласка, зверніть увагу на важливу деталь у пастці get, у рядку (*):

get(target, prop) {
  // ...
  let value = target[prop];
  return (typeof value === 'function') ? value.bind(target) : value; // (*)
}

Чому нам потрібна функція для виклику value.bind(target)?

Причина в тому, що методи об’єкта, такі як user.checkPassword(), повинні мати можливість отримати доступ до _password:

user = {
  // ...
  checkPassword(value) {
    // метод об’єкта повинен мати можливість зчитати _password
    return value === this._password;
  }
}

Виклик user.checkPassword() отримує проксійований user як this (об’єкт перед крапкою стає this), тому, коли він намагається отримати доступ до this._password, активується пастка get (вона запускається на будь-якому зчитуванні властивості) і видає помилку.

Отже, ми прив’язуємо контекст методів об’єкта до вихідного об’єкта, target, у рядку (*). Тоді їхні майбутні виклики використовуватимуть target як this, без жодних пасток.

Це рішення зазвичай працює, але не є ідеальним, оскільки метод може передати непроксійований об’єкт кудись ще, і тоді ми заплутаємося: де вихідний об’єкт, а де проксійований?

Крім того, об’єкт може бути проксійований кілька разів (кілька проксі можуть додавати різні “налаштування” до об’єкта), і якщо ми передаємо розгорнутий об’єкт до методу, можуть виникнути несподівані наслідки.

Отже, такий проксі не варто використовувати всюди.

Приватні властивості класу

Сучасні інтерпретатори JavaScript підтримують приватні властивості в класах із префіксом #. Вони описані в статті Приватні та захищені властивості та методи. Проксі для цього не потрібні.

Однак такі властивості мають свої проблеми. Зокрема, вони не передаються у спадок.

“В діапазоні” з пасткою “has”

Подивімося більше прикладів.

У нас є об’єкт діапазону:

let range = {
  start: 1,
  end: 10
};

Ми хотіли б використовувати оператор in, щоб перевірити, чи знаходиться число в range.

Пастка has перехоплює виклики in.

has(target, property)

  • target – це цільовий об’єкт, який передається як перший аргумент до new Proxy,
  • property – назва властивості

Ось демо:

let range = {
  start: 1,
  end: 10
};

range = new Proxy(range, {
  has(target, prop) {
    return prop >= target.start && prop <= target.end;
  }
});

alert(5 in range); // true
alert(50 in range); // false

Чудовий синтаксичний цукор, чи не так? І дуже простий у реалізації.

Обгортання функцій: "apply"

Ми також можемо обгорнути проксі навколо функції.

Пастка apply(target, thisArg, args) обробляє виклик проксі як функцію:

  • target – це цільовий об’єкт (функція – це об’єкт в JavaScript),
  • thisArg – це значенням this.
  • args – це список аргументів.

Наприклад, згадаймо декоратор delay(f, ms), який ми робили у розділі Декоратори та переадресація виклику, call/apply.

У цьому розділі ми зробили це без проксі. Виклик до delay(f, ms) повернув функцію, яка перенаправляє всі виклики до f через ms мілісекунд.

Ось попередня реалізація на основі функцій:

function delay(f, ms) {
  // повертає обгортку, яка передає виклик до f після тайм-ауту
  return function() { // (*)
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Привіт, ${user}!`);
}

// після цього обгортання виклики sayHi будуть відкладені на 3 секунди
sayHi = delay(sayHi, 3000);

sayHi("Іван"); // Привіт, Іван! (через 3 секунди)

Як ми вже бачили, це переважно працює. Функція-обгортка (*) виконує виклик після тайм-ауту.

Але функція-обгортка не перенаправляє операції зчитування/запису властивостей або щось інше. Після обгортання втрачається доступ до властивостей оригінальних функцій, таких як name, length та інших:

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

function sayHi(user) {
  alert(`Привіт, ${user}!`);
}

alert(sayHi.length); // 1 (length функції — це кількість аргументів у її оголошенні)

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 0 (в оголошенні обгортки нуль аргументів)

Proxy набагато потужніші, оскільки вони перенаправляють все до цільового об’єкта.

Використаймо Proxy замість функції-обгортки:

function delay(f, ms) {
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    }
  });
}

function sayHi(user) {
  alert(`Привіт, ${user}!`);
}

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 1 (*) проксі перенаправляє операцію "get length" до цілі

sayHi("John"); // Привіт, Іван! (через 3 секунди)

Результат той самий, але тепер не тільки виклики, а й усі операції на проксі пересилаються до оригінальної функцію. Отже, sayHi.length повертає правильне значення після обгортання в рядку (*).

У нас є “багатша” обгортка.

Існують й інші пастки: повний список на початку цієї статті. Схема їх використання схожа на описану вище.

Reflect

Reflect – це вбудований об’єкт, який спрощує створення Proxy.

Раніше було сказано, що внутрішні методи, такі як [[Get]], [[Set]] та інші, призначені лише для специфікації, вони не можуть бути викликані безпосередньо.

Об’єкт Reflect робить це певним чином можливим. Його методи – мінімальні обгортки навколо внутрішніх методів.

Ось приклади операцій і викликів Reflect, які роблять те саме:

Операція Виклик Reflect Внутрішній метод
obj[prop] Reflect.get(obj, prop) [[Get]]
obj[prop] = value Reflect.set(obj, prop, value) [[Set]]
delete obj[prop] Reflect.deleteProperty(obj, prop) [[Delete]]
new F(value) Reflect.construct(F, value) [[Construct]]

Наприклад:

let user = {};

Reflect.set(user, 'name', 'Іван');

alert(user.name); // Іван

Зокрема, Reflect дозволяє нам викликати оператори (new, delete…) як функції (Reflect.construct, Reflect.deleteProperty, …). Це цікава здатність, але тут важливо інше.

Для кожного внутрішнього методу, перехопленого Proxy, є відповідний метод у Reflect з тими ж іменами та аргументами, що й пастка Proxy.

Таким чином, ми можемо використовувати Reflect для пересилання операції до оригінального об’єкта.

У цьому прикладі обидві пастки get і set прозоро (наче їх не існує) перенаправляють операції зчитування/запису до об’єкта, показуючи повідомлення:

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

user = new Proxy(user, {
  get(target, prop, receiver) {
    alert(`GET ${prop}`);
    return Reflect.get(target, prop, receiver); // (1)
  },
  set(target, prop, val, receiver) {
    alert(`SET ${prop}=${val}`);
    return Reflect.set(target, prop, val, receiver); // (2)
  }
});

let name = user.name; // показує "GET name"
user.name = "Петро"; // показує "SET name=Петро"

Тут:

  • Reflect.get зчитує властивість об’єкта.
  • Reflect.set записує властивість об’єкта і повертає true у разі успіху, інакше false.

Тобто все просто: якщо пастка хоче перенаправити виклик до об’єкта, достатньо викликати Reflect.<method> з тими ж аргументами.

У більшості випадків ми можемо зробити те ж саме без Reflect, наприклад, зчитування властивості Reflect.get(target, prop, receiver) можна замінити на target[prop]. Але є важливі нюанси.

Проксі для геттера

Подивімося на приклад, який демонструє, чому Reflect.get краще. І ми також побачимо, чому get/set має третій аргумент receiver, який ми раніше не використовували.

У нас є об’єкт user з властивістю _name і геттер для нього.

Ось проксі навколо нього:

let user = {
  _name: "Гість",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop];
  }
});

alert(userProxy.name); // Гість

Пастка get тут є “прозорою”, вона повертає оригінальну властивість і більше нічого не робить. Цього достатньо для нашого прикладу.

Начебто все гаразд. Але зробімо приклад трохи складнішим.

Після успадкування іншого об’єкта admin від user ми можемо спостерігати неправильну поведінку:

let user = {
  _name: "Гість",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop]; // (*) target = user
  }
});

let admin = {
  __proto__: userProxy,
  _name: "Адмін"
};

// Очікується: Адмін
alert(admin.name); // виводиться: Гість (?!?)

Зчитування admin.name має повертати "Адмін", а не "Гість"!

Що трапилось? Можливо ми зробили щось не так з успадкуванням?

Але якщо ми видалимо проксі, то все буде працювати, як очікувалося.

Проблема насправді в проксі, у рядку (*).

  1. Коли ми читаємо admin.name, оскільки об’єкт admin не має такої своєї властивості, пошук переходить до його прототипу.

  2. Прототипом є userProxy.

  3. Під час зчитування властивості name з проксі спрацьовує пастка get і повертає її з вихідного об’єкта як target[prop] у рядку (*).

    Виклик target[prop], коли prop є геттером, запускає його код у контексті this=target. Таким чином, результатом є this._name з оригінального об’єкта target, тобто: від user.

Щоб виправити такі ситуації, нам потрібен receiver, третій аргумент пастки get. У ньому зберігається правильний this для передачі гетеру. У нашому випадку це admin.

Як передати контекст для геттера? Для звичайної функції ми можемо використовувати call/apply, але це геттер, він не “викликається”, а лише доступний.

Reflect.get може це зробити. Все буде працювати правильно, якщо ми цим скористаємося.

Ось виправлений варіант:

let user = {
  _name: "Гість",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) { // receiver = admin
    return Reflect.get(target, prop, receiver); // (*)
  }
});


let admin = {
  __proto__: userProxy,
  _name: "Адмін"
};

alert(admin.name); // Адмін

Тепер receiver, який зберігає посилання на правильний this (тобто admin), передається до геттера за допомогою Reflect.get у рядку (*).

Ми можемо переписати пастку ще коротше:

get(target, prop, receiver) {
  return Reflect.get(...arguments);
}

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

Тож, return Reflect... забезпечує безпечну та просту переадресацію операції та оберігає від того, що ми забудемо щось, що пов’язане з цим.

Обмеження проксі

Проксі надають унікальний спосіб змінити або налаштувати поведінку існуючих об’єктів на найнижчому рівні. Все-таки це не ідеально. Існують обмеження.

Вбудовані об’єкти: внутрішні слоти

Багато вбудованих об’єктів, наприклад Map, Set, Date, Promise та інші, використовують так звані “внутрішні слоти”.

Це подібні властивості, але зарезервовані для внутрішніх цілей, призначених лише для специфікації. Наприклад, Map зберігає елементи у внутрішньому слоті [[MapData]]. Вбудовані методи отримують доступ до них безпосередньо, а не через внутрішні методи [[Get]]/[[Set]]. Тому Proxy не може перехопити це.

Чому це має значення? Вони ж все одно внутрішні!

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

Наприклад:

let map = new Map();

let proxy = new Proxy(map, {});

proxy.set('test', 1); // Помилка

Всередині Map зберігає всі дані у своєму внутрішньому слоті [[MapData]]. Проксі не має такого слота. Вбудований метод Map.prototype.set намагається отримати доступ до внутрішньої властивості this.[[MapData]], але, оскільки this=proxy, не може знайти його в proxy і просто завершується з помилкою.

На щастя, є спосіб виправити це:

let map = new Map();

let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

proxy.set('test', 1);
alert(proxy.get('test')); // 1 (працює!)

Тепер все працює нормально, тому що пастка get пов’язує властивості функції, такі як map.set, із самим цільовим об’єктом (map).

На відміну від попереднього прикладу, значення this всередині proxy.set(...) буде не proxy, а оригінальним map. Тому, коли внутрішня реалізація set намагатиметься отримати доступ до this.[[MapData]] внутрішнього слота, операція завершиться успішно.

Array не має внутрішніх слотів

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

Саме тому вищевказана проблема не виникає при проксіюванні масиву.

Приватні поля

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

Наприклад, метод getName() отримує доступ до приватної властивості #name і перестає працювати після проксіювання:

class User {
  #name = "Гість";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {});

alert(user.getName()); // Помилка

Причина в тому, що приватні поля реалізуються за допомогою внутрішніх слотів. JavaScript не використовує [[Get]]/[[Set]] під час доступу до них.

У виклику getName() значенням this є проксійований user, і він не має слота з приватними полями.

Знову ж таки, рішення з прив’язкою методу змушує його працювати:

class User {
  #name = "Гість";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

alert(user.getName()); // Гість

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

Проксі != цільовий об’єкт

Проксі та оригінальний об’єкт – це різні об’єкти. Це природно, правда?

Отже, якщо ми використовуємо оригінальний об’єкт як ключ, а потім проксіюємо його, то проксі не буде знайдено:

let allUsers = new Set();

class User {
  constructor(name) {
    this.name = name;
    allUsers.add(this);
  }
}

let user = new User("Іван");

alert(allUsers.has(user)); // true

user = new Proxy(user, {});

alert(allUsers.has(user)); // false

Як бачимо, після проксіювання ми не можемо знайти user у наборі allUsers, оскільки проксі є іншим об’єктом.

Проксі не можуть перехопити перевірку на сувору рівність ===

Проксі можуть перехоплювати багато операторів, таких як newconstruct), inhas), deletedeleteProperty) тощо.

Але немає способу перехопити перевірку на сувору рівність для об’єктів. Об’єкт суворо рівний тільки самому собі, і жодному іншому значенню.

Таким чином, усі операції та вбудовані класи, які порівнюють об’єкти на рівність, відрізнятимуть об’єкт від проксі. Тут немає прозорої заміни.

Проксі, що відкликаються

Проксі, що відкликаються (revocable) — це проксі, що можна вимкнути.

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

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

Синтаксис такий:

let {proxy, revoke} = Proxy.revocable(target, handler)

Виклик повертає об’єкт із функціями proxy та revoke, щоб вимкнути його.

Ось приклад:

let object = {
  data: "Важливі дані"
};

let {proxy, revoke} = Proxy.revocable(object, {});

// передати проксі десь замість об’єкта...
alert(proxy.data); // Важливі дані

// пізніше в нашому коді
revoke();

// проксі більше не працює (відкликано)
alert(proxy.data); // Помилка

Виклик revoke() видаляє всі внутрішні посилання на цільовий об’єкт із проксі, тому вони більше не пов’язані.

Спочатку revoke існує окремо від proxy, тому ми можемо передавати proxy скрізь, залишаючи revoke у поточній області.

Ми також можемо прив’язати метод revoke до проксі, встановивши proxy.revoke = revoke.

Інший варіант – створити WeakMap, який має proxy як ключ і відповідне значення revoke як значення, що дозволяє легко знайти revoke для проксі:

let revokes = new WeakMap();

let object = {
  data: "Важливі дані"
};

let {proxy, revoke} = Proxy.revocable(object, {});

revokes.set(proxy, revoke);

// ..ще десь у нашому коді..
revoke = revokes.get(proxy);
revoke();

alert(proxy.data); // Помилка (відкликано)

Тут ми використовуємо WeakMap замість Map, оскільки він не блокує збір сміття. Якщо об’єкт проксі стає “недоступним” (наприклад, жодна змінна більше не посилається на нього), WeakMap дозволяє стерти його з пам’яті разом із його revoke, що нам більше не знадобиться.

Посилання

– Специфікація: Proxy.

Підсумки

Проксі – це обгортка навколо об’єкта, яка перенаправляє операції над нею до об’єкта, маючи можливість перехоплювати деякі з них.

Проксіювати можна будь-який тип об’єкта, включаючи класи та функції.

Синтаксис такий:

let proxy = new Proxy(target, {
  /* пастки */
});

…Тоді ми повинні використовувати proxy скрізь замість target. Проксі не має своїх властивостей чи методів. Він перехоплює операцію, якщо пастка передбачена, в іншому випадку пересилає її до цільового об’єкта.

Ми можемо захопити:

  • Зчитування (get), запис (set), видалення (deleteProperty) властивості (навіть неіснуючої).
  • Виклик функції (пастка apply).
  • Оператор new (пастка construct).
  • Багато інших операцій (повний список на початку статті та в документації).

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

Ми також можемо обгорнути об’єкт кілька разів у різні проксі, прикрашаючи його різними аспектами функціональності.

Reflect API розроблено для доповнення Proxy. Для будь-якої пастки Proxy є виклик Reflect з тими самими аргументами. Ми повинні використовувати їх для переадресації викликів цільовим об’єктам.

Проксі мають деякі обмеження:

  • Вбудовані об’єкти мають “внутрішні слоти”, доступ до них не може бути проксійованим. Перегляньте обхідний шлях вище.
  • Те ж саме стосується полів приватного класу, оскільки вони внутрішньо реалізовані за допомогою слотів. Тому виклики методів через проксі повинні мати цільовий об’єкт як this для доступу до них.
  • Перевірки об’єктів на сувору рівність === не можуть бути перехоплені.
  • Продуктивність: контрольні показники залежать від інтерпретатора, але зазвичай доступ до властивості за допомогою найпростішого проксі займає в кілька разів більше часу. На практиці це має значення лише для деяких “особливо навантажених” об’єктів.

Завдання

Зазвичай при спробі прочитати неіснуючу властивість повертається undefined.

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

Це може допомогти виявити помилки програмування раніше.

Напишіть функцію wrap(target), яка приймає об’єкт target і повертає проксі, що додає цей аспект функціональності.

Ось як це має працювати:

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

function wrap(target) {
  return new Proxy(target, {
      /* ваш код */
  });
}

user = wrap(user);

alert(user.name); // Іван
alert(user.age); // ReferenceError: Властивість не існує: "age"
let user = {
  name: "John"
};

function wrap(target) {
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (prop in target) {
        return Reflect.get(target, prop, receiver);
      } else {
        throw new ReferenceError(`Властивість не існує: "${prop}"`)
      }
    }
  });
}

user = wrap(user);

alert(user.name); // Іван
alert(user.age); // ReferenceError: Властивість не існує: "age"

У деяких мовах програмування ми можемо отримати доступ до елементів масиву за допомогою негативних індексів, відрахованих з кінця.

Наприклад ось так:

let array = [1, 2, 3];

array[-1]; // 3, останній елемент
array[-2]; // 2, за крок від кінця
array[-3]; // 1, за два кроки від кінця

Іншими словами, array[-N] це те саме, що array[array.length - N].

Створіть проксі для реалізації такої поведінки.

Ось як це має працювати:

let array = [1, 2, 3];

array = new Proxy(array, {
  /* ваш код */
});

alert( array[-1] ); // 3
alert( array[-2] ); // 2

// Іншу функціональність масиву слід зберегти "як є"
let array = [1, 2, 3];

array = new Proxy(array, {
  get(target, prop, receiver) {
    if (prop < 0) {
      // навіть якщо ми намагаємося отримати доступ як arr[1]
      // prop є рядком, тому його потрібно перетворити на число
      prop = +prop + target.length;
    }
    return Reflect.get(target, prop, receiver);
  }
});


alert(array[-1]); // 3
alert(array[-2]); // 2

Створіть функцію makeObservable(target), яка “робить об’єкт доступним для спостереження”, повертаючи проксі.

Ось як це має працювати:

function makeObservable(target) {
  /* ваш код */
}

let user = {};
user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "Іван"; // сповіщає: SET name=Іван

Іншими словами, об’єкт, повернутий makeObservable, такий же, як оригінальний, але також має метод observe(handler), який встановлює функцію handler для виклику при будь-якій зміні властивості.

Щоразу, коли властивість змінюється, викликається handler(key, value) з назвою та значенням властивості.

P.S. У цьому завданні подбайте лише про запис у властивість. Подібним чином можна реалізувати й інші операції.

Рішення складається з двох частин:

  1. Щоразу, коли викликається .observe(handler), нам потрібно десь запам’ятати обробник, щоб мати можливість викликати його пізніше. Ми можемо зберігати обробники прямо в об’єкті, використовуючи наш символ як ключ властивості.
  2. Нам потрібен проксі з пасткою set для виклику обробників у разі будь-яких змін.
let handlers = Symbol('handlers');

function makeObservable(target) {
  // 1. Ініціалізуємо сховище обробників
  target[handlers] = [];

  // Збережемо функцію-обробник в масиві для майбутніх викликів
  target.observe = function(handler) {
    this[handlers].push(handler);
  };

  // 2. Створимо проксі для обробки змін
  return new Proxy(target, {
    set(target, property, value, receiver) {
      let success = Reflect.set(...arguments); // перенаправимо операцію на об’єкт
      if (success) { // якщо під час запису властивості не було помилок
        // викличемо всі обробники
        target[handlers].forEach(handler => handler(property, value));
      }
      return success;
    }
  });
}

let user = {};

user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "Іван";
Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)