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

Автор Сообщение
news_bot ®

Стаж: 6 лет 4 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
09-Сен-2020 13:30

Лирическое вступление 
Получив в очередной раз кучу вопросов про прототипы на очередном собеседовании, я понял, что слегка подзабыл тонкости работы прототипов, и решил освежить знания. Я наткнулся на кучу статей, которые были написаны либо по наитию автора, как он "чувствует" прототипы, либо статья была про отдельную часть темы и не давала полной картины происходящего. 
Оказалось, что есть много неочевидных вещей из старых времён 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.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.
Наивно было бы ожидать, что одна статья ответит на все вопросы. Если у Вас есть интересные вопросы, экскурсы в историю, аргументированные или беспочвенные заявления о том, что я сделал все не так, либо правки по ошибкам, пишите в комментарии. 
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_javascript, #_prototype, #___proto__, #_class, #_nasledovanie_v_javascript (наследование в javascript), #_javascript
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 30-Июн 09:23
Часовой пояс: UTC + 5