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

Примеси (Mixins)

В системе интернет-магазина есть User, Product, Order. Каждому нужна сериализация в JSON, временные метки и система событий. Наследование не подходит — нельзя наследовать от трёх классов одновременно. Решение — примеси: наборы методов, которые копируются в любой класс.

Проблема: нет множественного наследования

JavaScript поддерживает только одиночное наследование — класс может наследовать только от одного другого класса. Но что делать, если нужно добавить к классу несколько независимых наборов поведения?

class User extends Serializable, Validatable { }  // ОШИБКА — так нельзя!

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

  • классы: синтаксис class и наследование
  • наследование: extends и super
  • Object.assign: копирование свойств — основа mixins
  • this: методы примесей используют this
  • Примесь (Mixin) — решение

    Примесь — это обычный объект с методами, которые можно скопировать в прототип любого класса через Object.assign:

    const Serializable = {
      toJSON() {
        return JSON.stringify(this)
      },
      fromJSON(json) {
        return Object.assign(Object.create(Object.getPrototypeOf(this)), JSON.parse(json))
      },
    }
    
    class User {
      constructor(name, email) {
        this.name = name
        this.email = email
      }
    }
    
    // Копируем методы примеси в прототип класса
    Object.assign(User.prototype, Serializable)
    
    const user = new User('Иван', 'ivan@example.ru')
    console.log(user.toJSON())  // '{"name":"Иван","email":"ivan@example.ru"}'

    Mixin vs наследование vs интерфейс

    | | Наследование | Mixin | Интерфейс (TypeScript) |

    |---|---|---|---|

    | Реализация | Да | Да | Нет |

    | Множественное | Нет | Да | Да |

    | Связь классов | Жёсткая | Слабая | Контракт |

    | JS-поддержка | Да | Да | Только TS |

    Реальные примеры примесей

    Serializable — сериализация в JSON

    const Serializable = {
      serialize() {
        return JSON.stringify(this)
      },
      toObject() {
        return JSON.parse(JSON.stringify(this))
      },
    }

    Timestamped — автоматические временные метки

    const Timestamped = {
      setTimestamps() {
        this.createdAt = this.createdAt || new Date().toISOString()
        this.updatedAt = new Date().toISOString()
      },
    }

    EventEmitter — события

    const EventEmitter = {
      on(event, listener) {
        if (!this._listeners) this._listeners = {}
        if (!this._listeners[event]) this._listeners[event] = []
        this._listeners[event].push(listener)
        return this
      },
      emit(event, ...args) {
        if (!this._listeners || !this._listeners[event]) return
        this._listeners[event].forEach(fn => fn(...args))
      },
      off(event, listener) {
        if (!this._listeners || !this._listeners[event]) return
        this._listeners[event] = this._listeners[event].filter(fn => fn !== listener)
      },
    }

    Применение нескольких примесей

    class Product {
      constructor(name, price) {
        this.name = name
        this.price = price
      }
    }
    
    // Применяем сразу несколько примесей
    Object.assign(Product.prototype, Serializable, Timestamped, EventEmitter)
    
    const p = new Product('Ноутбук', 89990)
    p.setTimestamps()
    console.log(p.serialize())  // {"name":"Ноутбук","price":89990,"createdAt":"..."}
    p.on('priceChanged', (newPrice) => console.log('Цена изменена:', newPrice))
    p.emit('priceChanged', 79990)  // 'Цена изменена: 79990'

    Важные правила примесей

    1. Mixin не должен иметь конструктора — только методы

    2. Методы примеси используют this — они работают в контексте объекта

    3. Следите за коллизиями имён — Object.assign перезапишет метод класса

    4. Примеси для несвязанных capabilities: сериализация, валидация, события

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

    Ошибка 1: коллизия имён — примесь перезаписывает метод класса

    const Logging = {
      toString() { return '[Logging]' }  // перезапишет toString класса!
    }
    
    class Product {
      toString() { return `Product: ${this.name}` }
    }
    
    Object.assign(Product.prototype, Logging)
    const p = new Product()
    p.toString()  // '[Logging]' — метод класса перезаписан!
    
    // Исправлено: проверяем перед применением
    function applyMixin(TargetClass, mixin) {
      for (const key of Object.keys(mixin)) {
        if (key in TargetClass.prototype) {
          console.warn(`Коллизия: метод "${key}" в ${TargetClass.name} будет перезаписан`)
        }
      }
      Object.assign(TargetClass.prototype, mixin)
    }

    Ошибка 2: примесь с конструктором

    // Сломано: мixin не должен иметь конструктора
    const BadMixin = {
      constructor() {  // проблема — Object.assign перенесёт это!
        this.created = Date.now()
      },
      getCreated() { return this.created }
    }
    
    // Исправлено: инициализацию вызывают явно как метод
    const GoodMixin = {
      initTimestamps() { this.createdAt = Date.now() },
      getCreated() { return this.createdAt }
    }

    Ошибка 3: примесь для поведения, которое лучше решается наследованием

    // Сломано: AdminUser и User — явная иерархия, mixin лишний
    const AdminMixin = { canDelete: () => true, canBan: () => true }
    Object.assign(User.prototype, AdminMixin)  // все Users стали Admin!
    
    // Исправлено: наследование для "is-a" отношений
    class AdminUser extends User {
      canDelete() { return true }
      canBan() { return true }
    }

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

  • React: раньше HOC (High Order Components) использовали принцип mixin, сейчас — хуки
  • Vue 2: Options API поддерживал mixins напрямую; Vue 3 заменил их на Composables
  • TypeScript: mixins через тип (Base: T) => class extends Base { ... } — типобезопасно
  • EventEmitter: Node.js EventEmitter применяют как mixin к сокетам, HTTP-серверам
  • Примеры

    Примеси Serializable и Timestamped применяются к классу User через Object.assign

    // Примесь 1: сериализация
    const Serializable = {
      serialize() {
        return JSON.stringify(this)
      },
      toObject() {
        return JSON.parse(JSON.stringify(this))
      },
      clone() {
        const obj = Object.create(Object.getPrototypeOf(this))
        return Object.assign(obj, JSON.parse(JSON.stringify(this)))
      },
    }
    
    // Примесь 2: временные метки
    const Timestamped = {
      touch() {
        if (!this.createdAt) {
          this.createdAt = new Date().toISOString()
        }
        this.updatedAt = new Date().toISOString()
        return this
      },
      getAge() {
        if (!this.createdAt) return null
        const ms = Date.now() - new Date(this.createdAt).getTime()
        return Math.floor(ms / 1000)  // секунды
      },
    }
    
    // Классы — без каких-либо знаний о сериализации и временных метках
    class User {
      constructor(name, email, role) {
        this.name = name
        this.email = email
        this.role = role
      }
    
      greet() {
        return `Привет, я ${this.name}!`
      }
    }
    
    class Product {
      constructor(name, price, category) {
        this.name = name
        this.price = price
        this.category = category
      }
    
      getPriceFormatted() {
        return this.price.toLocaleString('ru-RU') + ' ₽'
      }
    }
    
    // Применяем примеси к обоим классам
    Object.assign(User.prototype, Serializable, Timestamped)
    Object.assign(Product.prototype, Serializable, Timestamped)
    
    // User
    const user = new User('Мария Иванова', 'maria@example.ru', 'admin')
    user.touch()
    
    console.log('User:')
    console.log(user.greet())           // 'Привет, я Мария Иванова!'
    console.log(user.serialize())       // JSON-строка
    console.log(user.toObject())        // { name, email, role, createdAt, updatedAt }
    console.log('Возраст (сек):', user.getAge())  // ~0
    
    const userClone = user.clone()
    userClone.name = 'Клон Марии'
    console.log('\nОригинал:', user.name)  // 'Мария Иванова'
    console.log('Клон:', userClone.name)  // 'Клон Марии'
    
    // Product
    const product = new Product('iPhone 15', 89990, 'Смартфоны')
    product.touch()
    
    console.log('\nProduct:')
    console.log(product.getPriceFormatted())  // '89 990 ₽'
    console.log(product.serialize())          // JSON-строка
    
    // Оба класса получили одни и те же возможности без наследования!
    console.log('\nUser имеет serialize:', typeof user.serialize)       // function
    console.log('Product имеет serialize:', typeof product.serialize)  // function

    Примеси (Mixins)

    В системе интернет-магазина есть User, Product, Order. Каждому нужна сериализация в JSON, временные метки и система событий. Наследование не подходит — нельзя наследовать от трёх классов одновременно. Решение — примеси: наборы методов, которые копируются в любой класс.

    Проблема: нет множественного наследования

    JavaScript поддерживает только одиночное наследование — класс может наследовать только от одного другого класса. Но что делать, если нужно добавить к классу несколько независимых наборов поведения?

    class User extends Serializable, Validatable { }  // ОШИБКА — так нельзя!

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

  • классы: синтаксис class и наследование
  • наследование: extends и super
  • Object.assign: копирование свойств — основа mixins
  • this: методы примесей используют this
  • Примесь (Mixin) — решение

    Примесь — это обычный объект с методами, которые можно скопировать в прототип любого класса через Object.assign:

    const Serializable = {
      toJSON() {
        return JSON.stringify(this)
      },
      fromJSON(json) {
        return Object.assign(Object.create(Object.getPrototypeOf(this)), JSON.parse(json))
      },
    }
    
    class User {
      constructor(name, email) {
        this.name = name
        this.email = email
      }
    }
    
    // Копируем методы примеси в прототип класса
    Object.assign(User.prototype, Serializable)
    
    const user = new User('Иван', 'ivan@example.ru')
    console.log(user.toJSON())  // '{"name":"Иван","email":"ivan@example.ru"}'

    Mixin vs наследование vs интерфейс

    | | Наследование | Mixin | Интерфейс (TypeScript) |

    |---|---|---|---|

    | Реализация | Да | Да | Нет |

    | Множественное | Нет | Да | Да |

    | Связь классов | Жёсткая | Слабая | Контракт |

    | JS-поддержка | Да | Да | Только TS |

    Реальные примеры примесей

    Serializable — сериализация в JSON

    const Serializable = {
      serialize() {
        return JSON.stringify(this)
      },
      toObject() {
        return JSON.parse(JSON.stringify(this))
      },
    }

    Timestamped — автоматические временные метки

    const Timestamped = {
      setTimestamps() {
        this.createdAt = this.createdAt || new Date().toISOString()
        this.updatedAt = new Date().toISOString()
      },
    }

    EventEmitter — события

    const EventEmitter = {
      on(event, listener) {
        if (!this._listeners) this._listeners = {}
        if (!this._listeners[event]) this._listeners[event] = []
        this._listeners[event].push(listener)
        return this
      },
      emit(event, ...args) {
        if (!this._listeners || !this._listeners[event]) return
        this._listeners[event].forEach(fn => fn(...args))
      },
      off(event, listener) {
        if (!this._listeners || !this._listeners[event]) return
        this._listeners[event] = this._listeners[event].filter(fn => fn !== listener)
      },
    }

    Применение нескольких примесей

    class Product {
      constructor(name, price) {
        this.name = name
        this.price = price
      }
    }
    
    // Применяем сразу несколько примесей
    Object.assign(Product.prototype, Serializable, Timestamped, EventEmitter)
    
    const p = new Product('Ноутбук', 89990)
    p.setTimestamps()
    console.log(p.serialize())  // {"name":"Ноутбук","price":89990,"createdAt":"..."}
    p.on('priceChanged', (newPrice) => console.log('Цена изменена:', newPrice))
    p.emit('priceChanged', 79990)  // 'Цена изменена: 79990'

    Важные правила примесей

    1. Mixin не должен иметь конструктора — только методы

    2. Методы примеси используют this — они работают в контексте объекта

    3. Следите за коллизиями имён — Object.assign перезапишет метод класса

    4. Примеси для несвязанных capabilities: сериализация, валидация, события

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

    Ошибка 1: коллизия имён — примесь перезаписывает метод класса

    const Logging = {
      toString() { return '[Logging]' }  // перезапишет toString класса!
    }
    
    class Product {
      toString() { return `Product: ${this.name}` }
    }
    
    Object.assign(Product.prototype, Logging)
    const p = new Product()
    p.toString()  // '[Logging]' — метод класса перезаписан!
    
    // Исправлено: проверяем перед применением
    function applyMixin(TargetClass, mixin) {
      for (const key of Object.keys(mixin)) {
        if (key in TargetClass.prototype) {
          console.warn(`Коллизия: метод "${key}" в ${TargetClass.name} будет перезаписан`)
        }
      }
      Object.assign(TargetClass.prototype, mixin)
    }

    Ошибка 2: примесь с конструктором

    // Сломано: мixin не должен иметь конструктора
    const BadMixin = {
      constructor() {  // проблема — Object.assign перенесёт это!
        this.created = Date.now()
      },
      getCreated() { return this.created }
    }
    
    // Исправлено: инициализацию вызывают явно как метод
    const GoodMixin = {
      initTimestamps() { this.createdAt = Date.now() },
      getCreated() { return this.createdAt }
    }

    Ошибка 3: примесь для поведения, которое лучше решается наследованием

    // Сломано: AdminUser и User — явная иерархия, mixin лишний
    const AdminMixin = { canDelete: () => true, canBan: () => true }
    Object.assign(User.prototype, AdminMixin)  // все Users стали Admin!
    
    // Исправлено: наследование для "is-a" отношений
    class AdminUser extends User {
      canDelete() { return true }
      canBan() { return true }
    }

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

  • React: раньше HOC (High Order Components) использовали принцип mixin, сейчас — хуки
  • Vue 2: Options API поддерживал mixins напрямую; Vue 3 заменил их на Composables
  • TypeScript: mixins через тип (Base: T) => class extends Base { ... } — типобезопасно
  • EventEmitter: Node.js EventEmitter применяют как mixin к сокетам, HTTP-серверам
  • Примеры

    Примеси Serializable и Timestamped применяются к классу User через Object.assign

    // Примесь 1: сериализация
    const Serializable = {
      serialize() {
        return JSON.stringify(this)
      },
      toObject() {
        return JSON.parse(JSON.stringify(this))
      },
      clone() {
        const obj = Object.create(Object.getPrototypeOf(this))
        return Object.assign(obj, JSON.parse(JSON.stringify(this)))
      },
    }
    
    // Примесь 2: временные метки
    const Timestamped = {
      touch() {
        if (!this.createdAt) {
          this.createdAt = new Date().toISOString()
        }
        this.updatedAt = new Date().toISOString()
        return this
      },
      getAge() {
        if (!this.createdAt) return null
        const ms = Date.now() - new Date(this.createdAt).getTime()
        return Math.floor(ms / 1000)  // секунды
      },
    }
    
    // Классы — без каких-либо знаний о сериализации и временных метках
    class User {
      constructor(name, email, role) {
        this.name = name
        this.email = email
        this.role = role
      }
    
      greet() {
        return `Привет, я ${this.name}!`
      }
    }
    
    class Product {
      constructor(name, price, category) {
        this.name = name
        this.price = price
        this.category = category
      }
    
      getPriceFormatted() {
        return this.price.toLocaleString('ru-RU') + ' ₽'
      }
    }
    
    // Применяем примеси к обоим классам
    Object.assign(User.prototype, Serializable, Timestamped)
    Object.assign(Product.prototype, Serializable, Timestamped)
    
    // User
    const user = new User('Мария Иванова', 'maria@example.ru', 'admin')
    user.touch()
    
    console.log('User:')
    console.log(user.greet())           // 'Привет, я Мария Иванова!'
    console.log(user.serialize())       // JSON-строка
    console.log(user.toObject())        // { name, email, role, createdAt, updatedAt }
    console.log('Возраст (сек):', user.getAge())  // ~0
    
    const userClone = user.clone()
    userClone.name = 'Клон Марии'
    console.log('\nОригинал:', user.name)  // 'Мария Иванова'
    console.log('Клон:', userClone.name)  // 'Клон Марии'
    
    // Product
    const product = new Product('iPhone 15', 89990, 'Смартфоны')
    product.touch()
    
    console.log('\nProduct:')
    console.log(product.getPriceFormatted())  // '89 990 ₽'
    console.log(product.serialize())          // JSON-строка
    
    // Оба класса получили одни и те же возможности без наследования!
    console.log('\nUser имеет serialize:', typeof user.serialize)       // function
    console.log('Product имеет serialize:', typeof product.serialize)  // function

    Задание

    Создай примесь Validatable с методом validate(), который проверяет, что все поля из массива this.requiredFields заполнены (не null и не undefined и не пустая строка). Примени эту примесь к классам User и Product. Оба класса должны определять свой массив requiredFields.

    Подсказка

    const Validatable = { validate() { return this.requiredFields.every(f => this[f] != null && this[f] !== '') } }; Object.assign(User.prototype, Validatable); Object.assign(Product.prototype, Validatable)

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