In depth understanding of dependency injection in Javascript

  • 2020-03-30 02:25:33
  • OfStack


Sooner or later, you need to use other developers' abstractions -- that is, you rely on other people's code. I like modules that rely on freedom, but that's hard to achieve. Even those beautiful black box components you create depend on something more or less. This is where dependency injection comes in. The ability to manage dependencies effectively is now an absolute necessity. This article summarizes my exploration of the problem and some of the solutions.

A, goals,
Imagine we have two modules. The first is responsible for the Ajax request service, and the second is the router.


var service = function() {
    return { name: 'Service' };
}
var router = function() {
    return { name: 'Router' };
}

We have another function that USES these two modules.

var doSomething = function(other) {
    var s = service();
    var r = router();
};

To make things more interesting, this function takes an argument. Of course, we could use the above code, but it's not flexible enough. What if we want to use ServiceXML or ServiceJSON, or if we need some test modules? We can't just edit the body of the function to solve the problem. First, we can solve dependencies by taking arguments to functions. That is:

var doSomething = function(service, router, other) {
    var s = service();
    var r = router();
};

We achieve the functionality we want by passing in extra parameters, however, this creates new problems. Imagine if our doSomething method were scattered throughout our code. If we need to change the dependency condition, we cannot change all the files that call the function.

We need a tool to help us do this. This is what dependency injection is trying to solve. Let's write down some goals that our dependency injection solution should achieve:

We should be able to register dependencies
1. The injection should accept a function and return a function we need
2. We can't write too much -- we need to simplify the beautiful grammar
3. The injection should keep the scope of the passed function
4. The passed function should be able to accept custom arguments, not just dependent descriptions
A perfect list, let's make it happen.
Method of RequireJS/AMD
As you've probably heard before, RequireJS is a great solution to dependency injection.


define(['service', 'router'], function(service, router) {       
    // ...
});

The idea is to describe the required dependencies first and then write your function. The order of the arguments is important here. As mentioned above, let's write a module called injector that accepts the same syntax.

var doSomething = injector.resolve(['service', 'router'], function(service, router, other) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
    expect(other).to.be('Other');
});

DoSomething (" Other ");
I should explain the doSomething body of the function before going any further. I use expect.js (a library for assertions) only to make sure that the code I write behaves as I expect, with a little TDD (test-driven development) in mind.
Let's start with our injector module, which is a great singleton pattern, so it works well in different parts of our application.

var injector = {
    dependencies: {},
    register: function(key, value) {
        this.dependencies[key] = value;
    },
    resolve: function(deps, func, scope) {
    }
}

This is a very simple object with two methods, one for storing properties. All we have to do is check the deps array and search the dependencies variable for the answer. All that's left is to call the.apply method and pass the arguments to the previous func method.

resolve: function(deps, func, scope) {
    var args = [];
    for(var i=0; i<deps.length, d=deps[i]; i++) {
        if(this.dependencies[d]) {
            args.push(this.dependencies[d]);
        } else {
            throw new Error('Can't resolve ' + d);
        }
    }
    return function() {
        func.apply(scope || {}, args.concat(Array.prototype.slice.call(arguments, 0)));
    }        
}

The scope is optional, Array. Prototype. Slice. The call (the arguments, 0) is a must, used to convert the arguments variables to real Array. So far so good. Our test passed. The problem with this implementation is that we need to write the required parts twice, and we can't confuse their order. Additional custom parameters are always placed after the dependency.

Four, reflection method
According to wikipedia, reflection is the ability of a program to examine and modify the structure and behavior of an object at runtime. Simply put, in the context of JavaScript, this refers specifically to the source code of an object or function that is read and analyzed. Let's complete the doSomething function mentioned at the beginning of this article. If you output dosomething.tostring () in the console. You will get the following string:


"function (service, router, other) {
    var s = service();
    var r = router();
}"

The strings returned through this method give us the ability to iterate over the parameters and, more importantly, get their names. This is actually Angular's way of implementing its dependency injection. I stole a little bit of laziness by intercepting the regular expression in Angular code to get the parameters.

/^functions*[^(]*(s*([^)]*))/m

We can modify the resolve code as follows:

resolve: function() {
    var func, deps, scope, args = [], self = this;
    func = arguments[0];
    deps = func.toString().match(/^functions*[^(]*(s*([^)]*))/m)[1].replace(/ /g, '').split(',');
    scope = arguments[1] || {};
    return function() {
        var a = Array.prototype.slice.call(arguments, 0);
        for(var i=0; i<deps.length; i++) {
            var d = deps[i];
            args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
        }
        func.apply(scope || {}, args);
    }        
}

The result of our regular expression execution is as follows:

["function (service, router, other)", "service, router, other"]

It looks like we only need the second term. Once we clear the Spaces and split the string we get the deps array. Only one big change:

var a = Array.prototype.slice.call(arguments, 0);
...
args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());

We iterate through the dependencies array, and if we find a missing item, we try to get it from the arguments object. Thankfully, when the array is empty, the shift method simply returns undefined instead of throwing an error (thanks to the web idea). The new injector can be used as follows:

var doSomething = injector.resolve(function(service, other, router) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
    expect(other).to.be('Other');
});
doSomething("Other");

There is no need to override the dependencies and their order can be scrambled. It still works, and we've successfully copied Angular's magic.

However, this practice is not perfect, which is a very big problem with reflex type injections. Compression will break our logic because it changes the name of the parameter and we will not be able to maintain the correct mapping. For example, doSometing() might look like this after compression:


var doSomething=function(e,t,n){var r=e();var i=t()}
Angular The team's proposed solution looks like: 
var doSomething = injector.resolve(['service', 'router', function(service, router) {
}]);

This looks a lot like the solution we started with. I couldn't find a better solution, so I decided to combine the two approaches. Here's the final version of injector.

var injector = {
    dependencies: {},
    register: function(key, value) {
        this.dependencies[key] = value;
    },
    resolve: function() {
        var func, deps, scope, args = [], self = this;
        if(typeof arguments[0] === 'string') {
            func = arguments[1];
            deps = arguments[0].replace(/ /g, '').split(',');
            scope = arguments[2] || {};
        } else {
            func = arguments[0];
            deps = func.toString().match(/^functions*[^(]*(s*([^)]*))/m)[1].replace(/ /g, '').split(',');
            scope = arguments[1] || {};
        }
        return function() {
            var a = Array.prototype.slice.call(arguments, 0);
            for(var i=0; i<deps.length; i++) {
                var d = deps[i];
                args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
            }
            func.apply(scope || {}, args);
        }        
    }
}

The resolve visitor takes two or three arguments, and if there are two, it's actually the same as it was written earlier in the article. However, if it has three arguments, it converts the first argument and populates the deps array. Here is a test example:

var doSomething = injector.resolve('router,,service', function(a, b, c) {
    expect(a().name).to.be('Router');
    expect(b).to.be('Other');
    expect(c().name).to.be('Service');
});
doSomething("Other");

You may notice that there are two commas after the first argument -- note that this is not a clerical error. The null value actually represents the "Other" parameter (placeholder). This shows how we control the order of parameters.


Related articles: