Five Traits of Well-Managed JavaScript

At if(we), we're committed to improving all aspects of our tech stack. This includes our client-side JavaScript, which consists of hundreds of thousands of lines of code across thousands of files.

As JavaScript projects grow, they tend to become difficult to manage if you're not careful. We found ourselves running into common problems including code that was difficult to reuse or test, and code that broke when introduced in new pages.

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.

To help solve this problem, we've adopted the Asynchronous Module Definitions (AMD) pattern and introduced require.js to our tech stack. After exploring AMD further we've identified that well-organized javascript takes on the following five traits:

  1. Dependencies are always declared.
  2. 3rd-party code is shimmed.
  3. Definitions are separate from executions.
  4. Dependency loading is async.
  5. 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.

By always declaring our dependencies, we eliminated about 90% of the reoccurring issues with our javascript. Reusable code became more reliable, and the number of unit tests increased by a factor of 4x.

3rd-party code is shimmed

One of the interesting problems with managing JavaScript dependencies is that older 3rd-party libraries may not be configured to work with your chosen dependency management solution. For example, let's say you pull in a cool plugin that internally uses jQuery, but it knows nothing about require.js. This can become a problem because trait #1 is violated by introducing this plugin.

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

This is a problem we saw often in our JavaScript that limited both reusability and testability. The problem manifests when a single file both defines a class/function and invokes it. Consider the following code:

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

To further strengthen our JavaScript code base, we've (almost) entirely eliminated the use of global variables (with the exception of the globals provided by require.js, e.g. require() and define()). Global variables are notorious for sneaking their way into modules as "hidden dependencies" and can make code difficult to reuse or test.

By eliminating global variables, we've found it is much easier to build JavaScript that correctly adheres to the various traits above.

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.

Conclusion

Managing dependencies is hard, but it doesn't have to be difficult. By using a dependency management solution that allows your code to contain the traits above, you'll find that your JavaScript is much more dependable.