Модульная архитектура в JavaScript

в 6:51, , рубрики: architecture, jasmine, javascript, oop patterns, ооп, Программирование

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

Подробности описания модульной структуры изложены по вышеуказанной ссылке, но если вы еще не знакомы с этим материалом, то вкратце: масштабируемые JavaScript-приложения строятся на трёх китах, а точнее — на трёх паттернах: Медиатор, Фасад, Модуль.

Основные критерии в реализации:

  1. Приватность. Функционал разделен на модули, каждый из которых отвечает за отдельную реализацию, например, один из таких модулей может быть модуль чата, который в свою очередь разделен на еще несколько модулей: показ смайлов и отправку сообщений. Модули представляют собой самовызывающуюся функцию, доступ к которой ограничен для других модулей.
  2. Тестирование кода. Каждый модуль должен быть покрыт тестами, которые пишутся еще до начала разработки самого модуля, тем самым мы заранее прорабатываем будущий функционал и лучше понимаем, что конкретно нам нужно от данного модуля, как именно он должен себя вести в той или иной ситуации.
  3. Слабая связанность. Модули никак не связаны между собой. В них не импортируются другие модули (кроме основных библиотек типа jQuery и т.п.) в качестве зависимостей. Все это делается ради гибкости архитектуры, то есть мы можем взять тот же модуль чата и добавить его в другой проект, при этом ни изменив ни строчки кода в самом модуле. Так как модуль не имеет зависимостей и работает сам по себе, то нам не нужно настраивать его индивидуально под каждый проект. Или, к примеру, один из модулей перестал работать, если бы модули взаимодействовали друг с другом напрямую, то вся архитектура бы обвалилась: модуль вернул бы ошибку, которая передалась в другой модуль и на этом все закончилось бы, но при слабой связанности модулям безразлично, что творится в каждом из них. Вся модульная реализация скрыта за паттерном Фасад, который передает данные прямиком в ядро приложения и если один из модулей перестал работать, то остальные продолжают выполнять свои функции.
  4. Загрузка по требованию. Для улучшения производительности каждый модуль загружается только тогда, когда он действительно необходим. Так, модуль чата в gmail загрузится только тогда, когда пользователь кликнет на него, чтобы отправить сообщение. Или, к примеру, пользователь читает статью, которая разбита на две части, в каждой из них много картинок, видео и интерактивного материала. Мы наверняка знаем, что дочитав первую часть до конца, в большинстве случаев пользователь решит ознакомится с продолжением, но чтобы не грузить все модули и данные, отвечающие как за первую, так и за вторую часть материала, и не заставлять пользователя ждать, мы грузим лишь первую часть контента с прилегающими к ней модулями, после чего, когда материал готов для чтения и взаимодействия — можно загружать остальные файлы.

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

Модульная архитектура в JavaScript - 1

Функцию ядра берет на себя паттерн Медиатор (он же Посредник). Использование этого шаблона исключает прямые взаимодействия между независимыми объектами за счет введения объекта-посредника. Когда какой-либо из модулей изменяет свое состояние, он извещает об этом ядро приложения, а то в свою очередь сообщает об изменениях всем остальным модулям, которые должны знать об этом.

Что собой будет представлять ядро (Медиатор/Посредник) в модульной архитектуре?

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

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

Давайте рассмотрим это на примере. Пусть переменная 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?

  1. Загрузка файлов. Ведь этот код не очень хорошо смотрится:
    <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, а все остальные модули будут грузиться тогда, когда будем передавать их в ядро приложения или грузить по требованию. То есть, если приложение действительно нуждается в этом самом модуле (кликнули на чат — загрузился модуль чата).

  2. Пространство имен. Сейчас все модули находятся в одном пространстве — Namespace.modules, но если мы захотим разбить их на под-пространства: Namespace.MATH и Namespace.TEXT? Сейчас это не получится, функция Namespace пока не выполняет данную реализацию, но в будущем я планирую улучшить функционал и реализовать, как первый таки второй пункт.

Все вышеизложенное было написано с целью показать и объяснить новичкам, как работает модульная структура в JavaScript. Сейчас есть много хороших библиотек, которые уже умеют работать с модулями (тот же require.js). Да и через пару тройку лет можно будет во всю использовать EcmaScript 6, где уже внедрена модульная структура.

Источники

Оригинальная статья Эдди Османи: «Patterns For Large-Scale JavaScript Application Architecture».
Та же статья на русском: «Паттерны для масштабируемых JavaScript-приложений».
JavaScript Patterns — есть что почитать про модули и пространство имен, да и вообще советую тем, кто по каким-то причинам не знаком с этой книгой.

Спасибо за внимание. Буду признателен за комментарии по улучшению статьи.

Автор: sir_Galahad

Источник

  1. Владимир Старков:

    Необязательно ждать несколько лет до внедрения ES6 modules — CommomJS как стандарт дистрибуции в NPM применяется примерно с 2011 года

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js