◐ Shell
clean mode source ↗

Методи прототипів, об’єкти без __proto__

В першій главі цього розділу, ми згадували сучасні методи роботи з прототипом.

Встановлення або читання прототипу за допомогою obj.__proto__ вважається застарілим і не рекомендуються для використання в майбутньому (перенесено до так званого “Annex B” стандарту JavaScript, призначеного лише для браузерів).

Сучасні методи отримання/встановлення прототипу:

Єдине використання __proto__, яке не викликає негативного ставлення, це як властивість під час створення нового об’єкта: { __proto__: ... }.

Хоча і для цього є спеціальний метод:

  • Object.create(proto[, descriptors]) – створює порожній об’єкт із заданим proto як [[Prototype]] і необов’язковими дескрипторами властивостей.

Наприклад:

let animal = {
  eats: true
};

// створюється новий об’єкт з прототипом animal
let rabbit = Object.create(animal); // same as {__proto__: animal}

alert(rabbit.eats); // true

alert(Object.getPrototypeOf(rabbit) === animal); // true

Object.setPrototypeOf(rabbit, {}); // змінює прототип об’єкту rabbit на {}

Object.create має необов’язковий другий аргумент: дескриптори властивостей.

Ми можемо надати додаткові властивості новому об’єкту, як тут:

let animal = {
  eats: true
};

let rabbit = Object.create(animal, {
  jumps: {
    value: true
  }
});

alert(rabbit.jumps); // true

Формат дескрипторів описаний в цій главі Прапори та дескриптори властивостей.

Ми можемо використати Object.create, щоб клонувати об’єкт ефективніше ніж циклом for..in:

let clone = Object.create(
  Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj)
);

Таким чином ми створюємо справжню копію об’єкта obj, включаючи всі властивості: перелічувані та не перелічувані, сетери/гетери – з правильним значенням [[Prototype]].

Коротка історія

Якщо порахувати всі способи керування властивістю [[Prototype]], їх буде багато! Багато способів робити одне й те ж саме! Як так сталося? Чому?

Так склалося історично.

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

  • Властивість prototype функції-конструктора працювала з дуже давніх часів. Це найстаріший спосіб створення об’єктів із заданим прототипом.
  • Пізніше, в 2012 році, метод Object.create став стандартом. Це дало можливість створювати об’єкти з певним прототипом, проте не дозволяло отримувати або встановлювати його. Тому браузери реалізували не стандартний аксесор __proto__, що дозволяв користувачу отримувати та встановлювати прототип в будь-який час, щоб надати розробникам більше гнучкості.
  • Ще пізніше, в 2015 році, методи Object.setPrototypeOf та Object.getPrototypeOf були додані до стандарту, для того, щоб виконувати аналогічну функціональність як і __proto__. Оскільки __proto__ було широко реалізовано, воно згадується в Annex B стандарту як не обов’язкове для не-браузерних середовищ, але вважається свого роду застарілим.
  • Пізніше, у 2022 році, було офіційно дозволено використовувати __proto__ в об’єктних літералах {...} (винесено з Annex B), але не як геттер/сеттер obj.__proto__ (ця можливість все ще в Annex B).

Чому __proto__ було замінено методами getPrototypeOf/setPrototypeOf?

Чому __proto__ було частково відновлено і його використання дозволено в {...}, але не як геттер/сеттер?

Це цікаве запитання, яке вимагає від нас розуміння, чому __proto__ поганий.

І незабаром ми отримаємо відповідь.

Не змінюйте [[Prototype]] в існуючих об’єктів, якщо швидкість важлива

Технічно, ми можемо отримати/встановити [[Prototype]] в будь-який момент. Але зазвичай ми тільки встановлюємо його один раз під час створення об’єкту та більше не змінюємо: rabbit наслідується від animal і це не зміниться.

JavaScript рушій є високо оптимізованим для цього. Зміна прототипу “на льоту” за допомогою Object.setPrototypeOf або obj.__proto__= дуже повільна операція, яка порушує внутрішню оптимізацію для операції з доступу до властивості об’єкта. Щоб уникнути цього, ви маєте розуміти що ви робите і для чого або швидкодія виконання JavaScript коду повністю не важлива для вас.

"Прості" об’єкти

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

…Проте якщо ми спробуємо зберегти створені користувачем ключі в ньому (наприклад, словник з користувацьким вводом), ми можемо спостерігати цікавий збій: усі ключі працюють правильно окрім "__proto__".

Розгляньте приклад:

let obj = {};

let key = prompt("Введіть ключ", "__proto__");
obj[key] = "певне значення";

alert(obj[key]); // [object Object], не "певне значення"!

Тут, якщо користувач вводить __proto__, призначення в рядку 4 ігнорується!

Це, безумовно, може бути дивним для нерозробника, але досить зрозумілим для нас. Властивість __proto__ є особливою: вона має бути або об’єктом, або null. Рядок не може стати прототипом. Ось чому призначення рядка __proto__ ігнорується.

Проте ми не намагалися реалізувати таку поведінку. Ми хотіли зберегти пари ключ/значення і при цьому ключ з назвою "__proto__" не зберігся. Тому це помилка!

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

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

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

Як ми можемо уникнути цієї проблеми?

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

let map = new Map();

let key = prompt("What's the key?", "__proto__");
map.set(key, "some value");

alert(map.get(key)); // "some value" (як і передбачалося)

…Але синтаксис Object часто більш привабливий, оскільки він більш стислий.

На щастя, ми можемо використовувати об’єкти, тому що творці мови давно подумали про цю проблему.

__proto__ не є властивістю об’єкту, але є аксесором Object.prototype:

Таким чином, якщо obj.__proto__ зчитується або встановлюється, відповідний гетер/сетер викликається з прототипу та отримує/встановлює [[Prototype]].

Як було сказано на початку цього розділу: __proto__ це спосіб доступу до [[Prototype]], але не є самим [[Prototype]].

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

let obj = Object.create(null);
// or: obj = { __proto__: null }

let key = prompt("Введіть ключ", "__proto__");
obj[key] = "певне значення";

alert(obj[key]); // "певне значення"

Object.create(null) створює пустий об’єкт без прототипу ([[Prototype]] дорівнює null):

Таким чином, відсутні наслідувані гетер/сетер для __proto__. Тепер ми працюємо зі звичайною властивістю, тому приклад вище працює правильно.

Ми можемо називати такі об’єкти “простими” або “чистим словниковим” об’єктом, тому що вони навіть простіше ніж звичайні об’єкти {...}.

Недоліком є те, що такі об’єкти не мають вбудовані методи для роботи з ними, наприклад toString:

let obj = Object.create(null);

alert(obj); // Помилка (відсутній метод toString)

…Але це зазвичай нормально для асоціативних масивів.

Зверніть увагу, що більшість методів пов’язаних з об’єктом Object.something(...), такі як Object.keys(obj) – не розміщуються в прототипі, тому вони будуть працювати з такими об’єктами:

let chineseDictionary = Object.create(null);
chineseDictionary.hello = "你好";
chineseDictionary.bye = "再见";

alert(Object.keys(chineseDictionary)); // hello,bye

Підсумки

  • Щоб створити об’єкт із заданим прототипом, використовуйте:

    • літеральний синтаксис: { __proto__: ... }, дозволяє вказати кілька властивостей
    • або Object.create(proto[, descriptors]), дозволяє вказати дескриптори властивостей.

    Object.create забезпечує простий спосіб поверхневого копіювання об’єкта з усіма дескрипторами:

    let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));
  • Сучасні методи отримання/встановлення прототипу:

  • Отримання/встановлення прототипу за допомогою вбудованого __proto__ геттера/сеттера не рекомендується, тепер це в Annex B специфікації.

  • Ми також розглядали об’єкти без прототипів, створені за допомогою Object.create(null) або {__proto__: null}.

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

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