Очень популярный вопрос от моих менти, его задают стабильно: «Что такое прототипное наследование в JS? Как оно работает? Чем отличается от наследования в других языках?»

Поскольку вопрос достаточно тривиальный, решил сюда набросать небольшую статью.

Так вот: для того, чтобы понять, почему такой вопрос вообще возникает и чем наследование в JS отличается от классического наследования в ООП, давайте это самое классическое наследование рассмотрим.

Классическое наследование работает примерно так: есть родительский класс, когда мы наследуем от него другой класс, в него копируются атрибуты и методы родительского класса. В общем-то, это все. Вот так вот просто.

Почему так нельзя в JavaScript? И тут тоже все просто — в JS классов нет. Есть объекты. Конечно, есть ключевое слово class — правда, оно, во-первых, появилось сравнительно недавно, в 2015 году, вместе с ES6, а, во-вторых, не создает на самом деле никаких классов — оно все еще создает те же объекты.

Так как реализовать что-то похожее на классическое ООП-наследование, не имея классов? В JavaScript у объектов есть скрытое свойство [[Prototype]]. Свойство это может быть либо равным null, либо содержать в себе ссылку на другой объект.

Собственно, так мы и реализуем наследование: если мы пытаемся прочитать свойство или использовать метод объекта, а его в этом объекте нет, JS смотрит в его прототип, если находит там ссылку на объект, то ищет это свойство или метод уже в этом объекте. Если и там его нет, JS смотрит в прототип уже этого объекта — если и там нужного метода / свойства, JS снова идет в прототип — и так либо пока не найдет нужный метод / свойство, либо не встретит в прототипе null.

В таком случае JS либо вернет undefined, если мы искали свойство, либо, если мы искали функцию, упадет с TypeError: obj.nonExistingMethod is not a function.

Это и называется прототипным наследованием в JavaScript.

Кстати, из этого всего можно сделать два интересных вывода: во-первых, нужно стараться не делать цепочки связанных через прототипы объекты слишком длинными, чтобы не влиять на производительность негативно. Во-вторых, наследование в JavaScript как минимум не слабее классического — к примеру, мы можем отнаследовать класс от другого уже после его создания.

Теперь немного о практике. К прототипу объекта мы можем обращаться либо через геттер / сеттер __proto__ (obj.__proto__ = anotherObj), либо (более современный способ) использовать методы Object.getPrototypeOf(obj) и Object.setPrototypeOf(obj, prototype). Еще мы можем делать class ClassSecond extends ClassFirst — по сути, раньше мы бы сделали то же самое так: ClassSecond.__proto__ = ClassFirst или Object.setPrototypeOf(ClassSecond, ClassFirst).

Здесь стоит упомянуть цикл for..in — он перебирает не только свойства самого объекта, но и унаследованные. То есть, следующий код выведет свойства как объекта obj2, так и объекта obj1, от которого он унаследован:

const obj1 = { test1: 'test1', test2: 'test2' };
const obj2 = { test3: 'test3', test4: 'test4' };

Object.setPrototypeOf(obj2, obj1); // наследуем obj2 от obj1

for (const prop in obj2) {
  console.log(`${prop}: ${obj2[prop]}`);
}

// выведет:

test3: test3
test4: test4
test1: test1
test2: test2

Как небольшой итог: прототипное наследование ничем не хуже, а иногда и лучше классического. Да и понять его не так и сложно.