Создание и обслуживание больших приложений JavaScript

Алекс Секстон | 4 июня 2010 г.

На сегодняшний день JavaScript, вероятно, печально славится любительскими, или дилетантскими начинаниями. Часто программисты, пишущие на"более авторитетных" языках, называют JavaScript недоязыком или игрушкой. Эти люди понятия не имеют, о чем говорят. Честное слово.

То, что JavaScript и многообразнее, и быстрее некоторых уважаемых языков (ага, что-то здесь не так…), не означает, что приложения JavaScript не могут иметь устойчивую структуру. Фактически нужно просто выбрать наиболее удобный способ создания такой структуры. Знаю, при принятии решения можно сделать ошибку, но давно ли вы стали пессимистом?

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

Для начала предположим, что вам известно следующее:

  • JavaScript отличается от Java
  • В Javascript есть объекты-литералы.
  • Мой друг Пол не любит (прямо-таки ненавидит) холодные закуски.

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

У JavaScript есть встроенная схема наследования, очень близкая к прототипной, хотя по синтаксису чуть более похожая на классическую. Дуглас Крокфорд окрестил эту странную франкенштейновскую схему "псевдоклассической".

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

В JavaScript все начинается с ‘объекта. Если хотите, смотрите на него как на "источник" из фильма Матрица, но в любом случае он будет родительским элементом всего созданного. У объектов есть два варианта сохранения заданных свойств (которые могут быть функциями, литералами, другими объектами и т. д.): в виде собственного свойства или в прототипе.

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

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

// Вызов функции Constructor
var Father = function(firstName, age) {
  this.firstName  = firstName;
  this.age        = age;
};
 
// Фамилию нужно передать дальше, так что включите ее в прототип
Father.prototype.lastName = "Jones";
 
// Создание родительского объекта (отца) с помощью конструктора
var myFather = new Father("Daddio", 45);
 
// Создание дочернего объекта (сына) путем запуска Object.create в родительском объекте
var son = Object.create(myFather);
 
// Изменение данных сына, отличающихся от данных отца
son.firstName = "Kiddo";
son.age = 8;
 
console.log(myFather.firstName+" "+myFather.lastName); // Daddio Jones
console.log(son.firstName + " " + son.lastName); // Kiddo Jones
 
// Попытка изменить фамилию в прототипе; сын наследует изменение
Father.prototype.lastName = "Smith";
 
console.log(myFather.firstName+" "+myFather.lastName); // Daddio Smith
console.log(son.firstName + " " + son.lastName); // Kiddo Smith

Чтобы заставить наследование работать, можно использовать разные стили. У каждого из них есть свои преимущества; наивно было бы думать, что один заметно лучше другого. Некоторые более популярные реализации классической схемы наследования в JavaScript, например, MooTools Class, Base2, LowPro и Simple-Inheritance, лучше подходят для тех, кто привык к классической архитектуре или просто хочет добавить в схему наследования еще несколько возможностей.

Я бы лично посоветовал выбрать прототипное наследование. Официально оно было добавлено к языку с выходом ECMAScript 5, но для обеспечения обратной совместимости можно использовать следующий переход.

if (typeof Object.create !== 'function') {     
  Object.create = function (o) 
    function F() {}         
    F.prototype = o;         
    return new F();     
  }; 
}

Прелесть прототипной схемы наследования в ее простоте.

  1. Объект создается вручную.
    var someObject = {  “type”: “Cool Guy Object”,  “name” : “Cool Guy 1”};
  2. В определенный момент клонируйте объекты с помощью Object.create.
    var otherObject = Object.create(someObject);
  3. Добавьте отличия.
    otherObject.name = “Cool Guy 2”;

В сочетание с системой ‘множественного наследования, например, слиянием Dojo ‘ или расширением jQuery ‘, можно в большой степени сделать код чистым и не повторяющимся. Можно взять два объекта, смешать их интерфейсы и получить один супермегакрутой интерфейс.

var superObject = jQuery.extend({}, objectOne, objectTwo);

Смысл не в том, чтобы получить ясную структуру объекта и наследование по семейной линии. Более важно получить доступ к нужным методам, заключенным в объект. Как это сделать — зависит от приложения. Ничто не поддается структурированию с такой очевидностью, как неизменно популярный и полностью реалистичный класс Person, который вы изучали в школе.

Что еще более важно, система наследования в JavaScript в целом великолепна, но в некоторых случаях чересчур. В любом случае, я бы предложил сгруппировать связанные методы в объекты-литералы и распространить их. Позднее вы еще обрадуетесь этому. Результат покажется друзьям и родным куда более впечатляющим.

// При следующей операции не будет
// даже у тех, кто боится ‘этого ключевого слова
 
var utils = {
  accessDom: function() {
    ...
  },
  doSomething: function() {
    ...
  }
};
 
// Далее...
 
utils.accessDom(‘input’);

DOM

Если бы передо мной поставили задачу создать самый неуправляемый код, безусловно, я бы тесно связал его со структурой DOM. Это особенно верно для приложений jQuery, поскольку в них к DOM очень легко получить доступ для различных манипуляций.

Вот простое практическое правило взаимодействия с DOM:

“"Ждите, чтовсе может измениться”.

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

Объекты конфигурации

Это довольно простой пример. Вместо жесткого программирования классов и идентификаторов для взаимодействия используйте объект конфигурации. Когда потребуется сделать выбор, сошлитесь на него. Не забудьте предусмотреть способ обхода объекта. Это не относится и не должно относиться только к ‘селекторам, но для начала благодаря простоте выбора они вполне подойдут.

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

jQuery.fn.pluginName = function(options) {
  var defaultOptions = {
    mainContainer : "#main-container",
    buttonClass : ".pluginButton",
    width : "96px"
  };
 
  // Смешение наших параметров с параметрами по умолчанию. Наши имеют преимущество
  var options = jQuery.extend({}, defaultOptions, options);
};

Обход DOM

Глупо было бы советовать ‘никогда не пользоваться обходом DOM. Тем не менее я бы предложил хорошо подумать, прежде чем полагаться на структуру DOM, созданную кем-то другим. Если структура DOM не поддается контроль, ее как пить дать изменят непосредственно перед выпуском приложения, и весь ваш замечательный код рассыплется.

Альтернативные варианты чуть менее впечатляющие, но если вы готовы пожертвовать несколькими миллисекундами ради своего спокойствия, рекомендую использовать, например, функции jQuery ‘closest()’ и ‘children()’, или лучше дать элементам более конкретные имена и выбирать их напрямую.

В случае с функциями ‘closest()’ и ‘children(),’ никто не сможет добавить в DOM дополнительные элементы, обычно разрушающие решение element.parentNode(). Они обходят DOM, пока не найдут соответствующий селектор.

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

К примеру, в случае с меню просмотра видео с кнопками воспроизведения, приостановки и перемотки лучше не обходить контейнер “controls”, прикрепляя события к первым трем дочерним элементам, а сохранить ссылки на каждую кнопку при ставке в DOM. Позднее можно будет прикрепить события к известным элементам.

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

Объекты и элементы Dom

Это несколько более специальный вопрос, но он дает исключительно полезную информацию о том, как разделить структуру DOM и ваше приложение. Часто при использовании правильных схем объектов JavaScript нужно бывает непосредственно связать интерфейс объекта с определенным элементом DOM или коллекцией элементов. Вероятно, чаще всего это происходит при использовании подключаемых модулей jQuery. Даже не будучи активным пользователем jQuery, можно встретиться с концепцией: "выбор набора элементов и выполнение с ними действия".

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

Например, если в коде есть объект "уведомление" с методами "отображение", "удаление" и "мигание", возможно, стоит связать его с большим красным или желтым полем, которое опускается на иллюминатор. При нажатии кнопки ‘x’ в правом верхнем углу нужно будет выполнить функцию закрытия объекта ‘уведомления. Таким образом, начав с элемента DOM, нам нужно будет вызвать метод для связанного экземпляра объекта.

Напротив, можно программно скрыть элемент DOM "поле уведомления" из функции закрытия ‘. Вы находитесь внутри объекта уведомления, а не элемента DOM, так что метод jQuery hide(), например, не всегда можно использовать без подготовки. Вот где возникает связь между DOM и объектом.

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

var alert = {
  init: function(options, element) {
    this.options = options;
    this.element = element; // ooh that was easy
  },
  close: function() {
    // Наш единичный элемент в нашем объекте доступен в любое время
    this.element.style.display = "none";
  },
  open: function() {
    this.element.style.display = "block";
  }
};
 
 
 
var bridge = {
  storage: {},
  connect: function(element, obj) {
    this.storage[element.id] = obj;
  },
  getObject: function(element) {
    return this.storage[element.id];
  }
};
 
// Настроим его
var myElement = document.getElementById(‘alertbox’);
alert.init({color: "brown"},);
// Подключаем элемент к объекту
bridge.connect(myElement, alert);
 
// Используем его
 
// Это обработчик щелчков мышью для кнопки закрытия
function XClickHandler(theAlertDOMElement) {
  // Объектов уведомления может быть несколько
  // Найдем все связанные объекты с помощью моста
  var myAlert = bridge.getObject(theAlertDOMElement);
 
  // Теперь объект уведомлений можно использовать
  myAlert.close();
}

При изучении этого примера важно отметить, что все манипуляции с элементом DOM выполняются в API связанного объекта. Это чудо. Когда позднее придет ваш начальник и попросит заменить кнопку закрытия на соскальзывающую вверх картинку, нужно будет только изменить функцию `close`, присвоив ей метод `slideUp` из библиотеки и это сработает, а вы будете выглядеть волшебником по определению и везде. Конечно, можно найти и заменить ‘ в файле источника, но при большом количестве файлов это намного сложнее и вполовину не так круто.

Управление зависимостями

Это сложная тема, заслуживающая на самом деле отдельного учебника. Тем не менее, нам удалось уместить все сведения о наследовании в несколько параграфов, так что давайте рассмотрим и ее. Управление зависимостями необходимо при создании любого приложения из нескольких частей. Вместо того, чтобы подробно описывать различные факты об управлении зависимостей, думаю, лучше будет уделить внимание двум, на мой взгляд, оптимальным подходам. Я не призываю отказаться от прочих доступных библиотек и подключаемых модулей для управления зависимостями, а просто предлагаю эффективное и популярное решение.

LABjs

При создании LABjs управление зависимостями не учитывалось. Это скорее средство обеспечения производительности, чем что-либо другое, но по сути с его помощью можно заранее найти все зависимости и повысить эффективность. Таким образом, это не средство управления производительностью, а скорее библиотека для улучшения управления с дополнительным ускорением!

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

Код довольно легко читается:

$LAB
  .loadscript1.js’)
  .loadscript2.js’) // скрипты 1 и 2 ни от чего не зависят
  .wait()
  .loadscript3.js’) // скрипт 3 ожидает выполнения скрипта 1 и 2
  .wait(function(){
    // вызов функций со скриптами 1, 2 или 3
  });

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

RequireJS

RequireJS — это средство, написанное на JavaScript в качестве реализации спецификаций управления зависимостями CommonJS. Иными словами, это довольно приятная штука. У нее есть некоторые особенности, к которым не всегда привыкаешь сразу, но вообще привыкнуть довольно легко. Я приведу несколько примеров:

require([‘app’]);

Это отличное начало. Довольно остроумный вариант — мы можем просто сделать обязательным вызов своего "модуля приложения" вместо того, чтобы запускать произвольную функцию (ну да, знаю, мы все равно вызываем ‘произвольную функцию обязательного вызова…).

require.def(‘app’, [‘lib/a’, ‘lib/b’], function(a, b) {
  a.init("example");
  b.init("example");
});

Вот, мы добрались до интересного момента. Мы задаем модуль приложения ‘, но предварительно нужно запустить еще два модуля. RequireJS скачивает эти зависимости и загружает экземпляры модулей в функцию обратного вызова в указанном порядке. Учитывается структура папок, так что для некоторого базового URL-адреса ‘lib/a’ будет находиться по адресу http://www.base-url.com/lib/a.js.

Определять реальные модули несколько сложновато. "Паттерн модуля" в той или иной степени предусматривает возврат уникального объекта в конце функции. Появляются некоторые возможности, например, переменные private-ish возможность вернуть уникальные экземпляры объектов при вызове функции. Для RequireJS это очень удобная схема. Заданные модули возвращают объекты с указанными интерфейсами.

require.def(‘lib/a’, [‘utils/f’], function(f){
  // Сделаем что-нибудь с нашим модулем f util
  function privateFunction(name) {
    return f.munge(name);
  }
  
  // Возврат объекта, представляющего ‘a’
  return {
    init: function(name) {
      this.name = "a: " + privatefunction(name);
    }
  };
});

Если предположить, что это было содержимое нашего модуля ‘lib/a’, то при запуске нашего кода приложения ‘ получается объект, переданный в функцию обратного вызова, возвращающего значение модуля ‘a’. Важно не запутаться в переменных за пределами модуля, поскольку несколько экземпляров одинакового модуля будут мешать друг другу. В этих функциях можно делать что угодно, но при загрузке модуля в конце обязательно должен возвращаться объект.

RequireJS позволяет разделить основу кода на более интеллектуальную структуру папок. Кроме того, это позволяет создавать более короткие и ориентированные на конкретные задачи файлы. В данном случае принудительно используются объекты, а не произвольные коллекции функций, заданные в глобальном пространстве имен. Все это необходимо для управления кодом. Исправить неполадки намного проще, если знаешь, какой модуль нарушает работу конкретной функции. Благодаря таким API модуля, как в этом простом примере, код еще и легче тестировать.

Теоретически RequireJS может пройти по вложенным зависимостям на любую глубину и всегда успешно справляется с двумя разными модулями с одной зависимостью. Файл загружается только один раз, а далее модуль извлекается из кэша для получения нового экземпляра практически без расхода ресурсов. Таким образом, модули четко распределяются между экземплярами модуля, даже есть одинаковые модули используются в разных местах.

Конец

Ждите перемен. По мере сил не заводите тесных отношений. И никогда не повторяйтесь.

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