DEV Community

loading...
Cover image for Make your own truly flexible apps: Inversion of Control Pt 2

Make your own truly flexible apps: Inversion of Control Pt 2

miketalbot profile image Mike Talbot ・6 min read

We use a game for demonstration and tutorial purposes, this series is NOT a game development tutorial, and all of the techniques here can be applied to business systems and web apps.

In the first part of this series, we looked at how Inversion of Control allows us to create a flexible architecture that aligns with SOLID principles, and to do this; we examined a game built using such a framework.

In this part, we will look at how we can extend that framework so that other code, not compiled at the same time as the original game, can plugin and extend the solution to add some vital features.

There was one dimension sadly lacking from our original game, and that is sound. In this installment, we will:

  • Add dynamic loading to our framework
  • Extend and refactor the framework to enable functions written later to be injected into the game
  • Add sound effects to our game

Throughout this series, we will demonstrate how it is possible to create powerful frameworks, far beyond games, that enable dynamic customization and per user specialism, even in multi-tenant applications.

If you haven't read the first part, it's probably going to help you understand the core principles behind this extension.

Extending the framework

We left part 1 with a pretty robust framework for writing a game, but that framework lacked a few things:

  • Events raised on initialization to enable further extensions to be loaded
  • A few more events and integration points that have no immediate purpose, but foresee a use by a developer who wants to extend our solution later.

With Inversion of Control (IoC), we need to provide ways for code to load code into our application from different sources. We can do this in several ways. In the next installment, we will look at code-splitting; meanwhile, in this part, we will examine a method to load vanilla Javascript.

So we want to be able to extend our solution? Let's write an insertion point for that extension into our start-up scripts.

export default function App() {
    const [ready, setReady] = React.useState(false)
    const loaded = React.useRef(true)
    React.useEffect(()=>{
        start().catch(console.error)
        return ()=>{
            loaded.current = false
        }
    }, [])
    if(ready) {
        const [uiElements] = raise("ui", [])
        return (

            <div className="App">
                <GameSurface>{uiElements}</GameSurface>
            </div>
        )
    } else {
        return null
    }

    async function start() {
        const parameters = parse(window.location.search)
        await raiseAsync("initializeGame", parameters)
        await raiseAsync("postInitializeGame", parameters)
        await raiseAsync("gameReady", parameters)
        if(loaded.current) {
            setReady(true)
        }
    }
}

Now, rather than getting straight into the game, we begin by issuing a series of asynchronous events and waiting for them to complete.

We parse out the search parameters on the URL and pass them to three events in sequence. We imagine that "initializeGame" will do the heavy lifting. We then provide a "postInitializeGame" and a "gameReady" in case anything we load needs to wire together other dynamically loaded components. These additional events are an excellent example of predicting possible future uses of the framework and providing useful integration points now, rather than adding such features later and having to re-release.

The next extension is to push our core framework API out into the global space so that simple, vanilla Javascript extensions can consume it.

//lib/event-bus.js

// Previous code
//...

const Framework = window.Framework = window.Framework || {}

//Expose our code to outside audiences
Framework.EventBus = {
    plug,
    Socket,
    raise,
    raiseLater,
    raiseAsync,
    once,
    handle,
    useEvent,
    events,
    stopPropagationAndExit
}
//Add React so that external component can use 
//one copy
Framework.React = React

We create a global Framework object and provide that with an API for the Event Bus we use as the core of our IoC solution.

Additionally, I decided that a core sound module made sense inside the framework, rather than presuming plugin modules would provide their own - though, of course, they are perfectly capable of using whatever they like. So I added Howler.js and made that available on the global window too:

//lib/sound.js
import {Howl, Howler} from 'howler'

const Framework = window.Framework = window.Framework || {}

Framework.Sounds = {
    Howl,
    Howler
}

Choosing what you will include inside the framework and what should be included by plugins is a design choice you have to make. Sometimes it will be apparent, like in this case, we need sound in a game. Sometimes you may begin by including libraries or modules in plugins and later "promote them to the framework."

Now we have created an environment capable of supporting extension; it's time to write a plugin that will load other plugins!

Dynamic Code Loading

We will start with some simple dynamic code loading. We will react to the "initializeGame" event and use that opportunity to load any Javascript modules listed in the URL, ensuring they have fully initialized and added any handlers they require to the Event Bus.

import { ensureArray, handle, once } from "./event-bus"

handle("initializeGame", async function loadJS(parameters) {
    const promises = []
    const toLoad = ensureArray(parameters.load)
    let id = 0
    for (let load of toLoad) {
        const thisId = id++
        let response = await fetch(load)
        if (response.ok) {
            let script = await response.text()

            //Add a promise for the script loading
            promises.push(
                new Promise((resolve) => {
                    once(`loaded${thisId}`, () => {
                        console.log("loaded", load)
                        resolve()
                    })
                })
            )

            script = `${script};Framework.EventBus.raise("loaded${thisId}");`
            const element = document.createElement("script")
            element.innerHTML = script
            document.body.appendChild(element)
        }
    }
    await Promise.all(promises)
})

We load the script for each Javascript module as text. Next, we append a line of code to raise an event indicating that all other top-level functions in the module have executed. Finally, we create a script tag, set the code, and insert it into the DOM. We wait for all of our promises to be complete before we return.

By including this module in our start-up script, we've created a system that will load and extend our system based on parameters passed to the URL!

https://someapp.com/?load=one.js&load=two.js

Writing an extension or two

So now we want to write some sounds! We have taken all of the sounds from the excellent free source zapsplat with music from Komiku on the Free Music Archive.

I've chosen to serve them all from the same location as the app, but it would be fine to use any URL, not only ones packaged with the app. You need to encode URLs so that they can be safely used as a search parameter.

So next we will write some vanilla ES5 Javascript to be loaded, here is the music:

var handle = window.Framework.EventBus.handle
var Howl = window.Framework.Sounds.Howl

var music = new Howl({
    src: ['Komiku_-_02_-_Chill_Out_Theme.mp3'],
    loop: true,
    autoplay: false,
    volume: 0.3
})

handle("startGame", function() {
    music.play()
    music.volume(0.05)
})

handle("nextLevel", function() {
    music.fade(0.3, 0.05, 400)
})

handle("startLevel", function() {
    music.fade(0.05, 0.3, 1000)
})

handle("gameOver", function() {
    music.stop()
})

We use the newly published global Framework elements for the EventBus and for Howler to load up the music and start it playing when the game begins.

When we move on to the next level screen, we make it quieter, fade it up when a new level begins, and stop it when the game is over.

Game music done.

Then for some sound effects:

var handle = window.Framework.EventBus.handle
var Howl = window.Framework.Sounds.Howl

var pops = []
var popIndex = 0
var produce = new Howl({
    src:['zapsplat_cartoon_bubble_002_46660.mp3'],
    loop: false,
    preload: true,
    volume: 0.1
})

for(var i = 0; i < 10; i++) {
    pops.push(new Howl({
        src: ['zapsplat_cartoon_bubble_pop_005_40277.mp3'],
        loop: false,
        autoplay: false,
        preload: true,
        volume: 0.7
    }))
}

let lastTime = 0

handle("bob", function() {
    if(Date.now() - lastTime > 300) {
        lastTime = Date.now()
        produce.play()
    }
})

handle("popped", function() {
    pops[popIndex ++ % pops.length].play()
})

We have a sound for the creation of bubbles, on the "bob" event issued by bottles. We also create a few of the bubble popping sounds as we often hit a bunch at the same time. We select the next available one and play it on the "popped" event.

Conclusion

In this part, we've seen how we can refactor a framework to include loading "extension code," which itself is written without the need for a compilation step and can access our core framework API through a global object.

As we move through the series and discover other ways of compiling and loading code, this already powerful paradigm will come to the fore. IoC enables our goals of a framework that supports large teams working on the same codebase and the extension of applications without the need to change the existing code.

Exercise

  • Try loading just a few of the plugins by changing the URL to read https://v8mxq.csb.app/?load=ambient-sounds.js&load=music.js etc.
    • music.js
    • ambient-sounds.js
    • apple-sounds.js
    • bubble-sounds.js
    • level-sounds.js
  • Write your own vanilla JS extension and change some of the sound effects.
  • Use the Framework.React property to add some UI by handling the "ui" event.

Discussion (1)

pic
Editor guide
Collapse
zoedreams profile image
☮️✝️☪️🕉☸️✡️☯️ • Edited

Thats great! I really enjoyed how simple you reduced the concept of IoC.. And made it fun!

Here is a snazzy resource assembler which takes IoC and modifies it with a Factory like constructor.. One of the main take backs here is that once you dynamic load your resource with IoC< lets meta program a namespace around that module and inject it into my current applications scope..

const Api = require("../Api");

class ResourceAssembler {

  constructor() {
  }

  static inject(clazz) {
    if(!clazz.hasOwnProperty('resource')) {
      throw new Error("All resources of type 'BaseResource' require the static function 'resource'");
    }
    global.talk.express.post(Api.URI[clazz.name], (..._) => clazz.resource(..._));
  }
}

module.exports = ResourceAssembler;

as you noticed I was able to solve the object Reflection by creating a localized data dictionary (ENUM of classes, procedurally created array Api.URI[], and then a complex function which takes a dynamic sized array "_" and pumps this value into the class's resource property. Please note that this design pattern well for say IPC event types, or even states.

In my implementation of this classes, it is used to inject a http resource that is used by expressed based on a the URN of the URL. (the stuff after the server..) this allows us to not have to define individual files used to implement the express http interfaces. We have one assembler which build our resources by using a complex function, inline curry (not to be confused with cumin), and analytical continuance.