JavaScript – один из главных языков нашего стека в Хекслете. Мы используем ReactJS и NodeJS в интерактивных частях платформы, и сделали вводный курс (более продвинутые – на подходе). Любовь к JS помогла опубликовать этот перевод хорошего эссе «Prototypes are Objects (and why that matters)».

Этот пост рассчитан на тех, кто знаком с объектами в JavaScript и знает, как прототип определяет поведение объекта, что такое функция-конструктор и как свойство .property конструктора относится к объекту, который он конструирует. Общее понимание синтаксиса ECMAScript 2015 тоже не помешает.

Мы всегда могли создать класс в JavaScript таким образом:

function Person (first, last) {
  this.rename(first, last);
}

Person.prototype.fullName = function fullName () {
  return this.firstName + " " + this.lastName;
};


Person.prototype.rename = function rename (first, last) {
  this.firstName = first;
  this.lastName = last;
  return this;
}


Person это функция-конструктор, а также класс в JavaScript’овом понимании этого слова. ECMAScript 2015 дает возможность использовать ключевое слово class и т.н. “compact method notation”. Это синтаксический сахар для написания функций и присвоения методов его прототипу (там все чуть сложнее, но сейчас это не важно). Так что мы можем написать класс Person вот так:

class Person {
  constructor (first, last) {
    this.rename(first, last);
  }
  fullName () {
    return this.firstName + " " + this.lastName;
  }
  rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
};


Клево. Но под капотом все равно есть функция-конструктор с привязкой к имени Person, и есть объект Person.prototype, который выглядит так:

{
  fullName: function fullName () {
    return this.firstName + " " + this.lastName;
  },
  rename: function rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
}


Прототипы это объекы

Если нужно изменить поведение объекта в JavaScript, можно добавить, удалить или изменить методы объекта через добавление, удаление или изменение функций, привязанных к свойствам этого объекта. В этом отличие от многих “классических” языков, в которых есть специальная форма (например, в Руби есть def) для задания методов.

Прототипы в JavaScript это “всего лишь объекты”, и благодаря этому мы можем добавлять, удалять или изменять методы прототипа через добавление, удаление или изменение функций, привязанных к свойствам этого прототипа.

Именно это и делает ECMAScript 5 код выше, и синтаксис class “рассахаривает” его в эквивалентный код.

Прототипы это “всего лишь объекты”, и это означает, что мы можем использовать любые техники, которые работают на объектах. Например, вместо привязки одной функции к прототипу, мы можем совершать массовую привязку с помощью Object.assign:

function Person (first, last) {
  this.rename(first, last);
}

Object.assign(Person.prototype, {
  fullName: function fullName () {
    return this.firstName + " " + this.lastName;
  },
  rename: function rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
})


И, конечно, мы можем использовать компактный синтаксис если захотим:

function Person (first, last) {
  this.rename(first, last);
}

Object.assign(Person.prototype, {
  fullName () {
    return this.firstName + " " + this.lastName;
  },
  rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
})


Mixins (примеси)

Так как class “рассахаривает” код в конструктор-функции и прототипы, мы можем использовать примеси вот так:

class Person {
  constructor (first, last) {
    this.rename(first, last);
  }
  fullName () {
    return this.firstName + " " + this.lastName;
  }
  rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
};

Object.assign(Person.prototype, {
  addToCollection (name) {
    this.collection().push(name);
    return this;
  },
  collection () {
    return this._collected_books || (this._collected_books = []);
  }
})


Мы только что “вмешали” методы по сбору книг в класс Person. Круто, что можно вот так просто писать код, но можно и давать названия:

const BookCollector = {
  addToCollection (name) {
    this.collection().push(name);
    return this;
  },
  collection () {
    return this._collected_books || (this._collected_books = []);
  }
};

class Person {
  constructor (first, last) {
    this.rename(first, last);
  }
  fullName () {
    return this.firstName + " " + this.lastName;
  }
  rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
};

Object.assign(Person.prototype, BookCollector);


Так можно продолжать сколько захочется:

const BookCollector = {
  addToCollection (name) {
    this.collection().push(name);
    return this;
  },
  collection () {
    return this._collected_books || (this._collected_books = []);
  }
};

const Author = {
  writeBook (name) {
    this.books().push(name);
    return this;
  },
  books () {
    return this._books_written || (this._books_written = []);
  }
};

class Person {
  constructor (first, last) {
    this.rename(first, last);
  }
  fullName () {
    return this.firstName + " " + this.lastName;
  }
  rename (first, last) {
    this.firstName = first;
    this.lastName = last;
    return this;
  }
};

Object.assign(Person.prototype, BookCollector, Author);


Зачем использовать примеси

Сборка классов с помощью базовой функциональности (Person) и миксинов (BookCollector и Author) дает некоторые преимущества. Во-первых, иногда функциональность невозможно хорошо разложить на части в красивой древовидной структуре. Авторы книг могут быть корпорациями, а не людьми. И антикварные книжные лавки собирают книги так же, как книголюбы.

Такие примеси, как BookCollector или Author могут быть вмешаны в несколько разных классов. Попытки композиции функциональности с помощью наследования не всегда удачны.

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

Существуют другие способы декомпозиции ответственности в классах (например, делегирование и композиция), но суть в том, что если вы решили использовать mixins, то это очень простой путь, потому что в JavaScript нет большого и сложного механизма ООП, который бы загонял вас в четкие рамки.

Например, в Руби использоват миксины легко, потому что с самого начала там есть специальная фича – модули. В других ОО-языках использовать миксины сложно, потому что система классов не поддерживает их, и они не очень вяжутся с мета-программированием.

Комментарии (11)


  1. turbo_exe
    16.06.2015 15:24

    хороший способ композиции, но надо соблюдать какие-нибудь name conventions чтобы одна примесь не помешала другой (не перезаписала) что-либо выполнить в рамках контекста this. js в данном случае не плюнет никаким exception'ом.


    1. StreetStrider
      17.06.2015 13:25

      Если примешивалка будет немного сложней чем Object.assign, то можно сделать предупреждения, и даже более сложные вещи, типа проверки вхождения в миксин.


  1. hell0w0rd
    16.06.2015 16:19

    А еще можно использовать для этого декораторы, которые предложили для ES7 и уже давно есть в babel.
    Вот пример:

    // create-mixin.js
    function createMixin(obj) {
      return (SomeClass) => {
        Object.assign(SomeClass.prototype, obj);
      };
    }
    
    // mixins/say-hello.js
    const SayHelloMixin = createMixin({
      sayHello() {
        console.log(this.name);
      }
    });
    
    // human.js
    @SayHelloMixin
    class Human {
      constructor(name) {
        this.name = name;
      }
    }
    
    const nkt = new Human('nkt');
    nkt.sayHello();
    


  1. gro
    16.06.2015 17:44

    Синтаксический сахар, это i++.
    С class'ами мы, наконец, можем программировать имея в голове только предметную область, а не думая параллельно, как правильно состыковать прототипы с конструкторами. А то, что под капотом всё те же прототипы, так можно и всё назвать сахаром над машинными командами.


    1. Zenitchik
      16.06.2015 21:46
      +1

      А раньше не могли?
      А то, что под капотом прототипы — это прекрасно. Это позволяет использовать оба подхода одновременно.


      1. gro
        17.06.2015 12:10
        -2

        >А раньше не могли?
        Раньше, если совсем без всяких обёрток, параллельно с предметной областью нужно было в голове держать низкоуровневую реализацию и следить за тем, чтобы все свойства типа «constructor», «prototype» были правильно установлены.

        >А то, что под капотом прототипы — это прекрасно. Это позволяет использовать оба подхода одновременно.
        В JS вообще любую вещь можно десятком разных способов сделать.
        По моему скромному мнению, это не так чтобы преимущество.
        Хотя многие, конечно, думают иначе.


        1. Zenitchik
          17.06.2015 14:52

          Установка prototype затруднений не вызывает.
          C constructor — отдельная тема. Спецификация неудобна. С точки зрения эксплуатации было бы лучше, чтобы constructor устанавливалось в каждый объект конструктором, а не наследовалось из прототипа, но встроенные объекты сделаны иначе. Увы.
          Конечно, без обёртки большой проект не напишешь, посмотрим, как приживётся нативный class.

          На счёт прототипов — я в последнее время думаю над такой обёрткой, которая позволила бы удобно использовать оба варианта, причём, чтобы с прототипом тоже можно было работать, и это предсказуемо влияло на отнаследованные от него объекты. Ну, например, у меня несколько комбо-боксов с общим списком, логично было бы положить список в общий прототип группы комбо-боксов, и менять его там, организовав реакцию наследников на его изменения. Пока чёткой архитектуры (а главное — удобного API) для этого не выдумал, но обязательно выдумаю.


          1. gro
            17.06.2015 15:31

            Своя обёртка для классов, это, конечно, полезное упражнение для ума.
            Но в последнее время это уже несколько моветон.
            Уже и ECMA новый и TypeScript и всё остальное. Лучше на что-то другое силы тратить.


            1. Zenitchik
              17.06.2015 16:16

              Моветон — если она ничем не лучше аналогов. А если даёт возможность снять классовые шоры — то это стоящая задача.

              Мне скорее представляется моветоном зоопарк языков, компилирующихся в другой интерпретируемый язык…


              1. gro
                17.06.2015 16:22

                Любой человек, который имел сколько-то плотное общение с JS обязательно делал обёртку над прототипами, которая обязательно снимала шоры и всё такое.


                1. Zenitchik
                  17.06.2015 21:57

                  Либо брал чужую и допиливал. Но использования прототипности я не видел. Везде просто реализация класса (что теперь можно и нативно сделать).