Философия AMD, RequireJs и модульная разработка веб-приложений на JavaScript

1 февр. 2013 г. | | |

Разделяй и влавствуй - подход на все времена. Концепция модульного программирования не нова, и хорошо себя зарекомендовала. В мире разработки web-приложений на JavaScript существует подход Asynchronous Module Definition или AMD, который
specifies a mechanism for defining modules such that the module and its dependencies can be asynchronously loaded [1]
т.е. по сути определяет API, следуя которому можно реализовать асинхронную загрузку самих модулей и их зависимостей. Следуя этому подходу, непроизвольно создаешь более элегантные решения, но это всего лишь бонусный побочный эффект. Главное - уменьшается хаос js-кода, сложность разработки и поддержки приложения. Каждый модуль явно указывает свои зависимости и получает только их, работая изолированно, что означает минимум мусора глобальной области видимости.
Всё, что нужно, чтобы начать использовать AMD подход в проекте, - это загрузчик, который знает, что такое AMD-модуль, и умеет подгружать зависимости.  Итак,

Require.js

Ключевым объектом в Require.js, как и в AMD-философии в целом, является модуль. Опеределяется модуль с помощью функции define
define("<module_name>",
        ["dependency1", "dependency2],
        function(dependency1, dependency2) {            
       }
    );
Метод, который идёт последний аргументом в списке,  - это фабрика, которая создает и возвращает сам модуль. Фабрика выполняется только после загрузки всех зависимостей, указанных во втором аргументе. Модули, указанные как зависимости, ищутся в той же папке, где находится и сам Require.Js (подробнее на теме загрузки модулей я останавлюсь ниже).  Ппервый аргумент - это имя модуля. Всё вроде просто. Значит, надо создать свой модуль в AMD-стиле! Выходя за рамки учебных простых примеров, создадим что-то более близкое к задачам из реальной жизни. Например, мне нужен объект, который умеет строить календарь на указанный месяц и год и отмечать в нём даты.
//zeitproject.calendar.js
define(
    'calendar',
    ['jquery'],
    function( $ ){      
        function Calendar()
        {
            var self = this;
            self.builCalendar = function(month, year)
            {
               var body = "";
               //неинтересные детали
               return body;
            }

            self.markDays = function(days_array, css_class, calendar)
            {               
               //неинтересные детали
            }           
        };
        return new Calendar();
    }
);

Из зависимостей - только jquery (ну куда без него!). Знак $ в параметрах функции-фабрики - конечно же, ссылка на jquery. Фабрика возвращает экземпляр класса Calendar, объявленного в внутри самой фабрики, что ограничивает область видимости класса. А куда и кому она возвращает объект? Туда, откуда кем-то была вызвана. А кем и когда она была вызвана? А вызвана она могла быть, когда модуль попал в чей-то список зависимостей, и Require.Js пытается подгрузить модуль. Продолжая пример, Calendar.js является одной из зависимостей в app.js:
//app.js
require(["jquery", "calendar", "tooltip"], function($, calendar, tooltip) {  
    //init vars
    //...//
              
    calendar.builCalendar(month, year, $('.calendar'));
    calendar.markDays(days[i][0], days[i][1], $('.calendar'));
    tooltip.init();
});

app.js - это точка входа в наше приложение. В нём конфигурируется Require.Js и инициализируется логика приложения. Это тот единственнй javascript-файл, который подгружается через Require.Js в index.html. Путь к нему указывается в атрибуте data-main:
<script data-main="/js/app" src="/js/require-jquery.js"></script>
Для краткости расширение .js можно не указывать.
Конечно, тут есть маленькая хитрость - в примере подгружается Require.Js сразу в связке с jquery, поэтому не нужно отдельно подгружать jquery.

Ручная настройка

Require.Js - гибкая легконастраиваемая штука. Например, нужно настроить загрузку AMD-модулей для такой структуры каталогов:
Для этого в app.js перед вызовом require() нужно сконфигурировать Require.Js.
require.config({
    baseUrl: "/js",
    paths: {

        "calendar" : "zeitproject.calendar",
        "jquery"   : "require-jquery",

        "formatter": "/utils/formatter",
        "tooltip"  : "/controls/zeitproject.tooltip",
        "tiptip"   : "/controls/jquery.tiptip.min"
    }

  });
Всё довольно интуитивно: baseUrl - корневая папка, относительно которой указываются все последующие пути. Дальше указывается имя AMD-модуля (имя мы указывали в методе define() первым аргументом во время определения модуля). А что, если нужно подгрузить не AMD-модуль, который добавляет в глобальное пространство имён переменные? По сути, таким модулем может быть любой jquery-плагин. Или, даже, фреймворк: тот же Backbone.js является AMD-несовместимым. Не переписывать же его под AMD-сигнатуру?! Для этого в конфиге есть специальный раздел shim (наверно, имелось в виду "shame, не AMD-модуль!"):
require.config({
    baseUrl: "/js",
    paths: {
/*...*/
        "tiptip"  : "
/controls/jquery.tiptip.min",
        "tooltip" : "
/controls/zeitproject.tooltip"
    },
    shim: {
        'tiptip' : {
            deps: ['jquery'],
            exports: 'tiptip'
        }
    }
  });

В shim-секции определяется, какие зависимости есть у не AMD-модуля и псевдоним, под которым его можно указывать в списке зависимостей AMD-модулей.
//zeitproject.tooltip.js
define("tooltip", ["jquery", "tiptip"], function($){
    function Tooltip()
    {

/*..*/
}); 
Вот и всё. Модули изолированы друг от друга, и знают лишь о существовании тех, без которых не могут работать. Это позволяет держать под контролем зависимости и облегчает жизнь. Надеюсь, вы прониклись Require.js и AMD-подходом к разработке веб-приложений.

Ссылки

http://requirejs.org официальный сайт require.js, отличная документация и примеры
AMD - описание API

14 коммент.:

WoZ комментирует...

Отличная статья, спасибо автору.

Guest комментирует...

Только вот документация у них не отличная

Shemyakina Tatiana комментирует...

потому что не на русском? ;)

Fungi комментирует...

Читаю и слезы благодарности наворачиваются на глаза. Спасибо Вам, автор, пишите еще!

Rufin комментирует...

Прошу прощенья, может я что-то не понимаю, т.к. я начинающий программист и хотел бы спросить:
Я использую в работе smarty и в каждом шаблоне у меня свой js-код и jquery-плагины. При использовании RequireJS в шаблонах получается так, что грузятся все скрипты, которые предназначены для других шаблонов. Ведь точка входа только одна. Как можно сделать так, чтоб грузились только те скрипты, которые нужны для текущего шаблона или только тогда, когда они нужны? Или RequireJS совсем для другого предназначен?

Rufin комментирует...

Простите, но я никак не пойму как Вам удается сделать это: "в нужный момент я подгружаю этот модуль", если точка входа только одна? Все модули и их зависимости уже описаны и ждут когда их вызовут, затем они асинхронно загружаются и выполняются следуя правилам зависимостей. Или я в корне не понял технологию AMD.

На самом деле у меня возникла задача: подгрузить скрипты (модули) только тогда, когда появляется в них необходимость. Например, юзер нажал кнопку, с сервера пришли данные (построилось выпадающее меню с пунктами) на которые необходимо повесить js-обработчики. Сейчас, получается так, что обработчики загружаются вместе со страничкой, хотя выпадающего меню в данный момент нет, его не вызвали и возможно не вызовут. Сможет ли решить эту задачу RequireJS? Судя по описанию может, но опять же вопрос остался открытым, т.к. когда я начал описывать зависимости и создавать отдельные модули, получилось так, что все эти модули оказались в одном месте. Одна точка входа, на первый взгляд, подгружает все эти модули! Но, возможно я ошибаюсь здесь и модули не подгружаются все, а они будут вызваны только тогда, когда в них возникнет необходимость. Как в Вашем случае, где Вы создаете объект с нужными методами.

Правильно ли я понял, что в коде, при нажатии, например, на кнопку Вы ставите обработчик и в его коде пишите: require ['modules/action'], (Action) => someObject = new Action. Т.е. создаете объект и вызываете нужный метод? Вот в этом случае и грузиятся все зависимости и нужные скрипты (модули)?

Shemyakina Tatiana комментирует...

Правильно ли я понял, что в коде, при нажатии, например, на кнопку<..> - да, да.

возможно мне не стоит заморачиваться с AMD ради пару десятков строчек кода <..> - зависит от того, с какой стороны смотреть. Если вы планируете дальше развивать проект, то стоит сделать всё правильно, а не быстро накодить. Если вы поддерживаете старый проект и решили порефакторить - то на ваш выбор. RequireJs очень удобен для single page application. В другом случае польза от require только в разбиении js-кода на модули, что поможет немного структурировать js-hell.

стоит перейти на ООП <..> - однозначно

Rufin комментирует...

Спасибо за Ваши ответы. Хотелось бы кодить "правильно", модульно, чтобы можно было поддерживать и развивать проект дальше. Идея AMD мне нравится, но пока не раскусил его прелести так, как Вы. Поэтому позвольте еще спросить!?

У меня есть главный шаблон loyout.tpl, который не изменяется и содержит только и .

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

У каждого такого шаблона есть свои js-скрипты.

Пытаюсь перейти на RequireJS и все js-скрипты оформить в виде модулей, которые бы подгружались когда нужно.

Что я сделал:

1. Убрал скрипты из loyout.tpl и других шаблонов и сделал одну точку входа в loyout.tpl:



2. В app.js прописал конфиг, который указывает на пути расположения моих модулей:

require.config({
calendar: "/js/lib/calendar",
popup: "/js/admin/catalog/popup",
catalog: "/js/admin/catalog/common",
news: "/js/admin/news/common",
});

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

require(["jquery", "calendar"], function($, calendar) {
//...jQuery плагин календаря...//
});

require(["jquery", "popup"], function($, popup) {
//...Обработчики для всплывающего меню...//
});

require(["jquery", "catalog"], function($, catalog) {
//...Функции для работы со справочниками...//
}


require(["jquery", "news"], function($, news) {
//...Функции для работы с новостями...//
});

(еще 25 модулей, которые требуются там или сям...)

Запускаю, работает. Но при загрузке странички вижу, что загружаются все скрипты и нужные и ненужные, все что я здесь описал в require.

Скорее всего я что-то неправильно делаю!?


А нельзя ли сделать, например так:
Создать модуль, которы бы требовался только для шаблона с новостями:

define(
'news',
['jquery'],
function( $ ){
return {
showAll : function(){$.ajax()};
addNews : function(){$.ajax()};
delNews : function(){$.ajax()};
};
});

И в app.js написать:

require.config({
jquery: "/js/lib/jquery/1.10.min",
calendar: "/js/lib/calendar",
popup: "/js/admin/catalog/popup",
catalog: "/js/admin/catalog/common",
news: "/js/admin/news/common",
});

require(["jquery"], function($) {
$(document).ready(function(){
$(document).on('click', '#showAll', function(){
require(["jquery", "news"], function($, news) {news.showAll(); });
});
$(document).on('click', '#addNews', function(){
require(["jquery", "news"], function($, news) {news.addNews(); });
});
});
});

Можно ли так сделать?

И будет ли в этом случае подгружен мой модуль "/js/admin/news/common.js" асинхронно, по мере нажатия на кнопку?

Хотя я совсем не уверен, что правильно создаю структуру в app.js.

Rufin комментирует...

Есть главный шаблон loyout.tpl, который не изменяется и содержит только head и footer.

В body главного шаблона подгружаются данные с сервера, которые в свою очередь тоже имеют шаблоны .tpl.

У каждого такого шаблона есть свои js-скрипты.

Пытаюсь перейти на RequireJS и все js-скрипты оформить в виде модулей, которые бы подгружались когда нужно.

Что я сделал:

1. Убрал скрипты из loyout.tpl и других шаблонов и сделал одну точку входа в loyout.tpl:

script data-main="/js/app" src="/js/require-jquery.js"

2. В app.js прописал конфиг, который указывает на пути расположения моих модулей:

require.config({
calendar: "/js/lib/calendar",
popup: "/js/admin/catalog/popup",
catalog: "/js/admin/catalog/common",
news: "/js/admin/news/common",
});

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

require(["jquery", "calendar"], function($, calendar) {
//...jQuery плагин календаря...//
});
require(["jquery", "popup"], function($, popup) {
//...Обработчики для всплывающего меню...//
});
require(["jquery", "catalog"], function($, catalog) {
//...Функции для работы со справочниками...//
}
require(["jquery", "news"], function($, news) {
//...Функции для работы с новостями...//
});
(еще 25 модулей, которые требуются там или сям...)

Запускаю, работает. Но при загрузке странички вижу, что загружаются все скрипты и нужные и ненужные, все что я здесь описал в require.

Скорее всего я что-то неправильно делаю. А нельзя ли сделать, например так:

Создать модуль, которы бы требовался только для шаблона с новостями:
define(
'news',
['jquery'],
function( $ ){
return {
showAll : function(){$.ajax()};
addNews : function(){$.ajax()};
delNews : function(){$.ajax()};
};
});

И в app.js написать:

require.config({
jquery: "/js/lib/jquery/1.10.min",
calendar: "/js/lib/calendar",
popup: "/js/admin/catalog/popup",
catalog: "/js/admin/catalog/common",
news: "/js/admin/news/common",
});


require(["jquery"], function($) {
$(document).ready(function(){
$(document).on('click', '#showAll', function(){
require(["jquery", "news"], function($, news) {news.showAll(); });
});
$(document).on('click', '#addNews', function(){
require(["jquery", "news"], function($, news) {news.addNews(); });
});
});
});

Можно ли так сделать?

И будет ли в этом случае подгружен мой модуль "/js/admin/news/common.js" асинхронно, по мере нажатия на кнопку?

Хотя я совсем не уверен, что правильно создаю структуру в app.js.

Shemyakina Tatiana комментирует...

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

Rufin комментирует...

В любом случае, буду следить. Спасибо за терпение ;)

Rufin комментирует...

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

Shemyakina Tatiana комментирует...

Я использую knockout, к нему есть очень удобный плагин "knockout-amd-helpers", который автоматически подгружает скрипт + шаблон. Если интересно, я могу отдельным постом выложить пример.

Сергей комментирует...

Скажите, пожалуйста, как сделать собственный модуль только так, чтобы он поддерживал не только АМД загрузку, но и обычную?

В коде jQuery можно найти такую конструкцию:
if ( typeof define === "function" && define.amd ) {
define( "jquery", [], function() {
return jQuery;
});
}

Я решил пойти тем же путём, всё сработало отлично, однако, мой модуль зависит от Google Maps. Получается такая картина (файл AddressToMap.js):

if (typeof define === 'function' && define.amd) {
define('addresstomap', ['googlemaps'], function(google) {
// здесь переменная "google" определёна
return AddressToMap;
});
}

AddressToMap.prototype = {
// здесь идёт код прототипа, который использует "google"
}

Если модуль будет работать без АМД и Google Maps будут подключены через тэг script, как обычно, то переменная "google" будет существовать, модуль отработает. Однако, при АМД загрузке "google" видна только внутри define, модуль упадёт. Возможно ли внутри блока define экспортировать переменную в "глобальную область видимости"?

Отправить комментарий