← JavaScript/Классы#80 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Классы в JavaScript

Реальная проблема: читаемый ООП-код

Прототипное наследование работает, но код громоздкий: Constructor.prototype.method = function() {...}, Object.create(...), восстановление constructor. Классы решают это: тот же механизм прототипов, но с чистым, привычным синтаксисом. В React, Angular, NestJS классы используются повсеместно.

Что такое класс в JS

Класс — это синтаксический сахар над конструктором и прототипами. typeof User === 'function' — класс компилируется в функцию. Под капотом методы класса живут в User.prototype.

На основе предыдущих уроков

  • «Прототипы» — классы это удобный синтаксис для того же механизма
  • «Конструктор/new» — new ClassName() работает так же как new Function()
  • «this» — в методах класса this = экземпляр
  • Базовый синтаксис

    class User {
      // constructor — специальный метод, вызывается при new User()
      constructor(name, email) {
        this.name  = name
        this.email = email
      }
    
      // Метод — автоматически попадает в User.prototype
      greet() {
        return `Привет, я ${this.name}`
      }
    
      // toString — переопределение стандартного метода
      toString() {
        return `[User: ${this.email}]`
      }
    }
    
    const user = new User('Алексей', 'alex@mail.ru')
    console.log(user.greet())     // 'Привет, я Алексей'
    console.log(String(user))     // '[User: alex@mail.ru]'
    console.log(typeof User)      // 'function' — класс это функция!

    Приватные поля (#)

    С ES2022 — действительно приватные, недоступные снаружи класса:

    class BankAccount {
      #balance = 0        // приватное поле
      #owner              // объявление без значения
    
      constructor(owner, initialBalance = 0) {
        this.#owner   = owner
        this.#balance = initialBalance
      }
    
      deposit(amount) {
        if (amount <= 0) throw new RangeError('Сумма должна быть > 0')
        this.#balance += amount
        return this
      }
    
      withdraw(amount) {
        if (amount > this.#balance) throw new Error('Недостаточно средств')
        this.#balance -= amount
        return this
      }
    
      get balance() { return this.#balance }
      get owner()   { return this.#owner }
    }
    
    const acc = new BankAccount('Алексей', 5000)
    acc.deposit(3000).withdraw(1000)
    console.log(acc.balance)   // 7000
    // acc.#balance             // SyntaxError — приватное поле!

    Геттеры и сеттеры

    Позволяют выглядеть как свойство, но работать как метод:

    class Temperature {
      #celsius
    
      constructor(celsius) { this.#celsius = celsius }
    
      get fahrenheit()      { return this.#celsius * 9/5 + 32 }
      set fahrenheit(value) { this.#celsius = (value - 32) * 5/9 }
    
      get celsius()         { return this.#celsius }
      set celsius(value) {
        if (value < -273.15) throw new RangeError('Ниже абсолютного нуля')
        this.#celsius = value
      }
    }
    
    const t = new Temperature(100)
    console.log(t.fahrenheit)  // 212
    t.fahrenheit = 32
    console.log(t.celsius)     // 0

    Статические методы и поля

    Принадлежат классу, а не экземплярам. Используются как фабрики и утилиты:

    class Product {
      static #count = 0  // сколько товаров создано
    
      constructor(name, price) {
        this.id    = ++Product.#count
        this.name  = name
        this.price = price
      }
    
      static getCount() {
        return Product.#count
      }
    
      // Фабричный метод — альтернативный конструктор
      static fromJSON(json) {
        const { name, price } = typeof json === 'string' ? JSON.parse(json) : json
        return new Product(name, price)
      }
    
      static compare(a, b) {
        return a.price - b.price  // для сортировки
      }
    }
    
    const p1 = new Product('Ноутбук', 75000)
    const p2 = Product.fromJSON('{"name":"Мышь","price":1500}')
    
    console.log(Product.getCount())  // 2
    console.log([p1, p2].sort(Product.compare).map(p => p.name))
    // ['Мышь', 'Ноутбук']

    Типичные ошибки

    Ошибка 1: вызов класса без new

    // Сломано:
    const u = User('Алексей', 'alex@mail.ru')  // TypeError: Class must be called with new
    
    // Исправлено:
    const u = new User('Алексей', 'alex@mail.ru')

    Ошибка 2: поднятие (hoisting) — класс нельзя использовать до объявления

    // Сломано:
    const u = new User()  // ReferenceError — класс ещё не объявлен
    class User { }
    
    // Функции можно (function declaration hoisting):
    const u = new User()  // OK для function User() {}
    function User() { }

    Ошибка 3: потеря this при деструктуризации метода

    // Сломано:
    const user = new User('Алексей', 'alex@mail.ru')
    const { greet } = user
    greet()  // TypeError — this = undefined в strict mode
    
    // Исправлено — используй стрелочный метод или bind:
    class User {
      greet = () => `Привет, я ${this.name}`  // стрелочное поле — this привязан
    }

    В реальных проектах

  • React: классовые компоненты (устаревшие), Component, PureComponent
  • NestJS: @Controller, @Service — декораторы на классах
  • TypeScript: классы + типизация = основа крупных проектов
  • ORM: class User extends Model { } — Sequelize, TypeORM
  • Ошибки: class AppError extends Error { } — кастомные ошибки
  • Примеры

    Класс ShoppingCart с приватными полями, геттерами и статическими методами

    class ShoppingCart {
      #items = []
      #discountPercent = 0
    
      static #TAX_RATE = 0.2  // НДС 20%
    
      addItem(product, qty = 1) {
        const existing = this.#items.find(i => i.id === product.id)
        if (existing) {
          existing.qty += qty
        } else {
          this.#items.push({ ...product, qty })
        }
        return this  // цепочка
      }
    
      removeItem(id) {
        this.#items = this.#items.filter(i => i.id !== id)
        return this
      }
    
      applyDiscount(percent) {
        if (percent < 0 || percent > 100) throw new RangeError('Скидка от 0 до 100')
        this.#discountPercent = percent
        return this
      }
    
      get subtotal() {
        return this.#items.reduce((sum, i) => sum + i.price * i.qty, 0)
      }
    
      get discount() {
        return Math.round(this.subtotal * this.#discountPercent / 100)
      }
    
      get tax() {
        return Math.round((this.subtotal - this.discount) * ShoppingCart.#TAX_RATE)
      }
    
      get total() {
        return this.subtotal - this.discount + this.tax
      }
    
      get itemCount() {
        return this.#items.reduce((sum, i) => sum + i.qty, 0)
      }
    
      getReceipt() {
        const lines = this.#items.map(i =>
          `  ${i.name} x${i.qty} @ ${i.price} ₽ = ${i.price * i.qty} ₽`
        )
        return [
          '--- Чек ---',
          ...lines,
          `Подытог: ${this.subtotal} ₽`,
          this.#discountPercent ? `Скидка ${this.#discountPercent}%: -${this.discount} ₽` : null,
          `НДС 20%: +${this.tax} ₽`,
          `Итого: ${this.total} ₽`,
        ].filter(Boolean).join('\n')
      }
    
      // Статический фабричный метод
      static fromItems(items) {
        const cart = new ShoppingCart()
        items.forEach(({ product, qty }) => cart.addItem(product, qty))
        return cart
      }
    }
    
    const cart = new ShoppingCart()
    
    cart
      .addItem({ id: 1, name: 'Ноутбук', price: 75000 }, 1)
      .addItem({ id: 2, name: 'Мышь',    price: 1500  }, 2)
      .addItem({ id: 3, name: 'Коврик',  price: 500   }, 1)
      .applyDiscount(10)
    
    console.log(`Товаров: ${cart.itemCount}`)   // 'Товаров: 4'
    console.log(`Подытог: ${cart.subtotal} ₽`) // 'Подытог: 78500 ₽'
    console.log(cart.getReceipt())
    // --- Чек ---
    //   Ноутбук x1 @ 75000 ₽ = 75000 ₽
    //   Мышь x2 @ 1500 ₽ = 3000 ₽
    //   Коврик x1 @ 500 ₽ = 500 ₽
    // Подытог: 78500 ₽
    // Скидка 10%: -7850 ₽
    // НДС 20%: +14130 ₽
    // Итого: 84780 ₽

    Классы в JavaScript

    Реальная проблема: читаемый ООП-код

    Прототипное наследование работает, но код громоздкий: Constructor.prototype.method = function() {...}, Object.create(...), восстановление constructor. Классы решают это: тот же механизм прототипов, но с чистым, привычным синтаксисом. В React, Angular, NestJS классы используются повсеместно.

    Что такое класс в JS

    Класс — это синтаксический сахар над конструктором и прототипами. typeof User === 'function' — класс компилируется в функцию. Под капотом методы класса живут в User.prototype.

    На основе предыдущих уроков

  • «Прототипы» — классы это удобный синтаксис для того же механизма
  • «Конструктор/new» — new ClassName() работает так же как new Function()
  • «this» — в методах класса this = экземпляр
  • Базовый синтаксис

    class User {
      // constructor — специальный метод, вызывается при new User()
      constructor(name, email) {
        this.name  = name
        this.email = email
      }
    
      // Метод — автоматически попадает в User.prototype
      greet() {
        return `Привет, я ${this.name}`
      }
    
      // toString — переопределение стандартного метода
      toString() {
        return `[User: ${this.email}]`
      }
    }
    
    const user = new User('Алексей', 'alex@mail.ru')
    console.log(user.greet())     // 'Привет, я Алексей'
    console.log(String(user))     // '[User: alex@mail.ru]'
    console.log(typeof User)      // 'function' — класс это функция!

    Приватные поля (#)

    С ES2022 — действительно приватные, недоступные снаружи класса:

    class BankAccount {
      #balance = 0        // приватное поле
      #owner              // объявление без значения
    
      constructor(owner, initialBalance = 0) {
        this.#owner   = owner
        this.#balance = initialBalance
      }
    
      deposit(amount) {
        if (amount <= 0) throw new RangeError('Сумма должна быть > 0')
        this.#balance += amount
        return this
      }
    
      withdraw(amount) {
        if (amount > this.#balance) throw new Error('Недостаточно средств')
        this.#balance -= amount
        return this
      }
    
      get balance() { return this.#balance }
      get owner()   { return this.#owner }
    }
    
    const acc = new BankAccount('Алексей', 5000)
    acc.deposit(3000).withdraw(1000)
    console.log(acc.balance)   // 7000
    // acc.#balance             // SyntaxError — приватное поле!

    Геттеры и сеттеры

    Позволяют выглядеть как свойство, но работать как метод:

    class Temperature {
      #celsius
    
      constructor(celsius) { this.#celsius = celsius }
    
      get fahrenheit()      { return this.#celsius * 9/5 + 32 }
      set fahrenheit(value) { this.#celsius = (value - 32) * 5/9 }
    
      get celsius()         { return this.#celsius }
      set celsius(value) {
        if (value < -273.15) throw new RangeError('Ниже абсолютного нуля')
        this.#celsius = value
      }
    }
    
    const t = new Temperature(100)
    console.log(t.fahrenheit)  // 212
    t.fahrenheit = 32
    console.log(t.celsius)     // 0

    Статические методы и поля

    Принадлежат классу, а не экземплярам. Используются как фабрики и утилиты:

    class Product {
      static #count = 0  // сколько товаров создано
    
      constructor(name, price) {
        this.id    = ++Product.#count
        this.name  = name
        this.price = price
      }
    
      static getCount() {
        return Product.#count
      }
    
      // Фабричный метод — альтернативный конструктор
      static fromJSON(json) {
        const { name, price } = typeof json === 'string' ? JSON.parse(json) : json
        return new Product(name, price)
      }
    
      static compare(a, b) {
        return a.price - b.price  // для сортировки
      }
    }
    
    const p1 = new Product('Ноутбук', 75000)
    const p2 = Product.fromJSON('{"name":"Мышь","price":1500}')
    
    console.log(Product.getCount())  // 2
    console.log([p1, p2].sort(Product.compare).map(p => p.name))
    // ['Мышь', 'Ноутбук']

    Типичные ошибки

    Ошибка 1: вызов класса без new

    // Сломано:
    const u = User('Алексей', 'alex@mail.ru')  // TypeError: Class must be called with new
    
    // Исправлено:
    const u = new User('Алексей', 'alex@mail.ru')

    Ошибка 2: поднятие (hoisting) — класс нельзя использовать до объявления

    // Сломано:
    const u = new User()  // ReferenceError — класс ещё не объявлен
    class User { }
    
    // Функции можно (function declaration hoisting):
    const u = new User()  // OK для function User() {}
    function User() { }

    Ошибка 3: потеря this при деструктуризации метода

    // Сломано:
    const user = new User('Алексей', 'alex@mail.ru')
    const { greet } = user
    greet()  // TypeError — this = undefined в strict mode
    
    // Исправлено — используй стрелочный метод или bind:
    class User {
      greet = () => `Привет, я ${this.name}`  // стрелочное поле — this привязан
    }

    В реальных проектах

  • React: классовые компоненты (устаревшие), Component, PureComponent
  • NestJS: @Controller, @Service — декораторы на классах
  • TypeScript: классы + типизация = основа крупных проектов
  • ORM: class User extends Model { } — Sequelize, TypeORM
  • Ошибки: class AppError extends Error { } — кастомные ошибки
  • Примеры

    Класс ShoppingCart с приватными полями, геттерами и статическими методами

    class ShoppingCart {
      #items = []
      #discountPercent = 0
    
      static #TAX_RATE = 0.2  // НДС 20%
    
      addItem(product, qty = 1) {
        const existing = this.#items.find(i => i.id === product.id)
        if (existing) {
          existing.qty += qty
        } else {
          this.#items.push({ ...product, qty })
        }
        return this  // цепочка
      }
    
      removeItem(id) {
        this.#items = this.#items.filter(i => i.id !== id)
        return this
      }
    
      applyDiscount(percent) {
        if (percent < 0 || percent > 100) throw new RangeError('Скидка от 0 до 100')
        this.#discountPercent = percent
        return this
      }
    
      get subtotal() {
        return this.#items.reduce((sum, i) => sum + i.price * i.qty, 0)
      }
    
      get discount() {
        return Math.round(this.subtotal * this.#discountPercent / 100)
      }
    
      get tax() {
        return Math.round((this.subtotal - this.discount) * ShoppingCart.#TAX_RATE)
      }
    
      get total() {
        return this.subtotal - this.discount + this.tax
      }
    
      get itemCount() {
        return this.#items.reduce((sum, i) => sum + i.qty, 0)
      }
    
      getReceipt() {
        const lines = this.#items.map(i =>
          `  ${i.name} x${i.qty} @ ${i.price} ₽ = ${i.price * i.qty} ₽`
        )
        return [
          '--- Чек ---',
          ...lines,
          `Подытог: ${this.subtotal} ₽`,
          this.#discountPercent ? `Скидка ${this.#discountPercent}%: -${this.discount} ₽` : null,
          `НДС 20%: +${this.tax} ₽`,
          `Итого: ${this.total} ₽`,
        ].filter(Boolean).join('\n')
      }
    
      // Статический фабричный метод
      static fromItems(items) {
        const cart = new ShoppingCart()
        items.forEach(({ product, qty }) => cart.addItem(product, qty))
        return cart
      }
    }
    
    const cart = new ShoppingCart()
    
    cart
      .addItem({ id: 1, name: 'Ноутбук', price: 75000 }, 1)
      .addItem({ id: 2, name: 'Мышь',    price: 1500  }, 2)
      .addItem({ id: 3, name: 'Коврик',  price: 500   }, 1)
      .applyDiscount(10)
    
    console.log(`Товаров: ${cart.itemCount}`)   // 'Товаров: 4'
    console.log(`Подытог: ${cart.subtotal} ₽`) // 'Подытог: 78500 ₽'
    console.log(cart.getReceipt())
    // --- Чек ---
    //   Ноутбук x1 @ 75000 ₽ = 75000 ₽
    //   Мышь x2 @ 1500 ₽ = 3000 ₽
    //   Коврик x1 @ 500 ₽ = 500 ₽
    // Подытог: 78500 ₽
    // Скидка 10%: -7850 ₽
    // НДС 20%: +14130 ₽
    // Итого: 84780 ₽

    Задание

    Создай класс EventEmitter — упрощённый аналог Node.js EventEmitter. Приватное поле #handlers хранит Map<eventName, Set<handler>>. Методы: on(event, handler) — подписаться на событие, off(event, handler) — отписаться, emit(event, ...args) — вызвать всех подписчиков события, once(event, handler) — подписаться только на одно срабатывание (после первого вызова автоматически отписаться).

    Подсказка

    off: this.#handlers.get(event)?.delete(handler). emit: handler(...args) в forEach. once: в wrapper после вызова handler вызови this.off(event, wrapper) — отписываем саму обёртку.

    Загружаем среду выполнения...
    Загружаем AI-помощника...