Этот перевод — для новичков, делающих первые шаги в JavaScript, или даже в программировании вообще.


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

О чём мы поговорим в этой статье:

  • Пространство имён.
  • Объекты.
  • Объектные литералы.
  • Функции-конструкторы.
  • Наследование.

Пространство имён


В сети появляется всё больше сторонних библиотек, фреймворков и зависимостей, поэтому определение пространства имён является необходимостью в JavaScript-разработке, если мы хотим избежать коллизий между объектами и переменными в глобальном пространстве имён.

К сожалению, JS не имеет встроенной поддержки определения пространства имён, но мы можем использовать объекты для достижения того же результата. Есть много разных паттернов для реализации, но мы рассмотрим только самый распространённый — вложенные пространства имён.

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

var MyApp = MyApp || {};

По той же методике можно создавать подпространства имён:

MyApp.users = MyApp.user || {};

Сделав контейнер, мы можем использовать его для определения методов и свойств, а затем применять их в нашем глобальном пространстве имён без риска возникновения коллизий.

MyApp.users = {
    // свойства
    existingUsers: [...],
    // методы
    renderUsersHTML: function() {
      ...
    }
};

Подробнее о паттернах определения пространств имён в JavaScript можно почитать здесь: Essential JavaScript Namespacing Patterns.

Объекты


Если вы уже писали код на JavaScript, то в той или иной мере использовали объекты. JavaScript имеет три различных типа объектов:

Нативные объекты (Native Objects)
Нативные объекты — часть спецификации языка. Они доступны нам вне зависимости от того, на каком клиенте исполняется наш код. Примеры: Array, Date и Math. Полный список нативных объектов.

var users = Array(); // Array — нативный объект

Хост-объекты (Host Objects)
В отличие от нативных, хост-объекты становятся нам доступны благодаря клиентам, на которых исполняется наш код. На разных клиентах мы в большинстве случаев можем взаимодействовать с разными хост-объектами. Например, если пишем код для браузера, то он предоставляет нам window, document, location и history.

document.body.innerHTML = 'Hello'; // document — это хост-объект

Пользовательские объекты (User Objects)
Пользовательские объекты, иногда называемые предоставленными (contributed objects), — наши собственные объекты, определяемые в ходе run time. Есть два способа объявления своих объектов в JS, и мы рассмотрим их далее.

Объектные литералы (Object Literals)
Мы уже коснулись объектных литералов в главе про определение пространства имён. Теперь поясним: объектный литерал — это разделённый запятыми список пар имя-значение, помещённый в фигурные скобки. Эти литералы могут содержать свойства и методы, и как и любые другие объекты в JS могут передаваться функциям и возвращаться ими. Пример объектного литерала:

var dog = {
  // свойства
  breed: ‘Bulldog’,
  // методы
  bark: function() {
    console.log(“Woof!”);
  },
};
// обращение к свойствам и методам
dog.bark();

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

Объектные литералы полезны, но не могут быть инстанцированы и от них нельзя наследовать. Если вам нужны эти возможности, то придётся обратиться к другому методу создания объектов в JS.

Функции-конструкторы


В JavaScript функции считаются объектами первого класса, то есть они поддерживают те же операции, что доступны для других сущностей. В реалиях языка это означает, что функции могут быть сконструированы в ходе run time, переданы в качестве аргументов, возвращены из других функций и присвоены переменным. Более того, они могут иметь собственные свойства и методы. Это позволяет использовать функции как объекты, которые могут быть инстанцированы и от которых можно наследовать.

Пример использования определения объекта с помощью функции-конструктора:

function User( name, email ) {
  // свойства
  this.name = name;
  this.email = email;
  // методы
  this.sayHey = function() {
   console.log( “Hey, I’m “ + this.name );
  };
}
// инстанцирование объекта
var steve = new User( “Steve”, “steve@hotmail.com” );
// обращение к методам и свойствам
steve.sayHey();

Создание функции-конструктора аналогично созданию регулярного выражения за одним исключением: мы используем ключевое слово this для объявления свойств и методов.
Инстанцирование функций-конструкторов с помощью ключевого слова new аналогично инстанцированию объекта в традиционном языке программирования, основанном на классах. Однако здесь есть одна неочевидная, на первый взгляд, проблема.

При создании в JS новых объектов с помощью ключевого слова new мы раз за разом выполняем функциональный блок (function block), что заставляет наш скрипт КАЖДЫЙ РАЗ объявлять анонимные функции для каждого метода. В результате программа потребляет больше памяти, чем следует, что может серьёзно повлиять на производительность, в зависимости от масштабов программы.

К счастью, есть способ прикрепления методов к функциям-конструкторам без загрязнения глобальной области видимости.

Методы и прототипы
JavaScript — прототипный (prototypal) язык, то есть мы можем использовать прототпы в качестве шаблонов объектов. Это поможет нам избежать ловушки с анонимными функциями по мере масштабирования наших приложений. Prototype — специальное свойство в JavaScript, позволяющее добавлять к объектам новые методы.

Вот вариант нашего примера, переписанный с использованием прототипов:


function User( name, email ) {
  // свойства
  this.name = name;
  this.email = email;
}
// методы
User.prototype.sayHey = function() {
  console.log( “Hey, I’m “ + this.name );
}
// инстанцирование объекта
var steve = new User( “Steve”, “steve@hotmail.com” );
// обращение к методам и свойствам
steve.sayHey();


В этом примере sayHey() будет совместно использоваться всеми экземплярами объекта User.

Наследование


Также прототипы используются для наследования в рамках цепочки прототипов. В JS каждый объект имеет прототип, а раз прототип — всего лишь ещё один объект, то и у него тоже есть прототип, и так далее… пока не дойдём до прототипа со значением null — это последнее звено цепочки.

Когда мы обращаемся к методу или свойству, JS проверяет, задан ли он в определении объекта, и если нет, то проверяет прототип и ищет определение там. Если и в прототипе не находит, то идёт по цепочке прототипов, пока не найдёт или пока не достигнет конца цепочки.

Вот как это работает:

// пользовательский объект
function User( name, email, role ) {
  this.name = name;
  this.email = email;
  this.role = role;
}
User.prototype.sayHey = function() {
  console.log( “Hey, I’m an “ + role);
}
// объект editor наследует от user
function Editor( name, email ) {
   // функция Call вызывает Constructor или User и наделяет Editor теми же свойствами
   User.call(this, name, email, "admin"); 
}
// Для настройки цепочки прототипов мы с помощью прототипа User создадим новый объект и присвоим его прототипу Editor
Editor.prototype = Object.create( User.prototype );
// Теперь из объекта Editor можно обращаться ко всем свойствам и методам User
var david = new Editor( "David", "matthew@medium.com" );
david.sayHey();

У вас может уйти какое-то время на привыкание к прототипному наследованию, но важно освоить эту концепцию, если вы хотите достигнуть высот в ванильном JavaScript. Хотя её часто называют одним из слабых мест языка, прототипная модель наследования фактически мощнее классической модели. Например, не составит труда построить классическую модель поверх прототипной.

В ECMAScript 6 появился новый набор ключевых слов, реализующих классы. Хотя эти конструкты выглядят так же, как в основанных на классах языках, это не одно и то же. JavaScript по прежнему основан на прототипах.

* * *

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

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


  1. AxisPod
    21.10.2017 15:57

    Нда, слегка косяков немного.

    User.prototype.sayHey = function() {
      console.log( “Hey, I’m an “ + role);
    }

    Тут потерялся this. Но это самая безобидная ошибка.

    Editor.prototype = Object.create( User.prototype );

    После данной строки необходимо восстановить конструктор в прототипе класса Editor, ибо конструктор прилетит от User.


    1. mayorovp
      22.10.2017 18:59

      Конструктор можно не восстанавливать если нет необходимости обращаться к этому свойству в дальнейшем.


      1. AxisPod
        22.10.2017 19:18

        Чтобы потом другие разработчики крыли матом автора сего кода? Вы можете дать гарантии, что не понадобится в будущем? А потом возможна очень даже весёлая отладка. А уж в статье, которая рассказывает о наследовании и в которой допущена данная ошибка/упущение, это как-то не очень правильно.


        1. mayorovp
          22.10.2017 19:24

          Скорее ошибка была допущена когда свойство constructor придумывали.


  1. aamonster
    21.10.2017 16:21

    Насчёт классов в ECMAScript 6 — разве они не введены отчасти для того, чтобы "законно" иметь настоящие классы, без оверхеда от эмуляции через прототипы? Тут недавно проходила статья про оптимизации в V8, так там описывалось, как prototype-based объекты фактически преобразуются в class-based для ускорения, а тут можно сразу быстро сделать.


    1. Large
      21.10.2017 21:18

      нет, это просто сахар для тех же прототипов.


      1. aamonster
        21.10.2017 23:29

        Абсолютно уверены? Перечитайте https://habrahabr.ru/post/154537/ и скажите, не сделает ли разумный разработчик javascript-движка классы классами, скатываясь в прототипы только при невозможности остаться в рамках класса?


        1. justboris
          22.10.2017 12:48

          Конечно же движок будет делать классы классами. Однако ключевое слово class здесь никак не помогает. Потому что с JS-классы можно все так же динамически изменять:


          class Point {};
          Point.prototype.newMethod = function() {};
          
          const p = new Point();
          point.newMethod(); //будет вызвано


          1. aamonster
            22.10.2017 16:46
            +1

            В Objective C так тоже можно, хотя язык не на прототипах.
            Наверное, в качестве примера вам следовало задать функцию не прототипу, а экземпляру объекта, или переопределить метод где-то в середине цепочки наследования — при этом как раз всплывает разница между наследованием от прототипа и от класса.
            Но я понял вашу мысль: что бы там ни было внутри — prototype-based поведение javascript будет эмулироваться до упора.


            Моя мысль была — что если написали "class" — можно считать, что это настоящий класс (и получать выигрыш по быстродействию), пока программист не докажет обратное (а во всех руководствах будет написано "не делать так никогда, performance penalty"). Довольно очевидная оптимизация.


            1. justboris
              22.10.2017 19:44

              Нет, упоминание слова class в исходном коде никак не помогает оптимизатору. До настоящего времени V8 (и другие движки, наверное тоже), научились довольно уверенно детектировать классы которые с prototype


              function Point() {}
              Point.prototype.method = function() {}

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


    1. Apathetic
      21.10.2017 22:28

      В class-based объектах совсем не те классы используются.


    1. Keyten
      22.10.2017 01:54

      prototype-based объекты фактически преобразуются в class-based
      Не наоборот ли? Везде и всюду написано, что классы — сахар для прототипов.


      1. aamonster
        22.10.2017 08:02

        Статья, где описано преобразование — https://habrahabr.ru/post/154537/
        Т.е. на итог получим "классы — сахар для прототипов, которые потом будут преобразованы в классы".
        Вот я и думаю — разве создатели js-движка преобразования туда-обратно не захотят убрать? И не думали ли они об этом, ещё когда предложение только вносилось?


        1. Apathetic
          22.10.2017 12:59

          Еще раз: классы в JS — это совсем не те скрытые классы, которые используются под капотом V8.


          1. aamonster
            22.10.2017 17:10

            Ok, не те же (в принципе, понятно: скрытые классы делаются и без слова class; можно реализовать классы без скрытых классов, чисто как синтаксический сахар).
            Вопрос: при "правильном" использовании класса (все поля инициализируются в конструкторе) объекты одного класса попадут в один скрытый класс?
            Если да, то вопрос 2: использует ли это движок для оптимизации?
            Ну и чуть сторонний вопрос 3: какой-нибудь linter может отслеживать, чтобы не попасть на деоптимизацию, или это надо переходить на TypeScript?


            1. mayorovp
              22.10.2017 19:02

              Вопрос: при "правильном" использовании класса (все поля инициализируются в конструкторе) объекты одного класса попадут в один скрытый класс?

              Да. Точно так же как в один скрытый класс попадут все объекты, созданные через одну функцию-конструктор, т.е. ключевое слово class тут ничего не добавляет и не убавляет.


              Если да, то вопрос 2: использует ли это движок для оптимизации?

              Разумеется, использует. Именно благодаря этому свойству скрытые классы вообще имеют смысл.


              Ну и чуть сторонний вопрос 3: какой-нибудь linter может отслеживать, чтобы не попасть на деоптимизацию, или это надо переходить на TypeScript?

              Ни то ни другое не гарантирует непопадание на деоптимизацию.


  1. lxsmkv
    21.10.2017 16:39

    Айтишный русский язык может и состоит чуть больше чем наполовину из англицизмов, но от «дефиниции», честно, сводит скулы. (Вот как теперь стереть это словообразование из головы..)

    без риска возникновения коллизий с существующими дефинициями.
    или «определениями», или «обьявлениями» или в худшем случае «декларациями». А лучше, вообще опустить это дополнение. Просто, "… без риска возникновения коллизий". Точка.
    Русский читатель достаточно сообразителен, и подкован, чтобы понять о чем идет речь.


  1. radist2s
    21.10.2017 16:44

    Мне одному кажется, что подобные статьи в 2017 году на Хабре — это как немного запоздало?


    1. lxsmkv
      21.10.2017 16:58

      Краткий актуальный справочный обзор, как мне кажется, вполне оправданный формат.
      Сейчас ведь, если в интернете начинаешь искать, не всегда ясно, актуальная ли это информация. Пробуешь — бац — а оно не работает, потому, что когда писали статью, актуальным был, ну, например, ангуляр 1.4 и все его называли ангуляр. Никто и не задумался в статье приписать какой версии касаются эти инструкции. Такая же история с гайдами по персонажам из игр, их переделывают с такой скоростью, что как правило гайд через три-шесть месяцев теряет свою актуальность. Получается интернет завален кучей устаревшей информации.


      1. radist2s
        21.10.2017 17:10

        Ну так в том и дело, мануал даже 10 летней давности в сравнении с этой статьей актуальности не теряет. Прототипы всегда работали одинаково. Единственное серьезное отличие, которое уже тоже всему разобрано это то, что можно лет как 5 делать


        Editor.prototype = Object.create( User.prototype );

        вместо


        Editor.prototype = new User

        Опять же, если говорить про актуальность статьи, приводится выражение


        var MyApp = MyApp || {};

        но никак не объясняется, почему это вообще работает. Либо пояснить про всплытие забыли, либо посчитали, что это и так всем известно, но тогда зачем вообще приводить этот пример.


        1. lxsmkv
          21.10.2017 18:21

          согласен, если речь про «ванильный» js, то лучше обходиться стандартным синтаксисом языка, либо давать комментарии.


        1. romy4
          21.10.2017 22:35

          Или чему будет равно

          MyApp && {}
          (если MyApp это объект, а не undefined/null)


        1. mayorovp
          22.10.2017 19:04

          На самом деле, Editor.prototype = new User всегда было плохой идеей. Общепринятый подход после длительных танцев на граблях был такой:


          function temp() {}
          temp.prorotype = User.prototype;
          Editor.prototype = new temp();


          1. radist2s
            22.10.2017 19:32

            Справедливо, иначе конструктор User будет запускаться, и это никому не нужно. Но для совсем корректного наследования нужно еще после всего сделать так:


            Editor.prototype.constructor = Editor

            Кто его знает, кому из наследующих объектов понадобится прототип конструктора родителя.


      1. Apathetic
        21.10.2017 22:07

        Что в этой статье, позвольте поинтересоваться, актуального?


    1. Keyten
      22.10.2017 02:00

      На Хабре последнее время засилье таких статей. Компании (в данном случае Райфу, но постоянно вижу подобное и от других компаний), чтобы попиариться, нужно написать статью.

      Но ведь новый и интересный контент — это сложно. Проще ещё раз написать то, о чём за 15 лет уже написано вдоль, поперёк и по диагонали.
      А если посмотреть ещё и на количество ошибок, страшно предположить, кто писал.


  1. Apathetic
    21.10.2017 22:12
    +1

    Допустим, с "дефинициями" вместо "определений" и "прототипичным наследованием" вместо "прототипного" еще можно смириться.
    Но елки-палки, неймспейсы? Наследование без классов? В 2к17?! Особенно мне нравится пассаж "С появлением ES2015 ситуация начала медленно меняться". Медленно. Медленно?! Да 16-17 годы прошли под знаком статей про "Javascript fatuque" — про усталость, вызванную неимоверно возросшей скоростью развития JS, а вы пишите "медленно"?
    Как же у меня бомбит от этой статьи, как же у меня бомбит.
    Я понимаю, что это перевод, и претензии немного не по адресу, но надо ж как-то повнимательней относиться к материалу, который публикуете.


  1. belousovsw
    22.10.2017 11:11

    Спасибо, благодаря Вашему посту до меня наконец-то дошло что же всё-таки такое прототипы и как с ними работать. Очень легко и понятно объяснено.
    Спасибо!


  1. dopusteam
    22.10.2017 11:11

    Статья фактически о наследовании в jS, а не об ООП. Будет продолжение?


  1. RuslanTimuziyev
    22.10.2017 11:11

    «А зачем?» (с)


  1. KhodeN
    22.10.2017 14:31

    Очень неактуальный набор хаков.
    А для реального понимания, как работают прототипы, лучше читать что-то типа такого: Д.Сошников — JS. Ядро