ООП в JavaScript. Часть 1

Введение

Как вы, должно быть, знаете, в JavaScript используется особенная модель объектно-ориентированного программирования. Оставим философские размышления о правильности или неправильности подобного подхода за рамками данного доклада и попытаемся понять, как с таким подходом жить. Тем более, что выбора у нас особо и нет.

Итак, обо всем по порядку.

Объекты

Объекты в JavaScript представляют собой, в первую очередь, ассоциативный массив. В нем строковым или числовым (а при большом желании и типа boolean) ключам соответствует одно любое значение. Значением массива может быть и функция, в таком случае она является методом этого объекта.

Для наглядности я буду обозначать объекты таким вот образом:

Кстати, многие знают, что у обыкновенных массивов и объектов практически никаких различий, кроме набора доступных методов и небольшого количества синтаксического сахара. Например, нет никакой разницы между obj.property и obj['property'] и следующий код:

var obj = {};
var arr = [];

генерирует идентичные объекты1. Единственной разница — в указателе на прототип. Кстати, о прототипах.

Прототипы

Прототип примерно соответствует классу в стандартной объектно-ориентированной модели. Особо обратите внимание на слово «примерно», потому что эти понятия всё-таки не тождественны: есть множество различий.

Прототип — это то место, где будет искаться поле или метод в том случае, если его нет в самом объекте. Например, когда мы пишем a.method(), то сначала наличие метода method будет проверено в объекте a, и, если там его нет, то в прототипе объекта a. Каждый объект имеет свой прототип.

«[[прототип]]» на рисунке выше — это внутреннее свойство объекта, его нельзя прочитать напрямую. Но есть стандартная функция Object.getPrototypeOf(obj)2, которая позволяет всё-таки посмотреть, что именно находится в прототипе.

Но по своей сути прототип тоже является объектом, т. е. ассоциативным массивом. А это значит, что у него тоже есть свой прототип. Таким образом строятся цепочки наследования. Например, если мы напишем arr = [], то у нас будет сформирована вот такая структура:

И даже у Object есть прототип, но он равен null, а потому никуда не ссылается.

Конструктор

Идем дальше. Как вы, должно быть, знаете из ООП, конструктор — это специальная функция, которая вызывается при создании объекта. В JavaScript тоже есть такая функция. Вернее даже так: в JavaScript любая функция может исполнять роль конструктора. Я понимаю, это сложно сразу воспринять, особенно учитывая тот факт, что функции зачастую возвращают какие-то данные с помощью конструкции return. Так вот, если вы воспользуетесь подобной функцией как конструктором, возвращаемое значение будет успешно отброшено и конструктор вернёт сконструированный объект3.

Для того чтобы вызвать какую-либо функцию как конструктор, достаточно написать перед ней слово new:

var f = new F();

Необходимо упомянуть ещё об одной детали перед тем, как мы перейдём к связи конструкторов и прототипов. Любая функция может вести себя как объект. Вы же помните, что в JavaScript всё (почти) может вести себя как объект? Т. е. ничто не мешает нам написать4:

F.someValue = 1;
F.someMethod = function () {};

У объекта функции есть несколько стандартных свойств/методов. Одно из них называется prototype. Это свойство и является будущим прототипом нашего объекта, сконструированного из этой функции.

Т. е. в момент создания объекта внутренняя ссылка этого объекта на прототип начинает ссылаться на тот же объект, что и свойство prototype у его конструктора. Важно понимать, что у объектов нет свойства prototype, это свойство есть только у функций. И по-умолчанию оно равно пустому объекту {}, а это то же самое, что и new Object().

Пример создания объекта

var F = function () {
  this.a = 1;
  this.b = 2;
}
F.prototype.a = 3;
F.prototype.c = 4;
var f = new F();
f.a = 5;
f.c = 6;

Разберём происходящее в этом примере более подробно.

К моменту создания объекта в строке 7 мы приходим с такой структурой:

Затем происходит вызов конструктора и создаётся новый пустой объект с прототипом, взятым из функции F:

Вслед за этим наш свежесозданный объект заполняется свойствами в конструкторе F.

И уже затем конструктор возвращает объект. Дальше он присваивается переменной f и дозаполняется.

Свойство __proto__

Помните, я сказал, что значение прототипа нельзя получить напрямую? Я несколько слукавил. Большая часть браузеров (кроме Internet Explorer) предоставляют подобную возможность. Для этого у каждого объекта есть свойство __proto__.

Более того, его можно не только читать, но и писать. И эта его особенность пригодится нам в упрощённой схеме наследования. Как именно, я расскажу чуть позже.

Некоторые считают свойство __proto__ устаревшим (deprecated), но это не так. Оно скорее нестандартное, но и это будет в относительно скором времени исправлено. В разрабатываемой спецификации ECMAScript 6 оно уже описано. Если вдруг кто не знает, JavaScript — это один из диалектов ECMAScript (наряду с ActionScript), а потому должен подчиняться общей спецификации.

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

Теперь, когда основные концепции разобраны, пора переходить к наследованию. Начнём с неправильных вариантов, которые зачастую выдаются за верные.

Вариант №1

Простое копирование прототипов.

var ParentClass = function () {};
ParentClass.prototype.method1 = function () {};
var ChildClass = function () {};
ChildClass.prototype = ParentClass.prototype;
ChildClass.prototype.method2 = function () {};

Вы, наверное, уже и сами видите, почему этот вариант неправильный. Но всё равно давайте разберём.

В строке 1 объявляем конструктор родителя:

Дальше добавляем в прототип ParentClass метод method1:

Строка 3. Объявляем дочерний конструктор:

Присваиваем дочернему прототипу родительский:

«Добавляем» ещё один метод к дочернему прототипу:

Как видите, получилось совсем не то, что ожидалось.

Вариант 2

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

var ParentClass = function () {}; // ParentClass contructor
ParentClass.prototype.method1 = function () {};
var ChildClass = function () {}; // ChildClass constructor
ChildClass.prototype = ParentClass.prototype;
var child = new ChildClass();
child.method2 = function () {};

Различие только в двух последних строчках. Мы создаём экземпляр класса и добавляем в уже созданный объект ещё один метод.

Но ведь в таком случае нужно каждый раз писать включение дополнительных методов в объект! Согласен, можно вынести инициализацию в конструктор, ведь именно для этого он и предназначен.

var ChildClass = function () { // ChildClass constructor
  this.method2 = function () {};
};

Большая (и не всегда очевидная) проблема с этим методом — количество используемой памяти. Чуть позже я покажу результаты тестирования, они играют не в пользу данного метода. Ещё одна проблема в том, что наследование от ChildClass неосуществимо прямыми методами, придётся самостоятельно вызывать конструктор родителя, чтобы он добавил недостающие методы.

Вариант 3

В этом варианте будем простым копированием добавлять необходимые поля и методы в прототип:

var ParentClass = function () {}; // ParentClass contructor
ParentClass.prototype.method1 = function () {};
var ChildClass = function () {}; // ChildClass constructor
for (var prop in ParentClass.prototype) {
  if (ParentClass.prototype.hasOwnProperty(prop)) {
    ChildClass.prototype[prop] = ParentClass.prototype[prop];
  }
}
ChildClass.prototype.method2 = function () {};

Тут стоит обратить внимание на условие ParentClass.prototype.hasOwnProperty(prop). Эта конструкция for..in перебирает не только то, что находится в самом объекте, но ещё и то, что находится в его прототипе (строка [[прототип]] на диаграммах выше). Поэтому получается, что будут скопированы и те методы, которые достались по наследству от класса Object. А зачем нам их копировать, если они и так будут у дочернего класса? Метод hasOwnProperty как раз и позволяет избежать подобного поведения. Это стандартный метод класса Object и он возвращает true, если свойство находится в самом объекте, а не в его прототипе.

В результате выполнения кода выше мы получим 2 класса, как и задумывали:

Однако в таком способе, помимо необходимости вручную копировать поля и методы, есть и ещё один недостаток: невозможно использовать оператор instanceof. Т. е. если в любом объектно-ориентированном языке childObject instanceof ParentClass вернёт true, то здесь это не так. Кстати, то же самое касается и предыдущего варианта.

Вариант 4. Правильный

Что же, самое время разобраться с правильным вариантом. Как вообще должна выглядеть правильная структура объекта? На мой взгляд, так:

Так как же получить подобную красоту? Начнём с того, что у нас есть 2 конструктора: ChildClass и ParentClass.

Требуется, чтобы ChildClass.prototype был равен пустому объекту, у которого в прототипе будет стоять ParentClass.prototype.

Чтобы получить такой результат, достаточно одной строки:

ChildClass.prototype = new ParentClass();

Хотя в этом случае будет выполнен и конструктор ParentClass, что не всегда желательно. Поэтому лучшим решением будет создать временный класс с таким же прототипом, как в ParentClass, но пустым конструктором. И уже экземпляр этого временного конструктора присваивать дочернему прототипу:

var tempCtor = function () {};
tempCtor.prototype = ParentClass.prototype;
ChildClass.prototype = new tempCtor();

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

Вариант 5. Используем Object.create

Идем дальше. Вы же помните, что в JavaScript есть несколько способов сделать одно и то же? Тоже и с наследованием. Всё шаманство с созданием временного класса и последующим созданием объекта можно выразить с помощью вызова всего лишь одного метода — Object.create.

Посмотрим описание этого метода. Он принимает 2 параметра: прототип объекта и описание свойств. Свойства выходят за рамки этой статьи, можете посмотреть их сами, а вот первый параметр — это именно то что нужно. Получается, что если вызвать Object.create только с одним параметром (прототипом создаваемого объекта), то результат будет аналогичным предыдущему способу.

ChildClass.prototype = Object.create(ParentClass.prototype);

К сожалению, Object.create объявлен только в спецификации ECMAScript 5, поэтому данный способ наследования работает только в относительно новых браузерах (IE9+, FF4+, O12+).

Вариант 6. Магия с prototype.__proto__

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

ChildClass.prototype.__proto__ =  ParentClass.prototype;

Приведу ещё раз для наглядности схему с тем, что делает эта инструкция:

Вот так, легко и просто. Но посмотрим на список браузеров, где подобная конструкция будет работать правильно: Chrome, Firefox, Safari >= 5, Opera >= 10.50. Ни одна из версий Internet Explorer сюда не входит.

Упомяну так же, что данный способ очень часто используется в node.js, т. к. в этом случае нет необходимости поддерживать целый зоопарк различных браузеров.

Сравнение различных вариантов

Итак, у нас получилось 3 рабочих варианта, 2 более-менее и один совсем не рабочий. Неплохо бы их сравнить.

Поддержка браузерами

 Google ChromeMozilla FirefoxOperaIE6IE7IE8IE9IE10
Добавление методов в конструкторе
Копирование прототипа через for..in
Временный конструктор
Object.create
(4+)

(12+)
__proto__

Производительность

 Память (Мб)Скорость определения (оп/с)Скорость создания (оп/c)
Добавление методов в конструкторе74.9112 152 481 (34375,7%)31 716 (0,2%)
Копирование прототипа через for..in5.617 946 (22,5%)13 880 306 (96,7%)
Временный конструктор5.0320 484 (57,9%)14 361 306 (100%)
Object.create4.9835 352 (100%)14 103 497 (98,2%)
__proto__4.9822 474 (63,6%)13 893 309 (96,7%)

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

Второй способ уступает по скорости определения, но всё ещё вполне годен для использования, если не принимать в расчёт тот факт, что это не совсем наследование. На самом деле, он активно используется, когда вопрос заходит об аналоге mixin или traits.

Остальные способы примерно равны по производительности и потреблению памяти.

Использование памяти измерялось в Google Chrome. В нем есть инструмент Profiles, который позволяет создавать снимки памяти и изучать, на что она была потрачена. Вы можете и сами провести исследование и сравнить с моим. Вот для этого вам несколько ссылок: генератор страниц для проверки потребления памяти, тестирование скорости определения классов на jsperf и тестирование скорости создания объектов на jsperf.

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

Часть 2


  1. На самом деле, при создании массива будет еще создано поле length, в котором содержится длина.

  2. Поддерживается начиная с IE9.

  3. Это не верно, если функция-конструктор возвращает объект. В этом случае результатом будет возвращённый объект, а не полученный из этого конструктора. Т. е. в этом случае нет большой разницы между вызовом функции с new и вызовом без new.

  4. Кстати, именно так реализуются статические методы и свойства.

getElementsByClassName в IE

А вы знаете, что в Internet Explorer вплоть до 8-го у document нет метода getElementsByClassName? А следовательно, все ваши верстки, основанные только на классах, будут сильно проигрывать в производительности. Все фреймворки эмулируют метод вызовом getElementsByTagName('*') с дальнейшей ручной фильтрацией.

var els = document.getElementsByTagName("*"), filtered = [], len = els.length, i;
for (i = 0; i < len; ++i) {
    el = els[i];
    el.className.search(/^(.* |)some-class( .*|)$/) !== -1 && filtered.push(el);
}

Ну это код на случай, если вам придется использовать vanilla.js. Обратите внимание, что в цикле нельзя изменять DOM-дерево. Нужно сначала скопировать все подходящие элементы в отдельный массив и только потом их обрабатывать. Все потому, что getElementsByTagName (как и все остальные похожие функции) возвращает NodeList, который динамически изменяется вместе с деревом.

May the Force be with you.

Загрузка файлов на Rackspace

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

Oсторожно, сейчас будет простыня.

Во-первых, нам нужна форма загрузки:

<form class="well form-inline upload-form" action="/vault/fileupload" enctype="multipart/form-data" method="post">
  <strong>Загрузить: </strong>
  <input type="hidden" name="container" id="file-container" />
  <input type="file" name="file" id="file-upload"
    style="position: absolute; top: -100px; left: -100px;" />
  <button class="btn" type="button" id="file-button"> Выбрать </button>
  <input type="text" name="name" id="file-name" placeholder="Под именем" />
  <button class="btn" type="submit"> Отправить </button>
</form>

Мне захотелось кастомную кнопку выбора файлов, для этого оригинальную кнопку мы прячем, а клик по ней эмулируем:

$('#file-button').click(function () {
    $('#file-upload').click();
    return false;
});

Стоит обратить внимание на способ, которым кнопка спрятана. Если просто указать display: none, то опера перестанет воспринимать поддельные клики и окошко с выбором файла перестанет появляться. А если указать visibility: hidden, то старое поле будет занимать положенное ему место и получится дырка на форме.

Итак, форму нарисовали, можно писать обработчик:

vaultFileUpload :: AppHandler ()
vaultFileUpload = do
  tmpDir <- liftIO getTemporaryDirectory
  response <- handleFileUploads tmpDir uploadPolicy partUploadPolicy processForm
  writeBS $ pack $ encode response

Тут всё просто: получаем имя временной папки, обрабатываем форму и выводим результат, поэтому посмотрим внимательнее на функцию обработки:

response <- handleFileUploads tmpDir uploadPolicy partUploadPolicy processForm

Функция handleFileUploads принимает на вход 4 параметра: временная папка, глобальная политика загрузки, функция, возвращающая по описанию части локальную политику загрузки, и, наконец, функция для обработки полученных данных. Т.е. мы загружаем все или только некоторые файлы во временную папку и вызываем обработчик:

processForm ((_, Left uploadError) : []) = 
  return $ ServiceError $ show uploadError

processForm ((_, Right path) : []) = do
  container <- getPostParam "container"
  name <- getPostParam "name"
  if container == Nothing || name == Nothing
    then return $ ServiceError "Container or name not defined"
    else liftIO $ curlDo $ UploadFile path (unpack $ fromMaybe "" container) (unpack $ fromMaybe "" name)

processForm [] = return $ ServiceError "No files were transmitted"
processForm _ = return $ ServiceError "Too many files"

Интересен только второй случай, когда успешно загружен только один файл. Мы получаем два POST-параметра (имя контейнера Rackspace CloudFiles и имя, под которым файл должен быть загружен) и вызываем нашу функцию curlDo с описанием нужной операции. UploadFile — это конструктор моего типа ServiceAction, где просто перечислены все необходимые операции:

data ServiceAction
  = GetContainers
  | GetContainerItems String
  | UploadFile FilePath String String

Переходим к curlDo.

curlDo :: ServiceAction -> IO ServiceResponse
curlDo action = withCurlDo $ do
    h <- initialize
    response <- curl h "https://auth.api.rackspacecloud.com/v1.0"
      [CurlHttpHeaders 
        [ "X-Auth-Key: "  ++ rackspaceAuthKey
        , "X-Auth-User: " ++ rackspaceAuthUser
        ]
      ]
    
    let headers = M.map (dropWhile (== ' ')) $ M.fromList $ respHeaders response
    case respStatus response of
      204 -> processAction action 
        (headers M.! "X-Storage-Url") (headers M.! "X-CDN-Management-Url") 
        (headers M.! "X-Auth-Token")
      _ -> return $ ServiceError "Can't authenticate"

Мы отправляем аутентификационные данные на Cloud-сервер и проверяем их правильность. В случае удачи вызывается processAction с действием, которое нужно выполнить. Ну и кроме того передаются некоторые заголовки из ответа сервера — там указано, к каким серверам дальше обращаться.

В функции processAction несколько паттернов, по одному на каждую выполняемую операцию из типа ServiceAction. Вот тот, что используется для операции загрузки файла:

processAction (UploadFile filePath container name) url _ =
  uploadFile filePath container name url

Как видите, ничего особенного, просто вызов. Переходим к самому интересному — uploadFile. Каркас этой функции выглядит так:

uploadFile filePath container name url token = do
  h <- initialize
  response <- withBinaryFile filePath ReadMode processFile
  case respStatus response of
    201 -> return ServiceSuccess
    _ -> return $ ServiceDebug $ show $ respStatus response

Сначала мы инициализируем handle от curl c помощью initialize, затем оборачиваем в withBinaryFile работу с файлом (отправку) и отдаем результат в зависимости от кода, возвращенного сервером. Отправка файла реализуется через processFile:

processFile fh = do
  fileSize <- hFileSize fh
  curl h (url ++ "/" ++ container ++ "/" ++ name)
    [ CurlPut True
    , CurlHttpHeaders ["X-Auth-Token: " ++ token]
    , CurlReadFunction readFunction
    , CurlInFileSize $ fromInteger fileSize
    ]

Тут задаются опции для вызова curl. Самое интересное и сложное — это функция readFunction, которая отвечает за чтение файла и передачу данных в curl. Т.к. функция будет вызываться из libcurl, написана она весьма специфически с использованием библиотеки Foreign:

readFunction :: Ptr CChar -> CInt -> CInt -> Ptr () -> IO (Maybe CInt)
readFunction ptr size nmemb _ = do
  actualSize <- hGetBuf fh ptr $ fromInteger $ toInteger (size * nmemb)
  return $ if (actualSize > 0) then Just $ fromInteger $ toInteger actualSize else Nothing

hGetBuf читает из файла fh в область по указателю pts (size * nmemb) байт и возвращает количество действительно прочитанных байт. Ну и сама функция должна вернуть Maybe CInt, причем Nothing возвращается в случае, если ничего не прочитано и читать дальше не надо.

Если собрать весь код функции в одно место, получится как-то так:

uploadFile filePath container name url token = do
  h <- initialize
  let 
    processFile fh = do
      let 
        readFunction :: Ptr CChar -> CInt -> CInt -> Ptr () -> IO (Maybe CInt)
        readFunction ptr size nmemb _ = do
          actualSize <- hGetBuf fh ptr $ fromInteger $ toInteger (size * nmemb)
          return $ if (actualSize > 0) then Just $ fromInteger $ toInteger actualSize else Nothing

      fileSize <- hFileSize fh
      curl h (url ++ "/" ++ container ++ "/" ++ name)
        [ CurlPut True
        , CurlHttpHeaders ["X-Auth-Token: " ++ token]
        , CurlReadFunction readFunction
        , CurlInFileSize $ fromInteger fileSize
        ]

  response <- withBinaryFile filePath ReadMode processFile
  case respStatus response of
    201 -> return ServiceSuccess
    _ -> return $ ServiceDebug $ show $ respStatus response

Вот такими нехитрыми действиями можно пересылать файлы с клиента через промежуточный сервер на сервера Rackspace CloudFiles. Все исходники лежат на GitHub, где и подлежат и вдумчивому изучению (trollface).

HTML5 Audio

На рынке браузеров сложилась довольно интересная ситуация с поддержкой тега <audio>. Оставим в стороне IE6–8, в которых вообще нет поддержки нативного воспроизведения аудио, и посмотрим на весь остальной зоопарк. Самый распространенный формат mp3 поддерживают Google Chrome, Safari, IE9-10. Firefox и Opera решили отказаться от него, видимо, из-за лицензионных ограничений. Зато эти браузеры поддерживают ogg, а IE и Safari — нет. Т.е. если хочешь поддерживать все браузеры, то будь добр сконвертировать свое аудио в mp3 и ogg. А еще не забудь прикрутить flash-плеер для совсем старых браузеров, ну или хотя бы ссылочку приложи на файлы, чтобы пользователи могли тебя услышать.

Кстати, точно так же обстоит дело с поддержкой еще двух форматов: WebM и AAC. WebM поддерживают Chrome, Firefox и Opera, а AAC — Chrome, IE и Safari. Получаем две воинствующие группировки IE-Safari и Firefox–Opera, которые работают с непересекающимися множествами форматов. Ну и Chrome, которому всё равно и он играет все, что ему подсунешь.

Табличка для тех, кто совсем ничего не понял:

 MP3OGGAACWebM
Chrome
Firefox
Opera
Safari
Internet Explorer

HTML5 audio вы можете видеть в предыдущем посте.