Акт 1: „Ядрото“ — Част 6 (Наследяване на прототип)

Това е продължение на поредицата за JavaScript, която започна „тук“.

JavaScript има вграден механизъм, за да има обект, който наследява полета и методи от други обекти. Въпреки че е препоръчително никога да не позволявате йерархиите на наследяване да станат много сложни, в някои сценарии това е полезен модел, който позволява да се избегне репликацията на код. В тази история ще разгледаме всички основни аспекти на наследяването на прототипи.

Ако не сте запознати с наследяването на прототипи, тази история може да се окаже твърде трудна. Може би е най-добре да го прочетете няколко пъти и да опитате да експериментирате с всички дадени примери.

Прототипът:

Всеки обект в JavaScript има връзка към обект, наречен прототип. Този прототип разширява дадения обект със свойства, които не притежава. Например, ако обектът obj има прототип {a: 1} и самият обект не съдържа свойство с име a, тогава извикването на obj.a ще намери стойността от a в прототипа.

Тъй като самият прототип е обект, той също има прототип. Ако сега прототипът на obj, нито самият обект obj съдържат a, свойството се търси в прототипа на прототипа.

Виждате ли, имотите се търсят във верига, подобна на конструкция. Последният се нарича прототипна верига. Тази верига обикновено завършва с прототипа на Object. Така че obj.a връща undefined само в случай, че нито един прототип във веригата до Object не съдържа стойност за свойството a.

Това е механизмът на JavaScript за наследяване на свойства. Работи за всички видове свойства, нека да са методи или свойства.

В следващия раздел ще видим как да четем и променяме прототипа на обект.

Създаване на обекти:

JavaScript предоставя няколко начина за създаване на обекти. Най-лесният начин е просто като напишете

const obj = {}; // or with possible properties

Ако направите това, прототипът автоматично се настройва на Object. Но ако искате да промените прототипа на обекта, можете да направите следното:

const myProto = { a: 1 };
const obj = Object.create(myProto);
console.log(obj.a); // 1

Този метод създава празен обект и задава дадения аргумент като негов прототип. И както научихме преди, неговият прототип, тоест myProto, отново е Object.

Третият метод е важен за познаване и отваря възможността за деклариране на класове:

function MyClass(){
  this.a = 1;
  this.b = 2;
}
const m = new MyClass();
console.log(m); // MyClass { a: 1, b: 2 }

Функцията MyClass, написах я с главна буква, за да ни напомня за класовете, може да се използва за създаване на обект чрез извикването му с оператора new. Този оператор извиква функцията и задава this-контекста на резултантния обект. По този начин можем да поставим произволни свойства и методи върху новия обект. Функции като MyClass често се наричат ​​конструкторски функции, тъй като се държат като тези.

Прототипът на новосъздадения обект е обектът, който е посочен от MyClass.prototype:

Не забравяйте, че в JavaScript функциите също са обекти и затова имат прототип. Прототипът на всички функции е Function. Този обект Function има свойство с име prototype. Следователно MyClass има наследеното поле prototype, чиято стойност можем да зададем на всеки обект. Когато сега извикаме MyClass от new MyClass(), тогава прототипът на получения обект е зададен на обекта MyClass.prototype.

Преди да прочетете горните предишни изречения още десет пъти, нека първо да разгледаме един пример:

function MyClass(){
  this.a = 1;
  this.b = 2;
}
MyClass.prototype.c = 3;
MyClass.prototype.d = 4;
const m = new MyClass();
console.log(m); // MyClass { a: 1, b: 2 }
console.log(m.c); // 3
console.log(m.d); // 4

Виждате ли, използвахме свойството с име prototype на функционалния обект MyClass, за да променим prototype на m. И така, какво общо има прототипът на MyClass със стойността на неговото поле с име prototype? Нищо!

Обикновено се поставят само методи в прототипа на конструктора ( MyClass.prototpye ), за да се избегнат трудни за откриване конфликти. Следващият пример показва такъв „приятен“ капан:

function MyClass(){
  this.a = 1;
  this.b = 2;
}
MyClass.prototype.c = {d: 3};
const m1 = new MyClass();
const m2 = new MyClass();
console.log(m1.c.d); // 3
m1.c.d = 4;
console.log(m2.c.d); // 4
console.log(MyClass.prototype.c.d); // 4

Тук промяна на свойство, постижимо от m1, тоест m1.c.d = 4, променя стойността не само във всички други екземпляри на класа, но дори и в самия прототип на конструктора. Ако имате код като този в голям проект... успех!

Има и друг специален случай:когато функция конструктор върне стойност, тази стойност се използва като нов обект. Този нов обект нямада получи прототип от конструктора:

function MyClass() {
  this.a = 1;
  this.b = 2;
  return {};
}
MyClass.prototype.c = 3;
const m = new MyClass();
console.log(m); // {}
console.log(m.c); // undefined

Добро упражнение е да внедрите своя собствена версия на Object.create:

function create(p) {
  const f = function () { };
  f.prototype = p;
  return new f();
}
const obj = create({ a: 1 });
console.log(obj.a);

Най-накрая има още един метод за промяна на прототипа на обект:

const a = {
  p: 3
};
Object.setPrototypeOf(a, { q: 4 });
console.log(a.q); // 4

Тук прототипът на a е зададен на {q: 4}. Този метод се различава от всички останали дотолкова, че прототипът се променя на вече съществуващ обект.

Понякога е необходимо изрично да се получи прототипът на обект. Това може да стане чрез метод, предоставен от Object:

const a = {
  p: 3
};
Object.setPrototypeOf(a, { q: 4 });
console.log(Object.getPrototypeOf(a)); // { q: 4 }

Наследяване на прототип:

В предишния раздел видяхме как може да се използва функция като конструктор за обекти. По-специално, свойството „прототип“ на функцията се оказа полезно за дефиниране на прототипния обект за обекти, създадени от new-оператора. Всичко това води до модел, наречен „наследяване на прототип“, който позволява наследяване на методи и полета от един обект на друг. Основният модел показва следния пример:

function A(p, q) {
  this.p = p;
  this.q = q;
}
A.prototype.someMethod = function () {
  console.log('hello from someMethod');
};
A.prototype.anotherMethod = function () {
  console.log('anotherMethod from A');
}
function B(p, q) {
  A.call(this, p, q);   <-- place properties from A onto 'this'
  this.anotherMethod = function () {   <-- override method from A
    console.log('anotherMethod from B');
  }
}
B.prototype = Object.create(A.prototype);   <-- set prototype
B.prototype.constructor = B;    <-- correct the constructor prop.

Класът B наследява полета и методи от A. Типичен модел е всички методи да се поставят върху прототипа, но това не е строго необходимо. В B методът A се извиква чрез използване на контекста на B:

A.call(this, p, q)

Това поставя всички полета и методи на A в новосъздадения обект, посочен от this. Имайте предвид обаче, че се копират само примитивни стойности! Въведените в обект стойности се предоставят на екземпляра на B чрез препратка! Така промените в свойствата на такива обекти в инстанция на A са видими в инстанции на B.

За да имаме всички методи, дефинирани в прототипа на A, налични в B, задаваме прототипа на B на обект, който има прототипа на A като прототип:

B.prototype = Object.create(A.prototype)

Може би се чудите защо това допълнително Object.create. Причината за това е да се защити прототипът на A от промени, направени от B. Ще разгледаме това в пример скоро. Преди да обясним последния ред:

Всяка функция има свойство constructor в своето свойство с име prototype Последното сочи към самата функция. Тъй като сме заменили прототипа на B изцяло с прототипа на A, трябва отново да коригираме това свойство на конструктора (в противен случай то ще сочи към B, а не към A).

Нека разгледаме някои случаи на горните класове, за да направим модела по-ясен:

const b = new B(1, 2);
console.log(b.p); // 1
b.someMethod(); // 'hello from someMethod'   (inherited!)
b.anotherMethod(); // 'anotherMethod from B'  (overridden!)
const a = new A(3, 4);
console.log(a.p); // 3
a.anotherMethod(); // 'anotherMethod from A'
b.anotherMethod = function () {
  console.log('the new anotherMethod');
}
a.anotherMethod(); // 'anotherMethod from A'  (a's method remains!)

Първо, someMethod е наследено от B и може да бъде извикано от там. anotherMethod е по-вълнуващо, тъй като е заменено от B. Виждаме, че когато го извиквате чрез B, той изпълнява правилната версия на този метод.

Във втора стъпка ние предефинираме метода anotherMethod в B:

b.anotherMethod = function () {
  console.log('the new anotherMethod');
}

Решаващо за разбиране е, че въпреки че b.anotherMethod първоначално е наследен от A.prototype, промяната му на b не го променя на A.prototype. Но още повече, ако би променил прототипа на b, както например тук

Object.getPrototypeOf(b).anotherMethod = () => console.log('from B');

тогава това няма да повлияе изцяло на a. Гарантирахме, че като поставихме свойството на прототипа на B с create:

B.prototype = Object.create(A.prototype)

Въпреки че наследяването изглежда на пръв поглед като много използваем модел, който помага да се избегне репликацията на код, трябва да се избягва използването му, когато е възможно, или да се ограничава възможно най-много. Причината за това е, че кодът, който използва този модел, бързо става труден за разбиране и поддръжка. Алтернативен модел е „Композиция“, който възлага функционалност в услуги, които могат да бъдат инжектирани, където е необходимо.

Операторът „instanceof“:

Операторът instanceof проверява конструктора да се съдържа във веригата на прототипа. Не забравяйте, че в предишния раздел коригирахме полето prototype.constructor на дъщерния клас A. Една от причините за това е да се гарантира, че instanceof-операторът все още работи. Нека разгледаме един пример:

function A(p, q) {
  this.p = p;
  this.q = q;
}
function B(p, q) {
  A.call(this, p, q);
}
B.prototype = Object.create(A.prototype);
B.prototype.constructor = B;
const b = new B(1, 2);
const a = new A(3, 4);
console.log(`b instanceof B: ${b instanceof B}`); // true
console.log(`b instanceof A: ${b instanceof A}`); // true
console.log(`b instanceof Object: ${b instanceof Object}`); // true
console.log(`a instanceof Object: ${a instanceof B}`) // false

В този пример B наследява от A. Така че A е в прототипната верига на B. Това обяснява защо b instanceof B и b instanceof A. Тъй като веригите на прототипи обикновено завършват с Object, b също има конструктор Object във веригата на прототипите. И накрая, очевидно a няма B в своята прототипна верига.

Това е всичко за сега и не забравяйте да оставите коментари за въпроси или грешки, които откриете. Нека видим отново в следващата история, ако желаете.

Благодаря за четенето!