Recently, I was having a conversation about the basic concepts of IoC/DI, and, specifically, how they pertain to modern (single page) JavaScript Web Applications. This discussion was quite interesting, and so I felt inclined to share some thoughts on the subject with a wider audience.
Dependency Injection in JavaScript
Being a dynamic language, when designing JavaScript based architectures, in comparison to architectures which are under the constraints of a statically typed language, one is typically less inclined to consider the relevance of, or immediate need for, a complete IoC container. Of course, context is key, and so there are certainly JavaScript applications which can benefit from an IoC container (such as wire.js). As such, it would not be prudent for one to suggest otherwise; but rather, this is simply to say that for the majority of JavaScript applications, standard AMD loaders provide a sufficient means of managing dependencies; as the rigidness inherent to statically typed languages which IoC containers help manage are generally less relevant to dynamic languages.
With that being said, while a robust IoC container may not be necessary for the majority of JavaScript applications, it is quite important to emphasize the benefits of employing basic dependency management and Dependency Injection; as this is an essential design characteristic which is critical to the success and overall maintainability of large scale client-side web applications.
Facilitating Code Reuse
Anyone who has been responsible for developing and maintaining specific core features across multiple applications is likely to understand that the ability to facilitate reuse of JavaScript modules is crucial. This is particularly important in the context of architectures which must account for the ability to support mulitple implementations of the same application across different form-factors; for, the ability to manage and configure dependencies can prove paramount; allowing for a framework upon which various form-factor specific implementations of an application can be supported.
In addition to this, as one might expect, having the flexibility necessary for configuring dependencies lends itself, quite naturally so, to various unit testing scenarios.
Configuring Dependencies with RequireJS
Though not always immediately apparent, applications leveraging RequireJS are essentially using a basic form of Dependency Injection out of the box – even if not in the most purist sense of the term. However, the simple matter of mapping module names to module implementations can be considered, in-of-itself, a basic form of Dependency Injection, or perhaps, one could argue this as being more of a Service Locator, as RequireJS does not instantiate dependencies on a clients behalf. Regardless of the preferred classification, this mechanism of defining dependencies is quite important, as it affords developers the ability to change module implementations as desired without the need to change client code. Of course, such modules must adhere to a specific contract (interface) so as to ensure clients which depend on specific named modules receive the expected API.
Explicit Dependencies
Consider a rather contrived example of a shared Application
module which is used across two separate applications; one for Mobile and one for Desktop; with the Application
module having a dependency on an AppHelper
module:
| // shared/app/Application.js define( function( require ) { var AppHelper = require('AppHelper'); return { intialize: function() { AppHelper.doSomething(); } } }); |
Both the Mobile and Desktop applications can easily map the AppHelper
module to a context specific implementation via their respective main.js
configurations:
| // mobile/main.js requirejs.config({ paths : { ... // shared module , 'Application': '../shared/app/Application' // context specific application helper; i.e. mobile , 'AppHelper' : 'app/helpers/MobileHelper' // additional mobile module dependencies ... , ... }, ... }); |
| // desktop/main.js requirejs.config({ paths : { ... // shared modules , 'Application': '../shared/app/Application' // context specific application helper; i.e. desktop , 'AppHelper' : 'app/helpers/DesktopHelper' // additional desktop module dependencies ... , ... }, ... }); |
Based on the above, it is rather evident that the AppHelper
module is mapped to the appropriate application specific implementation; MobileHelper
for mobile, and DesktopHelper
for desktop. Additional context specific APIs can just as easily be defined, and thus provided as dependencies to other modules as needed using this very simple pattern.
Implicit Dependencies
Dependencies need not always be explicit, but rather they can also be implicitly mapped based on the path to which each application’s main.js
configuration resides, or based on the configured baseUrl path.
For instance, given the above example, we can map a Templates Module, and implicitly inject the path to each context specific template based on the application’s default path, or baseUrl
path:
| // mobile/main.js requirejs.config({ paths : { ... // shared modules , 'Templates' : '../shared/app/templates/Templates' , 'TemplateSource' : '../shared/app/templates/TemplateSource' , 'Application' : '../shared/app/Application' // mobile module dependencies ... , 'AppHelper' : 'app/helpers/MobileHelper' , ... }, ... }); |
| // desktop/main.js requirejs.config({ paths : { ... // shared modules , 'Templates' : '../shared/app/templates/Templates' , 'TemplateSource' : '../shared/app/templates/TemplateSource' , 'Application' : '../shared/app/Application' // desktop module dependencies ... , 'AppHelper' : 'app/helpers/DesktopHelper' , ... }, ... }); |
As can be seen, each application’s main.js
defines a Templates
module and a TemplateSource
module, respectively, with each being shared amongst both the Mobile and Desktop specific applications. The Templates
and TemplateSource
modules are defined as follows:
| // shared/templates/Templates.js define( function( require ) { var templates = require('underscore').template , source = require('TemplateSource'); return { someView: function() { return templates(source.someViewTpl)(); } } }); |
| // shared/templates/TemplateSource.js define( function( require ) { return { // loads some-view.tpl based on the application's context/main.js, i.e: // mobile/app/templates/some-view.tpl // desktop/app/templates/some-view.tpl someViewTpl: require('text!app/templates/some-view.tpl') } }); |
While both the Mobile and Desktop applications may share the same Templates
and TemplateSource
modules, the specific implementation of the templates loaded from TemplateSource
is determined via each application’s base path; thus, the path to app/templates/some-view.tpl
automatically resolves to the context specific template; i.e.: mobile/app/templates/some-view.tpl
for Mobile, and desktop/app/templates/some-view.tpl
for Desktop.
Concluding Thoughts
While the above examples are rather basic, they do serve well to demonstrate just how easily one can design for module reuse across different applications with RequireJS, which itself allows for much more robust configurations of modules; such as loading context specific modules at runtime, augmenting modules for differing contexts with mixins, providing third-party libraries based on a particular form-factor (e.g. jQuery for Desktop, Zepto for Mobile, etc.), and more.
You can clone the above example here.