šŸ“œ ā¬†ļø ā¬‡ļø

Another way to prepare one-page applications

Authors of the article: Boris Soldovskiy SoldovskijBB , Shevtsov Sergey s_shevtsov .

Greetings to all who read this post! We are a team of front-end developers Targetix. In this article we will tell you about how the client part of the Hybrid service is arranged - a web interface for interacting with our TradingDesk and DSP .

Picture to attract attention
')

Introduction


Even before we started working on Hybrid, when our client application development department was formed and possible options for implementing these applications were discussed, under the influence of trends, the choice fell on single-page applications that attracted the fact that with this approach there is no need to constantly load the same content, You can quickly manipulate the display of the page and, if desired, organize offline work. In addition, minimal dependence on back-end development groups. Over time, this approach has taken shape and is used for many of our web interfaces.

The framework of our applications is based on AMD modules that allow you to limit the scope, reuse the code and make it structured. For example, we have the module of the page and the module of some popup-window, and in the module of the popup-window some widget-module is used. At the same time, the popup-window module can be used on several pages. In this and similar cases it is convenient to use AMD modules, and the RequireJS library helps us in connecting and managing dependencies.

Knockout.js is used to display data - a library that implements the mvvm-pattern and allows you to dynamically change pages thanks to the template engine and the observed variables.

Application structure


If you look at the structure of the application, it is divided into categories according to the purpose of the modules: third-party libraries from third parties, user interface pages, service scripts and utilities. Some modules are loaded when the application starts, while others are loaded by events from user actions (click, scroll, hashchange).

Application structure content
Static content, such as images, styles, fonts - everything is like at all.

pages
User interface modules, i.e. pages, pop-ups and embedded widgets directly interacting with the user.

scripts
The remaining modules of our application:

controllers
A set of modules that provide other modules with a connection to the back-end, in essence, is a layer for working with data, basically each controller works with one set of entities. For example, NotificationController for working with notifications has methods such as getAll, getUnread, setReadDate.

model
These are modules-objects with a set of properties characterizing entities that are used when sending data to the server.

viewmodel
The view model is a binding on the model, which, reacting to user actions, changes the model, performs validation and other auxiliary operations.

service
It is difficult to unambiguously describe the purpose of these modules, let's look at one of them - the queryManager. It is a set of methods for working with AJAX, such as GET, POST, DELETE and others, but in addition it inserts a general top-level error handler into requests, concatenates URLs based on config data and request parameters, implements a caching mechanism in localStorage and allows you to track the status of the request and its progress. It is mainly not used directly and is intended for controllers.

plugins
This directory has another level of nesting, but it does not make sense to consider them in more detail. They contain third-party libraries: RequireJS, jQuery, Knockout and others, as well as our utilities such as dateUtils for working with dates and time.

Application architecture


To clearly demonstrate the principle of the application, we decided to describe the processes occurring in it from the moment it is opened in the browser to the moment when the user starts working with it. It can be schematically depicted as follows (beginning at the bottom left):

Application architecture

It will hardly be clear to the naked eye what is depicted here, although we have added a legend, but let's take it in order.

index.html
Of course, the first thing on the page is to connect all the styles and fonts, which will later be used in all possible elements of the user interface. Below on the page there is a block in which user interface modules will later be substituted. The next script in our chain is connected.

listing 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 and init.js
As you can see, judging by the legend, these blocks are designated as AMD modules, but in fact these are the most common scripts that are loaded by the browser and executed, it is just easier to display the dependencies and stages of execution.
The first one declares a global object that contains methods that return links to the back-end, a login page, a link to a CDN, as well as a field containing an anticash key, which is subsequently added to all GET requests. The first AJAX request is made to receive some configuration information from the server: whether to use minified scripts or their debug version, what logs to write to the console, whether to display server errors in the interface, and so on. It is at this moment that the back-end finds out about the client and determines whether authorization is needed or the user is already in the system.

listing 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 


In the first case, it redirects to the login page, in the second, the script continues to run, during which another script is added to index.html that runs the chain of AMD modules - require.js, and its data-main attribute indicates the configuration file for it - init.js. It configures the paths to modules and dependencies for non-AMD modules, eventually the first real module is connected.

init.js listing
 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
At this stage, our application begins to overgrow with meat: jQuery loads and initializes its $, Knockout prepares to insert the first page into its template, our global object is clogged, browser compatibility is checked, the messaging system between the ā€œinnerMessageā€ modules starts, and a series of requests begins for account data and not only on the server.

It's time to make a small digression regarding the interface of the application itself, it is based on the mvvm-patern , as already mentioned, which is provided by Knockout, and we will call the template or page that dynamically put it in the markup using a template or a page.
The template consists of a bunch of .html and .js files:

listing 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(); }); } } } ); 


The role of the onRender, onLoad and loadComplete functions will become clear later when we consider the state object.

listing state.html
 <div data-bind="foreach: $self.ads"> <span data-bind="text: name"> </div> 


A bunch of variables from the markup script provides the same Knockout template mechanism, which was slightly upgraded so that the same state can be used as a template. Returning to our script, this is exactly where it happens. After completion of the operations described above, a page with a module loader is formed (listing line 83).

listing 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
Cold start - that is, loading the main modules: controllers, models, pages, utilities and other data, the relevance of which is unlikely to change during the session of the application, and their presence will save the application from unnecessary loads in the future, which favorably affects the responsiveness of the interface.

listing 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); } }; } ); 


While the user is watching the splash screen, the following mechanisms in our chain come into action.

stateManager.js, state.js, and text.js
The time has come to consider state in more detail. This is an AMD module whose task is to load two components of a template: a script and its markup into the corresponding data and html fields.
After the script is downloaded and executed in the data field, we will already have a javascript object, and thanks to the text.js extension for require.js, the markup is downloaded as text and placed in the html field.

listing 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); } }); }; }); 


The only argument in the state constructor is an option object that has an onLoad (1) method; it should be noted that state itself also has an onLoad (2) method, which calls the corresponding onLoad (3) method on the data object.
To understand this confusion, let's look at the code stateManager.js

listing 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(); }); 


In this module, in a loop through the list of pages, the PageState (one of the variants of the state) is created in the loadPages method, which has a loadComplete argument, which is a function of nextProgress from loader.js.

For clarity on the steps:
  1. loader.js calls the stateManager.js method loadModul with an argument in the form of the nextProgress function, knowing the number of which calls and the number of loaded modules, we can track the progress of their loading.
  2. stateManager.js in the PageState constructor passes an argument as an object containing along with the name of the template files and the path to them the onLoad (1) method.
  3. state.js, in turn, after completing loading the template, calls the onLoad (1) method from the argument, passing in it a link to itself and other information about the template.
  4. stateManager.js puts the resulting template into an array with loaded templates and calls onLoad (2) on the state object, passing in a function reference from the first clause ( nextProgress ).
  5. state.js calls the onLoad (3) chain on the data object with forwarding arguments.
  6. Now the template module can immediately call loadComplete (it’s nextProgress from loader.js) in its onLoad handler, or it can send a request to the server to get the data needed by this template. Thus, in the process of cold start, the modules for their work receive the necessary data.

After loading the necessary pages and modules, loader.js prepares the main root page (master page), which contains a set of various other widgets and is intended to interact directly with the user, essentially being the first working screen of the application. In other words - the user interface is created and the application is ready to work.

The state of the interface is saved for the user, both in localStorage and on the server, that is, such settings as selected dates, selected filters, table columns, and so on. These settings are also loaded during the cold start process and are applied to each specific page when it is initialized.

The idea with templates was spied in an article on the habre ā€œWriting a complex application on knockoutjsā€ dated October 8, 2012. It is unlikely that the author suspected that his ideas live a full life in our applications, for which he thanks.

routie.js
This library as a module is connected to root and monitors the URL change, or rather its hash part, which, in turn, is changed by other code from the application, for example, when clicking a button or link. In response to this, we can initiate a change in the state of the application by replacing or substituting new templates.

A few words about the onRender method is called after the template has been applied to the application's markup, which means the end of the data-bind attribute initialization used by Knockout for binding to the observable fields.

Conclusion


This architecture turned out to be flexible, convenient, easily expandable and used in several of our projects, albeit in a slightly modified form. For example, in our other project, the interface is based on tabs and routing based on a URL is missing, and modules have additional tab-specific functions, such as onOpen, onClose, onActive, onDeactive along with onLoad and loadComplete.
Thanks to the modular approach, we can as a puzzle collect a page from different modules: you can connect any widget to each page, be it a jQuery UI or Kendo UI.

Thank you for reading this mess to the end!

Source: https://habr.com/ru/post/262917/


All Articles