⬆️ ⬇️

Cross-platform CommonJS in practice



What are we talking about?



About JS modules that can be used in the browser and on the server. About their interaction and external dependencies. Less theory, more practice. As part of the young fighter's course, we are implementing a simple and very original application based on Node.JS: ToDo-list. For this we have to:

  1. β€œStart” cross-platform modules based on the Express framework;
  2. Teach them to work with platform-dependent colleagues;
  3. Create a transport layer between the client and the server;
  4. Taki make ToDo-list;
  5. Comprehend the result.




Application Requirements



Let's focus on the essence of the whole idea and take on the implementation of the minimum functionality. Requirements are formulated as follows:

  1. The application is accessible using a browser;
  2. The user works with his ToDo list in one session. When reloading the page, the list should remain; after closing the tab or browser, a new one should be created;
  3. The user can add new items to the list;
  4. The user can mark the added item as completed.




Making a frame



Without problems, we raise the framework of the application based on the Express framework . We will slightly modify the structure we received from the box:

. β”œβ”€β”€ bin β”œβ”€β”€ client //     ,   β”œβ”€β”€ modules //  , ,  CommonJS  β”œβ”€β”€ public β”‚  └── stylesheets β”œβ”€β”€ routes └── views 




Let's create our first module from the subject area - Point, the constructor of the ToDo list item:

 // modules/Point/Point.js /** *    * @param {Object} params * @param {String} params.description * @param {String} [params.id] * @param {Boolean} [params.isChecked] * @constructor */ function Point(params) { if (!params.description) { throw 'Invalid argument'; } this._id = params.id; this._description = params.description; this._isChecked = Boolean(params.isChecked); } Point.prototype.toJSON = function () { return { id: this._id, description: this._description, isChecked: this._isChecked }; } 


Fully
 /** * @param {String} id */ Point.prototype.setId = function (id) { if (!id) { throw 'Invalid argument'; } this._id = id; } /** * @returns {String} */ Point.prototype.getId = function () { return this._id; } Point.prototype.check = function () { this._isChecked = true; } Point.prototype.uncheck = function () { this._isChecked = false; } /** * @returns {Boolean} */ Point.prototype.getIsChecked = function () { return this._isChecked; } /** * @returns {String} */ Point.prototype.getDescription = function () { return this._description; } module.exports = Point; 




Wonderful. This is our first cross-platform module and we can already use it on the server, for example, like this:

 // routes/index.js var express = require('express'); var router = express.Router(); /* GET home page. */ router.get('/', function (req, res) { var Point = require('../modules/Point'); var newPoint = new Point({ description: 'Do something' }); console.log('My new point:', newPoint); }); module.exports = router; 


There are several ways to work with the CommonJS module in the browser, the easiest to set up and use is middleware for Express browserify-middleware :

 // app.js // ... var browserify = require('browserify-middleware'); app.use('/client', browserify('./client')); // ... 


By adding such simple code, we can immediately write the first lines of our client application:

 // client/todo.js var console = require('console'); //  `node_modules/browserify/node_modules/console-browserify` var Point = require('../modules/Point'); 


')

Browserify uses the Nodov algorithm for loading modules , and also provides browser implementations of core libraries . This has already been written a lot, so I’ll just say that the script loaded at /client/todo.js is now fully functional in the browser.



Let's talk about the modules



In my project I used the following conditional division of modules:



Utilitarian modules

With their help, the developer organizes and accompanies the code. For example, in our case, this is a library of promises Vow , lodash , console. Most of these modules are not only cross-platform, but also support several download formats (CommonJS, AMD).



Domain Modules

Provide an interface for working with domain objects. We have already created one such module - the Point constructor, soon there will be a list module, providing the interface we need (addPoint, getPoints, checkPoint) and the user module, which is responsible for initializing the user session.



Such modules can be either fully cross-platform or have platform-specific parts. For example, some methods or properties should not be available in the browser. But most often the platform-specific part falls into the following category of modules.



DAL modules (Data Access Layer)

These are modules that are responsible for accessing data from an arbitrary set of sources and their conversion into an internal representation (objects, collections) and vice versa. For the browser, it can be localStorage, sessionStorage, cookies, external API. On the server, the choice is even greater: the whole chain of databases, the file system and, again, some external API.



If the cross-platform domain module interacts with the DAL, then the DAL module must have a browser and server implementation with a single interface. Technically, we can organize this using a useful browserify feature , which consists in specifying the browser property in the module's package.json. Thus, domain modules can work with different DAL modules depending on the execution environment:

 { "name" : "dal", "main" : "./node.js", //     "browser": "./browser.js" //   browserify     } 




We implement modules



What modules will be required for our task? Let memcache act on the server as storage, we will store our ToDo lists in it. User identification will occur in the browser, put the session identifier in sessionStorage and will transmit with each request to the server. Accordingly, on the server we will need to pick up this identifier from the request parameters.



It turns out that at the DAL level we have to implement the protocol of interaction with sessionStorage and memcache (we implement the retrieval of the request parameters using standard Express tools).

modules / dal / browser / sessionStorage.js
 module.exports.set = function () { sessionStorage.setItem.apply(sessionStorage, arguments); } module.exports.get = function () { return sessionStorage.getItem.apply(sessionStorage, arguments); } 




modules / dal / node / memcache.js
 var vow = require('vow'); var _ = require('lodash'); var memcache = require('memcache'); var client = new memcache.Client(21201, 'localhost'); var clientDefer = new vow.Promise(function(resolve, reject) { client .on('connect', resolve) .on('close', reject) .on('timeout', reject) .on('error', reject) .connect(); }); /** *    Memcache * @see {@link https://github.com/elbart/node-memcache#usage} * @param {String} clientMethod * @param {String} key * @param {*} [value] * @returns {vow.Promise} resolve with {String} */ function request(clientMethod, key, value) { var requestParams = [key]; if (!_.isUndefined(value)) { requestParams.push(value); } return new vow.Promise(function (resolve, reject) { requestParams.push(function (err, data) { if (err) { reject(err); } else { resolve(data); } }); clientDefer.then(function () { client[clientMethod].apply(client, requestParams); }, reject); }); } /** *     * @param {String} key * @param {*} value * @returns {vow.Promise} */ module.exports.set = function (key, value) { return request('set', key, value); } /** *     * @param {String } key * @returns {vow.Promise} resolve with {String} */ module.exports.get = function (key) { return request('get', key); } 




Now we can implement the following User domain module, which will provide us with an object with a single getId method:

modules / user / dal / browser.js
 var storage = require('../../dal/browser/sessionStorage'); var key = 'todo_user_id'; /** *   id * @returns {String} */ function makeId() { var text = ""; var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; var i; for (i = 0; i < 10; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } module.exports = { /** * @returns {String} */ getId: function () { var userId = storage.get(key); if (!userId) { userId = makeId(); storage.set(key, userId); } return userId; } }; 




modules / user / dal / node.js
 var app = require('../../../app'); module.exports = { /** * @returns {String} */ getId: function () { return app.get('userId'); //     middleware } }; 




modules / user / dal / package.json
 { "name" : "dal", "main" : "./node.js", "browser": "./browser.js" } 




 // modules/user/user.js var dal = require('./dal'); //     ./dal/browser.js,   - ./dal/node.js function User() { } /** *    * @returns {String} */ User.prototype.getId = function () { return dal.getId(); } module.exports = new User(); 




We will organize the interaction between the browser and the server based on the REST protocol, which will require us to implement it at the DAL level for the browser:

modules / dal / browser / rest.js
 var vow = require('vow'); var _ = require('lodash'); /** *    REST API * @param {String} moduleName -   * @param {String} methodName -   * @param {Object} params -   * @param {String} method -   * @returns {vow.Promise} resolve with {Object} xhr.response */ module.exports.request = function (moduleName, methodName, params, method) { var url = '/api/' + moduleName + '/' + methodName + '/?', paramsData = null; if (_.isObject(params)) { paramsData = _.map(params, function (param, paramName) { return paramName + '=' + encodeURIComponent(param); }).join('&'); } if (method !== 'POST' && paramsData) { url += paramsData; paramsData = null; } return new vow.Promise(function (resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open(method, url); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.responseType = 'json'; xhr.onload = function() { if(xhr.status === 200) { resolve(xhr.response); } else { reject(xhr.response || xhr.statusText); } }; xhr.send(paramsData); }); } 




and a special Express router that will work with our domain modules:

 // routes/api.js // ... router.use('/:module/:method', function (req, res) { var module = require('../modules/' + req.params.module), method = module[req.params.method]; if (!method) { res.send(405); return; } method.apply(module, req.apiParams) .then(function (data) { res.json(data); }, function (err) { res.send(400, JSON.stringify(err)); }); }); // ... 


Based on the conditions of the problem, we must provide the following methods to the API:

  1. GET, / list / getPoints - get a to-do list in the ToDo list of the current user;
  2. POST, / list / addPoint β€” get a new item in the ToDo list of the current user;
  3. POST, / list / checkPoint - mark the item as done;


In the case of adding a new item, we will have to impose additional responsibilities on the router: converting the request parameters into an internal representation for transfer to the module:

 router.post('/list/addPoint', function (req, res, next) { var Point = require('../modules/Point'), point; req.apiParams = []; try { point = new Point(JSON.parse(req.param('point'))); req.apiParams.push(point); } catch (e) {} next(); }); 


Great, now we can implement the final module of the subject domain list:

modules / list / dal / browser.js
 var _ = require('lodash'); var rest = require('../../dal/browser/rest'); var Point = require('../../Point'); module.exports = { /** * @param {User} user * @returns {vow.Promise} resolve with {Point[]} */ getPoints: function (user) { return rest.request('list', 'getPoints', {userId: user.getId()}, 'GET') .then(function (points) { return _.map(points, function (point) { return new Point(point); }); }); }, /** * @param {User} user * @param {Point} point * @returns {vow.Promise} resolve with {Point} */ addPoint: function (user, point) { var requestParams = { userId: user.getId(), point: JSON.stringify(point) }; return rest.request('list', 'addPoint', requestParams, 'POST') .then(function (point) { return new Point(point); }); }, /** * @param {User} user * @param {Point} point * @returns {vow.Promise} */ checkPoint: function (user, point) { var requestParams = { userId: user.getId(), pointId: point.getId() }; return rest.request('list', 'checkPoint', requestParams, 'POST'); } }; 




modules / list / dal / node.js
 var _ = require('lodash'); var memcache = require('../../dal/node/memcache'); var Point = require('../../Point'); /** *       * @param {User} user * @returns {String} */ function getListKey(user) { return 'list_' + user.getId(); } module.exports = { /** * @param {User} user * @returns {vow.Promise} resolve with {Point[]} */ getPoints: function (user) { return memcache.get(getListKey(user)) .then(function (points) { if (points) { try { points = _.map(JSON.parse(points), function (point) { return new Point(point); }); } catch (e) { points = []; } } else { points = []; } return points; }); }, /** * @param {User} user * @param {Point} point * @returns {vow.Promise} resolve with {Point} */ addPoint: function (user, point) { return this.getPoints(user) .then(function (points) { point.setId('point_' + (new Date().getTime())); points.push(point); return memcache.set(getListKey(user), JSON.stringify(points)) .then(function () { return point; }); }); }, /** * @param {User} user * @param {Point} point * @returns {vow.Promise} */ checkPoint: function (user, point) { return this.getPoints(user) .then(function (points) { var p = _.find(points, function (p) { return p.getId() === point.getId(); }); if (!p) { throw 'Point not found'; } p.check(); return memcache.set(getListKey(user), JSON.stringify(points)); }); } }; 




modules / list / dal / package.js
 { "name" : "dal", "main" : "./node.js", "browser": "./browser.js" } 




 // modules/list/list.js //   var _ = require('lodash'); var vow = require('vow'); var console = require('console'); // DAL- var dal = require('./dal'); //    var Point = require('../Point'); var user = require('../user'); var list = {}; var cache = {}; //   /** *       * @param {Point} newPoint * @returns {vow.Promise} resolve with {Point} */ list.addPoint = function (newPoint) { /* ... */ } /** *     * @param {String} pointId * @returns {vow.Promise} */ list.checkPoint = function (pointId) { /* ... */ } /** *      * @returns {vow.Promise} resolve with {Point[]} */ list.getPoints = function () { console.log('list / getPoints'); return new vow.Promise(function (resolve, reject) { var userId = user.getId(); if (_.isArray(cache[userId])) { resolve(cache[userId]); return; } dal.getPoints(user) .then(function (points) { cache[userId] = points; console.log('list / getPoints: resolve', cache[userId]); resolve(points); }, reject); }); } module.exports = list; 


Structurally, the modules of our application began to look like this:

 modules β”œβ”€β”€ dal β”‚  β”œβ”€β”€ browser β”‚  β”‚  β”œβ”€β”€ rest.js β”‚  β”‚  └── sessionStorage.js β”‚  └── node β”‚  └── memcache.js β”œβ”€β”€ list β”‚  β”œβ”€β”€ dal β”‚  β”‚  β”œβ”€β”€ browser.js //  dal/browser/rest.js β”‚  β”‚  β”œβ”€β”€ node.js //  dal/node/memcache.js β”‚  β”‚  └── package.json β”‚  β”œβ”€β”€ list.js β”‚  └── package.json β”œβ”€β”€ Point β”‚  β”œβ”€β”€ package.json β”‚  └── Point.js └── user β”œβ”€β”€ dal β”‚  β”œβ”€β”€ browser.js //  dal/browser/sessionStorage.js β”‚  β”œβ”€β”€ node.js β”‚  └── package.json β”œβ”€β”€ package.json └── user.js 




Together



It's time to implement the logic of our application. Let's start by adding a new item to the ToDo list:

 // client/todo.js // ... //    -           var console = require('console'); var _ = require('lodash'); var list = require('../modules/list'); var Point = require('../modules/Point'); var todo = { addPoint: function (description) { var point = new Point({ description: description }); list.addPoint(point); } }; // ... 


What happens when you call todo.addPoint ('Test')? I will try to draw the main steps in the diagrams. To begin, consider the interaction of modules in the browser:

Diagram




As you can see, the list module 2 times refers to its DAL-module, which makes http-requests to our API.

This is how the interaction of the same (for the most part) modules on the server side looks like:

Bigger chart




Here's what happens: the interaction between the modules of the subject area and the DAL modules in the browser and on the server is identical. Differ, as we planned, the interaction protocols and data sources at the DAL level.



The case will work in the same way with the β€œcrossing out” clause:

 list.checkPoint(pointId); 


Another couple of minutes - and our application is ready.

Code. Readable?
 // client/todo.js (function () { var console = require('console'); var _ = require('lodash'); var list = require('../modules/list'); var Point = require('../modules/Point'); var listContainer = document.getElementById('todo_list'); var newPointContainer = document.getElementById('todo_new_point_description'); var tmpl = '<ul>' + '<% _.forEach(points, function(point) { %>' + '<li data-id="<%- point.getId() %>" data-checked="<%- point.getIsChecked() ? 1 : \'\' %>" class="<% if (point.getIsChecked()) { %>todo_point_checked <% }; %>">' + '<%- point.getDescription() %>' + '</li><% }); %>' + '</ul>'; var todo = { addPoint: function (description) { var point = new Point({ description: description }); list.addPoint(point) .then(todo.render, todo.error); }, checkPoint: function (pointId) { list.checkPoint(pointId) .then(todo.render, todo.error); }, render: function () { list.getPoints() .then(function (points) { listContainer.innerHTML = _.template(tmpl, { points: points }); }); }, error: function (err) { alert(err); } }; newPointContainer.addEventListener('keyup', function (ev) { if (ev.keyCode == 13 && ev.ctrlKey && newPointContainer.value) { todo.addPoint(newPointContainer.value); newPointContainer.value = ''; } }); listContainer.addEventListener('click', function (ev) { var targetData = ev.target.dataset; if (!targetData.checked) { console.debug(targetData.checked); todo.checkPoint(targetData.id); } }); todo.render(); })(); 




Repository code : github .



Comprehend



Why, actually, this whole conversation? At the moment I have some moral satisfaction from the work done and a number of questions about its feasibility. How suitable is this model for complex projects and where are the limits of its application? Am I prepared to put up with the inevitable overhead that an application based on cross-platform modules will have?



Until full understanding so far away. In any case, it is good to be able to do something like this and think about the prospects. Cross-platform frameworks and component tests - why not?

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



All Articles