As we explored the problems in more detail, we found the root cause was most often due to ineffective dependency management. For example, script A relies on script B that relies on script C, and somehow the dependency chain would break on some pages because script C didn't get included properly.
- Dependencies are always declared.
- 3rd-party code is shimmed.
- Definitions are separate from executions.
- Dependency loading is async.
- Modules do not depend on globals.
Let's go over these in detail:
Dependencies are always declared
One of the most common problems we ran into was a script would be written to assume that certain dependencies were already loaded. For example, if we built a jQuery plugin, it wasn't considered necessary to declare jQuery as a dependency because most pages already had it loaded. While this seemed to work on most pages, it quickly became a problem once we tried to unit test that plugin or load it in a fresh new page.
3rd-party code is shimmed
The solution is to shim the plugin by informing your dependency management tool of its dependencies. In require.js, this can easily be done via config:
With this simple config change, each time a script loads lib/cool-plugin.js, require.js will automatically load jquery. The helps to ensure that all dependencies are met without requiring the developer to figure out the depencies on her own each time she needs to use it.
The end result is code that is easier to test and reuse because you're always just one require() call away from the needed functionality.
Definitions are separate from executions
In this example, a single file both defines the User class as well as invokes it. This would make it very difficult to reuse this code, because simply loading the script would cause an alert to appear for Alice. Similarly, this code would be difficult to test because there's no opportunity to stub out the greeter.
The solution is to keep definitions separate from executions as much as possible. This helps ensure reusability as well as testability:
With this change, the User class can safely be reused in many scripts.
Dependency loading is async
Since attempting to load a script synchronously would cause the browser to lock up, it is important that your scripts -- and therefore your modules -- are loaded asynchronously. Require.js does this by default by allowing you to place your module code in a function. The function does not get invoked until all the dependencies are met:
By using a closure, we can further benefit by using "use strict" within the module.
Modules do not depend on globals
Require.js also allows us to convert 3rd-party global variables to require()-able modules via the shimming functionality. In this example, lib/calculator creates a global Calc object, but we can configure require.js to take that global and export it as a local variable when this library is require()'ed.