You do come across the term Dependency Injection alot thanks to modern frameworks like angular. In most of implementations the devs make use of reflection to get the parameter types. But the reflect-metadata
npm package itself weigh ~300kb without any single line of external code. Moreover reflection won't work in case of minification. With all these problems on board let's implement a simple DI using built-in javascript features without a single external dependency.
Enough talk let's start with writing some code.
Typically in modern day framework terminologies, a dependency is named as either Provider
or Service
. For the sake of simplicity let us name our fucntion as Service
.
//service.js
const Service = (klass) => {
const instance = new klass();
console.log(instance);
}
export { Service };
easy peasy. when we execute this as follows:
// index.js
import { Service } from './service';
class ServiceA {
greet() {
console.log('hello world');
}
}
Service(ServiceA);
It prints instance of ServiceA. Nothing fancy.
Now the real stuff comes to picture.
Where we need to save this instance to access it later??
Let's see the possibilities:
- using class names as identifiers. This works but it will not work after minified. so you have to sacrifice bundle size.
- using strings as identifiers to save instance using
Map()
. Well this will work out but it ends up too much coding. And alsoMap
preserve the object even though nothing consumes it.
Then what's the solution??
Welcome to WeakMap
yes we leverage weakmap to save the instances. Ok then lets start this by creating an Injector
class with register
, getService
and clear
methods.
// injector.js
const Injector = new class {
register() {}
getService() {}
clear() {}
}
export { Injector };
awesome. let's import this in our service file.
//service.js
import { Injector } from './injector';
const Service = (klass) => {
const instance = new klass();
console.log(instance);
Injector.register();
}
export { Service };
huff our injector is not saving the instance. Let's implement the register method.
// injector.js
const Injector = new class {
#weakmap = new WeakMap()
register(klass, instance) {
if(!this.#weakmap.has(klass)) {
this.#weakmap.set(klass, instance);
}
}
getService() {}
clear() {}
}
export { Injector };
Okay. let me explain it. We all know a WeakMap()
need an object as key unlike Map()
. So the key here is the blueprint of the class and value is instance of that class. neat right?? see how we avoid using names to save the instance?? cool right π
ok now let's come back to service file.
//service.js
import { Injector } from './injector';
const Service = (klass) => {
const instance = new klass();
Injector.register(klass, instance);
}
export { Service };
Great. then where is the dependency injection. woahh hold on.. i'm coming to that part.
Let's create two classes where one depends on other.
// index.js
class ServiceA {
getGreeting() {
return 'hello world';
}
}
class ServiceB {
serviceA;
constructor(_serviceA) {
this.serviceA = _serviceA;
}
greet() {
return this.serviceA.getGreeting();
}
}
Service(ServiceA);
Service(ServiceB);
If you execute the above logic and try to log serviceB.greet()
you will get an error saying serviceA is undefined
.
Yes that's expected. Then how to supply the ServiceA as dependency. let's revisit our service implementation.
//service.js
import { Injector } from './injector';
const DEFAULT_SERVICE_OPTIONS = {
deps: []
}
const Service = (...args) => {
let options = DEFAULT_SERVICE_OPTIONS;
let klass;
if(args[0].hasOwnProperty('deps')) {
options = args[0];
klass = args[1];
} else {
klass = args[0];
}
const instance = new klass();
Injector.register(klass, instance);
}
export { Service };
That's a mouthful. Ok what we're trying to do is checking the arguments of Service implementation so that we can use it as
-
Service(someClassA)
if no dependencies are mentioned. -
Service({ deps: [someClassA] }, someClassB)
if dependencies are mentioned.
If you observe the 2nd use case, we're actually passing the blueprint of someClassA itself not a string or not using class.name. You remember, above we're saving the instance using blueprint of a class. That's the reason we're passing blueprints to deps.
Okay great. we got dependencies but we have to get their instances before creating the instance of target class. In above injector.js file we left getService
method as blank. let's fill it with some nonsense.
// injector.js
const Injector = new class {
#weakmap = new WeakMap()
register(klass, instance) {
if(!this.#weakmap.has(klass)) {
this.#weakmap.set(klass, instance);
}
}
getService(klass) {
return this.#weakmap.get(klass);
}
clear() {}
}
export { Injector };
See as we discussed, we're passing class blueprint to injector to fetch its instance. now come back to service.js
//service.js
import { Injector } from './injector';
const DEFAULT_SERVICE_OPTIONS = {
deps: []
}
const Service = (...args) => {
let options = DEFAULT_SERVICE_OPTIONS,
klass,
instance;
const dependencies = [];
if(args[0].hasOwnProperty('deps')) {
options = args[0];
klass = args[1];
} else {
klass = args[0];
}
// loop all the deps if any and get their instance from Injector.
// else simply create new instance.
if(options.deps.length) {
for(const dependency of options.deps) {
dependencies.push(Injector.getService(dependency));
}
instance = new klass(...dependencies);
} else {
instance = new klass();
}
Injector.register(klass, instance);
}
export { Service };
in our service.js file we're looping through all the dependencies we're passing and getting their instances from Injector. Now let's see index.js
// index.js
import { Service } from './service';
import { Injector } from './injector';
class ServiceA {
getGreeting() {
return 'hello world';
}
}
class ServiceB {
serviceA;
constructor(_serviceA) {
this.serviceA = _serviceA;
}
greet() {
return this.serviceA.getGreeting();
}
}
Service(ServiceA);
Service({ deps: [ServiceA] }, ServiceB);
console.log(Injector.getService(ServiceB).greet());
did you observe the last line before console?? right. as we discussed above, we're passing ServiceA blueprint as dependency to ServiceB. Now when we execute this we'll see 'hello world' message from ServiceB which we kept in ServiceA.
Now as an addon we can clear the all the service instances in Injector using clear
method.
// injector.js
const Injector = new class {
#weakmap = new WeakMap()
register(klass, instance) {
if(!this.#weakmap.has(klass)) {
this.#weakmap.set(klass, instance);
}
}
getService(klass) {
return this.#weakmap.get(klass);
}
clear() {
this.#weakmap = new WeakMap();
}
}
export { Injector };
So that's it. we neither used any external library for dependency injection nor used reflection and nor used any strings as identifiers. Damn clean right?? You can improve this even further.
Here is the below implementation of DI:
In typescript, you can read the type of constructor parameters using reflection. but it won't play nice with new bundlers like esbuild, vite, parcel etc.. because they won't preserve this metadata and moreover they mangle all the property names / class names. so relying on metadata / class names is not advisable.
Hope this helps you in creating your own DI in VanillaJS. VanillaJS has a lot of features which we seldom use. With the advent of more browsers bringing latest ECMAScript features, frameworks are becoming more redundant. Employing typescript with same logic will give you the typechecking prowess.
This implementation will work irrespective of unminified / minified version. so you can use this safely with any of existing bundlers. And probably this is the best usecase on how to use WeakMap in javascript(well as far i came to know. Bring me more examples π).
I personally used the above DI logic in my own framework PlumeJS which is built on webcomponents and typescript. See if you can contribute to it π
Hope you enjoyed this.. π
See you in next article..
Kiran π
Top comments (0)