📜 ⬆️ ⬇️

Thinking out loud about developing javascript applications on the example of a small Line Of Business framework

Hi, Habr!

Not so long ago, we ourselves set the task - to build a SPA application. Complicated, large, fast, with support for multiple devices, without memory leaks. In addition, constantly evolving to make it easy to maintain. In general, as we love - all at once.

The theme of SPA-applications and javascript-applications as a whole is not new, but we could not find even on paid resources solid guidelines on application development. They are more like a story about a particular MV * library than an example to follow. It does not consider examples of splitting into layers, building inheritance hierarchies and subtleties of inheritance in javascript, etc.
')
We will try to go the other way and describe, rather, the course of thoughts during development (with code and links), than any particular tool. We have to start at the hello world level in order to set one starting point for readers and a writer. But from the second section, the narrative will accelerate sharply.

We believe that this article will be useful:

  1. Front-end developers who already have some experience, but want to grow.
  2. Back-end developers who at some point had to start working on js-development and who feel some insecurity when working with javascript.
  3. The makers, who began to engage in js-development and would like to pump their skills.



Fiction turned out to be very voluminous, but we hope that it is just as useful.

For a good understanding of the article is necessary:


Formulation of the problem


We tried to choose a task that would be quite complex, but at the same time it would be realistic and understandable without long immersion into the context. We will build the foundation for lists with sorting, paging, filtering, displaying master-details, selection of rows, etc. We will also briefly touch upon the issues of maintaining the state of the application, interaction with the back end, etc.

We will take the starter kit and the Durandal + knockout bundle as the basis of our application, because both libraries are as simple as two kopecks (you can complete the tutorial knockout in just an hour; you will spend as much time on Durandal , and we almost don’t need its specificity just use it as a quick start platform).

We sincerely hope that the choice of technologies will not narrow the circle of potential readers. After all, in the end, all MV * frameworks have similar functionality, or the missing items add ECMAScript of a higher version if you are not interested in browser support like IE 8/9.

Hello world (expand starter kit)


First we need to start the application and add a stub model there, on which we will set up experiments:
  1. Download HTML starter kit .
  2. Open it in any convenient editor.
  3. Remove from the app / viewModels and app / views folders all files except shell.html and shell.js .
  4. Add the file TestList.js with the following code to the app / viewModels folder:
    define([], function () { 'use strict'; var testListDef = function () { }; return testListDef; }); 

  5. Add the TestList.html file to the app / views folder and draw the following markup there:
     <div>Hello</div> 

  6. In the shell.js file, we change the configuration of the router with this:
     { route: '', title:'Welcome', moduleId: 'viewmodels/welcome', nav: true }, { route: 'flickr', moduleId: 'viewmodels/flickr', nav: true } 
    On this one:
     { route: '', title: 'Test list', moduleId: 'viewmodels/TestList', nav: true } 

  7. We also need the underscore and moment libraries. They can be installed in any convenient way and put them in the config for requirejs in the main.js file in the paths section:
     'underscore': '../lib/underscore/underscore', 'moment': '../lib/moment/moment' 

  8. We start and make sure that we see a page with the inscription “Hello”.

Then you can go directly to the writing of our application.

Getting down to business


To begin, let's look at the pages of the application that we have to write, and highlight the general points. As we conduct our narration on the basis of real events, we have an abundance of pictures. At the same time, allow yourself to brag a little about our design.



From the general moments you can see the presence of the title and the clock. Also, each page follows the data on the server, and it makes sense to add a sign to display the status.

So, add the BaseViewModel.js file to the viewModels folder with this code:
 define(['ko', 'underscore'], function (ko, _) { 'use strict'; var baseViewModelDef = function (title) { this.title = ko.observable(title || null); this.state = ko.observable(ETR.ProgressState.Done); this.busy = ko.computed(function () { return this.state() === ETR.ProgressState.Progress; }, this); this.ready = ko.computed(function () { return this.state() !== ETR.ProgressState.Progress; }, this); this.disposed = false; this.inited = false; }; _.extend(baseViewModelDef.prototype, { nowDate: ko.observable(new Date()).extend({ timer: 5000 }), baseInit: function () { this.inited = true; }, baseDispose: function () { this.ready.dispose(); this.busy.dispose(); this.disposed = true; } }); return baseViewModelDef; }); 

Let us explain some structural points, conventions, etc .:
  1. We will write all the code in strict mode .
  2. All modules we declare as named functions instead of anonymous. This practice will be of great service to you when you will be profiling your code for memory leaks, for example, using chrome developer tools , as it will allow you to easily determine what kind of object is stuck in memory.
  3. Instead of using the common approach of capturing context using closures , we will write code using this . Perhaps the most important reason for this is discipline and clarity. Yes, there are often situations when this cannot be transmitted correctly. But in 99% of cases, the code can and should be written in such a way that this context is correct. For the remaining 1% of cases, we use call , apply , underscore bind , but we do it with an understanding of why and why.
  4. All that can be crammed into prototype, stuffed into prototype. The reasons for this are many. Starting from a more efficient use of memory and in some cases a noticeable difference in the speed of work (you can start research on this topic with this article ) and ending with the same discipline (there will be no possibility of being tied to any closures).
  5. We use the underscore extend function so that all declarations are in one object. Such code is easier to read, plus in many code editors such a block can be collapsed and hidden if it is not needed. In the case of writing a la
     object.prototype.something = function 
    so do not work.
  6. Instead of using magic strings and magic numbers, we declare some sort of enumeration. In the case of the code above, this is an ETR.ProgressState enumeration, which looks like this:
     ETR.ProgressState = { Done: 0, Progress: 1, Fail: 2 }; 

    We do not hesitate to put such objects into the global object ETR (acronym for the name of our company), believing that we do not violate the AMD approach, because if some static objects are needed almost in every module, then they can be easily rendered into global context instead of passing them as dependencies.
  7. In the markup, we will need to determine, for example, the view model state in order to show / not show the progress bar. But writing expressions in markup is not good and fraught. Therefore, we are very actively using knockout computeds . This allows us to write bindings like if: busy instead of if: state === 1 .
  8. In the case of complex hierarchies, some semblance of virtual methods is sometimes necessary with the possibility of calling basic methods. So, for example, will be with our baseInit and baseDispose . In modules-heirs, we will definitely define methods with the same name, but we should not lose the “base” one. For this we will use underscore wrap . As for the base prefix in the names, we agreed to call the methods that belong to the “abstract” modules, that is, which are intended only for inheritance. This naming allows you to simply dispose the method in the final modules, without wrapping the base method with wrap . That is, the final code (which you often have to look at) will be a bit cleaner.
  9. Each “page” of our application will have its own life cycle, which, in general, is superimposed on the life cycle of ViewModels in Durandal . In the case of the code above, these are the baseInit and baseDispose methods . Most often, these methods overlap with the activate and deactivate methods from Durandal, but sometimes you have to bind them to attached / detached methods, sometimes models do not participate in the Durandal life cycle, and you still need to initialize and clean them (for example, nested view model). Therefore, we called the methods so as to clearly separate the flies from cutlets.
  10. Flags inited and disposed are needed to avoid re-initialization / cleaning, working with an already destroyed object. They can also be useful for debugging and for profiling. Personally, we have to use only inited , and then only occasionally (but just in case we have them).

Inherit and draw


First of all, we need the inheritance function. We decided not to think too much and took it from here . To use it, we put it in the Object class and called inherit:
 Object.inherit = function (subClass, superClass) { var f = function () { }; f.prototype = superClass.prototype; subClass.prototype = new f(); subClass.prototype.constructor = subClass; subClass.superclass = superClass.prototype; if (superClass.prototype.constructor === Object.prototype.constructor) { superClass.prototype.constructor = superClass; } return subClass; }; 

Note: in general, it is recommended to create a separate module and load it just before starting the application to accommodate such a “setup” code (type extension, declaration declaration like ETR.ProgressState, etc.). In this particular example, we will not have much of it, so you can simply put such definitions in the main.js file.
After inheritance, our TestList class will look like this:
 define(['underscore', 'BaseViewModel'], function (_, baseViewModelDef) { 'use strict'; var testListDef = function () { testListDef.superclass.constructor.call(this, ' '); this.activate = this.baseInit; this.deactivate = this.baseDispose; }; Object.inherit(testListDef, baseViewModelDef); return testListDef; }); 

The case remains for the markup. Here it is worth highlighting one moment: the display of hours. Since you don’t have a clock in one html element on our design, then when approaching “on the forehead” in the model, we will need at least two fields: for the date and time. You also have to solve the issue of formatting time into a string, and doing it in the view model is not the best solution. Therefore, we will write a custom binding for knockout, which with the help of the library of the moment will solve this question:
 ko.bindingHandlers.dateFormat = { after: ['text'], update: function (element, valueAccessor, allBindings) { if (allBindings.get('text')) { var format = ko.unwrap(valueAccessor()) || 'L'; var value = ko.unwrap(allBindings.get('text')); if (value) { var dateVal = moment(value); var text = dateVal.isValid() ? dateVal.format(format) : value; } else { text = ''; } $(element).text(text); } } }; 

In general, nothing complicated. Interesting in this is the binding array after , which signals the knockout that you need to start processing this binding after the binding text has been processed. Thus, we guarantee that we will already have text in the markup, which we should reformat as a date.

In view we get something like this:
 <div data-bind="dateFormat, text: nowDate"></div> <div class="date" data-bind="dateFormat: 'DD MMM', text: nowDate"></div> <div class="year" data-bind="dateFormat: 'YYYY', text: nowDate"></div> 

Cute and compact.

Create a list


This time we will not post all the code at once, but will consistently add the necessary functionality, simultaneously voicing our thoughts. Routine and obvious code snippets, we leave behind brackets.

Let's take another look at the picture with the application and try to evaluate what we need from the lists:


Additionally, add two more things:

  1. Lists can be simply lists with the display of the number of records, with pagination, buffer loading, infinite loading by scroll.
  2. Each list must save its state (paging, sorting, values ​​in filters) in the query string so that the user can send the link and the recipient opens the application in the same state. Also, when leaving the window, the state of the list should be remembered and restored when you return to the window.

So it goes. Let's start with the sorting.
 define(['jquery', 'underscore', 'ko', 'BaseViewModel'], function (jquery, _, ko, baseViewModelDef) { 'use strict'; var listViewModelDef = function (title) { this.items = ko.observableArray([]).extend({ rateLimit: 0 }); this.defaultSortings = []; if (this.sortings && ko.isObservable(this.sortings)) { jquery.extend(true, this.defaultSortings, this.sortings()); this.sortings.extend({ rateLimit: 0 }); } else { this.sortings = ko.observableArray([]).extend({ rateLimit: 0 }); } listViewModelDef.superclass.constructor.call(this, title); this.baseInit = _.wrap(this.baseInit, function (baseInit, params) { baseInit.call(this, params); if (params && params.sort && jquery.isArray(params.sort)) { this.sortings(jquery.extend(true, [], params.sort)); } }); this.baseDispose = _.wrap(this.baseDispose, function (baseDispose) { this.defaultSortings = null; this.items.disposeAll(); this.sortings.removeAll(); baseDispose.call(this); }); }; _.extend(Object.inherit(listViewModelDef, baseViewModelDef).prototype, { setSort: function (fieldName, savePrevious){…}, changeSort: function (){…}, resetSettings: function (){…}, reload: function (){…} }); return listViewModelDef; }); 

We also need a customBinding to draw the sorted columns on the UI:
 ko.bindingHandlers.sort = { init: function (element, valueAccessor, allBindingsAccessor, viewModel) { var $el = $(element); var fieldName = ko.unwrap(valueAccessor()); var clickHandler = function (evt) { viewModel.setSort(fieldName, evt.ctrlKey); viewModel.changeSort(); }; $el.addClass('sortable').click(clickHandler); ko.computed( { read:{/*  asc/desc      */}, disposeWhenNodeIsRemoved: element }); ko.utils.domNodeDisposal.addDisposeCallback(element, function () { $el.off('click', clickHandler); $el = null; }); } }; 

The logic of what happens in the view model is as follows:
  1. We declare the defaultSortings array and copy the values ​​from the sortings array into it , if it was declared in the child class. This is necessary in order to restore the original sorts by pressing the reset button ( resetSettings method). Please note that we copy via jquery.extend with the deep flag in order to make a full copy and not get problems later due to the change of the reference object. If there is no sorting, we ourselves declare an array.
  2. We wrap the baseInit and baseDispose methods . In the first one, we try to pull out the sorting parameters from the query string (passing us the parameters is Durandal care). In the baseDispose method, we simply make order.
  3. The resetSettings and reload methods are tied to the buttons on the UI. Also, these methods will be wrapped in inheriting modules, making up some kind of pipeline. Thus, the entire functionality of these buttons (which, after the implementation of the entire functionality of the lists becomes very voluminous) will be hidden in the basic modules.
  4. The purpose of the setSort method is perhaps obvious from the name, and its code is trivial. Note only the savePrevious flag. We pass this parameter from the custom binding in case if Ctrl was pressed with the mouse on the header. This is exactly what should be divided: the logic of working with sortings is in ListViewModel , and the logic for which user actions to save sortings is in the UI part, that is, in custom binding .
  5. The changeSort method exists separately and forces the list to reload when the sort is changed . It is rendered separately in order to abstract from the UI, because there may be a situation when the user first selects several sorts and only after that we need to load the data. Also, we may need (and need) to be embedded in the method in the modules-heirs.
  6. At the very beginning we declare an array of items , that is, our records. We need it in order to clean the records from it when loading data in the list with the reload button and when dispose is called. Notice that the disposeAll method, our extension to observableArray , is used for cleaning. Its essence is that we call removeAll , after which we iterate over all the records, and, if they have a dispose method, we call it. We also do this via setTimeout 0 , so that the loop works while data is being downloaded from the server, and not in front of it — it improves usability a little if there are a couple of thousand entries in the list:
     ko.observableArray.fn.disposeAll = function (async) { async = async === undefined ? true : !!async; var items = this.removeAll(); setTimeout(function () { ko.utils.arrayForEach(items, function (item) { if (item.dispose) { item.dispose(); } }); items = null; }, 0); }; 


Now, in order for our TestList class to get the sorting functionality, change the base class in it from BaseViewModel to ListViewModel and in the markup we draw something like this:
 <table class="table table-bordered table-striped table-hover"> <thead> <tr> <td><span data-bind="sort: 'Column1Name', localizedText: 'Column1Title'"></span></td> <td><span data-bind="sort: 'Column2Name ', localizedText: 'Column2Title'"></span> </td> </tr> </thead> <tbody data-bind="foreach: items"> <tr> <td> </td> </tr> </tbody> <tfoot> </tfoot> </table> 

Now we should be puzzled by how to send these sorts to the server when requested. Since we don’t want to write code for this in every final view model, we will add one more conveyor method to our lists - toRequest . Modules-heirs will also wrap it, additionally stacking information about the number and size of the page, filters, etc. As a result, we will eliminate the need for the final module to write copy-paste code to collect the request to the server:
 this.toRequest = function () { var result = {}; result.sort = this.sortings(); return result; }; 

This method is also useful for saving the state of the model in the query string and in a certain cache (we described this as the requirements above), in order to restore this state when returning to the page. To save the state, you can create a separate stateManager class, which will save the state in the cache (for example, in localStorage ) and replace the url using the router . Also, this module will be integrated into the router and, when navigating through route, it will search the cache for the state of the object. If it is found, then it is necessary to supplement them with parameters that Durandal recognized from the query string . We will not give a detailed code here, since everything is trivial. We note only that Durandal has a rather weak query string parsing function. For example, it does not know how to parse arrays that jquery.param can serialize. Therefore, the standard function in Durandal can be replaced with the jquery.deparam extension: the inverse function param .

Paging


As we said above, we will have three pages:

1. Simple (in general, not paging, but simply a display of the number of records).


2. Page by page:


3. Buffer loading:


The latter will be additionally with auto-upload by scroll.

First of all, we will need information on how many records are now uploaded to the list and how many are there in total. Add two properties to the ListViewModel :
 this.totalRecords = ko.observable(0).extend({ rateLimit: 0 }); this.loadedRecords = ko.observable(0).extend({ rateLimit: 0 }); 

And then another thought comes to mind ...


We need to read this information with the back end, otherwise nothing. Yes, the loadedRecords property will also come from the server, and not be defined as the length of the array of incoming records, since the list can be grouped. Plus, we already have (at the expense of BaseViewModel ) a sign of state , which we still do not exhibit anywhere. And this is just the beginning. Further - more, but I don’t want to deal with copy-paste code. In addition, to implement pagers, we need to know exactly how to call the method of loading data from the server when changing the page, that is, we need a contract.

Here we decided to make the thing a bit strange, but quite workable. We will impose one restriction on the ListViewModel successor classes: all of them must have a loadData method that will return a promise (strictly speaking, jquery deferred will be returned, but in this context it does not matter much). We will also expect that data will come into the callback promise, from which we can pull out the totalRecords and loadedRecords we need . Further, we will wrap this method with our loadData method and add promise interceptors that will do all the necessary work. It turns out some kind of replacement of the abstract method, which is not in javascript.

Ultimately, for ListViewModel, it will look something like this:
 var listViewModelDef = function (title) { if (!this.loadData) { throw 'In order to inherit "ListViewModel" type you must provide "loadData" method'; } … this.loadData = _.wrap(this.loadData, function (q) { var opts = { total: 'totalCount', loaded: 'selectedCount' }; //-       if (this.namingOptions) { opts = _.extend(opts, namingOptions); } this.totalRecords(0); this.state(ETR.ProgressState.Progress); var promise = q.apply(this, Array.prototype.slice.call(arguments, 1)); promise.done(function (data) { this.loadedRecords(data[opts.loaded]); this.totalRecords(data[opts.total] || 0); this.state(ETR.ProgressState.Done); }).fail(function () { this.state(ETR.ProgressState.Fail); }); if (this.saveStateOnRequest === true) { this.saveRequestState(); } return promise; }); … } 

The code in our successor class is now complemented like this:
 define(['underscore', 'BaseViewModel', 'SomeViewModel'], function (_, baseViewModelDef, someViewModelDef) { 'use strict'; var testListDef = function () { testListDef.superclass.constructor.call(this, ' '); }; _.extend(Object.inherit(testListDef, baseViewModelDef).prototype, { loadData: function () { return this.someService.getSomeData(this.toRequest()) .done(function (result) { this.someCustomPropertyToSet(result.someCustomPropertyToSet); this.items.initWith(result.items, someViewModelDef); }); }, … }); return testListDef; }); 

The result was quite good. We do not need to copy-paste the code for setting the properties of the parent classes, but at the same time - complete freedom in sending a request to the back end and in receiving a response from it.

Next, we need to define two heir modules — PagedListViewModel and BufferedListViewModel . We will not show the whole and explain the code, due to their triviality, we just give the general structure. A noteworthy point is the use of writable computeds , the existence of which is not yet known to all users of this library. Their use allows us to check that the user enters strictly row numbers in the rowCount field, and the input value does not exceed the specified number of records per request, as well as the total number of records in the list.
 define(['ko', 'underscore', 'ListViewModel'], function (ko, _, listViewModelDef) { 'use strict'; var bufferedListViewModelDef = function (title) { this.defaultRowCount = 20; this.minRowCount = 0; this.maxRowCount = 200; this.rowCountInternal = ko.observable(this.defaultRowCount); this.skip = ko.observable(0).extend({ rateLimit: 0 }); this.rowCount = ko.computed({ read: this.rowCountInternal, write: function (value) {…}, owner: this }); bufferedListViewModelDef.superclass.constructor.call(this, title); this.loadData = _.wrap(this.loadData, function (q) {…}; this.baseInit = _.wrap(this.baseInit, function (baseInit, params) {…}; this.resetData = _.wrap(this.resetData, function (baseResetData) {…}; this.toRequest = _.wrap(this.toRequest, function (baseToRequest) {…}; this.changeSort = _.wrap(this.changeSort, function (baseChangeSort) {…}; this.baseDispose = _.wrap(this.baseDispose, function (baseDispose) {…}; }; Object.inherit(bufferedListViewModelDef, listViewModelDef); return bufferedListViewModelDef; }); 

Now, just changing the base class for TestList , we almost get the paging functionality. To get it all, we need a few bindings. View with them will look like this:
 <tfoot> <tr data-bind="progress"> <td colspan="6" class="progress-row"></td> </tr> <tr data-bind="nodata"> <td colspan="6" data-bind="localizedText: 'CommonUserMessageNoData'"></td> </tr> <tr class="pagination-row"> <td data-bind="pager"> </td> </tr> </tfoot> 

Here we also do without the obvious code and briefly explain in words:

, instanceof , ( proxy-, prototype “” ).

, bufferedListPager , pagedListPager , simpleListPager , . , tbody , . , Durandal knockout, Durandal widgets knockout components . , , widget binding , div-, . , , code base.


, , , : ?

, , , , back-end, query string , view model:
 define(['underscore', 'BufferedListViewModel'], function (_, bufferedListViewModelDef) { 'use strict'; var testListDef = function () { testListDef.superclass.constructor.call(this, ' '); }; _.extend(Object.inherit(testListDef, bufferedListViewModelDef).prototype, { loadData: function () { return this.someService.getSomeData(this.toRequest()) .done(function (result){…}); }, init: function () { this.baseInit(); }, dispose: function () { this.someService.dispose(); this.baseDispose(); } }); return testListDef; }); 

View:
  <table> <thead> <tr> <td><span data-bind="sort: 'SomeProperty'>Some Property</span></td> <td><span data-bind="sort: 'OtherProperty'>Other Property</span></td> </tr> </thead> <tbody data-bind="foreach: items "> <tr> <td data-bind="text: someProperty"></td> <td data-bind="text: otherProperty "></td> </tr> </tbody> <tfoot> <tr data-bind="progress"> <td colspan="2" class="progress-row"></td> </tr> <tr data-bind="nodata"> <td colspan="2" data-bind="localizedText: 'CommonUserMessageNoData'"></td> </tr> <tr class="pagination-row"> <td data-bind="pager"> </td> </tr> </tfoot> </table> 

, , , . , …


TestList , . toRequest . , . query string .
init query string (, params , query string+ ). resetSettings , . , toRequest , init resetSettings — . ?
, .

, , , . knockout — extenders ( , ). , extender rateLimit .
, extender, observable . Like this:
 this.filterProperty = ko.observable(null).extend({filter: { fieldName: 'nameForRequest', owner: this }}); 

, , . For example:

observable. , :
 ko.extenders.filter = function (target, options) { options.owner.filter = options.owner.filter || {}; options.owner.filter[options.fieldName] = target; target.defaultValue = options.defaultValue === undefined ? target() : options.defaultValue; target.ignoreOnAutoMap = (!!options.ignoreOnAutoMap) || false; target.formatter = options.formatter || undefined; target.emptyIsNull = options.emptyIsNull || false; return target; }; 

, view model- filter, , , , -.

/ , , . “convention”, .

, , . , reusable, extender — copy paste. , toRequest , , , . , back end, , wcf- Sharepoint 2010, ”/Date(1234567890000)/”. , :
 Date.prototype.toRequest = function () { return '\/Date\(' + this.getTime() + ')\/'; }; 

, : ? — *listViewModel BaseViewModel . — , . BaseViewModel — , .

mixin -. , mixin ListViewModel :
 modelWithFilterExtender.apply.call(this); 

extender :
 define(['ko', 'underscore'], function (ko, _) { 'use strict'; var modelWithFilterExtenderDef = { apply: function () { this.filter = this.filter || {}; this.initFilterParams = function (baseInit, params){…}; this.apllyFilterParams = function (data) {…}; this.resetFilterToDefault = function (){…}; this.clearFilterParams = function (){…}; } }; return modelWithFilterExtenderDef; }); 

, , .
success story.

mixin- selection ( , , , ). , , , Tab Ctrl Shift . selectionExtender ( ) selectedExtender ( ). “ / ”, “ / ”, /, master/details. , .

, mixin-, . , , — view model :
 selectedExtender.apply.call(this, this.key); 

:
 selectedExtender.apply.call(this, this.key); selectionExtender.apply.call(this, this.items); 

.

. Thanks to everyone who read to the end. See you soon!

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


All Articles