In the previous article “Decoupling from the Front End: From Object-oriented to Interface”, we have achieved a large degree of decoupling by converting the dependencies between modules from implementation details to abstract requirements interfaces through the dependency inversion principle (DIP). This is called interface oriented programming. The front-end projects I have encountered so far are good enough to handle coupling through the DIP principle +TS. But as engineering becomes more and more serious, we have to keep a sense of caution and consider the possibility of further decoupling.
At the end of the article, we mentioned some of the potential problems that the current code (see below) still faces: for example, we put the instantiation process of the low-level module in the high-level module, and if the construction method of the low-level module changes in the future (such as needing to pass parameters), we have to change the code of the high-level module. As a low-level module, it is likely to be depended on by multiple high-level modules, which further increases the maintenance burden. The next direction of decoupling is clear: deal with the coupling resulting from module instantiation.
// Define interface:
interface TWeaponClass{ // Define the weapon type
new(): TWeapon
}
interface TWeapon{ // Define the methods to be implemented
start(victim: string) : void.// The start method takes a single string argument and returns nothing
}
Copy the code
class Gun implements TWeapon{ // The specified weapon must implement the start method
private fire(victim: string){
console.log('gun is firing ' + victim)
}
public start(victim: string){
this.fire(victim)
}
}
class Knife implements TWeapon{
private cut(victim: string){
console.log('knife is cutting ' + victim)
}
public start(victim: string){
this.cut(victim)
}
}
Copy the code
class Soldier{
private weapon: TWeapon
constructor(InputWeapon: TWeaponClass){
this.weapon= new InputWeapon()
}
public attack(victim: string){ // The attck method accepts only one string argument
this.weapon.start(victim)
}
}
const soldierG= new Soldier(Gun)
const soldierS= new Soldier(Knife)
soldierG.attack('dog') // gun is firing dog
soldierS.attack('pig') // knife is cutting pig
Copy the code
Since the module instantiation process is the cause of the problem, it might be easy to think of unified management of the various modules. The instantiation of all modules takes place in the admin center. Modules that need to rely on the related low-level modules request invocation from this manager. This way, if the constructor of a module changes, we only need to modify the registration information in the administrator. Such changes are transparent or insensitive to the higher-level modules, because the management center has taken care of everything for them and the higher-level modules can still request the same invocation. If you think of this layer, you already understand the well-known IoC design philosophy of the back-end domain! The so-called modular unified management center is a core concept in IoC thought, IoC container.
What is IoC?
B: Inversion of Control. It’s not a technology, it’s just an idea, an important object-oriented programming discipline. IoC philosophy can guide us to design loosely-coupled, better programs. In traditional applications, we actively create dependent objects within classes, which leads to high coupling between classes and is difficult to test. With the IoC container, control of creating and finding dependent objects is handed over to the container, which injects composite objects. So objects are loosely coupled to each other, which is easy to test and reuse.
Why is it called inversion of control?
Control: Refers to the right to create (instantiate, manage) instances. In traditional development, we create instances directly within the module through new, and the program creates dependent objects on its own initiative. IoC has a container for creating these objects, which controls the creation of instance objects.
Inversion: Control is given to the external environment (framework, IoC container). Instead of going to the new instance ourselves, the IoC container helps us instantiate and manage the object. We just ask the IoC container which object we need to use. We lose a right (the right to create and manage objects) and gain a benefit (not having to worry about creating and managing objects, etc.).
What does the IoC container do?
- Class instantiation
- Find module dependencies
Ok, now let’s use the above ideas to design an IoC container that exposes the methods for registering modules and getting module instances.
interface TModule<T>{
new(... args:any[]): T
}
Copy the code
class Container{
private modules= new Map(a)public register<T>(identifier: string.Module: TModule<T>, singleton: boolean = true.args: any[] = []) {this.modules.set(identifier, {
singleton,
args,
module: Module,
instance: null as T,
})
}
public get(identifier: string){
const moduleInfo= this.modules.get(identifier)
if(! moduleInfo.singleton || ! moduleInfo.instance){ moduleInfo.instance=newmoduleInfo.module(... moduleInfo.args) }return moduleInfo.instance
}
}
export const container= new Container()
Copy the code
As you can see, the code for the IoC container content is fairly simple. The module registration method stores module information as key-value pairs in a Map inside the container. The GET method uses keys to get module instances (instantiating modules first if necessary). Let’s redesign the module code so that it relies on the module’s instantiation permission to be given to the IoC container to achieve the inversion of control.
class Gun implements TWeapon{
private fire(victim: string){
console.log('gun is firing ' + victim)
}
public start(victim: string){
this.fire(victim)
}
}
container.register<Gun>('Gun', Gun)
class Knife implements TWeapon{
private cut(victim: string){
console.log('knife is cutting ' + victim)
}
public start(victim: string){
this.cut(victim)
}
}
container.register<Knife>('Knife', Knife)
class Soldier{
private weapon: TWeapon
constructor(weaponIdentifier: string){
this.weapon= container.get(weaponIdentifier)
}
public attack(victim: string){
this.weapon.start(victim)
}
}
container.register<Soldier>('SoldierG', Soldier, false['Gun'])
container.register<Soldier>('SoldierK', Soldier, false['Knife'])
const soldierG= container.get('SoldierG')
const soldierK= container.get('SoldierK')
soldierG.attack('dog') // gun is firing dog
soldierK.attack('pig') // knife is cutting pig
Copy the code
Now we have completely decoupled the dependencies between modules! Although there is a registration action required after the module definition, the benefits are obvious. If there are any modules that need to be modified (such as the constructor), then we only need to change the input parameters (module registration information) when we call the Register method. Other higher-level modules that depend on this module do not need to make any changes. This significantly reduces the maintenance burden of the project and improves overall stability.
However, the implementation of IoC also has a disadvantage, that is, the code is a little cumbersome and not elegant, and the IoC container has to be directly introduced into the module, causing certain intrusion to the logic.
Is there a way to optimize it? There is. In the next article we will introduce the classic implementation of IoC, DI (dependency injection), which makes the idea of IoC elegant and simple at the implementation level while decoupling it.
Front end small white, the article inevitably has fallacy, asks everybody big guy light spray!