Photo by kevin laminto
Original link: IoC concept in the front-end – Zhihu column
background
Front application in the process of growing, internal module dependencies between may also be more and more complex, low reusability between modules Lead to difficult to maintain, but we can use some good in the field of computer programming concept to a certain extent, to solve these problems, then to tell the IoC is one of them.
What is the IoC
The IoC, which is called the Inversion of Control, consists of three principles:
- High-level modules should not depend on low-level modules, they should all depend on abstractions
- Abstraction should not depend on concrete implementation; concrete implementation should depend on abstraction
- Program to the interface, not to the implementation
Concepts are always abstract, so here’s an example to illustrate the above concepts:
Suppose we need to build an application called App, which contains a routing module Router and a page monitoring module Track. It may be implemented as follows at the beginning:
// app.js
import Router from './modules/Router';
import Track from './modules/Track';
class App {
constructor(options) {
this.options = options;
this.router = new Router();
this.track = new Track();
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () = > {this.router.to('home');
this.track.tracking();
this.options.onReady(); }); }}// index.js
import App from 'path/to/App';
new App({
onReady() {
// do something here...}});Copy the code
Well, this seems to be fine, but in practice the requirements are very variable and you may need to add new functionality to the route (such as implementing history mode) or update the configuration (enable history, new Router({mode: ‘history’})). In this case, the two modules have to be modified inside the App, which is an INNER BREAKING operation. For the App that passed the previous test, it also has to be re-tested.
Obviously, this is not a good application structure. The high-level module App relies on the Router and Track of two low-level modules. Any modification of low-level modules will affect the high-level module App. The solution to this problem is Dependency Injection.
Dependency injection
The so-called dependency injection, in simple terms, is to “inject” the dependency into the module through the way of parameter passing. The above code can be transformed into the following way through dependency injection:
// app.js
class App {
constructor(options) {
this.options = options;
this.router = options.router;
this.track = options.track;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () = > {this.router.to('home');
this.track.tracking();
this.options.onReady(); }); }}// index.js
import App from 'path/to/App';
import Router from './modules/Router';
import Track from './modules/Track';
new App({
router: new Router(),
track: new Track(),
onReady() {
// do something here...}});Copy the code
It can be seen that the problem of INNER BREAKING mentioned above can be solved through dependency injection. Each module can be directly modified outside the App without affecting the inside.
Would that be all? The ideal is full, but the reality is very skinny. Within two days, the product raised a new requirement for you, adding a sharing module Share to the App. This brings us back to the INNER BREAKING problem mentioned above: you have to modify the App module to add a line this.share = options.share, which is clearly not what we want.
Although App decouples the dependency relationship with other modules to some extent through dependency injection, it is not complete enough. The properties such as this.router and this.track are actually dependent on “concrete implementation”, which obviously violates the principle of IoC. So how do we further abstract the App module?
Talk is cheap, show you the code:
class App {
static modules = []
constructor(options) {
this.options = options;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () = > {this.initModules();
this.options.onReady(this);
});
}
static use(module) {
Array.isArray(module)?module.map(item= > App.use(item)) : App.modules.push(module);
}
initModules() {
App.modules.map(module= > module.init && typeof module.init == 'function' && module.init(this)); }}Copy the code
After the transformation, there is no “concrete implementation” in the App, no business code can be seen, so how to use the App to manage our dependencies:
// modules/Router.js
import Router from 'path/to/Router';
export default {
init(app) {
app.router = new Router(app.options.router);
app.router.to('home'); }};// modules/Track.js
import Track from 'path/to/Track';
export default {
init(app) {
app.track = newTrack(app.options.track); app.track.tracking(); }};// index.js
import App from 'path/to/App';
import Router from './modules/Router';
import Track from './modules/Track';
App.use([Router, Track]);
new App({
router: {
mode: 'history',},track: {
// ...
},
onReady(app) {
// app.options ...}});Copy the code
Use () to “inject” dependencies./modules/some-module.js to initialize the configuration according to certain “conventions”. For example, if you need to add a new Share module, No need to go inside the App to modify the content:
// modules/Share.js
import Share from 'path/to/Share';
export default {
init(app) {
app.share = new Share();
app.setShare = data= >app.share.setShare(data); }};// index.js
App.use(Share);
new App({
// ...
onReady(app) {
app.setShare({
title: 'Hello IoC.'.description: 'description here... '.// some other data here...}); }});Copy the code
You can use the Share module directly outside the App, which is very convenient for module injection and configuration.
Let’s start with the app. use method:
class App {
static modules = []
static use(module) {
Array.isArray(module)?module.map(item= > App.use(item)) : App.modules.push(module); }}Copy the code
You can clearly see that app. use does a very simple thing, which is to store dependencies in the app. modules property to be called later when the module is initialized.
Now let’s look at what the module initialization method this.initModules() does:
class App {
initModules() {
App.modules.map(module= > module.init && typeof module.init == 'function' && module.init(this)); }}Copy the code
This method also does a very simple thing, which is to iterate through all modules in app. modules and determine whether the module contains an init attribute and that attribute must be a function. If so, The method will execute the module’s init method and pass in an instance of App, this, to reference it in the module.
As you can see from this method, to implement a module that can be used by app.use (), two “conventions” must be met:
- Modules must contain
init
attribute init
It has to be a function
This is a good example of the IoC principle of programming to the interface, not to the implementation. App doesn’t care what the module implements, as long as it satisfies the “convention” for the interface init.
If you look at the Router implementation, it’s easy to understand why:
// modules/Router.js
import Router from 'path/to/Router';
export default {
init(app) {
app.router = new Router(app.options.router);
app.router.to('home'); }};Copy the code
conclusion
The App module is more appropriately called a “container” at this point. It has nothing to do with the business and simply provides methods to help manage injected dependencies and control how the module executes.
Inversion of Control is an idea, Dependency Injection is an implementation, and the App is a container for Dependency management.