- Прототипы в JS и малоизвестные факты
- Что такое прототип
- Как выглядит прототип
- Да кто такой этот ваш constructor
- Где живёт прототип
- О чем вам недоговаривает дебаггер, или он вам не прототип
- Как работать с прототипом объекта
- Функции и конструкторы
- Что такое new
- Наследование
- Сколько вам сахара к классу
- P. S.
- P. P. S.
Прототипы в 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 :
И теперь в консоли 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: # is not extensible
А теперь менее категоричный вопрос создания нового объекта с прототипом. Для этого есть следующие способы.
Стандартный способ:
const foo = Object.create(myPrototype);
Если нет поддержки Object.create , но есть __proto__ :
И в случае если отсутствует поддержка всего вышеперечисленного:
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 ()
И теперь вызов user.fullName() вернет строку «John Doe».
Что такое new
На самом деле оператор new не таит в себе никакой магии. При вызове new выполняет несколько действий:
- Создает новый объект self
- Записывает свойство prototype функции конструктора в прототип объекта self
- Вызывает функцию конструктор с объектом self в качестве аргумента this
- Возвращает self если конструктор вернул примитивное значение, иначе возвращает значение из конструктора
Все эти действия можно сделать силами самого языка, поэтому можно написать свой собственный оператор new в виде функции:
function custom_new(constructor, args) < // https://stackoverflow.com/questions/31538010/test-if-a-variable-is-a-primitive-rather-than-an-object function isPrimitive(val) < return val !== Object(val); >const self = Object.create(<>); const constructorValue = constructor.apply(self, args) || self; return isPrimitive(constructorValue) ? self : constructorValue; > 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.
Наивно было бы ожидать, что одна статья ответит на все вопросы. Если у Вас есть интересные вопросы, экскурсы в историю, аргументированные или беспочвенные заявления о том, что я сделал все не так, либо правки по ошибкам, пишите в комментарии.
P. P. S.
К сожалению главный кликбейт статьи перестал быть актуальным. В данный момент Chrome (версия 93, на момент обновления статьи) перестал использовать __proto__ для обозначения прототипа, и теперь отображает его как слот [[Prototype]] :
Справедливости ради хочу отметить что в Firefox (92) также не используется обозначение __proto__ :