Как устроено ООП в JavaScript

Есть несколько аспектов объект-ориентированного программирования, которые можно рассмотреть.

  • Философия — зачем вообще нужен этот подход, какие были предпосылки к появлению ООП, как люди писали раньше, когда не было классов;
  • Синтакис — как записывать классы, как обявлять методы, как в целом мыслить, решая задачи;
  • Механика — как это работает (конкретно в JS), откуда у объектов поялятся методы, как определяется, чему равен this и так далее.

В этой статье разбираются механизмы работы ООП в джаваскрипте. И тут нужно понять три отдельные вещи:

  • как работает __proto__;
  • как работает prototype;
  • как работает this.

Слово прототип я использую сейчас первый и последний раз, потому что под ним понимают и __proto__, и prototype. Зачем себя путать.

1. Делегирование доступа к свойствам через __proto__

У каждого объекта в джаваскрипте есть свойство __proto__. Даже если вы его не создавали. В этом свойстве может лежать либо объект, либо null. JavaScript обращается к этому полю, когда не находит ключ в самом объекте.

Ничего особенного в названии поля __proto__ нет. Символ подчеркивания является валидным символом для названия перменных (через _ обозначают неиспользуемые переменные в аргументах функции, через SCREAMING_SNAKE_CASE обозначают константы, а через _cache обозначали приватные поля в классах до появления нативного синтаксиса c символом #).

1.1. __proto__ работает на чтение

Если у объекта нет какого-то свойства, то мы идем в его __proto__ и ищем там. Если там нет, то идем дальше, пока не дойдем до null.

const a = {x: 1, y: 2, __proto__: null}; const b = {x: 10, z: 30, __proto__: a}; const c = {t: 400, __proto__: b}; console.log(c.t, "t" in c, Object.hasOwn(c, "t")); // 400, поле существет и является собственным полем console.log(c.x, "x" in c, Object.hasOwn(c, "x")); // 10, поле существет, но не является собственным полем // не найдя "x" в c, идет в c.__proto__ === b, находим там console.log(c.y, "y" in c, Object.hasOwn(c, "y")); // 2, поле существет, но не является собственным полем // не найдя "y" в c, идет в c.__proto__ === b, не находим там // тогда идем в b.__proto__ === a, находим там console.log(c.q, "q" in c, Object.hasOwn(c, "q")); // undefined, поле не существет и не является собственным полем // не найдя "y" в c, идет в c.__proto__ === b, не находим там // тогда идем в b.__proto__ === a, не находим там // дальше идем в a.__proto__, он равен null, останавливаем поиск

1.2. __proto__ не работает на изменение

delete c.x; // не удалит поле "x" из c (нечего удалять) // но и не удалит из b (не из него удаляли) console.log(c.x, b.x); // 10 10 c.z = 300; // запишет в c поле "z", но в b.z так же будет лежать 300 console.log(c.z, b.z); // 300 30

1.3. Как установить __proto__

Свойство __proto__ можно указать несколькими способами (напомню, что записать туда можно null или объект):

  • Object.create(x) создаст пустой объект с __proto__ равным x.
  • Object.setPrototypeOf(o, x) у объекта o установит __proto__ равным x.
  • То же самое сделает запись o.__proto__ = x
  • __proto__ устанавливается специальным образом, если сипользовать оператор new. Об этом дальше.

1.4. Как может мешать __proto__

Если я хочу использовать джаваскриптовый объект как словарь (структуру данных для хранения в формате ключ-значение), то «лишние» свойства из __proto__ могут мне навредить.

const obj = { "toNumber": 3, "toArray": 4 }; console.log("toNumber" in obj); // true — есть ключ, я его добавил console.log("toArray" in obj); // true — есть ключ, я его добавил console.log("toString" in obj); // true — но я этот ключ не добавлял!

Именно поэтому, например, в стандартном статическом методе Object.groupBy создается так называемый null prototype object. В node.js, например, он выводится как [Object: null prototype]. Это объект, у которого поле __proto__ содержит null.

const npo = Object.groupBy([], () => "x"); console.log(Object.getPrototypeOf(npo)); // null

Если мы хотим создать свой null prototype object с нуля, то можно это сделать с помощью Object.create(null).

1.5 Страшная правда жизни (для любознательных)

Если вы только изучаете ООП, переходите сразу к разделу 2.

Теперь для тех, кто остался. Если вы возьмете любой объект, в наличии __proto__ у которого вы не сомневаетесь, то ваша какртина мира может быть разрушена.

const arr = ["a", "b", "c"]; console.log(arr.__proto__); // объект со всеми методами массивов console.log(Object.hasOwn(arr, "__proto__")); // такого поля нет!

На самом деле поле __proto__ ищется через механизм делегирования, которое само оно и реализует. В современных реализациях джаваскрипта __proto__ реализовано как геттер, находящийся в Object.prototype, к которому есть доступ у всех объектов (кроме объектов, где __proto__ равно null).

Более того, также реализован сеттер! Если вы попробуете обновить __proto__ вручную, то поле все равно не появится.

console.log(Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")); // выводятся геттер и сеттер const obj = {}; console.log(obj.__proto__); // в поле __proto__ лежит какой-то объект console.log(Object.hasOwn(obj, "__proto__")); // а самого поля нет obj.__proto__ = {}; // записал в поле какое-то значение console.log(Object.hasOwn(obj, "__proto__")); // поля все равно нет

В связи с этим возникает парадоксальная ситуация и с Object.create(null). Эта функция по определению создает объект с определенным __proto__. Но если мы обратимся к этому полю напрямую, то там будет лежать undefined.

const npo = Object.create(null); console.log(npo.__proto__); // undefined console.log(Object.getPrototypeOf(npo)); // null

Разгадка кроется в том, что я дал упрощенное объяснение механизма делегирования через __proto__. В самой спецификации вообще используется запись [[Prototype]] (к нему нет доступа у разработчика), которая очень близка по смыслу с __proto__, который мы можем пощупать сами. Обращу внимание, что не нужно путать [[Prototype]] с полем prototype функций-конструкторов, о котором речь пойдет дальше.

2. Создание объектов через оператор new

2.1. Откуда берется поле prototype

Если вы создаете обычную функцию в джаваскрипте, то у нее изначально присутствует несколько свойств. Это возможно, так как функция в джаваскрипте является объектом (как и массив, например).

Что это за свойства?

function foo(x, y, z) { } console.log(Object.getOwnPropertyNames(foo)); // ['length', 'name', 'arguments', 'caller', 'prototype'] console.log(foo.name); // "foo" — название функции console.log(foo.length); // 3 — количество аргументов console.log(foo.prototype); // {} — выглядит как обычный объект

В контексте разговора об ООП нас интересует свойство prototype. Кстати, оно есть не у любой функции. Например, у стрелочных функций и методов объекта (синтаксис method definitions), поля prototype нет. Оно и понятно, мы не собираемся использовать их в качестве конструктора.

const obj = { f() {} }; const g = () => {} Object.getOwnPropertyNames(obj.f); // ['length', 'name'] Object.getOwnPropertyNames(g); // ['length', 'name']

Таким образом у всех функций (кроме исключений выше) есть поле prototype с самого начала. Можно еще сказать, что любая функция-конструктор (т.е. функция, вызываемая с помощью new) содержит поле prototype. Но штука в том, что в момент создания функции она еще не знает, будет ли она использоваться как конструктор :)

У обычных объектов поля prototype нет. Но будет, только если вы сами его туда не запишете.

2.2. Как связаны prototype и __proto__

В чем разница между объектами obj1 и obj2? Оба содержат поля x и y с значениями 5 и 10.

function foo1(x, y) { this.x = x; this.y = y; } function foo2(x, y) { return { x, y }; } const obj1 = new foo1(5, 10); // { x: 5, y: 10 } const obj2 = foo2(5, 10); // { x: 5, y: 10 }

Для того, чтобы увидеть, в чем разница, нужно рассмотреть, как работает оператор new и что скрывается за синтаксом {} (литерал объекта) при создании объекта.

function xf(x, y) { this = { __proto__: foo1.prototype }; // ← происходит неявно this.x = x; this.y = y; return this; // ← происходит неявно } const obj1 = new foo1(5, 10);

В тот момент, когда функция foo1 вызывается с оператором new, в код фуннции как бы добавляются две строчки. В начале идет создание переменной this, в которую кладется объект. А в конце — возврат этого this.

Единственная особенность этого объекта в том, что у него поле __proto__ равно полю foo1.prototype. Иными словами функция-конструктор создает объект, у которого поле __proto__ равно полю prototype этой функции. Это едиснтвенная связь __proto__ и prototype.

Функции-конструкторы называются с большой буквы, потому что есть такая договоренность между разработчиками. Если называть их с маленнькой буквы, то выхов через new будет работать точно так же. В некоторых примерах здесь я даже так и делаю для иллюстрации. В рабочем коде называйте функции с большо буквы (это еще называют PascalCase).

2.3. Как работают литерал объекта и литерал массива

На самом деле запись x = {} равнозначна записи x = new Object(). А это значит, что для обычного объекта работает то же самое правило, которое мы вывели в разделе выше.

Переменная x — результат работы функции-конструктора Object, вызванной с помощью оператора new. А значит, x.__proto__ === Object.prototype.

function foo1() {} const o1 = new foo1(); // o1.__proto__ === foo1.prototype const o2 = new Object(); // o2.__proto__ === Object.prototype

В объекте foo1.prototype нашей функции foo1 почти ничего нет. Единствненное поле, которое по умолчанию лежит в этом объекте, — constructor, ссылающееся на саму foo1. А вот в Object.prototype есть несколько полей, с некоторыми из которых вы даже знакомы.

Object.getOwnPropertyNames(Object.prototype) === [ "constructor", "__defineGetter__", "__defineSetter__", "hasOwnProperty", "__lookupGetter__", "__lookupSetter__", "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf", "__proto__", "toLocaleString" ]

Например, hasOwnProperty позволяет определять, есть ли в объекте какой-то ключ, а благодаря toString у нас не падает программа, когда мы складываем друг с другом не только числа и строки.

Поэтому, например, если мы создадим объект через {}, мы сможем на нем вызывать метод hasOwnProperty.

const obj = { p: 1 }; console.log(obj.hasOwnProperty("p")); // true

Более того, мы можем создать объект своего класса и также вызвать метод hasOwnProperty. За счет цепочки из __proto__ мы найдем нужный метод. Это называется наследование!

function foo() { this.p = 5; } const obj = new foo(); console.log(obj.hasOwnProperty("p")); // true — метод работает console.log(Object.hasOwn(obj, "hasOwnProperty")); // false // obj.__proto__ === foo.prototype console.log(Object.hasOwn(obj.__proto__, "hasOwnProperty")); // false // obj.__proto__.__proto__ === Object.prototype console.log(Object.hasOwn(obj.__proto__.__proto__, "hasOwnProperty")); // true

Для массивов и функций литералы работают так же.

const arr1 = ["a", "b", "c"]; const arr2 = new Array("a", "b", "c"); const fn1 = function(x, y) { return x + y; }; const fn2 = new Function("x", "y", "return x + y;");

Таким образом массивы, созданные через [], фактически создаются через конструктор Array, а функции — конструктор Function. Не удивительно, что в __proto__ у них лежат Array.prototype и Function.prototype соответственно.

3. Определение значения this

3.1. this не зависит от того от места объявлении функции

Теперь давайте посмотрим на то, как вычисляется this внутри метода. Он там есть наверняка. Ведь если бы this не было, то почему эта функция оформлена как метод.

function A() { } A.prototype.print = function() { console.log(this); } const a1 = new A(1); a1.print();

Что происходит в момент вызова a2.print()? JavaScript ищет поле print в объекте a1, но не находит. Тогда по правилу из раздела 1 он идет в a1.__proto__. Этот объкт по правилу из раздела 2 равен A.prototype, а в нем уже есть поле print, потому что мы его туда положили.

Таким образом фактически вызывается функция, находящаяся по адресу A.prototype.print. Но то, чему равен this, определяется в момент вызова функции. В данном случае мы видим, что «слева от точки» стоит объект a1, а значит, this равен a1.

3.2. План определения значения this

  1. (опциональный) Если функция стрелочная, найти ближайшую обычную функцию, внутри которой находится наша функция.
  2. Найти, где эта обычная функция вызывается.
  3. В зависимости от того, как эта функция вызывается, определить значение this.

3.3. Как this зависит от способа вызова функции

  • Как обычная фунция: foo()
    • this === undefined в строгом режиме ("use strict")
    • this === globalThis (window в браузере или global в node.js) в нестрогом режиме
  • Как метод: obj.foo() или arr[0]()
    • this равен объекту «слева от точки»: this === obj или this === arr
  • С явным указанием this: obj.foo.call(obj2) или obj.foo.apply(obj2)
    • this === obj2 — аргумент call или apply
    • обратите внимание: не важно, что foo мы взяли из obj
  • Как конструктор: new foo()
    • this === { __proto__: foo.prototype } — новый объект с определенным полем __proto__
  • Предварительно забайнденная: bar = foo.bind(obj3)
    • при вызове bar() this равен obj3
    • даже если мы еще раз сделаем bind или положим bar в какой-то объект и вызовем как его метод, все равно this будет равен obj3

3.4. Что значит «потерять контекст»?

Даже если функция объявлена как метод, ее this все равно определяется в момент вызова (см 3.3.). Но часто бывает, что мы сами явно функцию не вызываем (ни одним из способов, перечисленных выше).

const obj = { foo() { console.log(this); }, }; // мы сами вызываем функцию, this === obj obj.foo(); // мы передаем функцию в setTimeout и он ее вызывает через 100 мс const timeout = setTimeout(obj.foo, 100);

Когда функцию вызывает кто-то другой, то именно он определяет, чему равен this. Например, в случае setTimeout — в зависимости от среды — this определяется по-разному. Если мы запускаем в node.js, то this === timeout. Это объект класса Timeout, который можно будет потом передать в clearTimeout. Если мы запускаем в браузере, this === window. Но в обоих случаях this не равен obj.

Чтобы obj.foo вызвалась с this === obj, можно изменить передаваемую функцию.

// создали новую функцию, у которой this жестко зафиксирован (и равен obj) const boundObj = obj.foo.bind(obj); setTimeout(boundObj, 100); // передали новую анонимную функцию, внутри которой вызывается obj.foo(); // а так как сам вызов с объектом obj «слева от точки», то this === obj setTimeout(function() { obj.foo(); }, 100); // при этом сама функция может быть и стрелочной // нас же не this этой стрелочной функции интересует setTimeout(() => obj.foo(), 100);

Еще один пример функции, принимающей колбэк — метод массива map. У него есть второй опциональный аргумент, принимающий значение this для колбэка. Сам колбэк передается первым аргументом.

const obj = { x: 2, foo(num) { return num * this.x; }, } console.log([1, 2, 3].map(obj.foo)); // this потерялся console.log([1, 2, 3].map(num => obj.foo(num))); // исправили через функцию-обертку console.log([1, 2, 3].map(obj.foo.bind(obj))); // исправили через bind console.log([1, 2, 3].map(obj.foo, obj)); // воспользовались вторым аргументом метода map