Frontend Development 7 min read

Understanding Inversion of Control (IoC) and Dependency Injection in Front‑End Development

This article explains the Inversion of Control (IoC) principle, why it matters for growing front‑end applications, and demonstrates how to refactor a simple App component using dependency injection and a module registration system, turning the App into a container that manages its dependencies rather than directly instantiating them.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Understanding Inversion of Control (IoC) and Dependency Injection in Front‑End Development

What is IoC?

Inversion of Control (IoC) is a software design principle that transfers control from the application code to a container, which manages object lifecycles and dependency injection, making systems easier to extend, maintain, and test.

Why use IoC?

Front‑end applications are becoming larger, and inter‑module dependencies grow more complex, reducing reusability and maintainability.

Guidelines:

High‑level modules should depend on abstractions, not concrete implementations.

Abstractions should not depend on details; details should depend on abstractions.

Program to interfaces rather than implementations.

How to apply IoC?

Example

Initially an App class directly creates Router and Track instances and calls them in the DOMContentLoaded handler.

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();
        });
    }
}

import App from './app';
new App({
    onReady() {
        // do something here...
    },
});

Problems with this approach:

The high‑level App depends on concrete modules ( Router , Track ) and the onReady callback.

Implementation details are tightly coupled inside the event listener.

Adding new behavior to Router requires changes in both App and the module file.

To decouple, inject dependencies via the constructor:

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();
        });
    }
}

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...
    },
});

Further improvement: make App a container that only collects modules and invokes a standardized init method on each.

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.forEach(m => App.use(m)) : App.modules.push(module);
    }
    initModules() {
        App.modules.map(m => m.init && typeof m.init === 'function' && m.init(this));
    }
}

Modules must expose an init(app) method:

// 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 = new Track(app.options.track);
        app.track.tracking();
    }
};

The entry file registers modules and supplies configuration:

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 ...
    },
});

Adding new dependencies is straightforward—create a module with an init method and register it with App.use :

// modules/Share.js
import Share from 'path/to/Share';
export default {
    init(app) {
        app.share = new Share();
        app.setShare = data => app.share.setShare(data);
    }
};

App.use(Share);
new App({
    onReady(app) {
        app.setShare({
            title: 'Hello IoC.',
            description: 'description here...'
        });
    }
});

Summary

High‑level modules act as containers that expose an interface for registering dependencies; each dependency implements a standard init method, allowing the container to manage lifecycle and interactions without being coupled to concrete implementations.

design patternsfrontendJavaScriptIoCdependency injection
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.