DI
Install
npm install --save di.js
Usage
Lets imagine we want to cook russian salad.
All ingredients are in ingredients
directory:
ingredients
├──Salad.js
├──Pea.js
├──Pickles.js
├──Chicken.js
├──Mayonnaise.js
├──Eggs.js
└──Potato.js
import {createContainer, webpackResolver, then} from 'di.js';
var di = createContainer({
resolvers: [
webpackResolver([
require.context('./ingredients', true, /\.js$/)
])
],
dependencies: {
Salad: {
pea: 'Pea',
pickles: 'Pickles',
Mayonnaise: 'Mayonnaise',
chicken: 'boiledChicken',
eggs: 'boiledEggs',
potato: 'boiledPotato'
},
boiledChicken: 'Chicken.boilFactory',
boiledEggs: ['Eggs', {
water: 'Water'
}],
boiledPotato: [{
bundleName: 'MyFavoritePotato',
dependencies: {
water: 'Water'
}
}]
}
});
let syncSalad = di('Salad');
di('Salad').then(asyncSalad => {...});
Promise.resolve(di('Salad')).then(salad => {...});
then(di('Salad'), salad => {...});
Modules
Module stands for a CommonJS, AMD or ES6 module. Modules themselves cannot be used as dependencies, but module's instances can do.
After Module is resolved via resolver, it should be instantiated. The process looks like step by step algorithm:
- If module is ES6 (
__esModule
is defined) and there is no default
object export then container will extract first exported class (see examples below). - If module has factory method (
factory
by default), it will be invoked with dependencies as first argument. - If module is a function and doesn't have factory method it will be invoked with
new
keyword with dependencies as first argument. In other words an instance will be created. - If previous two steps will result into
thenable
(e.g. Promise), it will wait for this Promise to resolve. Notice, that you can't use promise as a dependency. - When instance is being resolved, di tries to invoke update method if it exists(
updateDependencies
by default) with dependencies as first argument.
export default class MyClass() {}
export class MyClass() {}
class A {}
class B {}
export {A, B};
class A {}
class B {}
export default {
factoryA: deps => new A(deps),
factoryB: deps => new B(deps),
};
Resolvers
Resolver is simply a function, which takes name as its argument and returns Module (or Promise.<Module>) if it can resolve given name, or null (or Promise.<null>) if it doesn't.
All resolvers which are passed to di.createContainer
method are invoked consequentially. First resolver which returns Module wins.
let myFirstSyncResolver = (name) => {
if (name === 'MyCommonJSModule') {
return require('./MyCommonJSModule');
}
};
let myFirstAsyncResolver = (name) => {
if (name === 'MyAMDModule') {
return new Promise(resolve => require(['./MyAMDModule'], resolve));
}
};
There are some useful resolvers out of the box.
staticResolver
staticResolver
is constructed with key
-value
pairs of your Modules and will then resolve Modules by key
.
import {createContainer, staticResolver} from 'di.js';
var di = createContainer({
resolvers: [
staticResolver({
User: require('./model/User'),
config: _ => require('./config.json')
})
],
dependencies: {}
});
webpackResolver
webpackResolver
is constructed with webpack's require.context objects and then will resolve all bundled Modules. It is very useful, when you are lazy enough to specify Modules manually or you want to split your application into bundles.
import {createContainer, staticResolver} from 'di.js';
var di = createContainer({
resolvers: [
webpackResolver({
require.context('./states/', true, /(State).js$/),
require.context('bundle!./views/', true, /(Layout).js$/),
require.context('promise?global!./views/', true, /(Content).js$/),
})
],
dependencies: {}
});
Webpack gives us information about where modules are placed and webpackResolver creates map with name
- path
pairs. Filename without extension will be used as a Module name. It means that file ./states/SidebarState.js
can be required from di as di('SidebarState')
. But keep your eyes open: unique names are required!
Dependency definition
You can specify dependencies in dependencies
key of the createContainer
configuration. If your module has no dependencies, there is no need to declare them.
Each item in the dependencies
map will be converted to the Definition
. It looks like this:
{
"id": "uniqueModuleId",
"bundleName": "myFavoriteBundle",
"factory": "factory",
"update": "updateDependencies",
"dependencies": {
"dependencyName": "dependencyDefinitionId"
}
}
As you can see it's not so simple and too verbose, but you can use some sugar:
Direct dependency declaration
dependencies: {
Dep1: {
a: 'Dep2',
b: 'Dep3.factory'
}
}
{
"id": "Dep1",
"bundleName": "Dep1",
"factory": "factory",
"dependencies": {
"a": "Dep2",
"b": "Dep3.factory"
}
}
Parenting
dependencies: {
Dep1: "Dep2"
}
{
"id": "Dep1",
"parentId": "Dep2",
"bundleName": "Dep2",
"factory": "factory",
"dependencies": {}
}
Deep parenting with factory overriding
All dependencies, factories and other properties will be copied from the User to the currentUser
dependencies: {
User: {
test: 'test'
},
currentUser: "User.factoryCurrentUser"
}
{
User: {
"id": "User",
"parentId": "User",
"bundleName": "User",
"factory": "factory",
"dependencies": {
"test": "test"
}
},
currentUser: {
"id": "currentUser",
"parentId": "User",
"bundleName": "User",
"factory": "factoryCurrentUser",
"dependencies": {
"test": "test"
}
},
}
Deep parenting with dependency overriding
dependencies: {
User: {
test: 'test'
},
currentUser: ['User.factoryCurrentUser', {
test2: 'test2'
}]
}
{
User: {
"id": "User",
"parentId": "User",
"bundleName": "User",
"factory": "factory",
"dependencies": {
"test": "test"
}
},
currentUser: {
"id": "currentUser",
"parentId": "User",
"bundleName": "User",
"factory": "factoryCurrentUser",
"dependencies": {
"test": "test",
"test2": "test2"
}
},
}
Update function declaration
Update function are invoked during sessions. For more information refer to the sessions section
{
user: 'User.newFactory#newUpdate'
}
{
User: {
"id": "User",
"parentId": "User",
"bundleName": "User",
"factory": "factory",
"update": "updateDependencies",
"dependencies": {}
},
currentUser: {
"id": "currentUser",
"parentId": "User",
"bundleName": "User",
"factory": "newFactory",
"update": "newUpdate",
"dependencies": {}
},
}
Complete manual definition
dependencies: {
Dep1: [{
"bundleName": "Dep2",
"factory": "produce",
"dependencies": {
"a": "Dep3.factory"
}
}]
}
Unnamed dependencies on the fly
dependencies: {
Dep1: {
a: ["b", {
c: "c"
}]
}
}
{
Dep1: {
"id": "Dep1",
"bundleName": "Dep1",
"factory": "factory",
"dependencies": {
"a": "Dep1/a"
}
},
'Dep1/a': {
"id": "Dep1/a",
"bundleName": "b",
"factory": "factory",
"dependencies": {
"c": "c"
}
}
}
Instance reuse
Instance reusing is useful, when dependencies are changing, but the instance should stay the same. For example Layouts can accept different views as dependencies, but should always stay the same to prevent rerendering.
With instance reusing di will use created instance of reused Module if it exists and will update only dependencies.
dependencies: {
basePage: ['BasePage', {
header: 'BaseHeader'
}],
homePage: ['!basePage', {
section: 'BaseSection'
}],
profilePage: ['!basePage', {
section: 'ProfileSection'
}]
}
{
basePage: {
id: 'basePage',
bundleName: 'BasePage',
dependencies: {
header: 'BaseHeader'
}
},
homePage: {
id: 'homePage',
dependencies: {
header: 'BaseHeader',
section: 'BaseSection'
},
reuse: 'basePage'
},
profilePage: {
id: 'profilePage',
dependencies: {
header: 'BaseHeader',
section: 'ProfileSection'
},
reuse: 'basePage'
}
}
Dependency lifecycle
Every definition is created once and its instance will be used for all dependencies. Definition allows to create dependencies graph.
If session mechanism is used, dependency can be destroyed via garbage collector. In this case it will be created when it will be needed again.
Sessions
Session is a mechanism to simplify dependency lifecycle. This DI container is designed to cover scenario, when application
has only one entry point for dependency loading. It could soundd strange, but if we place such a DI container into a router and will fetch the root dependency only in this place we can get a very pure and powerful garbage collection mechanism.
Let's have look at API:
import {createContainer} from 'di.js';
let di = createContainer(...);
let session = di.session();
session.load('Dep1');
session.close();
Well, some syntactic example of this point:
import {createContainer, webpackResolver, then} from 'di.js';
import {router} from './router';
let di = createContainer({
resolvers: [...],
dependencies: {
home: ['BaseLayout', {
header: 'BaseHeader',
content: 'HomeContent'
}],
profile: ['!home', {
header: 'BaseHeader',
content: 'ProfileContent'
}],
BaseHeader: {
model: 'UserAuth'
}
}
});
router.on(routeName => {
let session = di.session();
then(session(routeName), (layout) => {
layout.render();
layout.$el.appendTo('body');
session.close();
});
});
When route changes it fires an event and new session is opened. We load all dependencies and reuse existent. When all dependencies are loaded and layouts are rendered we close the session and thus destroy all instances, which were not used in the new session.
Take for example there was home
route initially on the page. It depends on BaseLayout
, BaseHeader
, HomeContent
and UserAuth
. Once route becomes profile
route, we load all of its dependencies and by closing session we destroy outdated dependencies. Hereby we load ProfileContent
Module and pass it to the existing BaseLayout
Module and also destroy HomeContent
Module since nobody requires it in the new session. Notice, that BaseLayout
Module remains the same through sessions since it is reused by profile
route.
Additionally you can pass default
dependencies to the session, which will be passed into every instance, which would be created or updated via the DI container.
let session = di.session({someKey: 'some value'});
let user = session('User'); // User module will be instantiated with {someKey: 'some value'} as dependencies
When instances are created for the first time or they were created in the previous session and someone requires them in the new session then update method will be invoked with new dependencies of the module.
It call for each instances once in session.
Serialization
You could serialize current DI state to restore it later. There is no magic: if you want to use this feature
you need to implement serialize
instance method and restore
static module method.
let data = di.serialize();
let newDi = createContainer(...);
newDi.restore(data);
Module can look like this:
export class User {
constructor(data) {
this.data = data;
}
serialize() {
return this.data;
}
static restore(data) {
return new this(data);
}
}