[Из песочницы] Прототипы в JS и малоизвестные факты

Лирическое вступление 

Получив в очередной раз кучу вопросов про прототипы на очередном собеседовании, я понял, что слегка подзабыл тонкости работы прототипов, и решил освежить знания. Я наткнулся на кучу статей, которые были написаны либо по наитию автора, как он «чувствует» прототипы, либо статья была про отдельную часть темы и не давала полной картины происходящего. 

Оказалось, что есть много неочевидных вещей из старых времён ES5 и даже ES6, о которых я не слышал. А еще оказалось, что вывод консоли браузера может не соответствовать действительности.


Что такое прототип

Объект в JS имеет собственные и унаследованные свойства, например, в этом коде:

var foo = { bar: 1 };
foo.bar === 1 // true
typeof foo.toString === "function" // true

у объекта foo имеется собственное свойство bar со значением 1, но также имеются и другие свойства, такие как toString. Чтобы понять, как объект foo получает новое свойство toString, посмотрим на то, из чего состоит объект:


g_gxrnilqtovgmygxzyvtrjh40y.png

Дело в том, что у объекта есть ссылка на другой объект-прототип. При доступе к полю 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, если прототипа нет).


bif-xzio0g8-qsnltwuph2ncrgk.png

Из-за того, что [[Prototype]] предназначался исключительно для самого JS движка, получить доступ к прототипу объекта было невозможно. Для случаев когда это было нужно, ввели нестандартное свойство __proto__, которое поддержали многие браузеры и которое по итогу попало в сам стандарт, но как опциональное и стандартизированное только для обратной совместимости с существующим JS кодом.


О чем вам недоговаривает дебаггер, или он вам не прототип

Свойство __proto__ является геттером и сеттером для внутреннего слота [[Prototype]] и находится в Object.prototype:


zrwov5qhumcf3zhg40teegq2nwu.png

Из-за этого я избегал записи __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__ превращается в тыкву и перестает работать с прототипом.
А теперь отработаем кликбейт из введения. Представим следующую цепочку прототипов:


02esbphjpwzdnxou6deuzqnktyg.png

var baz = { test: "test" };
var foo = { bar: 1 };
foo.__proto__ = baz;

В консоли Chrome foo будет выглядеть следующим образом:


kg_mcxk80_454bkanc2qiwyjwig.png

А теперь уберем связь между baz и Object.prototype:

baz.__proto__ = null;

И теперь в консоли Chrome видим следующий результат:


0rrpt0kyewzdxuxxtrczksiulmo.png

Связь с 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 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 тут является конструктором и создает два поля в новом объекте, а цепочка прототипов выглядит так:


4sqdxjhdgmjdvydbkruxtldrow4.png

Откуда взялся Person.prototype? При объявлении функции, у нее автоматически создается свойство prototype для того чтобы ее можно было использовать как конструктор (note 3), таким образом свойство prototype функции не имеет отношения к прототипу самой функции, а задает прототипы для дочерних объектов. Это позволит реализовывать наследование и добавлять новые методы, например так:

Person.prototype.fullName = function () {
    return this.firstName + ' ' + this.firstName;
}


u3grlwn8eog4bh4lrdq0dj0wjfi.png

И теперь вызов user.fullName() вернет строку «John Doe».


Что такое new 

На самом деле оператор new не таит в себе никакой магии. При вызове new выполняет несколько действий:


  1. Создает новый объект
  2. Записывает свойство prototype функции конструктора в прототип объекта
  3. Вызывает функцию конструктор с объектом в качестве аргумента this
  4. Возвращает объект

Все эти действия можно сделать силами самого языка, поэтому можно написать свой собственный оператор 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. Для этого нужно


  1. Создать конструктор Student с вызовом логики конструктора Person
  2. Задать объекту `Student.prototype` прототип от `Person`
  3. Добавить новые методы к `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);


e1wbene2qxgqjy5dzni59saoad8.png

Фиолетовым цветом обозначены поля объекта (они все находятся в самом объекте, т.к. 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.

Наивно было бы ожидать, что одна статья ответит на все вопросы. Если у Вас есть интересные вопросы, экскурсы в историю, аргументированные или беспочвенные заявления о том, что я сделал все не так, либо правки по ошибкам, пишите в комментарии. 

© Habrahabr.ru