Прочитав книгу «Паттерны для масштабируемых JavaScript-приложений», решил реализовать данную структуру на практике.
Подробности описания модульной структуры изложены по вышеуказанной ссылке, но если вы еще не знакомы с этим материалом, то вкратце: масштабируемые JavaScript-приложения строятся на трёх китах, а точнее — на трёх паттернах: Медиатор, Фасад, Модуль.
Основные критерии в реализации:
- Приватность. Функционал разделен на модули, каждый из которых отвечает за отдельную реализацию, например, один из таких модулей может быть модуль чата, который в свою очередь разделен на еще несколько модулей: показ смайлов и отправку сообщений. Модули представляют собой самовызывающуюся функцию, доступ к которой ограничен для других модулей.
- Тестирование кода. Каждый модуль должен быть покрыт тестами, которые пишутся еще до начала разработки самого модуля, тем самым мы заранее прорабатываем будущий функционал и лучше понимаем, что конкретно нам нужно от данного модуля, как именно он должен себя вести в той или иной ситуации.
- Слабая связанность. Модули никак не связаны между собой. В них не импортируются другие модули (кроме основных библиотек типа jQuery и т.п.) в качестве зависимостей. Все это делается ради гибкости архитектуры, то есть мы можем взять тот же модуль чата и добавить его в другой проект, при этом ни изменив ни строчки кода в самом модуле. Так как модуль не имеет зависимостей и работает сам по себе, то нам не нужно настраивать его индивидуально под каждый проект. Или, к примеру, один из модулей перестал работать, если бы модули взаимодействовали друг с другом напрямую, то вся архитектура бы обвалилась: модуль вернул бы ошибку, которая передалась в другой модуль и на этом все закончилось бы, но при слабой связанности модулям безразлично, что творится в каждом из них. Вся модульная реализация скрыта за паттерном Фасад, который передает данные прямиком в ядро приложения и если один из модулей перестал работать, то остальные продолжают выполнять свои функции.
- Загрузка по требованию. Для улучшения производительности каждый модуль загружается только тогда, когда он действительно необходим. Так, модуль чата в gmail загрузится только тогда, когда пользователь кликнет на него, чтобы отправить сообщение. Или, к примеру, пользователь читает статью, которая разбита на две части, в каждой из них много картинок, видео и интерактивного материала. Мы наверняка знаем, что дочитав первую часть до конца, в большинстве случаев пользователь решит ознакомится с продолжением, но чтобы не грузить все модули и данные, отвечающие как за первую, так и за вторую часть материала, и не заставлять пользователя ждать, мы грузим лишь первую часть контента с прилегающими к ней модулями, после чего, когда материал готов для чтения и взаимодействия — можно загружать остальные файлы.
Итак, опираясь на вышеперечисленное, решил написать небольшой код, который, надеюсь, поможет некоторым разработчикам лучше понять, что именно представляет из себя модульная архитектура и как она работает.
Функцию ядра берет на себя паттерн Медиатор (он же Посредник). Использование этого шаблона исключает прямые взаимодействия между независимыми объектами за счет введения объекта-посредника. Когда какой-либо из модулей изменяет свое состояние, он извещает об этом ядро приложения, а то в свою очередь сообщает об изменениях всем остальным модулям, которые должны знать об этом.
Что собой будет представлять ядро (Медиатор/Посредник) в модульной архитектуре?
Представим, что у нас есть два очень простых модуля, которые мы хотим добавить в свой проект: один получает два параметра в виде цифр, после чего возвращает их сумму, а второй получает параметр и выводит его на экран.
Прежде я хочу сказать пару слов о паттерне Фасад. Как я писал раньше: модуль — это самовызывающаяся функция, переменные и методы которой скрыты от пользователя, кроме тех переменных и тех методов, которые мы сами хотим сделать публичными. Эти публичные данные и называется фасадом, за которым находится архитектура модуля.
Давайте рассмотрим это на примере. Пусть переменная Namespace будет нашим пространством имен, в котором будет объект modules, в нем и будут хранится модули.
Namespace.modules = {};
Добавим функцию sum в Namespace.modules. Обратите внимание на комментарии к коду.
'use strict';
Namespace.modules.sum = (function () {
function getSum(x, y) {
return x + y;
}
/**
Это и есть паттерн Фасад.
Как видите, функция getSum() - является приватной и у пользователя нет доступа к ней,
но благодаря return мы возвращаем новый объект и записываем ссылку на приватную функцию getSum() в свойство getSum.
Теперь приватная функция будет доступна по ссылке возвращаемого метода getSum.
*/
return {
getSum: getSum // не обязательно давать публичным и приватным данным одни и те же названия
}
})();
Проделаем туже операцию со-вторым модулем.
'use strict';
Namespace.modules.createText= (function () {
function addText(txt) {
var paragraph = document.createElement('p');
paragraph.innerHTML = txt;
document.body.appendChild(paragraph);
}
return {
addText: addText
}
})();
Как видите, ничего сложного: один модуль складывает два числа, второй — выводит данные на экран. Я старался упростить все как можно больше, так как сейчас главное разобраться в самой архитектуре модульных приложений и понять, как она должна работать.
Модули готовы, теперь подготовим ядро, которое будет импортировать эти модули и работать с ними.
Как вы помните, Namespace.modues — это статическая переменная, которая хранит в себе модули, другим словом — это пространство имен.
Раз у нас уже есть одна глобальная переменная Namespace, то на ней и остановимся. Запишем в нее функцию, которая будет принимать два параметра. Первый — массив с модулями, а второй — функция обратного вызова, которая эти самые модули и будет использовать по своему назначению.
var sut = Namespace;
it("Проверим, что модуль sum и createText подключены и готовы к использованию в функции обратного вызова", function () {
sut(['sum', 'createText'], function (Modules) {
var sum = Modules.sum,
createText = Modules.createText;
expect(sum.getSum).toBeDefined(); // true
expect(createText.addText).toBeDefined(); // true
expect(sum.getMultiplication).toBeUndefined(); // false. А таких методов у нас нет.
expect(createText.deleteText).toBeUndefined(); // false. И этого метода нету
})
});
Из ядра приложения мы не должны изменять наши модули, функция Namespace будет возвращать только копии.
it("При удалении импортированных модулей, должны удалятся копии, но не оригиналы.", function () {
sut(['sum', 'createText'], function (Modules) {
var sum = Modules.sum,
createText = Modules.createText;
expect(sum.getSum).toBeDefined(); // true
expect(createText.addText).toBeDefined(); // true
/** удаляем методы импортируемых модулей */
delete sum.getSum;
delete createText.addText;
expect(sum.getSum).toBeUndefined(); // true
expect(createText.addText).toBeUndefined(); // true
/** так как мы работаем с копиями, а не с оригиналами, то модули работаю корректно */
expect(sut.modules.sum.getSum).toBeDefined(); // true
expect(sut.modules.sum.getSum(10, 10)).toBe(20); // true
expect(sut.modules.createText.addText).toBeDefined(); // true
});
});
Итак, когда мы понимаем, что именно мы хотим от функции Namespace , то давайте её реализуем.
'use strict';
(function () {
var Namespace = function () {
var args = [].slice.call(arguments),
/** Cохраняем в пеерменную последний параметр переданный ф-ии Namespace.
* Этот параметр - ф-я обратного вызова, в которояй мы будем работать с модулями */
callback = args.pop(),
/** modules будет хранить массив с модулями, которы мы будем импортирвоать в ядро */
modules = (args[0] && typeof args[0] === 'string') ? args : args[0],
/** В этот объект мы будем копировать модули и дальше работать с ним в ф-ии обратного вызова */
copyModules = {},
i;
/** Теперь мы можем вызывать Namespace не только, как экземпляр объекта,
* но и как функцию */
if (!(this instanceof Namespace)) {
return new Namespace(modules, callback);
}
/** Создаем копии модулей */
function addModuleToCopyObject(module) {
copyModules[module] = {};
function copyModule(parent, child) {
for (i in parent) {
if (parent.hasOwnProperty(i)) {
child[i] = parent[i]
}
}
return child;
}
copyModule(Namespace.modules[module], copyModules[module]);
}
modules.forEach(addModuleToCopyObject);
/** Пеердаем объект с копиями модулей в функцию обратного вызова */
callback(copyModules);
};
/** Если объекта Namespace нету в глобальном объекте, то мы его добавляем */
if (typeof window.Namespace === 'undefined') {
window.Namespace = Namespace;
}
})();
/** В modules будут хранится все наши модули */
Namespace.modules = {};
А вот и ядро нашего приложения, он же паттерн Медиатор, он же паттерн Посредник.
'use strict';
/** @param{array} - первый параметр - массив с импортируемыми модулями
* @param{function} - второй параметр - функция обратного вызова, в которой в качестве параметра Modules передается объект с импортируемыми копиями модулей.
*/
Namespace(['sum', 'createText'], function (Modules) {
/** Модули ничего не знаю друг о друге, они импортируются в ядро приложения, где и используются вместе */
var moduleSum = Modules['sum'],
moduleCreateText = Modules['createText'];
var a = 1000,
b = 1000;
moduleCreateText.addText(moduleSum.getSum(a, b));
});
Чего не хватает функции Namespace?
- Загрузка файлов. Ведь этот код не очень хорошо смотрится:
<script src="javascript/namespace-v0.0.3.js"></script> <script src="javascript/modules/module1.js"></script> <script src="javascript/modules/module2.js"></script> <script src="javascript/mediator.js"></script>
А если у нас сотня таких модулей? В идеале будет, если мы загрузим только файл с Namespace, а все остальные модули будут грузиться тогда, когда будем передавать их в ядро приложения или грузить по требованию. То есть, если приложение действительно нуждается в этом самом модуле (кликнули на чат — загрузился модуль чата).
- Пространство имен. Сейчас все модули находятся в одном пространстве — Namespace.modules, но если мы захотим разбить их на под-пространства: Namespace.MATH и Namespace.TEXT? Сейчас это не получится, функция Namespace пока не выполняет данную реализацию, но в будущем я планирую улучшить функционал и реализовать, как первый таки второй пункт.
Все вышеизложенное было написано с целью показать и объяснить новичкам, как работает модульная структура в JavaScript. Сейчас есть много хороших библиотек, которые уже умеют работать с модулями (тот же require.js). Да и через пару тройку лет можно будет во всю использовать EcmaScript 6, где уже внедрена модульная структура.
Источники
Оригинальная статья Эдди Османи: «Patterns For Large-Scale JavaScript Application Architecture».
Та же статья на русском: «Паттерны для масштабируемых JavaScript-приложений».
JavaScript Patterns — есть что почитать про модули и пространство имен, да и вообще советую тем, кто по каким-то причинам не знаком с этой книгой.
Спасибо за внимание. Буду признателен за комментарии по улучшению статьи.
Автор: sir_Galahad
Необязательно ждать несколько лет до внедрения ES6 modules — CommomJS как стандарт дистрибуции в NPM применяется примерно с 2011 года