Что такое классы в JavaScript и как они соотносятся с прототипами?

MiddleJavaScript #javascript #classes #oop

Вопрос

Что представляют собой ES6-классы под капотом, как работают extends/super, приватные поля (#field) и статические члены?

Короткий ответ

class в JavaScript — это синтаксический сахар над прототипным наследованием: под капотом методы класса по-прежнему кладутся в prototype, а не в сам объект. extends настраивает цепочку прототипов между классами, super даёт доступ к родительскому конструктору и методам. Приватные поля (#field) обеспечивают настоящую инкапсуляцию на уровне движка, а static члены принадлежат самому классу, а не экземплярам.

Подробный ответ

Как устроено прототипное наследование в деталях — отдельная большая тема; здесь фокус на том, что даёт именно синтаксис class.

class как сахар. Объявление class Foo {} создаёт функцию-конструктор, а все методы, объявленные в теле класса, добавляются в Foo.prototype — точно так же, как при ручной настройке через function + Foo.prototype.method = .... Отличия от "ручного" варианта:

  • Методы класса не перечисляемы (enumerable: false), в отличие от Foo.prototype.method = fn.
  • Класс нельзя вызвать без new — попытка бросает TypeError.
  • Тело класса всегда выполняется в строгом режиме ('use strict').
  • Классы не поднимаются как объявления функций — они находятся в temporal dead zone до объявления.

extends и super. class Dog extends Animal делает две вещи: Dog.prototype.__proto__ = Animal.prototype (наследование методов экземпляра) и Dog.__proto__ = Animal (наследование статических методов). Вызов super(...) в конструкторе вызывает конструктор родителя и обязателен до обращения к this, если класс что-то наследует — движок не создаёт this автоматически при наличии родителя, это делает super(). super.method() в обычном методе вызывает метод родительского прототипа, минуя переопределение в дочернем классе.

Приватные поля (#field). Начиная с ES2022, поля с префиксом # доступны только изнутри тела класса — это соблюдается на уровне синтаксиса и рантайма, а не соглашение об именовании (как раньше делали через _field). Обращение instance.#field снаружи класса — SyntaxError на этапе парсинга. Есть также приватные методы (#method()) и приватные статические поля.

static члены. Свойства и методы, объявленные с static, живут на самом классе (конструкторе), а не на prototype, то есть недоступны через экземпляр. Используются для фабричных методов, счётчиков экземпляров, констант, утилит, логически привязанных к классу, но не требующих состояния конкретного объекта.

Пример

class Animal {
  static kingdom = 'Animalia' // статическое поле
  #energy = 100 // приватное поле

  constructor(name) {
    this.name = name
  }

  static create(name) {
    // статический фабричный метод
    return new Animal(name)
  }

  eat(amount) {
    this.#energy += amount
    return this.#energy
  }

  speak() {
    return `${this.name} издаёт звук`
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name) // обязателен до обращения к this
    this.breed = breed
  }

  speak() {
    const base = super.speak() // вызов метода родителя
    return `${base} — гав! (${this.breed})`
  }
}

const rex = new Dog('Рекс', 'Лабрадор')
console.log(rex.speak()) // "Рекс издаёт звук — гав! (Лабрадор)"
console.log(rex.eat(10)) // 110
console.log(Dog.kingdom) // "Animalia" — статика наследуется тоже
// console.log(rex.#energy) // SyntaxError: Private field must be declared

// Под капотом это всё ещё прототипы
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype) // true
console.log(typeof Dog) // "function" — класс это функция
console.log(Dog.speak === undefined) // true — методы экземпляра не на классе

Дополнительные вопросы

  • Почему нельзя обратиться к this в конструкторе наследника до вызова super()?
  • Чем приватные поля (#field) отличаются от замыканий как способа скрыть состояние?
  • Как реализовать геттеры/сеттеры в классе и когда это уместнее обычных методов?