Авторы статьи: Борис Солдовский SoldovskijBB, Шевцов Сергей s_shevtsov.

Приветствуем всех, кто читает этот пост! Мы — команда front-end разработчиков Targetix. В этой статье расскажем вам о том, как устроена клиентская часть сервиса Hybrid — веб-интерфейса для взаимодействия с нашим TradingDesk и DSP.

Картинка для привлечения внимания

Введение


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

Каркас наших приложений основан на AMD-модулях, которые позволяют ограничивать область видимости, многократно использовать код и делают его структурированным. Например, у нас есть модуль станицы и модуль какого-нибудь popup-окна, а в модуле popup-окна используется какой-нибудь widget-модуль. При этом модуль popup-окна может быть использован на нескольких страницах. В этом и подобных случаях удобно использовать AMD-модули, а в их подключении и управлении зависимостями нам помогает библиотека RequireJS.

Для отображения данных используется Knockout.js — библиотека, которая реализует mvvm-патерн и позволяет динамически менять страницы благодаря шаблонизатору и наблюдаемым переменным.

Структура приложения


Если посмотреть на структуру приложения, она разделена на категории в соответствии с назначением модулей: сторонние библиотеки от третьих лиц, страницы пользовательского интерфейса, сервисные скрипты и утилиты. Некоторые модули загружаются при старте приложения, остальные загружаются по событиям от действий пользователя (click, scroll, hashchange).

Структура приложенияcontent
Статичный контент, такой как изображения, стили, шрифты — все как у всех.

pages
Модули пользовательского интерфейса, т.е. страницы, всплывающие окна и встраиваемые виджеты, непосредственно с которыми взаимодействует пользователь.

scripts
Остальные модули нашего приложения:

controllers
Набор модулей предоставляющих другим модулям связь с back-end’ом, по сути это прослойка для работы с данными, в основном каждый контроллер работает с одним набором сущностей. Например, NotificationController для работы с уведомлениями имеет такие методы, как getAll, getUnread, setReadDate.

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

viewmodel
Модель представления — обвязка над моделью, которая, реагируя на действия пользователя, меняет модель, проводит валидацию и другие вспомогательные операции.

service
Сложно однозначно описать назначение этих модулей, давайте разберем одни из них — queryManager. Он представляет собой набор методов для работы с AJAX, такие как GET, POST, DELETE и прочие, но помимо этого он подставляет в запросы общий обработчик ошибок верхнего уровня, конкатенирует URL’ы на основе данных конфига и параметров запроса, реализует механизм кеширования в localStorage и позволяет отслеживать состояние запроса и его прогресс. В основном не используется напрямую и предназначен для контроллеров.

plugins
Этот каталог имеет еще один уровень вложенности, но рассматривать подробнее их смысла не имеет. В них лежат сторонние библиотеки: RequireJS, jQuery, Knockout и другие, а также наши утилиты такие как dateUtils для работы со датами и временем.

Архитектура приложения


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

Архитектура приложения

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

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

листинг index.html
<!DOCTYPE html>
<html>
<head>
    <title>Hybrid</title>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta id="viewport" name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0">

    <link rel="shortcut icon" type="image/x-icon" href="/panel/content/images/favicon.ico" />
    <link rel="stylesheet" type="text/css" href="/panel/content/css/basic.css" media="screen" />
    <link rel="stylesheet" type="text/css" href="/panel/content/css/campaign.css" />
    <link rel="stylesheet" type="text/css" href="/panel/content/css/font-face.css" />
    <link rel="stylesheet" type="text/css" href="/panel/content/css/media.css" media="only screen and (max-width: 1690px)" />
</head>
<body class="custom-scrollbar">
    <div data-bind="template: { html: html, data: data }"></div>
    <script src="/panel/scripts/appconfig.js" id="appconfig-script"></script>
</body>
</html>


appconfig.js, require.js и init.js
Как вы могли заметить, судя по легенде эти блоки обозначены как AMD-модули, но по сути это самые обычные скрипты, которые подгружаются браузером и исполняются, просто так проще отобразить зависимости и этапы исполнения.
В первом объявляется глобальный объект, который содержит в себе методы, возвращающие ссылки на back-end, страницу логина, ссылку на CDN, так же поле содержащее антикэш ключ, который в последствии добавляется ко всем GET запросам. Делается первый AJAX запрос на получение некоторой конфигурационной информации от сервера: следует ли использовать минифицированные скрипты или же отладочную их версию, какие логи писать в консоль, отображать ли ошибки сервера в интерфейсе и прочее. Именно в этот момент back-end узнает о клиенте и определяет нужна ли авторизация или пользователь уже находится в системе.

листинг appconfig.js
window.ReJS = {};
window.ReJS.Config = {};

//#region Конструктор url
ReJS.CoreAPI = function (url) {
    return window.location.protocol + '//' + window.location.host + '/core/' + url;
};

ReJS.LoginUrl = function (url) {
    return window.location.protocol + '//' + window.location.host + '/login/' + url;
};

ReJS.CDN = function (url) {
    return ReJS.Config.cdnUrl + "/" + url;
};
//#endregion

//#region Запрос параметров приложенния
var query = new XMLHttpRequest();
query.open('GET', ReJS.CoreAPI('metadata/getconfig'), false);
query.setRequestHeader('Content-Type', 'application/json');
query.setRequestHeader('Accept', '*/*');
query.send(null);
if (query.status == 200) {
    ReJS.Config = JSON.parse(query.responseText);
    ReJS.Tail = ReJS.Config.isMinJs ? ".min" : "";
}
if (query.status == 401) {
    window.location = ReJS.LoginUrl("login?ReturnUrl=%2f") + window.location.hash;
}
//#endregion

//#region Антикеш хвост
ReJS.AntiCahceKey = "bust=46446348";
//#endregion

//#region Настройка отдладочных параметров
ReJS.DebugConfig = {
    route: true,
    state: true,
    stateCache: true,
    stateOnError: true,
    stateOnCallBackError: true,
    stateIncorrectQuery: true,
    stateLoadingState: true,
    resourceLoad: true,
    modalEvent: true,
    poolingEnables: true,
    showErrors: true,
    consoleLogging: function (flag) {
        this.route = flag;
        this.state = flag;
        this.stateCache = flag;
        this.stateOnError = flag;
        this.stateOnCallBackError = flag;
        this.stateIncorrectQuery = flag;
        this.stateLoadingState = flag;
        this.resourceLoad = flag;
        this.modalEvent = flag;
    },
    poolingSwitch: function (flag) {
        this.poolingEnables = flag;
    },
    displayErrors: function (flag) {
        this.showErrors = flag;
    }
};

ReJS.DebugConfig.consoleLogging(ReJS.Config.isConsoleLogging);
ReJS.DebugConfig.displayErrors(ReJS.Config.isDisplayErrors);
ReJS.DebugConfig.poolingSwitch(ReJS.Config.isPoolingSwitch);
//#endregion

//#region Встраивание скриптов на страницу
var startdrawtag = document.getElementById("appconfig-script");
var qwerty1tag = document.createElement("script");

qwerty1tag.setAttribute("src", '/panel/scripts/plugins/core/require' + ReJS.Tail + '.js');
qwerty1tag.setAttribute("data-main", '/panel/scripts/init' + ReJS.Tail);

startdrawtag.parentElement.appendChild(qwerty1tag);
//#endregion


В первом случае происходит перенаправление на страницу логина, во втором продолжается выполнение скрипта, в процессе которого в index.html добавляется еще один скрипт, который запускает цепочку AMD-модулей — require.js, а в его атрибуте data-main указывается конфигурационный для него файл — init.js. В нем происходит настройка путей до модулей и зависимостей для не AMD-модулей, в конце концов подключается первый настоящий модуль.

листинг init.js
requirejs.config({
    enforceDefine: true,
    catchError: true,
    waitSeconds: 20,
    min: ReJS.Tail,
    urlArgs: ReJS.AntiCahceKey,
    baseUrl: "/panel/scripts/",
    paths: {
        //jquery
        'jquery': 'plugins/jquery/jquery',
        'jquery-ui-custom': 'plugins/jquery/jquery-ui',
        'jquery-cropit': 'plugins/jquery/jquery-cropit',
        'jquery-datepicker': 'plugins/jquery/jquery-datepicker',
        'jquery-scrollTo': 'plugins/jquery/jquery-scrollTo',
        'jquery-stickytableheaders': 'plugins/jquery/jquery-stickytableheaders',

        //knockout
        'knockout': 'plugins/knockout/knockout-3-1-0',
        'knockout-mapping': 'plugins/knockout/knockout.mapping',
        'knockout-custom-bindings': 'plugins/knockout/knockout.custombindings',
        'knockout-both-template': 'plugins/knockout/knockout.bothtemplate',
        'knockout-validation': 'plugins/knockout/knockout.validation',
        'knockout-validation-rules': 'plugins/knockout/knockout.validation.rules'

        // etc...
    },
    shim: {
        'underscore': {
            exports: '_'
        },
        'routie': {
            exports: 'routie'
        },
        'browser-detect': {
            exports: 'bowser'
        }

        // etc...
    }
});

// AMD is here !!!
define(["appstart"], function () { });


appstart.js
На этом этапе наше приложение начинает обрастать мясом: подгружается jQuery и инициализирует свой $, Knockout готовится вставить в свой шаблон первую страницу, засоряется наш глобальный объект, происходят проверки на совместимость с браузером, запускается система обмена сообщениями между модулями «innerMessage» и начинается череда запросов за данными об аккаунте и не только на сервер.

Самое время сделать небольшое отступление, касаемо интерфейса самого приложения, оно основано на mvvm-патерне, как уже упоминалось, который нам предоставляет Knockout, а то, что будет динамически подставляться в разметку его средствами, мы называем шаблоном или страницей.
Шаблон состоит из связки .html и .js файлов:

листинг state.js
define(["knockout", "controller/advertisers/adLibraryController"],
        function (ko, map, adLibraryController) {
            return function () {

                var self = this;

                self.ads = ko.observableArray([]);
                // Код модуля

                self.onRender = function () {
                    // выполняется при отображении страницы
                };

                self.onLoad = function (loadComplete) {
                    // выполняется при загрузке данных для модуля

                    adLibraryController.GetAll(function (data) {
                        // data = [{'name': 'Вася'}, ...]

                        // простой пример
                        self.ads(data);

                        // модуль загружен
                        loadComplete();
                    });
                }
            }
        }
);


Роль функций onRender, onLoad и loadComplete станет понятна позже, когда рассмотрим объект state.

листинг state.html
<div data-bind="foreach: $self.ads">
    <span data-bind="text: name">
</div>


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

листинг appstart.js
define(["jquery", "knockout", "state/page", "browser-detect", "service/queryManager", "underscore", "knockout-both-template", "knockout-custom-bindings"],
        function ($, ko, PageState, browserDetect, QM, _) {
            ReJS.Root = {};
            ReJS.RootState = {};
            ReJS.RouteObject = {/* ... */};

            // Инструкция по пользованию innerMessage
            // Посыл сообщения производим так:
            // ReJS.innerMessage({example: true});
            //
            // Прием так:
            // ReJS.innerMessage(function (message) {
            //    if (message && message.example) {
            //        ...
            //    }
            // })
            ReJS.innerMessageListeners = [];
            ReJS.innerMessage = function (message) {
                if (typeof (message) == "function") {
                    ReJS.innerMessageListeners.push(message);
                } else {
                    for (var i = 0; i < ReJS.innerMessageListeners.length; i++) {
                        ReJS.innerMessageListeners[i](message);
                    }
                }
            };

            // Проверка на совместимость
            if ((browserDetect.msie && browserDetect.version < 10) ||
                    (browserDetect.chrome && browserDetect.version < 18) ||
                    (browserDetect.firefox && browserDetect.version < 10.0) ||
                    (browserDetect.safari && browserDetect.version < 5) ||
                    (browserDetect.opera && browserDetect.version < 12.0)) {
                new PageState({
                    file: "unsupport",
                    onLoad: function (pageInfo, state) {
                        ko.applyBindings(state);
                        state.onRender();
                    },
                    onError: ReJS.preLoadError
                });
            } else {
                loadMetadata();
            }

            // Загрузка metadata
            function loadMetadata() {
                $.when(
                        QM.ajaxGet({
                            url: "metadata/getenums",
                            success: function (recvddata) {
                                //
                            }
                        }),

                        QM.ajaxGet({
                            url: "metadata/getmodules",
                            success: function (recvddata) {
                                //
                            }
                        }),

                        QM.ajaxGet({
                            url: "metadata/getaccountinfo",
                            success: function (recvddata) {
                                //
                            }
                        })
                ).done(function () {
                            new PageState({
                                file: "loader",
                                onLoad: function (pageInfo, state) {
                                    ko.applyBindings(state);          // << --- loader.js
                                    state.onRender();
                                },
                                onError: ReJS.preLoadError
                            });
                        }
                );
            }
        }
);


loader.js
Холодный старт — то есть загрузка основных модулей: контроллеров, моделей, страниц, утилит и других данных, актуальность которых вряд ли изменится за сеанс работы приложения, а их наличие избавит приложение от лишних подгрузок в дальнейшем, что благоприятно сказывается на отзывчивости интерфейса.

листинг loader.js
define(["knockout", "stateManager/pages", "stateManager/shared", "stateManager/popup", "stateManager/menu", "dateUtils", "service/queryManager"],
        function (ko, SMPages, SMShared, SMPopup, SMMenu, dateUtils, QM) {
            return function () {
                // Ссылка на этот объект
                var self = this;

                // Количество шагов прогресса, которые необходимо достичь
                var totalProgress = SMPages.pageListCount + SMPopup.popupListCount + SMShared.molulListCount + SMMenu.menuModulListCount;

                // Количество шагов прогресса
                var currentProgress = 0;

                // Сделать следующий шаг прогресса
                var nextProgress = function () {
                    ++currentProgress;

                    console.log("totalProgress   ==> ", totalProgress);
                    console.log("currentProgress ++> ", currentProgress);

                    if (currentProgress == totalProgress) {
                        loadComplete();
                    }
                };

                // Вызывается, когда вcе metadata и вкладки загружены
                var loadComplete = function () {
                    ReJS.isLoadInterface = true;
                    ko.applyBindings(SMShared.shared.Root.state);
                    SMShared.shared.Root.state.onShow();
                };

                // Страница отрисована
                self.onRender = function () {

                    // Начать загрузку модулей
                    SMShared.loadModul(nextProgress);
                    SMMenu.loadMenuModul(nextProgress);
                    SMPages.loadPages(nextProgress);
                    SMPopup.loadPopup(nextProgress);
                }
            };
        }
);


Пока пользователь наблюдает splash screen, в дело вступает следующие механизмы в нашей цепочке.

stateManager.js, state.js и text.js
Пришло время более детально рассмотреть state. Это AMD-модуль, задачей которого является загрузка двух составляющих шаблона: скрипта и его разметки в соответствующие поля data и html.
После того, как скрипт будет скачан и исполнен в поле data мы будем иметь уже javascript объект, а благодаря расширению text.js для require.js разметка скачивается как текст и помещается в поле html.

листинг state.js
define(["knockout", "text"], function (ko) {
    return function (options) {
        // Ссылка на этот объект
        var self = this;

        // Переменные, которые после загрузки страницы будут содержать в себе ее части
        self.data = ko.observable(null);    //js
        self.html = ko.observable(null);    //html
        self.onRender = function () {
            self.data().onRender();
        };
        self.onLoad = function (params) { // (2)
            self.data().onLoad(params); // (3)
        };

        // Префиксы, указывающие тип загружемого файла: js или text
        var dataPrefix = "/panel/modules";
        var textPrefix = "text!/panel/modules";

        // Перменные содержащие в себе вычисляемый путь до конкретного файла
        var dataSource = "";
        var textSource = "";

        // Переменные, которые содержат текстовое описание модуля; используются при формаривании объекта modulInfo
        var pageName = "";
        var pageType = "";

        // Берем значения из options или ставим значения поумолчанию
        var dir = options.dir ? options.dir : "";
        var file = options.file ? options.file : "";

        pageName = dir + file;
        dataSource = dataPrefix + "/" + dir + "/" + file + "/" + file + ReJS.Tail + ".js";
        textSource = textPrefix + "/" + dir + "/" + file + "/" + file + ".html";

        require([dataSource, textSource], function (data, html) {
            var data = typeof data === "function" ? new data() : data;

            // Сохраняем javascript код модуля в переменную data
            self.data(data);
            // Сохраняем html разметку модуля в переменную html
            self.html(html);

            //Задаем информацию о странице
            var pageInfo = {
                "pageFile": file,
                "pageDir": dir,
                "pageName": pageName
            };

            // Если есть обратный вызов у вызывающей функции
            if (options.onLoad && typeof options.onLoad === "function") {
                options.onLoad(pageInfo, self); // (1)
            }

            // errback, обработчик ошибок, вызываемый requirejs.
        }, function (err) {
            console.log('!ERR ' + file);

            // Если есть список модулей
            if (err.requireModules) {
                for (var reqmod in err.requireModules) {
                    console.log('>>>' + err.requireModules[reqmod]);
                }
            }

            // Если есть обработка ошибки у вызывающей функции
            if (options.onError && typeof options.onError === "function") {
                options.onError(options);
            }
        });
    };
});


Единственным аргументом в конструкторе state является объект option, имеющий метод onLoad(1), нужно заметить, что сам state тоже имеет метод onLoad(2), который вызывает соответствующий метод onLoad(3) уже на объекте data.
Чтобы разобраться в этой путанице давайте посмотрим на код stateManager.js

листинг stateManager.js
define(["state/page"], function (PageState) {
    var StateManager = function () {
        var self = this;

        self.modules = [
            { name: 'campaigns', dir: "advertiser/pages" },
            { name: 'adlibrary', dir: "advertiser/pages" },
            { name: 'audience', dir: "advertiser/pages" }
        ];

        // Загруженные модули
        self.pages = [];

        // Загрузка всех страниц
        self.loadPages = function (loadComplete) {
            // Предварительная загрузка доступных модулей
            for (var i = 0; i <self.modules.length; i++) {
                var module = self.modules[i];

                (function (module) {
                    new PageState({
                        file: module.name,
                        dir: module.dir,
                        onLoad: function (pageInfo, state) { // (1)

                            // Из списка всех страниц
                            for (var j = 0; j < self.modules.length; j++) {
                                // Выбираем ту, которая эквивалента загруженной
                                if (self.modules[j]['name'] == pageInfo.pageFile && self.modules[j]['dir'] == pageInfo.pageDir) {
                                    // Что бы сохранить о ней следующую информацию
                                    self.pages[pageInfo.pageName] = {
                                        'name': pageInfo.pageName,              // name  - уникальное имя вкладки
                                        'state': state,                         // state - состояние для привязки в knockout шаблон
                                        'pageName':self.modules[j]['name']      // pageName - имя страницы
                                    };
                                }
                            }

                            state.onLoad(loadComplete); // (2)
                        }
                    });
                })(module);
            }
        }
    };
    return new StateManager();
});


В этом модуле в цикле по списку страниц происходит создание PageState’ов (один из вариантов state’ов) в методе loadPages, у которого есть аргумент loadComplete, который является функцией nextProgress из loader.js.

Для ясности по шагам:
  1. loader.js вызывает у stateManager.js метод loadModul с аргументом в виде функции nextProgress, зная количество вызовов которой и количество загружаемых модулей мы можем отслеживать прогресс их загрузки.
  2. stateManager.js в конструктор PageState передает аргумент в виде объекта содержащий на ряду с именем файлов шаблона и путем к ним метод onLoad(1).
  3. state.js в свою очередь завершив загрузку шаблона вызывает метод onLoad(1) из аргумента передавая в него ссылку на себя и другую информацию о шаблоне.
  4. stateManager.js кладет полученный шаблон в массив с загруженными шаблонами и вызывает onLoad(2) на объекте state передавая в него ссылку на функцию из первого пункта (nextProgress).
  5. state.js вызывает по цепочке onLoad(3) на объекте data с пробросом аргументов.
  6. Теперь уже модуль шаблона может вызвать сразу loadComplete (он же nextProgress из loader.js) в своем обработчике onLoad, а может послать запрос на сервер для получения данных нужных этому шаблону. Таким образом в процессе холодного старта модули для своей работы получают необходимые данные.

После загрузки необходимых страниц и модулей loader.js подготавливает основную страницу root (мастер-страница), которая содержит в себе набор из других различных виджетов и предназначена уже для взаимодействия непосредственно с пользователем, по сути являясь первым рабочим экраном приложения. Другими словами — пользовательский интерфейс сформирован и приложение готово к работе.

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

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

routie.js
Эта библиотека в виде модуля подключается в root и следит за изменением URL, а точнее его хэш части, которая, в свою очередь, изменяется другим кодом из приложения, например, при клике на кнопку или ссылку. Реагируя на это, мы можем инициировать смену состояния приложения заменой или подстановкой новых шаблонов.

Пару слов об onRender — метод вызывается после того как шаблон был применен к разметке приложения, что означает окончание инициализации data-bind атрибутов используемые Knockout'ом для связки с observable — полями.

Вывод


Данная архитектура оказалась гибкой, удобной, легко расширяемой и применяется в нескольких наших проектах, хотя и в немного изменённом виде. Например, в другом нашем проекте интерфейс основан на вкладках и роутинг, основанный на URL отсутствует, а модули имеют дополнительные функции характерные для вкладок, такие как onOpen, onClose, onActive, onDeactive на ряду с onLoad и loadComplete.
Благодаря модульному подходу мы можем как пазл собирать страницу из различных модулей: на каждую страницу можно подключать любой виджет, будь то jQuery UI или Kendo UI.

Спасибо, что дочитали это безобразие до конца!

Комментарии (2)


  1. antirek
    24.07.2015 15:33
    +1

    Репозиторий на гитхабе с вашим кодом будет уместен. Можно склонировать и быстро попробовать.


    1. SoldovskijBB Автор
      24.07.2015 16:28
      +1

      Хорошая идея, спасибо за отзыв.

      Дело в том, что выложить как есть не можем, так как описанное в статье – часть большого продукта с собственным back-end’ом и прочими зависимостями.
      А вот сделать демо-приложение для примера такого подхода очень даже можно, при первой возможности постараемся реализовать и прикрепим к статье.