Recently, I began to write a lot on JS, now I am working on a complex application and a rather large library (~ 5K SLoC). Of course, I ran into the problem of modularity.
AMD was perfect for the application - you specify in the library dependencies, add the linking code, logic ... and the application is ready. But when developing the library, I ran into the problem of managing internal dependencies with AMD or
CommonJS — there are
too many connections (boilerplate), especially when
parts of the library are interdependent . Therefore, I have highlighted another approach to the definition of modules in JS -
YAMD .
Attention! This is not a replacement for AMD or CommonJS, I still use AMD to build the application, just one of the libraries that I connect is compiled using YAMD. Thus, YAMD is an approach to decomposing a complex library without external dependencies into parts and separate files, and a tool for assembling these files together.')
In the article I will describe the approach. You want to know from the comments that you use for the same tasks.
Yamd
Another approach to define modules for javascript. Unlike CommonJS and AMD:
- allows to write less bindings (boilerplate code)
- well supports mutually recursive modules
- repeats (as far as possible) the modularity of Java / C #
- created to create libraries, not application layouts
YAMD is an approach to building a library through decomposing its functionality into separate files and then assembling these files together. The output file can be designed as IIFE, introducing one name (library name) into the global scope, as a CommonJS or AMD wrapper.
I adhered to the principle that using YAMD in library development should not impose restrictions or obligations on the library user.
Applying YAMD makes sense when:
- you create a relatively complex library
- the library has no external dependencies
It is easier for me to understand something when an analogy is made with what I already know, so the description of YAMD will be given through a cross-comparison with AMD and CommonJS.
Comparing YAMD, AMD and CommonJS
Let's compare on a simple example YAMD, AMD and CommonJS. Imagine that we are writing a math library.
Select the library functions into separate files, so the source directory for all approaches will be the same:
> find . ./math ./math/multiply.js ./math/add.js
Let's look at the source:
AMD | Commonjs | Yamd |
./math/add.js
|
define([], function() { return function(a,b) { return a + b; }; }); | function add(a, b) { return a + b; } module.exports = add; | expose(add); function add(a, b) { return a + b; } |
./math/multiply.js
|
define( ["math/adding"], function(adding) { return function(a,b) { var result = 0; for (var i=0;i<a;i++) { result = adding(result, b); } return result; }; }); | var add = require('./add'); function multiply(a,b) { var result = 0; for (var i=0;i<a;i++) { result = add(result, b); } return result; } module.exports = multiply; | expose(multiply); function multiply(a,b) { var result = 0; for (var i=0;i<a;i++) { result = root.add(result, b); } return result; } |
USAGE (assuming all dependencies are included)
|
require( ["math/add", "math/multiply"], function(add, multiply) { console.info(add(2,7)); console.info(multiply(2,7)); }); | var multiply = require("./math/multiply"); var add = require("./math/add"); console.info(add(7,2)); console.info(multiply(7,2)); | console.info(math.add(7,2)); console.info(math.multiply(7,2)); |
The AMD approach turned out quite verbose, CommonJS reduces the number of straps due to the implicit wrapping of each file in the function, and YAMD takes another step forward by entering the root of the root library through which you can access any part of it without explicit import.
Work with YAMD
To build a YAMD-style library, you need to run
python yamd.py path/to/library
- as a result, the file
nameOfTheLibrary.js
will appear in the current directory. The name of the library is specified by the name of the source directory, in addition, this name is used to add the library to the global scope (unless, of course, the assembly is specified in the CommonJS or AMD module).
The directory name, as well as the names of all subdirectories and js-files (up to ".js") should be valid in terms of restrictions for variable names in JS.
The directory hierarchy defines the hierarchy of modules, and js-files fill these modules with functions (constructors) - something is obtained like packages and classes in Java or namespaces and classes in C #.
To add a function to a module, you need to create a js file in the directory corresponding to this module (the file name before .js specifies the name of the function), define a function with any name, for example,
add
, and call `expose` before it a function, for example, `expose (add);`. All other file content will be private and only the exported function will be visible.
It may seem strange that the function is used before the declaration -
expose(add);
, but this is not YAMD magic, but legal behavior for JS is
hoisting . But nevertheless, there is a requirement for
expose
go at the beginning of the file, to occur only once, and before it was called, there was not a single call to
root
.
The previous example (mathematical library) after assembly will be approximately equivalent to the following code:
var math = (function(){ var root = { add: function(a, b) { return a + b; }, multiply: function(a,b) { var result = 0; for (var i=0;i<a;i++) { result = root.add(result, b); } return result; } }; return root; })();
Suppose we decided to complicate our library and add distributions from theorove to it. It is logical to place them in a separate module (directory), after the changes, the source directory looks like this:
> find . ./math ./math/multiply.js ./math/add.js ./math/distributions ./math/distributions/normal.js ./math/distributions/bernoulli.js
Then after the assembly, we get about the following code
var math = (function(){ var root = { add: function(a, b) { return a + b; }, multiply: function(a,b) { var result = 0; for (var i=0;i<a;i++) { result = root.add(result, b); } return result; }, distributions: { normal: function() { throw new Error("TODO"); }, bernoulli: function() { throw new Error("TODO"); } } }; return root; })();
Returning to `expose`, in addition to the function, it can of course export lines, numbers or objects to the module. It turns out that we can rewrite the previous example by placing all distributions in one file in the root of the library, rather than creating a separate directory:
After assembling the library will be fully equivalent.
Mutually Recursive Modules
In YAMD, it is possible to add to the module a function that uses the function of another module, and that of the first. However, in the case of CommonJS and AMD this is also possible, the difference is only in the amount of code. For example, let's write a function that calculates the number of steps in
the Collatz process . As in the first example, the directory structure will not change in the case of AMD, CommonJS and YAMD:
> find math/collatz math/collatz math/collatz/steps.js math/collatz/inc.js math/collatz/dec.js
And now the code:
AMD | Commonjs | Yamd |
./math/collatz/steps.js
|
define( ["require", "math/collatz/inc", "math/collatz/dec"], function(require, inc, dec) { return function(n) { if (n==1) return 0; if (n%2==0) return require("math/collatz/dec")(n); if (n%2==1) return require("math/collatz/inc")(n); }; }); | function steps(n) { if (n==1) return 0; if (n%2==0) return require('./dec')(n); if (n%2==1) return require('./inc')(n); } module.exports = steps; | expose(steps); function steps(n) { if (n==1) return 0; if (n%2==0) return root.collatz.dec(n); if (n%2==1) return root.collatz.inc(n); } |
./math/collatz/inc.js
|
define( ["require", "math/collatz/steps"], function(require, steps) { return function(n) { return require("math/collatz/steps")(3*n+1)+1; }; }); | function inc(n) { return require('./steps')(3*n+1)+1; } module.exports = inc; | expose(inc) function inc(n) { return root.collatz.steps(3*n+1)+1; } |
./math/collatz/dec.js
|
define( ["require", "math/collatz/steps"], function(require, steps) { return function(n) { return require("math/collatz/steps")(n/2)+1; }; }); | function dec(n) { return require('./steps')(n/2)+1; } module.exports = dec; | expose(dec) function dec(n) { return root.collatz.steps(n/2)+1; } |
Mutual dependencies in the case of AMD were described according to
this document - we had to add a dependency on require and use it to explicitly import dependencies inside functions.
All three approaches coped with this example, but it is relatively simple - the recursive nature crawls out only when the user calls the library functions, and by that time the library is already loaded. The problem with mutual recursion occurs when the library itself needs to use the functions of the library itself. This problem is well dealt with in Tom's
message .
To combat it, a deferred initialization was added to
expose
: in the
expose
second argument can be passed to the function (module constructor), for which it is guaranteed that it will be called after all the modules are loaded.
Let's return to our example, let's say we decided to speed up the work of
steps
and for some
n
calculate the number of steps when loading the library. Suppose we have defined a
tableLookup
decorator.
function tableLookup(table, f) { return function(n) { if (n in table) return table[n]; return f(n); } }
Then we just need to change the steps.js file in the YAMD approach as follows:
When using CommonJS / AMD, we have two ways to implement the same thing:
- add an explicit initialization method to the library
- defer initialization of the table until the first call
It turns out badly - in CommonJS to solve this problem we must either change the API, or break the single responsibility principle and add laziness control to the steps:
If you turn a blind eye to SRP violation in CommonJS and consider heavy initialization processes, then both options are bad with brakes, in the case of YAMD, brakes when the library is connected, and in the case of CommonJS, brakes when you first call
steps
. But initialization is not always difficult, and if it is still the same, then using YAMD you can try to bring it into the WebWorker processes launched from the ctor and hope that by the first launch of `steps` it will be over. We cannot use CommonJS either, since we only get the chance to start initialization at the first request, therefore this request will not have time to be predicted and is guaranteed to slow down.
Thanks for attention. And do not forget to write in the comments how you develop complex JS libraries.