У програмуванні ми часто хочемо щось взяти і доповнити чи розширити.
Наприклад, ми маємо об’єкт user з його властивостями та методами, і хочемо створити admin та guest як дещо змінені варіанти об’єкта user. Тобто ми хочемо повторно використовувати те, що ми маємо в user, але також додати ще власні методи і властивості. Інакше кажучи, просто хочемо збудувати новий об’єкт поверх того, що існує.
Успадкування через прототипи – це те, що нам допоможе в цьому.
Спеціальна властивість [[Prototype]]
В JavaScript, об’єкти мають спеціальну приховану властивість [[Prototype]] (як зазначено в специфікаціях мови), яка може приймати значення: або null, або мати посилання на інший об’єкт. Цей об’єкт називається “прототип”:
Коли ми намагаємось зчитати якусь властивість об’єкта object, але її в ньому самому не має, JavaScript автоматично бере її з прототипу. В програмуванні це називається “успадкування через прототипи”. Скоро ми вивчимо багато прикладів такого успадкування та створимо цікаві приклади, які побудовані на їх основі.
Така властивість [[Prototype]] є внутрішньою та прихованою, але є багато шляхів щоб її встановити.
Одним з них є використання спеціального імені __proto__, наприклад:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // тут встановлюємо rabbit.[[Prototype]] = animal
І тепер, якщо ми зчитуємо властивість з об’єкта rabbit, а її немає, то JavaScript автоматично візьме її з animal.
Ось ще приклад:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// тепер ми можемо знайти обидві властивості в об’єкті rabbit:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true
В позначеному (*) рядку, об’єкт animal встановлюється як прототип для об’єкта rabbit.
І коли alert намагається прочитати властивість rabbit.eats (рядок позначено (**)), а її там немає, то JavaScript йде за посиланням [[Prototype]] та знаходить її в об’єкті animal (дивіться знизу вверх):
Ми можемо сказати, що “animal є прототипом для rabbit”, або що “об’єкт rabbit успадковує властивості об’єкта animal”.
Якщо animal має багато корисних властивостей та методів, вони стають автоматично доступними для rabbit. Такі властивості називаються “успадкованими”.
Також, якщо ми маємо методи в animal, то вони можуть бути викликані і в rabbit:
let animal = {
eats: true,
walk() {
alert("Тварина йде");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
// тут метод walk береться з прототипу
rabbit.walk(); // отримуємо "Тварина йде"
Методи автоматично беруться з прототипу, як тут:
Ланцюг прототипів може бути навіть довшим:
let animal = {
eats: true,
walk() {
alert("Тварина йде");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
let longEar = {
earLength: 10,
__proto__: rabbit
};
// Метод walk беремо з ланцюжка прототипів
longEar.walk(); // отримуємо "Тварина йде"
alert(longEar.jumps); // true (береться з об’єкта rabbit)
І тепер, якщо ми хочемо взяти метод з об’єкта longEar, а його там немає, JavaScript буде шукати його в rabbit, а далі в animal.
Існує два обмеження:
- Посилання через прототипи не може бути замкнено в кільце. JavaScript видасть помилку, якщо ми визначимо
__proto__в ланцюжку прототипів і замкнем його в кільце. - Значення
__proto__може бути або посиланням на об’єкт, абоnull. Інші типи значень ігноруються.
Хоч це і очевидно, але все ж таки: може бути тільки одна властивість [[Prototype]]. Об’єкт не може успадковувати властивості та методи від двох прототипів одночасно.
__proto__ є старим і давнім getter/setter для [[Prototype]]Вважається поширеною помилкою, особливо для початківців, неможливість чітко визначити різницю між двома поняттями __proto__ та [[Prototype]].
Будь ласка зауважте, що властивість __proto__ не є тою самою властивістю як внутрішня властивість [[Prototype]]. Це є getter/setter для [[Prototype]]. Пізніше ми побачимо ситуації, коли це важливо, а поки що давайте просто мати це на увазі, примножуючи своє розуміння мови JavaScript.
Властивість __proto__ вважається трохи застарілою. Вона існує з історичних причин, сучасна мова JavaScript пропонує використовувати функцію Object.getPrototypeOf/Object.setPrototypeOf замість get/set прототипу. Ми також розглянемо ці функції пізніше.
Згідно зі специфікацією мови, __proto__ має підтримуватись тільки в браузерах. Проте насправді, усі середовища, включаючи серверні, підтримують __proto__, а тому використовувати його можна досить безпечно.
Оскільки позначення __proto__ інтуїтивно більш зрозуміле, ми будемо його використовуємо в прикладах.
Операція по запису/видаленню не застосовується на прототипах
Прототипи можна використовувати тільки для зчитування властивостей.
А такі операції як запис/видалення, працюють напряму тільки на самому об’єкті.
У прикладі нижче, ми визначаємо власний метод walk для об’єкта rabbit:
let animal = {
eats: true,
walk() {
/* цей метод не буде використаний об’єктом rabbit */
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("Кролик! Скік-скік!");
};
rabbit.walk(); // тут отримаємо 'Кролик! Скік-скік!'
Як тільки ми задали метод rabbit.walk() таким чином, при виклику рушій одразу знайде його на самому об’єкті та виконає без використання такого самого методу, який визначений в прототипі:
Властивості ‘Accessor’ є винятком, оскільки присвоєння обробляється функцією встановлення (через ‘setter’). Тобто, коли ми намагаємось записати значення у таку властивість, відбувається зчитування і запуск функції, посилання на яку зберігається в тій властивості.
З цієї причини admin.fullName коректно працює в коді, що показаний нижче:
let user = {
name: "John",
surname: "Smith",
set fullName(value) {
[this.name, this.surname] = value.split(" ");
},
get fullName() {
return `${this.name} ${this.surname}`;
}
};
let admin = {
__proto__: user,
isAdmin: true
};
alert(admin.fullName); // John Smith (*)
// відпрацьовує setter
admin.fullName = "Alice Cooper"; // (**)
alert(admin.fullName); // Alice Cooper, стан об’єкта admin було змінено
alert(user.fullName); // John Smith, стан об’єкта user захищено
Тут на рядку позначеному (*), властивість admin.fullName викликається через getter, визначений в прототипі user. А на рядку позначеному (**) властивість задається через setter, який також визначений в прототипі.
Значення ключового слова “this”
Цікаве питання може виникнути в прикладі вище: яке значення ключового слова this всередині set fullName(value)? Де визначаютья властивості this.name та this.surname: в об’єкті user чи admin?
Відповідь проста: на this не впливає прототип узагалі.
Незалежно від того, де метод визначений: в об’єкті чи його прототипі, ключове слово this завжди вказує на об’єкт перед крапкою.
Таким чином, виклик в методі set виразу admin.fullName= буде брати як this значення властивостей з об’єкту admin а не user.
Це дуже важлива річ, бо наприклад, ми можемо мати дуже великий об’єкт з багатьма методами, і об’єкти, які успадковують властивості або методи від того великого батьківського об’єкту. І, якщо ми візьмемо успадкований об’єкт, поставимо після його імені крапку і далі викличемо метод, який зберігається в тому “батьківському” великому об’єкті, то цей метод змінить стан лише самого успадкованого об’єкта. На стан того великого “батьківського” об’єкта в цій ситуації цей метод не вплине.
У цьому прикладі, об’єкт animal надає “набір методів”, а об’єкт rabbit може використовувати якісь з цих методів.
Виклик rabbit.sleep() встановлює this.isSleeping в об’єкті rabbit:
// об’єкт animal має набір методів has
let animal = {
walk() {
if (!this.isSleeping) {
alert(`Я ходжу`);
}
},
sleep() {
this.isSleeping = true;
}
};
let rabbit = {
name: "Білий кролик",
__proto__: animal
};
// змінює тільки свій стан методом rabbit.isSleeping
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (немає такої властивості в прототипі)
Остаточний вигляд:
Якщо ми маємо інші об’єкти: bird, snake тощо, які успадковані від об’єкта animal, вони також будуть мати доступ до методів animal. Кожний раз, при виклику будь-якого методу, ключове слово this буде вказувати на той об’єкт, відносно якого був викликаний цей метод, а не на об’єкт animal. Отже, коли ми записуємо будь-які дані в this, вони зберігаються в об’єктах на які і вказує this.
Як результат, методи можуть успадковуватись (передаватись), але стани об’єктів – не можуть.
Цикл for…in
Цикл for..in проходить також по успадкованих властивостях.
Наприклад:
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// Object.keys повертає тільки власні ключі
alert(Object.keys(rabbit)); // jumps
// Цикл for..in повертає як власні, так і успадковані ключі
for(let prop in rabbit) alert(prop); // jumps, потім eats
Якщо це не те, що нам потрібно, і ми б хотіли виключити отримання успадкованих значень, існує вбудований метод obj.hasOwnProperty(key), який повертає true якщо властивість key об’єкта obj є його власною, а не успадкованою.
Отже, ми можемо відфільтрувати успадковані властивості (чи зробити з ними щось інше):
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
for(let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop);
if (isOwn) {
alert(`Наш: ${prop}`); // Наш: jumps
} else {
alert(`Успадковано: ${prop}`); // Успадковано: eats
}
}
У цьому прикладі ми маємо наступний ланцюжок: rabbit успадковує властивості від об’єкта animal, який, у свою чергу, успадковує властивості від глобального Object.prototype (бо animal типово є літералом об’єкта {...}) і на самому верху маємо null:
Зауважте одну цікаву річ: звідки взагалі взявся метод rabbit.hasOwnProperty? Ми його не визначали. Дивлячись на ланцюжок успадкувань ми можемо побачити, що його визначення йде від Object.prototype.hasOwnProperty. Інакше кажучи, він успадковується.
…але чому метод hasOwnProperty не з’являється в циклі for..in поряд з іншими властивостями (eats та jumps), якщо сам цикл for..in ітерує або проходить в тому числі успадкованими властивостям?
Відповідь проста: вони позначені (ще можна сказати, що в них стоять прапорці) як такі, що їх не можна перераховувати (not enumerable). Так само як і інші властивості в глобальному об’єкті Object.prototype. У цих властивостей прапорець стоїть як enumerable:false, а цикл for..in зчитує тільки ті властивості, які позначені як такі, що їх можна перераховувати. Ось чому і решта властивостей глобального об’єкту Object.prototype не зчитуються також.
Майже всі інші методи для отримання пар ключ/значення, такі як от Object.keys, Object.values та інші, ігнорують успадковані властивості.
Вони працюють лише з властивостями самого об’єкта і не чіпають властивостей з його прототипів.
Підсумки
- В JavaScript, усі об’єкти мають приховану властивість
[[Prototype]], яка може посилатись на інший об’єкт або бутиnull. - Ми можемо використати
obj.__proto__для доступу до цієї властивості (це історичний getter/setter; є й інші методи, які розглянемо згодом). - Об’єкт, на який посилається властивість
[[Prototype]]називається “прототип”. - Якщо ми хочемо прочитати властивості об’єкта
objчи викликати метод, яких не існує, тоді JavaScript намагається знайти їх в прототипі. - Операції по запису/видаленню працюють безпосередньо з об’єктом. Вони не зачіпають прототип (якщо ми запускаємо таку операцію з звичайною властивістю, а не з сеттером).
- Якщо ми викликаємо
obj.method(), і при цьому,methodбереться з прототипу, ключове словоthisвсеодно буде вказувати наobj. Таким чином, методи завжди працюють з поточним об’єктом, навіть, якщо ці методи успадковані. - Цикл
for..inітерує як по власні, так і успадковані властивості. Усі інші методи з отримання пар ключ/значення працюють тільки з власними властивостями об’єкта.
Коментарі
<code>, для кількох рядків – обгорніть їх тегом<pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)