Skip to content

Instantly share code, notes, and snippets.

@johanobergman
Last active February 7, 2019 21:01
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save johanobergman/ea8694df67d92e072a4a to your computer and use it in GitHub Desktop.
Save johanobergman/ea8694df67d92e072a4a to your computer and use it in GitHub Desktop.
Laravel-style DI in JavaScript

Laravel-style DI in JavaScript

Honestly, coming from PHP, I really don't like the way dependencies are handled in JavaScript. Using require() or import either gives me a singleton object, or a class that I have to instantiate myself.

The DI container in Laravel is wonderful, and allows you to basically just ask for a dependency in a class constructor and it hands it to you. You can bind things to the container, but you can also resolve things without explicitly binding them, which I think is awesome.

I made this small example just to see whether the Laravel practices work in JavaScript. Perhaps it violates the fundamental idea of JavaScript, who knows, but I'd love if frameworks like Ember and React worked like this out of the box. (I mean, how nice isn't this compared to React's context?)

Binding to the container:

// Import the container instance
import app from './Container';

// Import some classes
import Service       from './Service';
import ReportService from './ReportService';

// Bind to the container using a function, with a string as the key...
app.bind('service', () => {
    return new ReportService();
});

// ... or with a class as the key
app.bind(Service, () => {
    return new ReportService();
})

// Bind directly to a class
app.bind('service', ReportService);
app.bind(Service, ReportService);

// Bind as a singleton to recieve the same instance every time it's resolved
app.singleton(Service, () => {
    return new ReportService();
})
app.singleton('service', ReportService);

// Tell the container to resolve the same instance every time Service is asked for
app.singleton(Service);

// Bind an existing instance to the container
app.instance('service', new ReportService());
app.instance(Service, new ReportService());

Resolving from the container:

// Import the container instance
import app from './Container';

// Resolve an item from the container using familiar Laravel syntax
let service = app.make('service');

// If Service isn't bound to the container, it will instantiate it for you,
// along with all of its dependencies (see below)
let service = app.make(Service);

Resolving, Laravel-fu style with ES7 decorators:

// Import the container instance
import app, { depends } from './Container';

// Import some classes
import MailMan          from 'mail-man'; // or whatever
import ReportService    from './ReportService';

// Make a class with constructor dependencies. We have to use something
// to tell the container which class to look up. In PHP we have type hinting,
// but JavaScript doesn't. I think using decorators makes it as nice as it gets.
// You can also do Mailer.depends = [MailMan]; if that's your style.
@depends(MailMan)
class Mailer {
    constructor(mailMan) {
        this.mailMan = mailMan;
    }
    sendToAdmin(text) {
        this.mailMan.send('admin@example.com', text);
    }
}

// Another class which depends on our previous class
@depends(ReportService, Mailer)
class Dashboard {
    constructor(reports, mailer) {
        this.reports = reports;
        this.mailer  = mailer;
    }
    generateReport() {
        let report = this.reports.daily();
        this.mailer.sendToAdmin(report.body);

        return report;
    }
}

// And finally, we just have to get the ball rolling
let dashboard = app.make(Dashboard);
let report    = dashboard.generateReport();

If you don't have the luxury of instantiating your classes yourself (maybe your framework does it for you), you can use the @inject decorator to inject dependencies directly to the class prototype. Coming from Laravel, it feels a bit hacky, but hey, it's better than nothing.

// Import the container instance
import app, { inject } from './Container';

// Import some classes
import ReportService   from './ReportService';
import Mailer          from './Mailer';

@inject({ reports: ReportService, mailer: Mailer })
class Dashboard {
    generateReport() {
        let report = this.reports.daily();
        this.mailer.sendToAdmin(report.body);

        return report;
    }
}
/**
* A dependency injection container which closely resembles the one in the
* Laravel PHP framework. A lot of code is ported directly from Laravel.
*
* @class Container
*/
class Container {
constructor() {
this.bindings = new Map();
this.instances = new Map();
}
/**
* Register a binding in the container.
*
* @param {String|Class} abstract
* @param {Class|Function} concrete
* @param {Boolean} shared
* @return {Void}
*/
bind(abstract, concrete = null, shared = false) {
if ( ! concrete) {
concrete = abstract;
}
// Check if concrete actually is a class instead of a regular
// function. If it is, wrap it in a closure that returns the
// actual object, so that all bindings behave the same.
if (this.isClass(concrete)) {
concrete = this.getClosure(concrete);
}
this.bindings.set(abstract, { concrete, shared });
}
/**
* Register a singleton in the container.
*
* @param {String|Class} abstract
* @param {Class|Function} concrete
* @return {Void}
*/
singleton(abstract, concrete = null) {
this.bind(abstract, concrete, true);
}
/**
* Register an instance in the container.
*
* @param {String|Class} abstract
* @param {Object} instance
* @return {Void}
*/
instance(abstract, instance) {
this.instances.set(abstract, instance);
}
/**
* Resolve an instance from the container.
*
* @param {String|Class} abstract
* @return {Object}
*/
make(abstract) {
if (this.instances.has(abstract)) {
return this.instances.get(abstract);
}
let object = this.build(abstract);
if (this.isShared(abstract)) {
this.instances.set(abstract, object);
}
return object;
}
/**
* Get an object either from a registered binding or
* by automatically building it.
*
* @param {String|Class} abstract
* @return {Object}
*/
build(abstract) {
if (this.bindings.has(abstract)) {
return this.bindings.get(abstract).concrete();
}
if (typeof abstract !== 'function') {
throw new Error(`No binding found for ${abstract}`);
}
return this.buildObject(abstract);
}
/**
* Build an object from a class and pass it its
* resolved dependencies in the constructor.
*
* @param {Class|Function} concrete
* @return {Object}
*/
buildObject(concrete) {
let dependencies = concrete.depends;
if ( ! dependencies) {
return new concrete();
}
let instances = this.getDependencies(dependencies);
return new concrete(...instances);
}
/**
* Wrap an instantiation (with dependencies) of concrete in a closure.
*
* @param {Class|Function} concrete
* @return {Function}
*/
getClosure(concrete) {
return () => {
return this.buildObject(concrete);
};
}
/**
* Map over an array of dependencies and recursively
* resolve them out of the container.
*
* @param {Array} parameters
* @return {Array}
*/
getDependencies(parameters) {
return parameters.map(param => this.make(param));
}
/**
* Check if the given function is a class constructor.
*
* @param {Class|Function} concrete
* @return {Boolean}
*/
isClass(concrete) {
// Regular constructor calls without the 'new' operator returns
// 'undefined', and ES2015 classes throw a TypeError.
try {
if (typeof concrete() === 'undefined') {
return true;
}
} catch (e) {
if (e instanceof TypeError) {
return true;
}
}
return false;
}
/**
* Check if the given binding is shared.
*
* @param {String|Class} abstract
* @return {Boolean}
*/
isShared(abstract) {
return this.bindings.has(abstract) && this.bindings.get(abstract).shared === true;
}
}
/**
* The singleton instance of the Container.
*
* @type {Container}
*/
let app = new Container();
/**
* ES7 decorator function which allows classes to declare the bindings
* or objects that should be passed to its constructor. The class
* must be instantiated via the container for this to work.
*
* @param {...String|Class} dependencies
* @return {Class}
*/
export function depends(...dependencies) {
return (target) => {
target.depends = dependencies;
}
}
/**
* ES7 decorator which injects dependencies in a slightly more hacky way -
* direcly as properties on the prototype. This doesn't require that
* the class is instantiated via the container. Use this if you
* can't control the instantiation of the class yourself.
*
* @param {Object} dependencies
* @return {Class}
*/
export function inject(dependencies) {
return (target) => {
for (let key in dependencies) {
target.prototype[key] = app.make(dependencies[key]);
}
}
}
export default app;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment