The authors introduce

Arno (Qingnan), a member of proprietary nail front end, a fanatical follower of tools and efficiency, 🤩, is currently responsible for the front-end research and development of proprietary nail collaborative product system.

Yuan I, a member of the proprietary Dingding front-end team, responsible for the development of proprietary DINGding PC client, on-end application and on-end module plug-in.

background

Web-centric front-end technologies have developed rapidly in recent years, producing Typescript, Angular and other front-end technologies that support epic projects such as VSCode. In these large projects, architects are able to control complexity in order to keep the project in such large scale collaboration. In these projects, they deeply practiced object-oriented (OO) programming paradigms, including Inversion of Control (IoC) and Dependency Injection (DI), which were widely used.

Using Typescript as a programming language, this article discusses how to use IoC and DI mechanisms to make it easy for large front-end projects to resolve code dependencies, reuse, and extension.

What is inversion of control

First, let’s talk about inversion of control. Suppose we are a car manufacturer and have our own auto parts supplier. In the early stage, we cooperated deeply with our supplier to establish the dependence relationship of car engine:

import { V8Engine } from 'vendor';

class CarA {
  private engine: unknown;
  constructor() {
    this.engine = new V8Engine();
  }
  drive() {
    // ... drive ... 🚗}}const car = new CarA();
Copy the code

This looks fine at first, but over time we would like to replace the stock of engines with newer ones, such as V9. At this time, we will find that if all our models directly rely on V8Engine provided by vendor, this tightly coupled relationship will be very inflexible in the event of changes, affecting the whole body. To solve this problem, we used the IoC idea of relying not directly on the concrete design of V8Engine, but on the abstract design of an Engine: the standard of the Engine Engine.

In the context of programming, object-oriented programming (OO Design) has a very important idea: Interface Oriented programming (Interface Oriented programming), Interface describes the standard specification that abstract structures such as classes should follow. So we update our code so that the implementation depends on the interface rather than another concrete implementation.

First, we define an abstract interface that describes the Engine specification:

// standard-engine-interface.ts
export interface IEngine {
  // Engine cylinder number
  cylinders: number;
  // ... other properties
  // Engine start function
  start(): void;
  // Engine stop function
  stop(): void;
  // ... other methods
}
Copy the code

Next, the supplier only needs to implement the specific engine according to the engine interface. We implement the necessary interface according to the abstraction of the engine class by IEngine:

import { IEngine } from 'standard-engine-interface';

export class V9Engine implements IEngine {
  cylinders = 4;
  start() {
  	// start engine
  }
  stop() {
    // stop engine}}export class V8Engine implements IEngine {
  cylinders = 2;
  start() {
  	// start engine
  }
  stop() {
    // stop engine}}Copy the code

Next, we can try to assemble different engines for the same model:

import { IEngine } from 'standard-engine-interface';
import { V8Engine, V9Engine } from 'vendor';

export class CarB {
  private engine: IEngine;
  constructor(engine: IEngine) {
  	this.engine = engine; }}const carV9 = new CarB(new V9Engine());
const carV8 = new CarB(new V8Engine());
Copy the code

This is the classic inversion of Control (IoC) principle in object-oriented programming:

Upper modules should not depend on lower modules, they all depend on an abstraction, abstraction cannot depend on concrete, concrete must depend on abstraction.

Our car factories no longer rely on specific engine types to assemble, but on engine design standards. The same is true of programming, where implementations no longer rely on concrete implementations, but on abstract interfaces, so that the upper modules can replace the lower ones as instances change. Wouldn’t that be nice? Add a UML structure diagram to make it more clear how such a design can be structured in OO design:

Before: CarA and the V8Engine class are tightly coupled (directly coupled).

After: CarA, V8Engine, and V9Engine rely on an abstract interface called IEngine.

The advantage of this is that future Engine types will be compatible with CarA as long as they implement the interface required by IEngine, and CarA will be able to replace specific Engine types without awareness.

What is dependency injection

Understanding IoC makes DI easier to understand.

We continue to take car manufacturing as an example to describe how IoC and DI, the “Haier brothers”, cooperate well to solve problems in actual production.

Imagine in the process of building a Car, the Engine will also rely on the Engine Bearing, and there are many kinds of Bearing specifications, so when we are in the production of a Car (Car), we not only to make a reasonable Engine upstream, upstream suppliers also need to rely on its upstream bearings, Bearing also need to ask its upstream bearing material… Therefore, a complex production relationship will be formed, which is called Dependency Network in program design.

If you use code, it would be:

const car = new CarA(new V8Engine(new Bearing(newBearingMaterial(), ... ) ));Copy the code

Dependency injection mainly aims to help us analyze the dependency network systematically, encapsulating and simplifying the production process of an object or a class. Dependency injection frameworks are literally factories for classes or objects.

A simple DI system can dynamically resolve the object or other data type needed by the caller at runtime, which is an important implementation of decoupling in strongly typed language design.

If we use TS, it’s like:

// Providers of dependencies can provide constructors (classes) for engines, bearings, etc., via the bindDependency method
// Bind specific engine and bearing classes to factory production lines
DIFactory.bindDependency('engine', V8Engine);
DIFactory.bindDependency('bearing', Bearing);
// ...

// When the car needs to be picked up (GET), the factory will start the car building process according to the production line that has been implemented and bound
// Complex "assembly process" : dependency analysis, object instantiation... And so on, this dependency factory function is going to take care of the generation
const car = DIFactory.get<ICar>('car');
car.drive();
Copy the code

We encapsulated the assembly process and realized the assembly and assembly of auto parts only with a simple GET function. The dynamic assembly process (object dependency network instantiation process) is the dependency injection process.

Let’s expand it out a little bit:

  • 'engine''car'As well as'bearing'This identifier is what we call itDependency Injection TokenThey are typically designed to be globally unique and used to annotate unique types.
  • bindDependencyThe function, let’s call itRelying on the binding, that is, type implementations (classes, objects, etc.) and inversion of control containers (IoCContainerAlso called in the partial frameworkInjectorDependency injector).
  • getFunction, on the other hand, is a process of object precipitation (analyzing the dependency relationship and assembling the generated object). The DEPENDENCY injection framework will analyze the corresponding type of the required dependency injection Token, recursively analyze its dependencies, and finally instantiate these objects to complete the assembly of the final object.

The well-known inversion of control framework in the industry is typical of Inversify.

If you are interested in the dependency injection framework, you can read this article in depth. The key classes in the implementation of the framework are represented in UML as follows, which can form a preliminary impression.

Figure: The main class and object relationships in the Inversify framework

Exploration of best practices

With an understanding of DI and IoC, let’s try to find current best practices for DI and IoC in front-end projects, in particular, to find a design balance between program architecture and engineering.

Angular implementation mechanism

This section, which may seem obscure to those unfamiliar with Angular, can be read in the next section.

I have always found Angular to be the best framework for building complex Web UI systems. It defines a very complete Modular and DI system, especially suitable for OO experienced front-end teams. Take the key types Angular 12 defines:

Angular its main types fall into the structure above. You can see that the Service, as a Provider to the Angular dependency injection system, has been injected into the Angular Dependency Injector through the @ Injectable () decorator. In NgModule/Directive/Component, services can be provided declaratively on class decorators. When the Angular runtime instantiates these objects, it uses the internal Injector to generate the configured objects and inject them into the constructor function.

import { Injectable } from '@angular/core'; import { HEROES } from './mock-heroes'; import { Engine } from '.. /engine.service'; @Injectable({ providedIn: 'root', }) export class CarService { constructor(private engineService: Engine) { } getEngine() { this.engineService.start('start ... '); return this.engineService; }}Copy the code

Its DI form is elegant 😎 ~

The author has done a brief analysis of the Angular 12 DI system source code. Interested students can read this article to learn more about its design and implementation.

Build DI business framework based on Inversify

After looking at Angular system design, how to implement a similar DI system to do IoC well in business like Ali React? Here, the author gives some practical thinking, but also cast a brick to draw jade, and we discuss the best practice paradigm.

Assumed readers:

  • Familiarize yourself with Typescript and the Typescript Decorator, and enable configuration
  • You learned about the basic Inversify framework API.

1. Implement a dependency injection system using decorators

💍 Decorators to rule them all!

Thanks to Java’s Annotation system, Typescript provides similar Decorator methods for classes, constructor arguments, properties, and so on that implement the DI system in a way that is legible and less intrusive. Let’s continue with the car building example and take a look at the basic API usage for Inversify:

import { Container, injectable, inject } from "inversify";

// Declare that V8Engine is recognized by dependency injection frameworks
@injectable()
class V8Engine {
    public start() {
      	/ /... 🏁...
        return "v8";
    }
}

@injectable()
class V9Engine {
    public start() {
        / /... 🏁...
        return "v9"; }}// Declare that the Car class is recognized by the dependency injection framework
@injectable()
class Car implements ICar {
    private engine: V8Engine;
    public constructor(engine: V8Engine) {
        this.engine = engine;
    }
    public start() { return this.engine.start(); };
    public stop() { return this.engine.stop(); };
}

// Create a dependency injection container
const container = new Container();
// Perform the dependency binding
container.bind<IEngine>(V8Engine).to(V8Engine);
container.bind<IEngine>(V9Engine).to(V9Engine);
container.bind<ICar>(Car).to(Car);

// Parses the Car instance and passes the V8Engine instantiation into the Car constructor function as a parameter to initialize the Car instance
container.get(Car);
Copy the code

We can see that the whole DI process is light intruded into the DI framework through @Injectable (), making it possible to write extremely small amounts of code associated with the framework.

2. Further encapsulation of IoC container

🚀 Inversify is a good framework in itself, but the use of various fancy APIS also brings higher cognitive costs to the business layer. We can consider cleverly encapsulating part of the method and providing it to the upper layer to reduce the “cognitive costs”. Inversify still has a certain learning cost, the light API is more than 40+, direct use for beginners and white users is very unfriendly, take the 👇 document as an example, to understand the use of Inversify, at least read through the following article, form an understanding.

The Inversify pair provides a more primitive version of the IoC container, which provides the most atomic functionality. It can be further encapsulated to make it easier for users to use, regardless of the implementation details.

Here are a few practical design rules:

  • In the business dependency injection framework, only object types can be precipitated (single type precipitation can reduce a lot of understanding cost, in fact, objects can Cover 99% of the design, can eliminate the cognitive cost caused by complex API use).
  • willbindThe process is automated in the business framework, and developers do not need to carebindprocess
  • Provided at the IoC container layergetThe stability of the process is guaranteed, so that there is a fallback scheme if parsing goes wrong
  • Statistics are built into the IoC container layerIoCPrecipitate performance analysis plug-in, can be high – performance operation static
  • In the IoC container layer, configure the way to encapsulate the Inversify API, so that the business layer framework users do not care about the implementation of the Inversify bottom container
  • .

The IoC container can do a lot at the business layer to mask the underlying complexity and provide a simple solution for business development. For the consumer, all they need to know is that there is a get method.

// di-framework.ts
export class DIContainer {
  get<T>(serviceIdentifier: Token): T;
}
export const container = new DIContainer();
Copy the code

3. Dependency injection Token optimization

By specifying design rules for tokens in a business framework, we can simplify the cognitive complexity of dependency injection tokens by making the following rule restrictions:

  • Tokens use strings to ensure uniqueness, such as:ninjaEnsure global uniqueness of ninja type
  • Tokens can be implementedtoStringMethod, which allows the parsing Token for subsequent dependency injection to be integrated with subsequent interface-oriented design
// tokens.ts
import { createServiceIdentifier } from 'di-framework';
export const Engine = createServiceIdentifier('engine');
export const Tire = createServiceIdentifier('tire'); / / tire
export const Car = createServiceIdentifier('car');
Copy the code

We can then have Engine and Car implement toString() via the createServiceIdentifier Token factory function, and have functional decorators that can handle “semantics.” You can see how this is elegantly implemented later in the final effect.

4. Encapsulate the Provide decorator

To further converge decorators, we provide @provide decorators to users to provide classes or objects.

// impl.ts
import { Engine, Car, Tire } from 'tokens';
import { IEngine } from 'standard-engine-interface';

// engine.v8.impl.ts
@provide(Engine)
class V8Engine implements IEngine {
  start() {
    / /... 🏁 V8...}}// engine.v9.impl.ts
@provide(Engine)
class V9Engine implements IEngine {
  start() {
    / /... 🏁 V9...}}Copy the code

By using the @provide decorator in different files and providing different classes to bind Engine Token, you can achieve imperceptibly replacement of any car Engine that implements IEngine.

5. Implement consumer dependencies

From MyCar Constructor, we can inject the engine and tire directly into the class Constructor:

// car.impl.ts
import { container } from 'di-framework';
import { Engine, Tire, Car } from 'tokens';

// Direct implementation
import 'engine.v9.impl';

@provide(Car)
class MyCar {
  constructor(@Engine private engine: IEngine, @Tire private tire: ITire,) {
    // init other parts ...
  }
  drive() {
    this.engine.start(); }}// Finally we only need the Car Identifier to precipitate the Car instance
const car = container.get<MyCar>(Car);
Copy the code

As you can see, the above process condenses the complex usage of the Inversify framework into:

  • createServiceIdentifierCreates a unique Token for dependency injection that is also a decorator for constructor parameters (dual-use object, semantic)
  • @provide(ServiceIdentifier)Provides dependency injection services and automatically binds tokens to decorated classes
  • @ServiceIdentifierDecorators with constructor parameters with decorator semantics that represent dependency injection relationships in class constructors
  • container.getObtain the dependency analysis, separate out the required objects, and complete the whole DI process

Enabling users (business developers) to master only four apis to complete major DI system designs simplifies the overall cost of understanding dependency injection while maintaining the elegance of the DI style. Inversify can be implemented in the field at a lower cost. Of course, for its own requirements are relatively simple, you can also handdraft a simple version of Inversify or use other industry open source DI framework to assist implementation.

summary

I believe that with the large-scale development of front-end technology in the future, Web applications will inevitably introduce more complexity. When you are faced with your own modules, when designing their modular system, dependent system and extended system, Be sure to see the design mechanics of DI and IoC shine here!

reference

  • IoC Wiki & DI Wiki, official definition of Wikipedia
  • Angular DI system design and implementation analysis: Official Angular documentation
  • Inversify Framework Introduction and Usage Guide (Chinese)
  • Inversify framework design thinking by @arno (surfacew) personal thinking