[Из песочницы] Прототипы в JS и малоизвестные факты
Лирическое вступление
Получив в очередной раз кучу вопросов про прототипы на очередном собеседовании, я понял, что слегка подзабыл тонкости работы прототипов, и решил освежить знания. Я наткнулся на кучу статей, которые были написаны либо по наитию автора, как он «чувствует» прототипы, либо статья была про отдельную часть темы и не давала полной картины происходящего.
Оказалось, что есть много неочевидных вещей из старых времён ES5 и даже ES6, о которых я не слышал. А еще оказалось, что вывод консоли браузера может не соответствовать действительности.
Что такое прототип
Объект в JS имеет собственные и унаследованные свойства, например, в этом коде:
var foo = { bar: 1 };
foo.bar === 1 // true
typeof foo.toString === "function" // true
у объекта foo
имеется собственное свойство bar
со значением 1
, но также имеются и другие свойства, такие как toString
. Чтобы понять, как объект foo
получает новое свойство toString
, посмотрим на то, из чего состоит объект:
Дело в том, что у объекта есть ссылка на другой объект-прототип. При доступе к полю foo.toString
сначала выполняется поиск такого свойства у самого объекта, а потом у его прототипа, прототипа его прототипа, и так пока цепочка прототипов не закончится. Это похоже на односвязный список объектов, где поочередно проверяется объект и его объекты-прототипы. Так реализовано наследование свойств, например, у (почти, но об этом позже) любого объекта есть методы valueOf
и toString
.
Как выглядит прототип
У всех прототипов имеются два общих свойства, constructor
и __proto__
. Свойство constructor
указывает на функцию-конструктор, с помощью которой создавался объект, а свойство __proto__
указывает на следующий прототип в цепочке (либо null, если это последний прототип). Остальные свойства доступны через .
, как в примере выше.
Да кто такой этот ваш constructor
constructor
— это ссылка на функцию, с помощью которой был создан объект:
const a = {};
a.constructor === Object // true
Не совсем понятна идея зачем он был нужен, возможно, как способ клонирования объекта:
object.constructor(object.arg)
Но я не нашел подходящий пример его использования, если у Вас есть примеры проектов, где это использовалось, то напишите об этом. В остальном же использовать constructor
лучше не стоит, так как это writable свойство, которое можно случайно перезаписать, работая с прототипом, и сломать часть логики.
Где живёт прототип
На самом деле, объекты представляют собой не только поля, доступные для JS кода. Интерпретатор также сохраняет некоторые приватные данные объекта для работы с ним, для этого в стандарте определено понятие внутренних слотов, которые обозначены как имя в квадратных скобках [[SlotName]]
. Для прототипов отведен приватный слот [[Prototype]]
содержащий ссылку на объект-прототип (либо null
, если прототипа нет).
Из-за того, что [[Prototype]]
предназначался исключительно для самого JS движка, получить доступ к прототипу объекта было невозможно. Для случаев когда это было нужно, ввели нестандартное свойство __proto__
, которое поддержали многие браузеры и которое по итогу попало в сам стандарт, но как опциональное и стандартизированное только для обратной совместимости с существующим JS кодом.
О чем вам недоговаривает дебаггер, или он вам не прототип
Свойство __proto__
является геттером и сеттером для внутреннего слота [[Prototype]]
и находится в Object.prototype
:
Из-за этого я избегал записи __proto__
для обозначения прототипа. __proto__
находится не в самом объекте, что приводит к неожиданным результатам. Для демонстрации попробуем через __proto__
удалить прототип объекта и затем восстановить его:
const foo = {};
foo.toString(); // метод toString() берется из Object.prototype и вернет '[object Object]', пока все хорошо
foo.__proto__ = null; // делаем прототип объекта null
foo.toString(); // как и ожидалось появилась ошибка TypeError: foo.toString is not a function
foo.__proto__ = Object.prototype; // восстанавливаем прототип обратно
foo.toString(); // прототип не вернулся, ошибка TypeError: foo.toString is not a function
Как так получилось? Дело в том, что __proto__
— это унаследованное свойство Object.prototype
, а не самого объекта foo
. Из-за этого в момент когда в цепочке прототипов пропадает ссылка на Object.prototype
, __proto__
превращается в тыкву и перестает работать с прототипом.
А теперь отработаем кликбейт из введения. Представим следующую цепочку прототипов:
var baz = { test: "test" };
var foo = { bar: 1 };
foo.__proto__ = baz;
В консоли Chrome foo будет выглядеть следующим образом:
А теперь уберем связь между baz
и Object.prototype
:
baz.__proto__ = null;
И теперь в консоли Chrome видим следующий результат:
Связь с Object.prototype
разорвана у baz
и __proto__
возвращает undefined
даже у дочернего объекта foo
, однако Chrome все равно показывает что __proto__
есть. Скорее всего тут имеется в виду внутренний слот [[Prototype]]
, но для простоты это было изменено на __proto__
, ведь если не извращаться с цепочкой прототипов, это будет верно.
Как работать с прототипом объекта
Рассмотрим основные способы работы с прототипом: изменение прототипа и создание нового объекта с указанным прототипом.
Для изменения прототипа у существующего объекта есть всего два метода: использование сеттера __proto__
и метод Object.setPrototypeOf
.
var myProto = { name: "Jake" };
var foo = {};
Object.setPrototypeOf(foo, myProto);
foo.__proto__ = myProto;
Если браузер не поддерживает ни один из этих методов, то изменить прототип объекта невозможно, можно только создать его копию с новым прототипом.
Но есть один нюанс с внутренним слотом [[Extensible]]
который указывает на то, возможно ли добавлять к нему новые поля и менять его прототип. Есть несколько функций, которые выставляют этот флаг в false и предотвращают смену прототипа: Object.freeze
, Object.seal
, Object.preventExtensions
. Пример:
const obj = {};
Object.preventExtensions(obj);
Object.setPrototypeOf(obj, Function.prototype); // TypeError: #
А теперь менее категоричный вопрос создания нового объекта с прототипом. Для этого есть следующие способы.
Стандартный способ:
const foo = Object.create(myPrototype);
Если нет поддержки Object.create
, но есть __proto__
:
const foo = { __proto__: myPrototype };
И в случае если отсутствует поддержка всего вышеперечисленного:
const f = function () {}
f.prototype = myPrototype;
const foo = new f();
Способ основан на логике работы оператора new
, о которой поговорим чуть ниже. Но сам способ основан на том, что оператор new
берет свойство prototype
функции и использует его в качестве прототипа, т.е. устанавливает объект в [[Prototype]]
, что нам и нужно.
Функции и конструкторы
А теперь поговорим про функции и как они работают в качестве конструкторов.
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
const user = new Person('John', 'Doe');
Функция Person тут является конструктором и создает два поля в новом объекте, а цепочка прототипов выглядит так:
Откуда взялся Person.prototype
? При объявлении функции, у нее автоматически создается свойство prototype
для того чтобы ее можно было использовать как конструктор (note 3), таким образом свойство prototype
функции не имеет отношения к прототипу самой функции, а задает прототипы для дочерних объектов. Это позволит реализовывать наследование и добавлять новые методы, например так:
Person.prototype.fullName = function () {
return this.firstName + ' ' + this.firstName;
}
И теперь вызов user.fullName()
вернет строку «John Doe».
Что такое new
На самом деле оператор new не таит в себе никакой магии. При вызове new выполняет несколько действий:
- Создает новый объект
- Записывает свойство prototype функции конструктора в прототип объекта
- Вызывает функцию конструктор с объектом в качестве аргумента this
- Возвращает объект
Все эти действия можно сделать силами самого языка, поэтому можно написать свой собственный оператор new в виде функции:
function custom_new(constructor, args) {
const self = {};
Object.setPrototypeOf(self, constructor.prototype);
return constructor.apply(self, args) || self;
}
custom_new(Person, ['John', 'Doe'])
Но начиная с ES6 волшебство пришло и к new в виде свойства new.target, которое позволяет определить, была ли вызвана функция как конструктор с new, или как обычная функция:
function Foo() {
console.log(new.target === Foo);
}
Foo(); // false
new Foo(); // true
new.target
будет undefined для обычного вызова функции, и ссылкой на саму функцию в случае вызова через new
;
Наследование
Зная все вышеперечисленное, можно сделать классическое наследование дочернего класса Student
от класса Person
. Для этого нужно
- Создать конструктор Student с вызовом логики конструктора Person
- Задать объекту `Student.prototype` прототип от `Person`
- Добавить новые методы к `Student.prototype`
function Student(firstName, lastName, grade) {
Person.call(this, firstName, lastName);
this.grade = grade;
}
// вариант 1
Student.prototype = Object.create(Person.prototype, {
constructor: {
value:Student,
enumerable: false,
writable: true
}
});
// вариант 2
Object.setPrototypeOf(Student.prototype, Person.prototype);
Student.prototype.isGraduated = function() {
return this.grade === 0;
}
const student = new Student('Judy', 'Doe', 7);
Фиолетовым цветом обозначены поля объекта (они все находятся в самом объекте, т.к. this у всей цепочки прототипов один), а методы желтым (находятся в прототипах соответствующих функций)
Вариант 1 предпочтительнее, т.к. Object.setPrototypeOf
может привести к проблемам с производительностью.
Сколько вам сахара к классу
Для того чтобы облегчить классическую схему наследование и предоставить более привычный синтаксис, были представлены классы, просто сравним код с примерами Person и Student:
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
fullName() {
return this.firstName + ' ' + this.lastName;
}
}
class Student extends Person {
constructor(firstName, lastName, grade) {
super(firstName, lastName);
this.grade = grade;
}
isGraduated() {
return this.grade === 0;
}
}
Уменьшился не только бойлерплейт, но и поддерживаемость:
- В отличие от функции конструктора, при вызове конструктора без new выпадет ошибка
- Родительский класс указывается ровно один раз при объявлении
При этом цепочка прототипов получается идентичной примеру с явным указанием prototype у функций конструкторов.
P.S.
Наивно было бы ожидать, что одна статья ответит на все вопросы. Если у Вас есть интересные вопросы, экскурсы в историю, аргументированные или беспочвенные заявления о том, что я сделал все не так, либо правки по ошибкам, пишите в комментарии.