Как стать автором
Обновить

Простая минималистская реализация сложных JavaScript приложений

Время на прочтение 12 мин
Количество просмотров 8.6K
Я хочу описать простой минималистский подход к разработке сложных JavaScript приложений. Из внешних библиотек будут использоваться только jQuery и мой js-шаблонизатор, причём из jQuery используются только $.ready(), $.ajax() и $.proxy() — т.е. суть не в библиотеках (их тривиально заменить на предпочитаемые вами), а в самом подходе.

В основе подхода лежат две идеи:
  1. JavaScript виджеты — небольшие модули, каждый из которых «владеет» определённой частью веб-странички (т.е. всё управление этой частью странички происходит исключительно через методы этого модуля, а не через прямую модификацию DOM — инкапсуляция). Виджет отвечает исключительно за функциональность, но не за внешний вид; поэтому прямая модификация части DOM, которым «владеет» виджет, снаружи виджета допускается — но только для чисто дизайнерских задач (для архитектуры и общей сложности приложения нет принципиальной разницы между коррекцией внешнего вида через CSS или jQuery).
  2. Глобальный диспетчер событий. Взаимодействие между виджетами осуществляется путём посылки сообщений глобальному диспетчеру (слабая связанность, паттерн Mediator/Посредник), а уже он принимает решение что с этим сообщением делать — создать/удалить виджеты, дёрнуть методы других виджетов, выполнить дизайнерский код, etc. В отличие от динамического подхода к обработке событий (когда обработчики конкретного события добавляются/удаляются в процессе работы) статический диспетчер сильно упрощает понимание и отладку кода. Безусловно, есть задачи, для которых нужны именно динамические обработчики событий, но в большинстве случаев это избыточное усложнение, поэтому всё, что можно, делается статическими обработчиками.


О велосипедах


Я обычно разрабатываю серверную часть приложения (причём больше даже не столько сам веб-сайт, сколько сетевые сервисы), клиентская часть это не мой основной профиль. Так что когда у меня впервые возникла задача разработки достаточно сложного одностраничного веб-приложения (примерно два года назад), я поискал готовые архитектурные подходы для таких приложений, но, к сожалению, не нашёл (хотя к тому моменту уже прошло пол года с момента доклада Nicholas Zakas «Scalable JavaScript Application Architecture»: видео, слайды).

Разработанную мной тогда архитектуру я обкатывал эти два года на разных проектах, проверял как она работает на реальных задачах. Хорошо работает. :) Поэтому я и решил написать эту статью. В процессе написания ещё раз поискал другие решения, и обнаружил доклад Nicholas Zakas, плюс ещё из прочитанного на эту тему очень понравилась статья Addy Osmani Patterns For Large-Scale JavaScript Application Architecture.

Для тех, кто уже знаком с описанной там архитектурой, кратко опишу отличия моего подхода:
  • На мой взгляд, та архитектура необходима для приложений масштаба Yahoo!, GMail, etc. но для абсолютного большинства современных достаточно сложных веб-приложений это всё-таки перебор. Избыточная гибкость этой архитектуры имеет свою цену — она увеличивает сложность приложения. Вот пример фич, в которых у меня никогда не возникало реальной необходимости:
    • возможность просто заменять базовую библиотеку (напр. jQuery на Dojo)
    • проверка и контроль «прав доступа» модулей к функциональности системы
    • динамическое добавление/удаление модулей на странице
    • по поводу динамических обработчиков событий я уже писал выше
  • Это нигде не упоминается явно, но насколько я понял, в их архитектуре «модули» это практически целые большие и сложные приложения (вроде чата на странице GMail или виджета погоды на Yahoo). У меня «модули» реализуют минимально возможную функциональность, которую удаётся выделить в отдельную изолированную сущность. (Впрочем, в скринкасте Andrew Burgess «Writing Modular JavaScript» показаны такие же небольшие модули, как и у меня.) В качестве примера можно привести модуль отвечающий за форму с одним input-ом или модуль отвечающий за вывод одного значения внутри одного тэга. При этом эти модули реализуют именно бизнес-логику приложения, и не являются аналогами плагинов jQuery делающими из обычного <select> очень крутой, красивый и фичастый <select>.
    • Как следствие разницы в масштабе функциональности модулей, у них модули состоят из HTML+CSS+JavaScript, а у меня большинство модулей это только JavaScript плюс документация на минимально необходимую этому модулю структуру HTML.
  • Хоть я и абсолютно согласен с тем, что необходимо чётко ограничить, к какой внешней функциональности у модулей есть доступ (фактически, я об этом написал в первом абзаце статьи, к тому списку осталось только добавить диспетчер), но я считаю что самый надёжный, эффективный и безопасный код тот, которого нет. Поэтому если есть возможность заменить код, например, соглашениями, я обычно предпочитаю соглашения (хотя, безусловно, здесь тоже надо меру знать). В моём подходе нет кода, который бы ограничивал модули в доступе, но есть соглашение, куда модули могут лазить, а куда нет.

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

Начало


Создаём скелет странички нашего приложения.

index.html
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Простой минималистский пример сложного JavaScript приложения</title>
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
    <script type="text/javascript" src="http://powerman.name/download/js/POWER.js"></script>
    <!-- Здесь будем подключать виджеты -->
</head>
<body>
<script type="text/javascript">
/* Основной код приложения */
</script>
<!-- Здесь будет html-код странички и js-шаблоны -->
</body>
</html>


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

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

index.html
/* Основной код приложения */
notice = (function(){
    var w = {
    };
    return function(e,data){
        switch (e) {
        case 'document ready':
            break;
        default:
            alert('notice: unknown event "'+e+'"');
        }
    };
})();
$(document).ready(function(){ notice('document ready') });


Хеш w будет использоваться для хранения создаваемых объектов-виджетов — надо же их где-то хранить после создания — плюс чтобы у диспетчера (который и будет эти объекты создавать) всегда был к ним доступ.

А что мы делаем-то?


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

Давайте сделаем приложение, в которое можно будет добавлять/удалять фразы, а оно будет показывать количество каких-нибудь результатов по этим фразам (например количество ошибок найденное сервисом проверки правописания). Плюс суммарное количество результатов по всем фразам.

Архитектура


Разделим необходимую функциональность между виджетами. Чем меньше и проще получатся виджеты, тем проще будет приложение в целом, так что не удивляйтесь их небольшим размерам — это не особенность этого приложения, а рекомендуемый подход к архитектуре любых сложных приложений.
  • AddPhrase — виджет добавления фразы.
    • Методы: нет.
    • Генерирует события: 'add phrase'.
  • Phrase — виджет выводящий добавленную фразу, позволяющий её выбрать (для удаления) и добавить разные обработчики этой фразы (вроде проверки правописания).
    • Методы: add_handler.
    • Генерирует события: 'select phrase'.
  • SpellCheck — виджет выводящий количество ошибок в этой фразе. Так же отвечает за ajax-запрос на сервис.
    • Методы: нет.
    • Генерирует события 'spellcheck: started' и 'spellcheck: success'.
  • Sum — виджет выводящий общее количество результатов по всем фразам.
    • Методы: add и sub.
    • Генерирует события: нет.

Виджеты


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

Внутри класса виджета ему доступен абсолютный минимум внешних функций:
  • глобальная функция notice() для генерации событий;
  • объект jQuery $;
  • функции библиотеки-шаблонизатора.

За редким исключением виджет должен как-то отображаться на экране. Область странички, которой он «владеет» обычно либо уже существует (и тогда в конструктор виджета передаётся её #id), либо генерируется виджетом на лету из js-шаблона (и тогда в конструктор передаётся #id шаблона).

Все остальное не принципиально. Мой стиль:
  • Виджеты находятся в отдельных файлах w.widget_name.js.
  • Виджеты реализованы как классы в глобальном namespace W, обычно один виджет — один класс: W.WidgetName.
  • Классы реализованы в самом простом и естественном для JavaScript виде — обычные функции-конструкторы и прототипное наследование.
  • Приватные свойства и методы начинаются на подчёркивание.
  • В начале виджета документируется какой ему нужен формат js-шаблона (или блока HTML); конструктор, публичные методы/свойства, генерируемые события.
  • По соглашению, контролируемая виджетом область доступна через свойство объекта-виджета .$ (именно его внешний код вставляет в DOM чтобы вывести виджет на страничке).

Небольшое терминологическое уточнение. Нередко JavaScript-виджетом называют комплект из HTML+CSS+JavaScript. Мои виджеты тоже можно распространять вместе с их кусками HTML и CSS, чтобы облегчить их подключение «по умолчанию» в другие приложения, но писать виджеты я предпочитаю так, чтобы в них был абсолютный минимум информации о дизайне — это упрощает и код и изменение дизайна. Поэтому в описываемых далее виджетах вы не найдёте ни слова о дизайне.

AddPhrase

Требования этого виджета к HTML — нужна форма с одним input type=text. Всё остальное дизайнер может оформлять как угодно.

w.addphrase.js
/*
 * <form id="addphrase"><input type=text></form>
 *
 * w = new W.AddPhrase('addphrase');
 *
 * -> 'add phrase', phrase
 *
 */
var W; W = W || {};

W.AddPhrase = function(id){
    this.$      = $( '#'+id );
    this._input = this.$.find('input'); // cache
    this.$.submit( $.proxy(this,'_onsubmit') );
};

W.AddPhrase.prototype._onsubmit = function(){
    notice('add phrase', { phrase: this._input.val() });
    this._input.val('');
    return false;
};


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

index.html
    <!-- Здесь будем подключать виджеты -->
    <script type="text/javascript" src="w.addphrase.js"></script>
...
<!-- Здесь будет html-код странички и js-шаблоны -->
<form id="addphrase">
    Добавить фразу: <input type=text>
</form>


index.html
/* Основной код приложения */
notice = (function(){
    var w = {
        addphrase: null
    };
    return function(e,data){
        switch (e) {
        case 'document ready':
            w.addphrase = new W.AddPhrase('addphrase');
            $('#addphrase input').focus();
            break;
        case 'add phrase':
            break;


Собственно, это всё. :) У нас есть универсальный виджет, который можно «повесить» на любые формы, и который исправно генерирует события по мере ввода пользователем фраз (мы их пока в диспетчере игнорируем, т.к. ещё нет виджета, который должен их обрабатывать).

При этом в виджете нет ни одной «капли жира» — строчки, не относящейся к его непосредственной функциональности. В каком стиле бы не реализовалась эта функциональность, все эти строчки будут в любом случае. В диспетчере, конечно, «лишние» строчки есть — если сравнивать с не модульным приложением в стиле N-летней давности, когда весь код писался одним куском. Но на самом деле эти «лишние» строчки — самое ценное в нашем приложении, т.к. именно они наглядно и просто описывают высокоуровневую логику приложения.

Phrase

На примере этого виджета мы посмотрим на работу с js-шаблонами и вложенными виджетами.

Требования этого виджета к HTML — нужен js-шаблон, в который будет передана переменная phrase, и в котором должен быть элемент с классом handlers — в него будут добавляться виджеты с результатами.

w.phrase.js
/*
 * <script type="text/x-tmpl" id="tmpl_phrase">
 *      <div>
 *          <%= phrase %>
 *          <span class="handlers"></span>
 *      </div>
 * </script>
 *
 * w = new W.Phrase('tmpl_phrase', 'some phrase');
 * w.add_handler( new W.SomeHandler() );
 *
 * -> 'select phrase', phrase
 *
 */
var W; W = W || {};

W.Phrase = function(tmpl, phrase){
    this.$      = $( POWER.render(tmpl, { 'phrase': phrase }) );
    this._phrase= phrase;
    this._w     = [];
    this._h     = this.$.find('.handlers'); // cache
    this.$.click( $.proxy(this,'_onclick') );
};

W.Phrase.prototype.add_handler = function(w_handler){
    this._w.push( w_handler );
    this._h.append( w_handler.$ );
};

W.Phrase.prototype._onclick = function(){
    notice('select phrase', { phrase: this._phrase });
};


С шаблонами думаю, всё понятно. А вот работу с вложенными виджетами лучше пояснить.

Хоть виджет проверки правописания «вложен» визуально в виджет Phrase и виджет Phrase «владеет» виджетом проверки правописания (единственная ссылка на виджет проверки правописания находится в this._w, в то время как все глобальные виджеты удерживаются ссылками в хеше w), тем не менее, как вы видите, Phrase не создаёт объект виджета проверки правописания, а получает его параметром. Это внедрение зависимости (dependency injection) для увеличения гибкости — позволяет избежать зависимости между конкретными виджетами и упрощает создание объектов виджетов: дело в том, что обычно все необходимые данные для вызова конструктора вложенного виджета есть только у диспетчера, и такой подход позволяет избежать прозрачной передачи этих параметров для конструкторов вложенных виджетов через конструкторы внешних виджетов.

index.html
    <!-- Здесь будем подключать виджеты -->
    ...
    <script type="text/javascript" src="w.phrase.js"></script>
...
<!-- Здесь будет html-код странички и js-шаблоны -->
...
<div id="phrases">
    <p>
    <script type="text/x-tmpl" id="tmpl_phrase">
    <div>
        <%= phrase %>
        <span class="handlers"></span>
    </div>
    </script>
</div>


index.html
/* Основной код приложения */
notice = (function(){
    var w = {
        ...
        phrase: {}
    };
    return function(e,data){
        ...
        case 'add phrase':
            if(data.phrase in w.phrase)
                break;
            w.phrase[data.phrase] = new W.Phrase('tmpl_phrase', data.phrase);
            $('#phrases').append( w.phrase[data.phrase].$ );
            break;
        case 'select phrase':
            w.phrase[data.phrase].$.remove();
            delete w.phrase[data.phrase];
            $('#addphrase input').focus();
            break;


Пара пояснений: во-первых у нас ещё нет виджета W.SpellCheck, поэтому мы пока не вызываем w.phrase[phrase].add_handler( new W.SpellCheck() ); во-вторых после клика на виджете Phrase (для удаления фразы) теряет фокус строка ввода фразы, и его надо вернуть.

SpellCheck

На примере этого виджета мы посмотрим на работу с ajax.

Кроме того нам нужно будет показывать состояние ajax-запроса (в процессе, получен ответ). Есть два разных подхода: можно изменять DOM в диспетчере (обрабатывая события 'spellcheck: started' и 'spellcheck: success'), а можно прошить все состояния внутри js-шаблона и многократно выполнять/подменять шаблон. Второй способ немного сложнее, но т.к. у нас пример, то стоит реализовать именно его.

Для его реализации приходится использовать один трюк. Дело в том, что если мы сохраним выполненный шаблон в свойство .$ (как в виджете Phrase), то мы в дальнейшем уже не сможем его подменить — дело в том, что значение .$ будет добавлено куда-то в DOM (мы внутри виджета не знаем куда), и если мы просто заменим значение в .$ новым шаблоном, то в DOM останется ссылка на старое значение .$ и визуально на страничке ничего не изменится (а этот виджет окончательно потеряет доступ к «своей» части странички). Чтобы этого избежать в .$ сохраняется любой пустой тэг-контейнер (напр. span или div), а уже в него помещается результат выполнения шаблона. К сожалению, при этом на страничке появляется «неожиданный» для дизайнера тэг, но как эту проблему решить более элегантно я не придумал.

Требования этого виджета к HTML — нужен js-шаблон, в который будут переданы переменные status (возможные значения "started" и "success") и spellerrors (если значение status=="success").

w.spellcheck.js
/*
 * <script type="text/x-tmpl" id="tmpl_spellcheck">
 *      <% if (status == 'started') { %>
 *          Проверяю…
 *      <% } else { %>
 *          Найдено <%= spellerrors %> ошибок.
 *      <% } %>
 * </script>
 *
 * w = new W.SpellCheck('tmpl_spellcheck', 'some phrase');
 *
 * -> 'spellcheck: started', phrase
 * -> 'spellcheck: success', phrase, spellerrors
 *
 */
var W; W = W || {};

W.SpellCheck = function(tmpl, phrase){
    this.$      = $('<span>');
    this._tmpl  = tmpl;
    this._phrase= phrase;
    this._load();
};

W.SpellCheck.prototype._load = function(){
    $.ajax({
        url:        'http://speller.yandex.net/services/spellservice.json/checkText',
        data:       { text: this._phrase },
        dataType:   'jsonp',
        success:    $.proxy(this,'_load_success')
    });
    this.$.html( POWER.render(this._tmpl, {
        status: 'started'
    }) );
    notice('spellcheck: started', { phrase: this._phrase });
}

W.SpellCheck.prototype._load_success = function(data){
    this.$.html( POWER.render(this._tmpl, {
        status: 'success',
        spellerrors: data.length
    }) );
    notice('spellcheck: success', { phrase: this._phrase, spellerrors: data.length });
};


index.html
    <!-- Здесь будем подключать виджеты -->
    ...
    <script type="text/javascript" src="w.spellcheck.js"></script>
...
<!-- Здесь будет html-код странички и js-шаблоны -->
...
<div id="phrases">
    ...
    <script type="text/x-tmpl" id="tmpl_spellcheck">
        <% if (status == 'started') { %>
            (проверяю…)
        <% } else { %>
            (найдено <%= spellerrors %> ошибок)
        <% } %>
    </script>
</div>


index.html
        case 'add phrase':
            ...
            w.phrase[data.phrase].add_handler(new W.SpellCheck('tmpl_spellcheck', data.phrase));
            ...
            break;
        case 'spellcheck: started':
            break;
        case 'spellcheck: success':
            break;


Sum


w.sum.js
/*
 * <span id="sum"></span>
 *
 * w = new W.Sum('sum');
 * w.add(5);
 * w.sub(3);
 *
 */
var W; W = W || {};

W.Sum = function(id){
    this.$      = $( '#'+id );
    this.$.html(0);
};

W.Sum.prototype.add = function(n){
    this.$.html( parseInt(this.$.html()) + n );
};

W.Sum.prototype.sub = function(n){
    this.$.html( parseInt(this.$.html()) - n );
};


index.html
    <!-- Здесь будем подключать виджеты -->
    ...
    <script type="text/javascript" src="w.sum.js"></script>
...
<!-- Здесь будет html-код странички и js-шаблоны -->
...
<p><b>Итого: <span id="sum"></span> ошибок.</b></p>


index.html
/* Основной код приложения */
notice = (function(){
    var w = {
        ...
        sum: null
    };
    var spellerrors = {};
    return function(e,data){
        ...
        case 'document ready':
            ...
            w.sum = new W.Sum('sum');
            break;
        case 'select phrase':
            ...
            if(data.phrase in spellerrors){
                w.sum.sub(spellerrors[data.phrase]);
                delete spellerrors[data.phrase];
            }
            break;
        case 'spellcheck: success':
            w.sum.add(data.spellerrors);
            spellerrors[data.phrase] = data.spellerrors;
            break;


Итого


В сумме по всем файлам: 200 строк HTML+JavaScript, 5.5KB. Из них почти 50 строк — документация/комментарии.

За кадром остались вопросы обработки ошибок, тестирования, логирования и отладочного режима. Тут я ничего нового добавить не могу, всё стандартно (например смотрите доклад Nicholas Zakas «Enterprise JavaScript Error Handling»: слайды).

Дополнительная информация


Исходники описываемого в статье приложения выложены на bitbucket, с пошаговыми commit-ами соответствующими статье. Так же можно посмотреть на само приложение.

Желающие покритиковать мой подход к реализации модулей могут для начала ознакомиться с моим мнением о других подходах. Плюс когда-то была небольшая дискуссия о моём js-шаблонизаторе.

Другие статьи на хабре на эту тему: Масштабируемые JavaScript приложения.
Теги:
Хабы:
+53
Комментарии 42
Комментарии Комментарии 42

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн