Sooner or later you need to use the abstract results of other developers - that is, you rely on other people's code. I like to rely on free (dependency-free) modules, but that is difficult to achieve. Even those beautiful black box components you create will depend on something more or less. This is exactly what makes dependency injection great. The ability to effectively manage dependencies is now absolutely necessary. This article summarizes my exploration of the problem and some solutions.
1. Goal
Imagine we have two modules. The first one is responsible for the Ajax request service, and the second one is the router.
The code copy is as follows:
var service = function() {
return { name: 'Service' };
}
var router = function() {
return { name: 'Router' };
}
We have another function that needs to use these two modules.
The code copy is as follows:
var doSomething = function(other) {
var s = service();
var r = router();
};
To make it more interesting, this function accepts an argument. Of course, we can use the above code completely, but this is obviously not flexible enough. What if we want to use ServiceXML or ServiceJSON, or what if we need some test modules. We cannot solve the problem by editing the function body alone. First, we can solve the dependency through the parameters of the function. Right now:
The code copy is as follows:
var doSomething = function(service, router, other) {
var s = service();
var r = router();
};
We implement the functionality we want by passing additional parameters, however, this brings new problems. Imagine if our doSomething method is scattered in our code. If we need to change the dependency conditions, it is impossible for us to change all the files calling the function.
We need a tool that can help us get these things done. This is the problem that dependency injection tries to solve. Let's write down some of the goals our dependency injection solution should achieve:
We should be able to register dependencies
1. Injection should accept a function and return a function we need
2. We can't write too much - we need to streamline beautiful grammar
3. Injection should maintain the scope of the transferred function
4. The passed function should be able to accept custom parameters, not just depend on descriptions
5. A perfect list, let's achieve it below.
3. RequireJS/AMD method
You may have heard of RequireJS, which is a good choice to solve dependency injection.
The code copy is as follows:
define(['service', 'router'], function(service, router) {
// ...
});
The idea is to describe the required dependencies first, and then write your function. The order of parameters here is very important. As mentioned above, let's write a module called injector that can accept the same syntax.
The code copy is as follows:
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");
Before continuing, I should explain the content of the doSomething function body clearly. I use expect.js (assertion library) just to ensure that the code I wrote is the same as what I expected, reflecting a little TDD (test-driven development) method.
Let's start our injector module, which is a great singleton pattern, so it works well in different parts of our program.
The code copy is as follows:
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 the property. What we want to do is check the deps array and search for answers in the dependencies variable. All that is left is to call the .apply method and pass the parameters of the previous func method.
The code copy is as follows:
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)));
}
}
scope is optional, Array.prototype.slice.call(arguments, 0) is required to convert arguments variables into real arrays. So far it's not bad. 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 behind the dependency.
4. Reflection method
According to Wikipedia's definition, reflection refers to the ability of a program to check and modify the structure and behavior of an object at runtime. Simply put, in the context of JavaScript, this specifically refers 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 the article. If you output doSomething.tostring() in the console. You will get the following string:
The code copy is as follows:
"function (service, router, other) {
var s = service();
var r = router();
}"
The string returned by this method gives us the ability to traverse parameters, and more importantly, to get their names. This is actually Angular's method to implement its dependency injection. I was a little lazy and directly intercepted the regular expression that gets the parameters in the Angular code.
The code copy is as follows:
/^function/s*[^/(]*/(/s*([^/)]*)/)/m
We can modify the resolve code like this:
The code copy is as follows:
resolve: function() {
var func, deps, scope, args = [], self = this;
func = arguments[0];
deps = func.toString().match(/^function/s*[^/(]*/(/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 execution of regular expressions is as follows:
The code copy is as follows:
["function (service, router, other)", "service, router, other"]
It seems that we only need the second item. Once we clear the spaces and split the string we get the deps array. There is only one big change:
The code copy is as follows:
var a = Array.prototype.slice.call(arguments, 0);
...
args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
We loop through the dependencies array and try to get it from the arguments object if we find missing items. Thankfully, when the array is empty, the shift method simply returns undefined instead of throwing an error (this is thanks to the idea of the web). The new version of injector can be used like the following:
The code copy is 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 rewrite dependencies and their order can be disrupted. It still works, and we successfully copied Angular's magic.
However, this practice is not perfect, which is a very big problem with reflex type injection. Compression will destroy our logic because it changes the name of the parameter and we will not be able to maintain the correct mapping relationship. For example, doSometing() might look like this after compression:
The code copy is as follows:
var doSomething=function(e,t,n){var r=e();var i=t()}
The solution proposed by the Angular team looks like:
var doSomething = injector.resolve(['service', 'router', function(service, router) {
}]);
This looks a lot like the solution we started with. I was unable to find a better solution, so I decided to combine both. Here is the final version of injector.
The code copy is as follows:
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(/^function/s*[^/(]*/(/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);
}
}
}
resolve visitors accept two or three parameters, if there are two parameters, it is actually the same as what was written in the previous article. However, if there are three parameters, it converts the first parameter and fills the deps array, here is a test example:
The code copy is as follows:
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 parameter - note that this is not a typo. A null value actually represents the "Other" parameter (placeholder). This shows how we control the order of parameters.
5. Direct injection of Scope
Sometimes I use the third injection variable, which involves the scope of the operation function (in other words, this object). Therefore, this variable is not necessary in many cases.
The code copy is as follows:
var injector = {
dependencies: {},
register: function(key, value) {
this.dependencies[key] = value;
},
resolve: function(deps, func, scope) {
var args = [];
scope = scope || {};
for(var i=0; i<deps.length, d=deps[i]; i++) {
if(this.dependencies[d]) {
scope[d] = this.dependencies[d];
} else {
throw new Error('Can/'t resolve ' + d);
}
}
return function() {
func.apply(scope || {}, Array.prototype.slice.call(arguments, 0));
}
}
}
All we do is actually add dependencies to scope. The advantage of this is that developers no longer have to write dependency parameters; they are already part of the function scope.
The code copy is as follows:
var doSomething = injector.resolve(['service', 'router'], function(other) {
expect(this.service().name).to.be('Service');
expect(this.router().name).to.be('Router');
expect(other).to.be('Other');
});
doSomething("Other");
6. Conclusion
In fact, most of us have used dependency injection, but we don't realize it. Even if you don't know the term, you may have used it in your code a million times. I hope this article will deepen your understanding of it.