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



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


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


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


Что такое new 


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


  1. Создает новый объект self
  2. Записывает свойство prototype функции конструктора в прототип объекта self
  3. Вызывает функцию конструктор с объектом self в качестве аргумента this
  4. Возвращает 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. Для этого нужно


  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);


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


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

Комментарии 10

    +2
    Не совсем понятна идея зачем он был нужен

    Вызов статических методов инстанса


    class Test {
        constructor(a) {
            this.a = a;
        }
    
        static log(str) {
            console.log(str);
        }
    
        logA() {
            this.constructor.log(this.a);
        }
    }
    
    (object => {
        object.logA();
        object.constructor.log('456');
    })(new Test('123'));
      +1
      это имеет больше смысла чем клонирование объекта
      спасибо, хорошо хоть одним вопросом меньше)
      0

      Не только.
      Object.create появился только в es6.


      А до него было необходимо ручками работать.

        0

        А какое отношение Object.create имеет к свойству constructor?

          0

          Был не прав.


          Вспоминал как устроено наследование.
          Переопределять конструктор нужно и при использовании Object.create.

      +1

      В последнюю строку custom_new вкралась ошибка! Если constructor вернёт примитивное значение (не объект), результатом custom_new должен быть self. Кстати, оный self лучше через Object.create, строкой меньше.

        0
        спасибо за уточнение
        поправил в статье
        0
        constructor – это ссылка на функцию, с помощью которой был создан объект:

        Конструктор не всегда указывает ну функцию, с помощью которой он был создан

        function Foo() {
        	
        }
        
        function Bar() {
        
        }
        
        Bar.prototype = Foo.prototype;
        
        var foo = new Foo();
        var bar = new Bar()
        
        console.log(bar.constructor); // Foo
        


          0
          Интересный кейс
          Да, constructor это вообще writable свойство, поэтому его можно переопределить просто в лоб:
          foo.constructor = bar.constructor;
          

          поэтому полагаться на него не лучшая идея

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое