Масштабируемые JavaScript приложения

:

Более месяца назад в статье FAQ по JavaScript: задавайте вопросы был задан вопрос «Подскажите примеры хорошего подхода организации JS кода к сайту на достаточно высоком уровне. Как можно узнать подробнее практики реализации например gmail?».

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

Эта статья о том, как сделать крупное веб-приложение расширяемым и поддерживаемым: архитектура, подходы, правила.

Если вы работаете над веб-приложением, то большую часть времени вы тратите на дописывание кода, исправлении ошибок. Только малая часть уходит на дописывания нового функционала. Любое сложное веб-приложение постоянно меняется. Сегодня — 1 строка, завтра — 20, послезавтра — 300.

Давайте посмотрим какой же код есть на сайтах:

$(function () { // Типичный код для сайта
    $('#button').click(function (event) {
        alert(this.innerHTML);
    });

    $('#list').uberScrollerPluginStart({
        "theme": "red"
    });

    $('#lazy_thing').click(function () {
        $.get('/lazy/thing/body.html', doLazyLoad.bind(this));
    });

    /* Ещё десяток разных стилей
       и плагинов */
});

Чаще это jQeury, мы навешиваем события, подключаем плагины, выполняем ajax-запросы. Если какой-то плагин удалить, то все сломается. Мы получаем своеобразный клубок кода в который намешаны фреймворки, плагины, наш код. Такой клубок не очень большой (строк 100) и, как правило, создается и поддерживается одним человеком. Большинство сайтов создается 1 раз и не поддерживается вообще, поэтому для них что-то большее может быть вредно и может увеличить себестоимость всего сайта в целом.

Если применить такую архитектуру к GMail, Yandex.Mail, Портал Yahoo!, Twitter, то мы получим огромный клубок кода(10000+ строк), который создает несколько человек. Огромный клубок очень сложно распутать, а запутать ещё сожнее, чтобы ничего не сломать. Веб-приложения постоянно развиваются, поэтому такой клубок приходиться постоянно распутывать и запутывать.
Код сайта не структурирован, а его архитектура имеет сильную связанность. Для веб-приложений такую архитектуру использовать невозможно.

Архитектура


Рассмотрим одну из нескольких архитектур, которая позволяет без проблем создавать масштабируемые приложения. Архитектура взята у N.C. Zakas, она мне очень нравится (будет здорово если вы смотрели его презентацию и вспомните о чем идет речь), в ходе я её немного изменю, а конечный результат вы уведите в примерах.
Фреймворк

В любое приложение, как правило, входит фреймворк. Все клиентские фреймворки будь jQuery, Mootools, YUI, dojo — это всего лишь коробка с инструментами. Инструменты помогают вам забивать гвозди, пилить доски. Какие-то инструменты очень нужны, есть и которые пылятся. Если основных инструментов мало, то подключаются более тяжелые, например, Backbone.js + Underscore.js
В жизни заменить коробку от jQuery на Mootools не составит труда. Подумайте, что вам будет стоить отказ от jQuery или вашей любимой библиотеки сейчас? Чтобы замена была легкой необходимо добавить обертку над функциями библиотек — им может быть Ядро приложения.
Ядро

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

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

Модули веб-приложений состоят из HTML + CSS + JavaScript + Ресурсы

Ресурсы модуля — локализация, дескрипторы и другие приватные данные модуля.

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

Для обеспечения слабой связанности и для ограничения свободы модуля его необходимо огородить специальным объектом-медиумом — песочницей. Каждый модуль обязан находиться внутри своей песочницы и общаться только с ней. Это единственный объект о котором знает модуль. Роль песочницы проста. Она выступает в роли охранника — знает что может делать модуль, знает с кем может общаться модуль. Песочница обеспечивает связь модуля с ядром.

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

Субмодули

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

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

Подчиненность


Для уменьшения числа связей внутри системы нам нужна жесткая подчиненность

— Только библиотека знает о браузере и имеющемся АПИ
— Только ядро знает о библиотеке
— Только песочница знает о ядре
— Каждый модуль знает только о своей песочнице

Ни один из объектов не должен знать о всем приложении.

Наше приложение должно постоянно изменяться. Пользователи хотят новый функцинал — мы его добавляем. Браузеры получают новую фичу — мы её прикручиваем.

Поэтому каждый объект должен изменяться
— Библиотека расширяется за счет плагинов — Браузер получает новый API — добавляем плагин
— Ядро обновляется за счет расширений — Заменили протокол с XML на JSON, поменяли формат отправляемых данных, изменили AJAX транспорт — добавили расширение
— Все приложение расширяется за счет модулей — Пользователи желают новую функцию — мы добавляем какой-либо модуль

Коммуникация


Все знают, что HTML DOM изобилует событиями. События есть везде — есть у элементов, есть в API (XHR, Workers). События в DOM позволяет объектной модели документа бесконечно расширяться и создавать расширяемые приложения. Я считаю, что события — это лучшая база для веб-приложений.

Рассмотрим пример:

var Module1 = {
   "someAction": function () {
       Module2.getSomeValue();
   }
};
  
var Module2 = {
   "getSomeValue": function () {
       return 'data';     
   }
};

При обычной схеме модули общаются друг с другом напрямую. Модуль 1 зависит от модуля 2 и от его метода getSomeValue(). Если мы уберем модуль 2, то все сломается.

Если вызов метода заменить событием, то модули станут независимыми (будут слабо связаны).

// Слабая связанность
var Module1 = {
   "init": function ($) {
      $.on('event', function (e) { // $ - не jQuery, это экземпляр sandbox
         console.log(e.data);
      });
   }
};
    
var Module2 = {
   "someAction": function ($) { // $ - не jQuery
      $.trigger('event', 'data');  
   }
};

В модуле 1 мы слушаем событие event, модуль 2 вызывает событие event с какими-либо данными и хэндлер события отрисовывает данные в консоль

Да, архитектура сильно меняется, но мы избавляемся от сильной связанности.

Асинхронные функции


События неминуемо влекут за собой асинхронность, думаю все знаю, что HTML DOM изобилует асинхронными методами. XHR, JSONP, отправка данных из фрейма в фрейм, пересылка данные в воркер и обратно. Асинхронное программирование сложнее и его использование не всегда оправдано. Но в нашем случае асинхронные функции могут быть невероятно полезными.

Посмотрим пример:

// Синхронный код
var Storage = {
    "read": function (key) {
        return localStorage[key];
    }
};

var data = Storage.read('key'),
    pData = process(data);
$.trigger('data', pData);

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

Переделаем наш код с заменой localStorage:

// Асинхронный код
var Storage = {
    "read": function (key, cb) {
        $.get('/read/' + key, cb);
    }
};

Storage.read('key',function(data) {
    var pData = processData(data);
    $.trigger('data', pData);
}.bind(this));

У нас была простая функция получения данных и 10 строк исходного кода. Мы добавили 1 строку и изменили 4 строки. Получили практически 50% изменений (и то я учитывал скобки). Если бы не наши события, то пришлось менять и код, использующий Storage. Используя асинхронный подход в функциях получения и сохранения данных мы избавляем себя от части проблем в будущем.

Как правило, все асинхронные методы связаны с отправкой и получением данных (XHR, Location API, File API), поэтому все функции сохранения и получения данных лучше сделать асинхронными либо подключить события.

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

Это выдержки из доклада c Я.Субботника. На субботнике я пообещал проиллюстрировать теорию практикой. В теории все красиво, но теория без практики имеет малую ценность, поэтому давайте на основе нашей архитектуры создадим приложение.

Пример: масштабируемое JavaScript приложение, которое легко поддерживать


Будем создавать приложение, точнее огрызок приложения (статья получается очень большой, поэтому сокращу эту часть), демонстрирующий модульность. В состав приложения входит:
  • Ядро,
  • Песочница,
  • Модули: модуль, отображающий текст; модуль, генерирующий события по таймеру; модуль, фильтрующий это событие; модуль, логирующий некоторые события в консоль.
Часть примеров связанных со сборкой проекта, автоматической генерацией юнит-тестов и модульным тестированием я покажу на пальцах, чтобы не перегружать статью.
Библиотеки и технологии

Как водится, будет jQuery. Для систематизации верстки будем использовать упрощенную технологию БЭМ: Блок, Элемент, Модификатор. Для динамической загрузки скриптов мы будем использовать $script.js. Шаблоны — модифицированный шаблонизатор от Резига.
Структура проекта
    /app
        /css                             - верстка и стили блоков по упрощенному БЭМ
            /blocks
                /b-module-name
                    /b-module-name.css
                    ...
                ...
            /pages
                /index.css
                ...
        /descriptors                     - дескрипторы модулей
            /ModuleName.json
            ...
        /locales                         - локализация модулей
            /ModuleName.json
            ...
        /modules                         - логика модулей
            /ModuleName.js
            ...
        /templates                       - шаблоны модулей
            /ModuleName.html
            ...
        /views                           - семплы верстки
            /ModuleName.html
            ...
    /build                               - скрипты для сборки
    /lib                                 - наши скрипты
        /Core.js
    /test                                - тесты
        /lib
            /qunit.css
            /qunit.js
        /ModuleName                      - тест модуля ModuleName
            /index.html
            /index.js
        /TestData.js                     - семплы данных для теста
    /vendors                             - внешние библиотеки
        /Script.js
        /jQuery.js
        ...
    /index.html                          - базовая верстка
    /index.js                            - основной js файл, использующийся для сборки
    /index.json                          - дескриптор приложения
    ...

Модули

Начнем сперва с модулей. Каждая часть модуля должна подключаться динамически и статически(при сборке в один файл), либо и так и так. Все должно быть максимально прозрачно для разработчика: есть модуль — используем, нет — загружаем и используем.

Наши модули будут состоять из нескольких частей:

  1. Семпл верстки: HTML файл с подключенными стилями. Этот файл будет использоваться в юнит-тесте, он показывает как выглядит модуль из него могут быть взяты семплы для создания шаблона.
  2. Шаблон: Блоки HTML, которые использует модуль.
  3. Стили и изображения: простой CSS файл или файлы, изображения
  4. JavaScript: код модуля
  5. Дескриптор: JSON файл, содержащий имя модуля, его настройки и список тех событий, которые может слушать и порождать. Он используется для автоматической генерации Юнит-теста, его использует песочница для разграничени прав.
  6. Локализация: JSON файл, содержащий тексты на разных языках
Каждый модуль имеет адекватное название, каждая часть модуля расположена в отдельной директории и имеет такое же имя как и у модуля. Мы максимально отделили все логические части модуля (Разметка/Шаблон, Вид, Логика, Описание и конфигурация, Тексты). Каждый модуль экспортирует только 2 метода: init и destroy:
Пример модуля (модуль DataGenerator)
(function(global){
    "use strict";
    var intervalId;
        
    var DataGenerator = {
        init: function (sandbox) {
            intervalId = setInterval(function () {
                sandbox.trigger('newData', Math.random());
            }, sandbox.getResource('interval'));
        },
        destroy: function () {
            clearInterval(intervalId);    
        }
    };
    
// Экспортируем    
    if (!global) {
        return DataGenerator;
    }
    if (!global.exports) {
        global.exports = {};
    }
    global.exports.DataGenerator = DataGenerator;
}(this)) // ; не ставим!

Согласен, что много мусора в коде. У нас именно такой формат из-за требований к модулю: Ядро авторитарно оно само подключает модули, можуль должен подключаться как статически так и динамически. В JavaScript нет модулей как таковых, поэтому каждый создает свой вид. Есть какие-то " стандартные", но чаще — это велосипед под конкретные задачи.
Пример дескриптора (модуль DataGenerator)
{
    "name": "DataGenerator",
    "acl": {
        "trigger:newData": true // модуль может пораждать событие newData
    },
    "resources": {
        "interval": 1000
    }
}

Пример локали (модуль MessageView)
{
    "text_label": {
        "ru": "Он сказал: ",
        "en": "He said: "
    }
}

Пример шаблона (модуль MessageView)
<div class="b-message-view">
    <span class="b-message-view__label">{%=label%}</span>
    <span class="b-message-view__value">{%=value%}</span>
</div>

Плюсы такого формата
Каждая логическая часть отделена. Мы можем использовать каждую часть неоднократно. Например, дескриптор мы можем использовать для автоматический генерации скелета юнит-тестов.

Ядро

Нам необходимо загружать и регистрировать модули. Мы должны это уметь делать как при сборке так и динамически. jQuery Deffered нам в этом очень сильно помогут. Т.к процесс загрузки одной части модуля практически ни чем не отличается от другой, а частей у нас много, то нам необходимо выделить фабрику по производству функций загрузки:
    var loaderFactory = function (cacheObject, method, format, methodOwner, type) {
        return function (name) {
            var dfd = $.Deferred(),
                self = this;

            if (cacheObject[name]) {
                dfd.resolve();
                return dfd.promise();
            }

            function successOrFail(object) {
                var camelCasedType = type.slice(0, 1).toUpperCase() + type.slice(1);
                self['push' + camelCasedType](name, object);

                dfd.resolve();
                if (object) { // if fail
                    EventManager.trigger(type + ':loaded', {name: name});
                    EventManager.trigger(type + ':' + name + ':loaded');
                }
            }

            var path = Core.descriptor.path[type] + format.replace('$0', name);

            if (type === 'module') {
                method.call(methodOwner, path, successOrFail);
            } else if (type === 'template') {
                method.call(methodOwner, path, successOrFail, 'html').error(successOrFail);
            } else {
                method.call(methodOwner, path, successOrFail).error(successOrFail);
            }
            return dfd.promise();
        }
    };
ModuleManager

Менеджер модулей просто загружает части модулей и кэширует их. У него есть ряд методов для регистрации модуля без загрузки(статическая сборка).
    var ModuleManager = {
        modules: {},
        descriptors: {},
        locales: {},
        templates: {},
        pushModule: function (name, module) {},
        pushDescriptor: function (name, descriptor) {},
        pushLocale: function (name, locale) {},
        pushTemplate: function (name, template) {},
        load: function (name) {}
    };

    ModuleManager.getModule = 
        loaderFactory(ModuleManager.modules, require, '$0.js', this, 'module');
        
    ModuleManager.getDescriptor = 
        loaderFactory(ModuleManager.descriptors, $.getJSON, '$0.json', $,          
                      'descriptor');
                      
    ModuleManager.getLocale = 
        loaderFactory(ModuleManager.locales, $.getJSON, '$0.json', $, 'locale');
        
    ModuleManager.getTemplate = 
        loaderFactory(ModuleManager.templates, $.get, '$0.html', $, 'template');

Я оставил скелет объекта, чтобы много место не занимал. В любом случае ни кто не читает. Полная версия в исходниках.
Шаблонизатор

Будем использовать простой шаблонизатор от John Resig
var templateFactory = function(str, data) {}

EventManager

Менеджер событий регистрирует события, удаляет, вызывает. Все глобальные события в приложении идут через него. Не будем изобретать велосипед. EventManager будет использовать jQuery.bind jQuery.trigger jQuery.unbind кроме стандартных методов у него будет интересный метод — hook, который навешивает хук-функцию на событие. Хук-функция может менять содержимое параметров события, также оно может предотвращать вызов события.
    var EventManager = {
        $: $('<div/>'),
        hooks: {},
        trigger: function (event, data) {
            if (this.hooks[event]) {
                // Update event data
                var result = this.hooks[event](data);
                // Don't trigger event
                if (result === false) {
                    return this;
                }
                // Trigger with new data
                data = result || data;
            }
            this.$.trigger.apply(this.$, [event, data]);
            return this;
        },
        bind: function () {},
        unbind: function () {},
        hook: function (event, hookFunction) {
            // One hook for example
            this.hooks[event] = hookFunction;
            return this;
        },
        unhook: function (event) {
            delete this.hooks[event];
            return this;
        }
    };

Использование глобального менеджера событий имеет один важный плюс: Мы можем записать лог событий, а потом из лога восстановить ход событий (удобно для отлова багов на стороне пользователя).
Core
    var Core = {
        descriptor: {},
        runningModules: {},

        // Основной метод, инициализирующий ядро
        init: function (descriptorOrFileName, callback) {},

        // Загружает все модули
        _initModules: function (callback) {},
        
        // Загружает один модуль по имени
        initModule: function (name, callback) {},
        
        // Уничтожает модуль
        destroyModule: function (name) {},
        
        // Получает HTMLElement модуля по имени
        getBox: function (name) {},

        // Получает шаблон по имени подуля
        getTemplateFunction: function (moduleName, templateSelector) {}
    };

Удалил тело функций и JSDoc блоки.

Извне нам нужны только некоторые методы из всех наших модулей ядра. Будем экспортировать только их:

    var CorePublic = {
        trigger:         $.proxy(EventManager.trigger, EventManager),
        bind:            $.proxy(EventManager.bind, EventManager),
        unbind:          $.proxy(EventManager.trigger, EventManager),
        on:              $.proxy(EventManager.bind, EventManager),

        getModule:       $.proxy(ModuleManager.getModule, ModuleManager),
        getDescriptor:   $.proxy(ModuleManager.getDescriptor, ModuleManager),
        getLocale:       $.proxy(ModuleManager.getLocale, ModuleManager),
        getTemplate:     $.proxy(ModuleManager.getTemplate, ModuleManager),

        pushModule:      $.proxy(ModuleManager.pushModule, ModuleManager),
        pushDescriptor:  $.proxy(ModuleManager.pushDescriptor, ModuleManager),
        pushLocale:      $.proxy(ModuleManager.pushLocale, ModuleManager),
        pushTemplate:    $.proxy(ModuleManager.pushTemplate, ModuleManager),

        init:            $.proxy(Core.init, Core),
        destroyModule:   $.proxy(Core.destroyModule, Core),
        initModule:      $.proxy(Core.initModule, Core),

        getTemplateFunction:  $.proxy(Core.getTemplateFunction, Core)
    }; 

Песочница

Каждый модуль имеет свою собственную песочницу, поэтому мы создадим конструктор Sandbox, порождающий песочницы. Песочница получает в качестве аргумента дескриптор модуля. Все методы песочницы может использовать модуль.
    var Sandbox = function (descriptor) {
        this.descriptor = descriptor || {};
    };  
    
    Sandbox.prototype.getBox = function () {};
    // Проверяет может ли модуль сделать такое-то действие
    Sandbox.prototype.is = function (role) {};    
    Sandbox.prototype.bind = function (event, callback) {};
    Sandbox.prototype.unbind = function (event, callback) {};
    Sandbox.prototype.trigger = function (event, data) {};
    Sandbox.prototype.hook = function (event, hookFunction) {};
    Sandbox.prototype.unhook = function (event) {};
    Sandbox.prototype.getText = function (message) {};
    Sandbox.prototype.getResource = function (resource) {};
    Sandbox.prototype.getTemplate = function (templateSelector) {};

В тех функциях ( bind, trigger, hook, ...), которые могут повлиять на другие объекты песочница проверяет возможность выполнить данную функцию у данного модуля (используя дескриптор модуля).

Сборка ядра

Каждая часть ядра должна лежать в отдельном файле и собираться воедино препроцессором, в примере я не использую препроцессор(он сферический в вакууме), поэтому скинул все вместе.
(function(global, $, require, undefined){
    "use strict";

    var templateFactory = function(str, data){};

    var loaderFactory = function (cacheObject, method, format, self, type) {};

    var ModuleManager = {};

    var Sandbox = function (descriptor) {};
    
    var EventManager = {};

    var Core = {};

    var CorePublic = {};           

    if (!global) {
        return CorePublic;
    }
    if (!global.exports) {
        global.exports = {};
    }
    global.exports.Core = CorePublic;    
}(this, jQuery, $script));

Дескриптор приложения

Приложение тоже имеет дескриптор, который описывает какие модули входят в приложение, где находятся части модулей, определяет текущую локаль, описывает базовую разметку. Дескрипторов может быть несколько под разные сборки.
{
    "modules": ["MessageView", "DataGenerator", "Logger", "Hook"],
    "layout": {
        "MessageView": ".b-message-view"
    },
    "locale": "ru",
    "path": {
        "descriptor": "./app/descriptors/",
        "module": "./app/modules/",
        "locale": "./app/locales/",
        "template": "./app/templates/"
    }
}

Приложение: index.js

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

MessageView — отображает сообщение по событию newData
DataGenerator — раз в секунду генерирует событие newData с данными Math.random()
Logger — слушает событие newData и записывает в консоль то, что пришло
Hook — навешивает хук на событие newData. Если в событие приходит строка, то хук прерывает событие. Если приходит число меньше 0.5, то оно умножается на 100.

Сборка


Для каждой стадии (dev, test, prod) нужен свой сборщик (или своя конфигурация). Для разработки необходимо собирать документацию, а сжимать файлы не нужно. Для тестинга и продакшена могут быть разные пути до файлов, необходимо сжимать и собирать файлы, проверять код на наличие ошибок(статическая проверка). Может существовать много стратегий и инструментов сборки. Я опишу простейший. При сборке мы будем использовать дескриптор приложения index.json.
Сборка index.js

Для сборки index.js мы будем использовать сферический в вакууме препроцессор, который имеет функции: require, buildFrom. Функции препроцессора обрамлены в блочные комментарии, поэтому они не мешают работе всего приложения (и подходят как для JavaScript так и для CSS). index.js подается на вход препроцессору, который сканирует файл и собирает проект.
/*$require: ./lib/Core.js */
(function (Core) {
"use strict";

/*$buildFrom ./index.json */

Core.on('ready', function () {
    Core.trigger('newData', 'Pewpew');
});

Core.init(/*$require*/'./index.json'/*$*/);
}(this.exports.Core))

После сборки файл может выглядеть как-то вот так:
// Тут подключили Core.js

(function (Core) {
"use strict";

// + descriptors/Logger.json
Core.pushDescriptor("Logger", {
    "name": "Logger",
    "acl": {
        "listen:newData": true,
        "listen:ready": true
    }
});
// - descriptors/Logger.json

// + modules/Logger.js
Core.pushModule("Logger", (function(global){
    // ...
}(this)));
// - modules/Logger.js

// + locales/Logger.js
Core.pushLocale("Logger", {});
// - locales/Logger.js

// ... Ещё какие-то модули ....


Core.on('ready', function () {
    Core.trigger('newData', 'Pewpew');
});

Core.init({
    "modules": ["MessageView", "DataGenerator", "Logger", "Hook"],
    "layout": {
        "MessageView": ".b-message-view"
    },
    "locale": "ru",
    "path": {
        "descriptor": "./app/descriptors/",
        "module": "./app/modules/",
        "locale": "./app/locales/",
        "template": "./app/templates/"
    }
});
}(this.exports.Core))

C require все понятно, вот с buildFrom немного сложнее. Эта функция использует дескриптор приложения для подключения определенных файлов. В нашем случае я собрал модуль Logger (остальные тоже как-бы подключены). Логика buildFrom может быть немного сложнее она может чистить локализацию (в нашем случае удалит "en": "He said: "), выполнять предварительную проверку и тп.
Для среды dev приложение может не собираться — модули динамически загружаются ядром.

Сборка index.css

В примере у нас простая структура модулей: каждый модуль имеет 1 блок, поэтому мы ограничимся тем же сферическим в вакууме сборщиком:
/*$buildFrom: ../../index.json */

/* app/css/blocks/b-message-view/b-message-view.css */
.b-message-view {
    color: green;
    font-family: monospace;
}

Логика buildFrom в контексте css следующая: он смотрит конфиг приложение на наличие layout и для каждого лейаута, подключает соответствующий блок у нас это b-message-view — все просто. Это предельно упрощенный вариант сборки, который может не подойти для более сложных приложений, но для примера этого достаточно. Например, сборщик БЭМ использует json файл с описанием блоков приложения.
Сборка index.html

У нас есть index.css и index.js. Будем использовать тот же препроцессор для сборки index.html
<!DOCTYPE HTML>
<html>
    <head>
        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
        <title></title>
        <!--$require: index.css-->
        <link rel="stylesheet" href="app/css/pages/index.css" />
        <!--$-->
    </head>
    <body>
        <!--$buildFrom: index.json-->
        <div class="b-message-view"></div>
        <!--$-->
        <script type="text/javascript" src="http://yandex.st/jquery/1.6.1/jquery.js"></script>
        <!--$require: index.js-->
        <script type="text/javascript" src="./vendors/Script.js"></script>
        <script type="text/javascript" src="./lib/Core.js"></script>
        <script type="text/javascript" src="./index.js"></script>
        <!--$-->
    </body>
</html>

Логика buildFrom в контексте html следующая (похожа на css): он смотрит конфиг приложения на наличие layout и для каждого лейаута создает соответствующий div у нас это b-message-view. Ещё раз скажу, что это простейший вариант сборки. Вы же можете применять xslt, более умные сборщики.

На выходе мы получим:

<!DOCTYPE HTML>
<html>
    <head>
        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
        <title></title>
        <link rel="stylesheet" href="/index.css" />
    </head>
    <body>
        <div class="b-message-view"></div>
        <script type="text/javascript" src="http://yandex.st/jquery/1.6.1/jquery.js"></script>
        <script type="text/javascript" src="/index.js"></script>
    </body>
</html>

Makefile — Общая сборка

Что делает сборщик:
  1. Выполняет запуск препроцессора для сборки index.css
  2. Оптимизирует index.css: data/uri и т.п.
  3. Ужимает index.css (gz опционально)
  4. Выполняет сборку ресурсов index.css: картинки
  5. Запускает автоматизированные юниттесты (ниже)
  6. Выполняет запуск препроцессора для сборки index.js
  7. Проводит валидацию index.js
  8. Ужимает index.js (gz опционально)
  9. Собирает пакет под вашу OS .deb .rpm
  10. Кладет пакет в репозиторий
  11. Устанавливает пакет

Юнит-тесты


Ещё раз о наболевшем. Многие считают, что тесты нужны только для фреймворков, но ваше приложение тоже фреймворк, только узконаправленный, созданные под конкретную задачу и его используют другие разработчики, работающие с вами.

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

Как правило, каждое событие имеет свой собственный формат, чтобы упростить задачу тестирования мы создадим семплер событий:

var TestData = {
    "newData": function () {
        var data = [NaN, Infinity, window, Error, 'pewpewpew', 
        '<b>Pewpew</b>', '"', '\'', new Date, Date, Math, 42, 
        8, -1, 0, false, true];

        return data;
    }
};

Для тестирование мы будем использовать QUnit. Рассмотрим на примере MessageView. Генератор юнит-теста создает (автоматически) тест из одного модуля MessageView, создает скелеты тестов для проверки интерфейса модуля — те события, которые он слушает и генерирует. Это минимум того, что мы должны проверить.
Код теста: index.js
// MessageView test
(function (Core, $, TestData, ok, test, module, equals, expect, asyncTest, start, stop) {
"use strict";

// Текущая среда приложения
var ApplicationEnvironment =
{
    "modules": ["MessageView"],
    "layout": {
        "MessageView": ".b-message-view"
    },
    "locale": "ru",
    "path": {
        "descriptor": "../../app/descriptors/",
        "module": "../../app/modules/",
        "locale": "../../app/locales/",
        "template": "../../app/templates/"
    }
};

Core.on('ready', function () {
    module("MessageView");

    // Тест 1
    test("listen:newData", function() {
        var testItems = TestData["newData"](),
            $MessageView = Core.getBox("MessageView"),
            template = Core.getTemplateFunction("MessageView", '.b-message-view'),
            label = Core.getText("MessageView", "text_label");

        expect(testItems.length);

        // >>> put your code

        $.each(testItems, function (index, text) {
            Core.trigger("newData", [text]);

            // >>> put your code
            var expected = template({label: label, value: text}); // <<<
            equals(expected, $MessageView.html(), 'Should be "text_label: value"'); // <<<
        });
    });
    
    // Тест 2
    test("trigger:newData:display", function() {
        var testItems = TestData["newData"](),
            $MessageView = Core.getBox("MessageView"),
            template = Core.getTemplateFunction("MessageView", '.b-message-view');

        expect(testItems.length);

        // >>> put your code
        Core.on("newData:display", function () { // <<<
            ok(true); // <<<
        }); // <<<

        $.each(testItems, function (index, item) {
            Core.trigger("newData", [item]);
            // >>> put your code
        });
    });

});

Core.init(ApplicationEnvironment);

}(this.exports.Core, jQuery, TestData, ok, test, module, equals, expect, asyncTest, start, stop))

Из всей этой массы кода разработчик должен дописать всего 5 строк я их отметил " <<<".

Код теста index.html очевиден — не прикладываю.

Автоматизация тестирования и покрытие кода тестами


Если вы не используете автоматизированное тестирование, то полезность ваших тестов сокращается процентов на 60 (вы не сможете постоянно запускать тесты на всех браузерах, да и это неблагодарная работа). Если вы не проверяете покрытие кода тестами, то польза от таких тестов тоже сокращается (есть шанс пропустить важный момент). Есть несколько фреймворков, которые сильно упрощают эту задачу (это не тема данной статьи, поэтому упомяну вскользь):
js-test-driver — дружит с QUnit, имеет встроенный модуль покрытия кода.
TestSwarm ( вики) — модуль от Резига и Mozilla Labs
JSCoverage

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

Валидация кода и сборка документации


Тоже не тема данной статьи, но упомянуть стоит. Если вам нужны доки, то по заданным конфигам сборщик проекта может собрать документацию, используя Dox, jsdoc-toolkit и т.п. см. Написание документации
Перед сборкой проекта в тестинг и перед коммитом в репозиторий необходимой проверять код валидатором. Если не проверять код во время прекоммита (проверять перед сборкой или когда вздумается), то может быть слишком поздно — может накопиться большой объем кода, а большой объем кода сложнее исправлять и рано или поздно вы можете забить на валидацию. Для предотвращения ошибок сборки необходимо проверять код (не так строго) после сборки проекта.

Общие моменты


Оформлю данную часть в виде тезисов. Они справедливы не только для веб-приложений.
  1. С вами работают другие люди — уважайте их труд и цените их время
  2. Необходимо красиво оформлять код (отступы и необходимые пробелы). JSLint, JSHint в помощь!
  3. Обязательны хорошие комментарии как для функции вцелом так и для важных моментов кода.
  4. Оставляйте ссылки на тикеты внутри кода, чтобы читающий код знал почему это сделано так, а не по другому! Чтобы не удивлялись конструкциям $textarea.val($textarea.val())
  5. Нейминг: Давайте внятные имена. Длинное имя функции — хорошо! Переменные — существительные. Функции — глаголы или глагольные выражения. Избегайте бесполезных имен: foo, temp. Часто бывает, что имя не приходит в голову (так и хочется назвать tmp), подумайте, окиньте взглядом окружение кода — красота кода превыше всего!
  6. Держите JavaScript, HTML и CSS порознь (у них разные задачи). HTML — каркас, разметка. CSS — представление разметки. JavaScript — логика приложения.
  7. Избегайте объявления обработчиков событий в атрибутах. Избегайте Element.style. Избегайте функций, генерирующих html (шаблоны FTW!). Избегайте CSS Expressions!
  8. Разгружайте обработчиков событий. 2-3 строки кода им за глаза!
  9. Не пропатчивайте чужие объекты, если они это не разрешают явно. Если объект не ваш — не трогайте его! (Array.prototype, Function.prototype)
  10. Избегайте создание глобальных объектов
  11. Если код может выбросить ошибку, то лучше если это будет ваша ошибка — ошибка, сгенерированная вами!
  12. Проверяйте тип данные через instanceof, typeof, Object.prototype.toString magic
  13. Отделите конфигурацию от кода! URL Пути; тексты; константы, в частности, строковые
  14. Автоматизируйте процесс разработки: Используйте автоматизированные инструменты для тестирования(js-test-driver), проверки кода(JSLint, JSHint), сборки кода и построения документации(JSDocToolkit, Dox). Если тестовый сервер находится удаленно, то настройте автоматический аплоад файлов по Ctrl+S. Используйте генераторы кода: тесты, скелеты модулей.

Код приложения из примера


Исходники лежат на GitHub scalable-js-app (полные тексты модулей, комментарии)

Чего нет в приложении: автоматической сборки, автоматической генерации юнит-тестов, всех тестов (кроме MessageView), автоматической сборки документации.

Почитать/Посмотреть

  1. Andrew Dupont (Gowalla, Prototype.js, S2) — Maintainable JavaScript
  2. Nicholas Zakas (Yahoo!, YUI, YUI Test) — Writing Maintainable JavaScript. Слайды новые, старые
  3. Nicholas Zakas — Scalable JavaScript Application Architecture
  4. Моё видео "Масштабируемые JavaScript приложения" с Я.Субботника слайды
PS И вот, вы решили переписать весь код, почистить код с помощью JSHint/JSLint. Остановитесь! У вас уйдет много времени на переделку, ещё больше на тестирование и переделку переделанного т.к. обязательно хоть что-нибудь да сломается (если у вас нет Юнит-Тестов, то это наверняка случится). Создавайте новый код по новому стандарту, а старый модифицируйте по необходимости, тогда, когда вы меняете его часть.

Надеюсь, было интересно. Предложения, пожелания, критика приветствуются! В будущих статьях я хотел бы подробнее описать процесс сборки проекта и автоматизированного тестирования. Если у вас есть вопросы — самое время их задать.