Клиентская часть интерактивного сайта

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

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

Организация кода

Сборка

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

На вопрос "Какую систему сборки использовать?" в большинстве случаев правильный ответ: ту же, что и для сборки серверной части. Make, rake, maven, ant, rebar... - любому из них без труда можно поручить эту задачу. Для конкатенации можно использовать хоть консольную команду >>, для минимизации есть много альтернативных библиотек, в порядке моих симпатий:

Если хочется чего-то более гибкого, могу порекомендовать воспользоваться Webassets, который я уже упоминал в статье про Jinja2. В консольном режиме прекрасно подключается к любой системе сборки и языку программирования. Описать процесс сборки JavaScript и CSS можно очень подробно и именно так, как считаете нужным, естественно на Python. Сопоставимый по возможностям проект из мира Ruby - Asset Packager, наверняка есть много других.

Читабельный код

Не знаю как Вы, а я тихо ненавижу JavaScript все ~10 лет, которые я с ним знаком. Так как он по сути является монополией на рынке браузерных приложений (Flash, Java апплеты и ActiveX за альтернативы можно даже не считать), использовать его так или иначе приходится в любом сколько-либо серьезном интернет-проекте. Даже Google Dart вряд ли всерьез приживется.

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

Если Вас тоже не раз посещали подобные мысли, то Вы вероятно как и я при первой же возможности пересядете (или уже пересели) на CoffeeScript, компилируемый в JavaScript язык программирования. Немного рекламы этого проекта:

  • Золотое правило CoffeeScript: "It's just Javascript"(это просто JavaScript)
  • Прямое преобразование кода в JavaScript
  • Доступны абсолютно все JavaScript-библиотеки
  • Никаких точек с запятой в конце каждой строки
  • Структурирование кода на основе отступов, как в Python
  • Объявление функций просто стрелочкой ->
  • При вызове методов даже скобки писать не обязательно
  • Человеческое наследование: простое class MyClass extends MyParent превращается в довольно хитрую конструкцию с использованием прототипов и замыканий:

    MMyClass = (function() {
        __extends(MyClass, MyParent);
        function MyClass() {
            MyClass.__super__.constructor.apply(this,
                arguments);
        }
        MyClass.prototype.initialize = function() {};
        return MyClass;
    })();
    
  • Много укороченных команд ветвления кода (if, switch, циклы и т.п.)

  • В целом код выходит раза в полтора-два короче и намного приятнее для глаз
  • Консольный компилятор с функцией наблюдения за директориями
  • Легко подключается как фильтр в Webassets
  • Подробнее с примерами на официальном сайте
  • В общем рекомендую :)

Логическое разделение кода

Если Вы сталкивались со сколько-либо сложным пользовательским интерфейсом "на jQuery", то скорее всего не по наслышке понимаете откуда взялось выражение "спагетти-код". В связи с событийной парадигмой разработки браузерных приложений, очень часто JavaScript-код с использованием jQuery или альтернатив превращается в так называемый "коллбек на коллбеке, коллбеком погоняет" (коллбек - транслит от английского callback - обработчик события). При отсутствии четкой структуры такой код становится очень сложно поддерживать при его увеличении в объемах. Но это не повод отказываться от jQuery - от событий никуда не деться, и эта библиотека отлично справляется с абстракций от особенностей их реализации в различных браузерах.

На мой взгляд, одним из наиболее резонных способов решения (или заблаговременного предотвращения) этой проблемы является использование в разумных пределах объектно-ориентированные возможности JavaScript(благо CoffeeScript это дело сильно упрощает). Соответственно, используемые классы можно разумно располагать в какой-то иерархии с точки зрения наследования (для обеспечения DRY, don't repeat yourself - "не повторяйся") и с точки зрения расположения в файловой системе (с разложенными по папкам файлами работать намного проще, чем с здоровенной вереницей обработчиков событий в одном файле).

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

  • Модель (Model) - как и в традиционном MVC представляет собой класс, объект которого содержит локальную копию каких-то данных и предоставляет механизмы для её синхронизации с внешним хранилищем. Основное отличие от серверных моделей - хранилищем выступает не СУБД, а либо локальное хранилище браузера через HTML5, либо удаленный сервис через REST или другой интерфейс. Плюс так как они находятся вне "зоны доверия", то полученные от них данные нужно обязательно валидировать, фильтровать и проверять на серверной стороне, прежде чем что-либо с ними делать.
  • Представление(View) или контроллер(Controller) - тут, по моим впечатлениям, образовалась путаница и за обоими названиями в нашем контексте имеют ввиду примерно одно и то же. Объект такого класса следит за изменениями и событиями в связанных с ним моделях и элементах DOM, каким-либо образом на них реагируя. Таким образом большая часть кода, которая раньше была "вереницей обработчиков событий", оказывается методами этого класса. При этом базовый класс из библиотеки берет на себя нормальное поведение this и следит за тем, чтобы обработчики автоматически добавлялись на динамические созданные элементы DOM.
  • Маршрутизатор(Router) - следит за состоянием адресной строки и позволяет обрабатывать изменения, понадобится для восстановления поведения браузера.
  • Коллекция(Collection) - отсортированный набор однотипных моделей, с которым можно работать как с единым целым.

Не стоит рассматривать эти абстракций как единственный верный способ делать клиентские приложения, но при их использовании появляется хоть какая-то логика и становится более-менее понятно где какой кусок кода должен находиться и где его потом искать. Для абстракции особенностей реализаций браузеров они по-прежнему полагаются на $ в виде jQuery или Zepto.

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

  • Backbone.js - самая широко распространенная из трех, используется во многих серьезных проектах. Основана на библиотеке Underscore.js, которая с одной стороны предоставляет массу удобных функций и шаблонизатор, но с другой стороны - не особо-то и часто они оказываются нужны.
  • Spine.js - библиотека по-моложе, которая очень похожа на Backbone.js, но написана на CoffeeScript и из-за отсутствия внешних зависимостей вышла компактнее. Отличия в основном в терминологии и деталях реализации.
  • Knockout.js - эта библиотека пропагандирует использование data-* атрибутов из HTML5 для хранения метаданных, которые как-то управляют изменениями тегов-владельцев при определенных событиях, практически забирая на себя роль представления. Концепция кажется мне мутноватой, так что лично для себя я её использование всерьез и не рассматривал никогда.

Когда в этой статье дело будет доходить до примеров кода, я буду приводить их на основе Backbone.js, так как в свое время я остановился именно на ней. Почему? В основном из-за того, что она используется в очень многих проектах и стоит за ней целая компания, а не просто один разработчик, которому однажды может надоесть поддерживать проект (как в случае с Spine.js). Но в глубине души я, конечно, надеюсь, что однажды они уберут эту жесткую зависимость от Underscore.js, а то и может быть тоже перепишут все на CoffeeScript.

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

Обработчики событий

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

Напомню, то, что раньше было просто независимым обработчиком событий становится методом представления (по терминологии Backbone.js). У каждого представления создается "оглавление" методов-обработчиков в атрибуте events. Наверное многим хотелось бы увидеть какой-то пример кода, но так как статьями с примерами примитивных приложений на Backbone.js пестрит весь Интернет, тратить на это время желания совершенно никакого, сошлюсь на самый популярный: список задач TODO, для сравнения то же самое на Spine.js. К слову, при использовании CoffeeScript использовать стандартный механизм Backbone.****.extend({ ... }) не обязательно, class MyClass extends Backbone.**** прекрасно делает то же самое.

По мне, так намного интереснее не какие именно события обрабатываются (все равно 90% уникальны для проекта), а как их распределить по разным представлениям. Обычно получается что-то в этом духе:

  • Пользовательское представление будет модифицировать страницу в тех местах, где оно как-то связано с текущим пользователем: форма авторизации, надпись "Привет, ****!", кнопка выхода и пр. Вероятно, оно будет использовать модель текущего пользователя или в тривиальных случаях просто самостоятельно работать с cookie сессии.
  • Классы модели и представления, а вероятно и коллекции, понадобятся каждой логической сущности, которая каким-либо образом отражается в пользовательском интерфейсе. Это может быть что угодно, например задача в TODO-списке, статья, комментарий - все зависит от тематики проекта.
  • Если навигация по сайту каким-то образом динамически видоизменяется, то представление понадобится и для нее. Например, часто подсвечивают пункты в глобальной навигации на основе изменений в текущем адресе страницы.
  • И, последний пункт, который собственно и относится к сегодняшней теме - одно представление будет общим для всего сайта и будет отвечать за его интерактивность. Давайте его рассмотрим подробнее.

Для отсутствия перезагрузок браузера внутри сайта, нам нужно переопределить:

  • События клика на ссылки: по содержимому атрибута href нужно определить, что ссылка внутренняя и вызвать "цепную реакцию" в других представлениях, чтобы в итоге пользователь увидел то, что должен.
  • При отправке формы есть два сценария:
    • Обновляется связанная модель и синхронизируется с сервером. В таком сценарии при необходимости можно вообще скрыть кнопку отправки и "автосохранять" изменения в модели.
    • Связанной модели по каким-то причинам нет и нужно просто на основе данных формы что-то сделать, например выполнить поиск по указанной в форме фразе или отправить запрос на авторизацию.
  • Для отмены стандартной реакции браузера на события у jQuery есть два основных механизма: event.preventDefault() и return false. В данной ситуации (да и большинстве других), целесообразнее пользоваться последним, так как если вдруг в коде обработчика окажется какая-то ошибка, то пользователь просто увидит стандартную реакцию браузера, а не окажется в ситуации "некликающихся ссылок" и "неотправляющихся форм".

Восстановление поведения браузера

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

И первое, с чего стоит начать - с адресной строки, именно там должен появиться тот адрес, который был в href ссылки и action формы. Но на самом деле проще сказать, чем сделать:

  • Возможность просто полностью поменять текущий адрес в адресной строке из JavaScript без инициализации открытия страницы есть только в совсем свежих браузерах посредством HTML5 History API (pushState).
  • В старых браузерах переходы между внутренними страницами сайта можно эмулировать через изменения якоря ссылки, который в URL идет после # и обычно используется для "перелистывания" на середину HTML-документа. Для отслеживания таких изменений используется событие onhashchange.
  • В еще более старых браузерах это событие эмулируют разными трюками с iframe и setInterval.

Backbone.history.start() берет на себя абстракцию изменений в адресной строке, правда поддержку pushState нужно явно включить в аргументах. Заодно восстанавливается нормальное поведение кнопок "Назад" и "Вперед" в браузере.

Для обработки и создания событий, отражающихся в адресной строке, нужно сделать подкласс Backbone.Router. C ситуациями когда их имеет смысл создать несколько, я не сталкивался. По аналогии с серверными фреймворками в атрибуте routes задается соответствие паттернов адресов к методам-обработчикам, которые будут выполниться при переходе. В них вызываются необходимые изменения в коллекциях, моделях и представлениях, чтобы привести в нужное состояние текущий документ.

Для инициации "виртуального" перехода на новую внутреннюю страницу нужно вызвать метод navigate у нашего объекта-маршрутизатора, первым аргументом передав её адрес без первого /, а вторым - настройки:

  • trigger - вызывать ли обработчик из маршрутизатора?
  • replace - добавлять ли страницу, с которой мы уходим в историю браузера, чтобы можно было на нее вернуться при нажатии кнопки "назад"?

Таким образом, во внутренних ссылках мы используем нормальные относительные URL, начинающиеся с /. По ним будут нормально ходить роботы и браузеры без JavaScript. В обработчике кликов на них мы:

  • проверяем правда ли она внутренняя (начинается ли она с /);
  • "отменяем" стандартный переход, вернув false;
  • вызываем router.navigate(href.substring(1), {trigger: true}).

Осталась еще несколько атрибутов поведения браузера, которые необходимо починить, чтобы визуально все выглядело "как обычно":

  • Клик по ссылке с зажатым Shift должен открывать её в новом окне, а с зажатым Ctrl или при клике средней кнопкой мыши - в новой вкладке. Довольно не хитро делается на основе атрибутов объекта-события, который передает обработчику jQuery (button, shiftkey, metakey), для открытия окна или вкладки - window.open.
  • Если пользователь сделал какое-то действие, а прореагировать на него мгновенно не получается (так как что-то грузится, вероятно) - нужно включить курсор ожидания, установив в CSS cursor: wait, и, желательно, анимированный favicon.ico. И, соответственно, вернуть все как было, когда страница примет нужный вид. Для смены favicon до сих пор пользуюсь каким-то довольно старым плагином к jQuery, который не особо шикарно, но все же работает. Его сайт, видимо, накрылся, так что продублировал: https://gist.github.com/2320740, если кто знает более адекватные альтернативы - дайте знать в комментариях, пожалуйста, руки поискать все никак не доходят.

Синхронизация состояния

По-умолчанию Backbone.js предлагает хранить все состояние клиента в моделях и синхронизировать его с серверным посредством реализации простенького REST API на сервере (подробнее), к которому запросы отправляются посредством обычного $.ajax. Чтобы инициировать процесс нужно вручную вызвать у экземпляра модели метод fetch, чтобы обновить клиентское состояние данными с сервера, или метод save, для обратного процесса.

Для многих приложений этого, в целом, достаточно. Но ограничение очевидно - нет возможности мгновенно узнать, что на сервере что-то изменилось. Чего-то близкого можно достичь вызовом fetch раз в N секунд для каждой модели, но если пользователей предполагается хоть сколько-либо много, нагрузка на серверную часть будет неоправданно велика.

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

Эта статья - вторая в серии про Интерактивные сайты, автор - Иван Блинков, основано на личном опыте. До встречи на страницах Insight IT!