version 0.1
shoveJS
...is a JavaScript microframework (lightly) inspired by RobotLegs (ActionScript 3) and its default dependency injection library SwiftSuspenders. It's meant to provide implementations of common patterns needed while developing web apps. If you don't like a specific piece, you should be able to swap it out with another implementation, and if you don't need a piece, you should be able to omit it.
The overall goal is to make bootstrapping a modular application a little more sane. shoveJS encourages modularity, flexibility, and loose-coupling.
A few of the provided features:
- Dependency Injection
- Automatic view creation / mediation
- Service Initialization
- Serialization / Deserialization (JSON) of complex types (maintaining class/prototype)
It currently depends on the following 3rd party libraries:
- requirejs
- js signals
- Q - Promises - required for (de)serialization)
- json2 - required for (de)serialization support in older browsers
Some examples require additional 3rd party libraries, but they're not otherwise required:
- jQuery
Application Bootstrapping
An application's HTML typically includes requirejs and creates a new instance of Main in the callback (which is passed an HTMLElement to serve as the root view).
The application main will then:
- include a basket of classes (via requirejs)
- register the Model classes
- register View classes for Model classes
- register Meditor classes for View classes
- register Factory classes for Model classes
- register Service classes
- map Application signals to CommandClasses
- Context View???
- Get view(s) for the application context and append(s) them to the root view.
Dependency Injection
Defining Injectable Fields for a Class
Each class that will have properties injected, defines....
SomeClass.injectableFields = {fieldName:FieldClass, fieldName2:FieldClass};
In most cases, your classes will have their injectable fields mapped automatically. This is the case for:
- view classes
- mediator classes
- command classes
Manually register a class to have its injectable fields mapped:
context.injector.mapInjectionFields(SomeClass);
Defining Injection Values
Values are mapped to injectable fields:
context.injector.mapValue = (SomeClass, "injectionField", injectionValue)
The injectionValue will be mapped to any injectable field of type SomeClass matching "injectionField".
NOTE: the type of class being injected into does not matter. More on child Injectors later.
Injection Life-cycle
The above set-up must be in place first. Afterwards:
context.injector.injectInto(someInstance);
Serialization / Deserialization
Register Model Class
shoveJS makes no assumptions about your model classes. It should not be necessary to store unique ids, (de)serialization logic, etc in your models. shoveJS promotes a model containing nothing but data and (eg update) signals.
Any model (and the models it uses via composition) need to be registered in the class registry:
context.registerClass(SomeModelClass);
This feature relies on a ClassRegistry which maps Classes to an id. The proviced ClassRegistry implementation will call toString as a static method of the registering Class if an id is not provided.
For example:
context.registerClass(SomeClass.toString(), SomeClass);would be equivalent to:
context.registerClass(SomeClass);
Register Model Factories
var someModelFactoryInstance = new SomeModelFactory(); context.factoryRegistry.set(SomeModel, someModelFactoryInstance);
Factory Interface
- Factory.toString() -- returns a unique id for this Factory class
- Factory.toJSON() -- returns a JSON-ready serialization object
- Factory.fromJSON -- returns an object with fully classed and cross-referenced objects
Factory Patterns
toJSON
The Factory is passed the instance to be serialized:
DictionaryFactory.prototype.toJSON = function (dictionary) { /* */ };
A typical toJSON implementation starts by creating an empty object:
var jsonObj = {};
Fields containing only Numbers & Strings (or Arrays/Objects containing only Numbers, Strings, Arrays, and Objects) can be serialized directly:
jsonObj.field = model.field;
The JSONService exposes a method toJSONref which can be utilized by factory.toJSON methods to convert composed objects into "JSON references" pointing to registered Objects. Objects / values for which no entry is found are returned unchanged. //????
For example, the DictionaryFactory:
jsonObj.keys = []; for (var i=0, len=dictionary.keys.length; i<len; i++) { jsonObj.keys.push( this.jsonService.toJSONref(dictionary.keys[i]) ); }
A new keys field is created on the Dictionary's JSON object. For each key, a value is placed in that Array containing a special object described in the JSON Format section below.
fromJSON
A factory is passed the jsonObject to be de-serialized:
DictionaryFactory.prototype.toJSON = function (dictionary) { /* */ };
The typical fromJSON implementation starts by creating an empty model:
var dictionary = new Dictionary();
Fields containing only Numbers & Strings (or Arrays/Objects containing only Numbers, Strings, Arrays, and Objects) can be deserialized directly:
model.field = jsonObj.field;
JSONService uses the Q library's Promise implementation to provide instance references:
jsonObj.referencedObject.promise.then( function (referencedObject) {model.referencedObject = referencedObject;} );
If your model needs to do some processing once the value is available use a setter function:
jsonObj.normalizedPoint.promise.then ( function (point) {model.setNormalizedPoint(point); } );
Writing deserialization promise logic is by far the trickiest part of using the framework. (It's worth it!) The included Factory classes contain several patterns worth using as starting points.
JSON Format
The JSON returned from toJSON will always contain two fields:
- meta
- data
{ "meta":{ /* */ }, "data":{ /* */ } }
The meta field contains a hash of object references:
"meta": { "bfb37975-148e-5f87-6f12-f4a15dbf3b34:0": {}, "bfb37975-148e-5f87-6f12-f4a15dbf3b34:1": {} }
Each of the values contains two fields:
- type
- value
If any property within value's tree is itself a registered object, it will be converted to a JSON reference of the form:
{"$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:1"}:
The same reference objects are used in the data branch of the object tree. When sending the JSON string to the server the resulting JSON might look like:
{"meta" :{ "bfb37975-148e-5f87-6f12-f4a15dbf3b34:0": { "type": "[Some Class]", "value": { "field1": [ { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:1" } ] } }, "bfb37975-148e-5f87-6f12-f4a15dbf3b34:1": { "type": "[Some Class]", "value": { "field1": [ { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:0" } ], "field2": { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:7" } } }, "bfb37975-148e-5f87-6f12-f4a15dbf3b34:7": { "type": "[Dictionary Class]", "value": { "keys": [ { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:11" } ], "values": [ { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:12" } ] } }, "bfb37975-148e-5f87-6f12-f4a15dbf3b34:11": { "type": "[Data1 Class]", "value": 123 }, "bfb37975-148e-5f87-6f12-f4a15dbf3b34:12": { "type": "[Data2 Class]", "value": "xxx" } }, "data" :{ "a": { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:0" }, "a": { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:1" }, "c": { "$ref": "bfb37975-148e-5f87-6f12-f4a15dbf3b34:2" } }}
When deserializing, the returned object will match the structure defined in the data field.
About the ObjectRegistry
An ObjectRegistry maps a unique object id to an instance. Usually, this is handled automatically.
Manually:
objectRegistry.registerModel(id, instance);
The JSONService has dependencies mapped for injection:
- context: Context - provides access to class & factory mappings
- classRegistry: ClassRegistry - maps unique class ids to classes
- objectRegistry: ObjectRegistry - maps unique ids to instances
- objectIdentityService: ObjectIdentityService - unique id generator
These are mapped for you.
The JSONService handles calling factory.toJSON and factory.fromJSON for each object type referenced while walking data objects and json objects respectively & recursively.
Automatic View Creation / Mediation
Register Model Classes
(See above)
Map Model Classes to View Classes
context.mapModelView(Controls, ControlsView);
an additional parameter allows multiple views per model class
context.mapModelView(Controls, ControlsView, 'map');
Register View Factory Classes
(See above)
context.mapViewMediator(ControlsView, ControlsCommandMap);
Mediator Classes
Mediator instances are automatically created and linked to views when views are created. They're responsible for linking UI-events to Command classes. Signals published by the View class are automatically mapped to the Mediator's injection fields of the same name.
ControlsCommandMap.injectionFields = { context:Context, loadRequested:signals.Signal, saveRequested:signals.Signal };
View Classes
Views have:
- an "on" field containing named UI signals to publish for automatic mediation
- an observe method which will be passed the model instance to view (READ ONLY!)
The Mediator's map/unmap methods link/unlink these signals to Command classes.
ControlsCommandMap.prototype.map = function () { this.context.mapSignal(this.loadRequested, LoadNewFeatureDataCommand, this); this.context.mapSignal(this.saveRequested, SaveZoneDataCommand, this); } ControlsCommandMap.prototype.unmap = function () { this.context.unmapSignal(this.loadRequested, LoadNewFeatureDataCommand, this); this.context.unmapSignal(this.saveRequested, SaveZoneDataCommand, this); }
Command Interface
Commands:
- have an execute method
- have their injection fields automatically populated by
- UI signal data payload
- The view's controllerData (context.registerView) -- typically, this contains the model the view is observing
Creating / Getting a View
var controlsView = context.getViewByModel(controls); var controllerData = {controls:controls}; context.registerView(controlsView, controllerData);
If you're using the multi-views per model class case above:
var controlsView = context.getViewByModel(controls, 'map');
Service Initialization
Service Interface
Services have:
- an id to register themselves
- an optional init method
Service Life-cycle
When you register a service, the following things happen:
The service's injectable fields are mapped:
context.injector.mapInjectionFields(SomeService);
The service is registered under its id & class:
var someService = new SomeService(); // simplified here context.injector.mapValue(SomeService, 'someService', someService);
When the context is initialized:
- The service has its injectable fields populated:
context.injector.injectInto(someService);
- The service is initialized:
featureSelectionService.init();
Utilities
A few "internals" that might be useful.
Dictionary
Easily map from any kind of object to any other kind of object. The JavaScript Array/Object classes are limited to Numeric/String keys.
The Dictionary is used heavily throughout the framework. The implementation could be improved performance-wise, but I've left it in a more readable form, during development.
ModelMediator
Note: This is different from the View Mediator pattern discussed above.
Pass a model instance to the constructor function. The object you get back will proxy set/get access on the original model object while providing a change handler for each field in the model. In the default implementation, that's every field accessible by for..in
The change signals are stored in a Dictionary called __on__
var mediator = new ModelMediator(modelInstance); mediator.__on__.someField.add(function () { console.log("someField changed!") })