22 лютого 2024 р.

Методи прототипів, об’єкти без __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 об’єкти справді порожні.

Завдання

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

Дано об’єкт dictionary, створений за допомогою Object.create(null), щоб зберегти будь-які пари ключ/значення.

Додайте метод dictionary.toString() до об’єкта, який повертає перелік ключів через кому. Метод toString не повинен показуватися, якщо застосувати до об’єкта цикл for..in.

Тут показано як це має працювати:

let dictionary = Object.create(null);

// ваш код, щоб додати dictionary.toString метод

// додаємо певні дані
dictionary.apple = "Яблуко";
dictionary.__proto__ = "тест"; // __proto__ тут є звичайною властивістю об’єкта

// тільки ключі apple та __proto__ показуються в циклі
for(let key in dictionary) {
  alert(key); // "apple", потім "__proto__"
}

// ваш метод toString в дії
alert(dictionary); // "apple,__proto__"

Метод може взяти всі перелічувані ключі об’єкта за допомогою Object.keys та вивести їх перелік.

Для того щоб зробити метод toString не перлічуваним, визначимо його використовуючи дескриптор властивості. Синтаксис Object.create дозволяє нам надати об’єкту дескриптори властивостей як другий аргумент.

let dictionary = Object.create(null, {
  toString: { // визначаємо властивість toString
    value() { // value є функцією
      return Object.keys(this).join();
    }
  }
});

dictionary.apple = "Яблуко";
dictionary.__proto__ = "тест";

// apple та __proto__ показуються в циклі
for(let key in dictionary) {
  alert(key); // "apple", потім "__proto__"
}

// метод toString повертає перелік властивостей через кому
alert(dictionary); // "apple,__proto__"

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

Продивіться розділ Прапори та дескриптори властивостей.

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

Створимо новий об’єкт rabbit:

function Rabbit(name) {
  this.name = name;
}
Rabbit.prototype.sayHi = function() {
  alert(this.name);
};

let rabbit = new Rabbit("Кріль");

Чи виконують виклики нижче однакову дію чи ні?

rabbit.sayHi();
Rabbit.prototype.sayHi();
Object.getPrototypeOf(rabbit).sayHi();
rabbit.__proto__.sayHi();

Перший виклик має this == rabbit, інші мають this рівний Rabbit.prototype, тому що це об’єкт перед крапкою.

Таким чином тільки перший виклик покаже Кріль, інші покажуть undefined:

function Rabbit(name) {
  this.name = name;
}
Rabbit.prototype.sayHi = function() {
  alert( this.name );
}

let rabbit = new Rabbit("Кріль");

rabbit.sayHi();                        // Кріль
Rabbit.prototype.sayHi();              // undefined
Object.getPrototypeOf(rabbit).sayHi(); // undefined
rabbit.__proto__.sayHi();              // undefined
Навчальна карта