Bundling a Non-Modular Codebase: Lessons Learned

Intro: what did I do and why did I do it?

I recently had the unenviable task of refactoring my employer's main frontend codebase to use ES6 modules. The codebase is reasonably large, with more than 100k lines of JavaScript code across more than 30 files. The files were not modular, meaning they defined their own variables and functions in the global scope, where logic in other files would expect to find those variables and functions... If you're cringing at that, yes that's one of the reasons why I had this task, to finally do away with the problems caused by that system. As you can probably imagine, the developer experience wasn't great with this codebase, and we had no way of removing inactive crufty code from the final build. This meant our builds were larger than they needed to be, and we couldn't significantly and efficiently reduce load time for the end user. So, there I stood, with the weight of these problems on my shoulders. esbuild was our bundler of choice, particularly because of its speed - that animation on its landing page is pretty convincing.

Lessons Learned

Thankfully I finished off the task conclusively, and the rest of this article is a reflection on the lessons I learned in the process.

1: Be thoughtful of when to import modules

Dynamic import() is a powerful way to defer loading scripts until they're needed, so it's important to take advantage of this. Top-level imports are perfect if they're absolutely needed when your bundle is parsed and executed, but with a bit more thought you may find that most of your imports are actually not strictly needed as top-level imports. A good bundler with tree-shaking will isolate the modules that are import()ed, thereby excluding them from your main js bundle and reducing the time required to parse and execute said bundle. Of course your code can get very messy if you're import()ing a lot of little functions, so some balance is needed. A good policy is to group together and import() modules that are limited to specific parts of your web app. In my case I used import() for modules that were specific to certain pages.

2: Beware of circular dependencies

Circular dependencies can be introduced very easily due to the reusable nature of modules, so there is value in being mindful of when exactly functions are executed. Depending on how your files are organized, it may be almost impossible to avoid some files importing each other. Thankfully esbuild is quite good at finding and ordering imported modules to minimize conflicts, and I imagine other bundlers are similarly effective in this regard. Nevertheless, for the gnarly problems that persist, I found it helpful to move the affected logic out of the module's global scope and into functions that are executed when necessary. In Code This Means:

// module1.js
import { objectOfInterest } from './module2';

export const anInterestingString = 'an interesting string';

export function someFunc() {
  const { prop } = objectOfInterest;
  return `value of "prop": ${prop}`;
}

someFunc();

// module2.js
import { anInterestingString } from './module1';

export const objectOfInterest = {
  foo: 'bar',
  prop: `check this out: ${anInterestingString}`
};

can be changed to:

// module1.js
export function someFunc() {
-  const { prop } = objectOfInterest;
-  return `value of "prop": ${prop}`;
+  const { getPropVal } = objectOfInterest;
+  return `value of "prop": ${getPropVal()}`;
}

// module2.js
export const objectOfInterest = {
  foo: 'bar',
-  prop: `check this out: ${anInterestingString}`
+  getPropVal: () => `check this out: ${anInterestingString}`
};

That is a contrived and simplified example that mimics an actual situation I faced. The idea is that objectOfInterest was being initialized with the value of anInterestingString from the other module, so the value of anInterestingString needed to be known when objectOfInterest was defined. Both modules depend on each other, hence the possibility of errors due to undefined/uninitialized values. However, by switching the definition of objectOfInterest to use a function that returns the desired string, the value of anInterestingString does not need to be known when objectOfInterest is defined, hence removing the possibility of conflicts.

3: Choose between minification and sourcemaps for better debugging

Consider disabling minification if sourcemaps worsen your debugging experience. A sourcemap allows the browser to reconstruct and present the original file in the browser's developer tools, but for different reasons you may be unsatisfied with the reconstructed output. In my case the reconstructed files did not get the same names of variables and functions as the original files, so I was essentially only getting beautified versions of the minified files. This made debugging quite difficult as you can imagine. Disabling minification gave me exactly what I needed because the variable names remained almost exactly the same, allowing me to traverse the code as easily as I would in my code editor. Keep in mind, however, that minification helps significantly reduce the size of your final bundle and also obfuscates your source code, so you probably only want to disable it for debug builds rather than production builds.

4: Preserve nested folders by putting a non-nested entrypoint first

If it is important to preserve your folder structure in the final build folder, then you should consider ordering your entrypoints such that those in the root of the project come before those in nested folders. This may be specific to esbuild because it seems like an implementation quirk, but I could be wrong. Consider a folder structure like this:

- src
  |
  - index.js
  - pages
    |
    - shop.js
  - funcs.js
  - helpers
    |
    - utils.js

index.js and pages/shop.js are entrypoints. esbuild accepts entry points as an array of file paths, and I found that using ['pages/shop.js', 'index.js'] caused the pages folder to not be recreated in the final build folder. Instead, the equivalent of shop.js was placed at the root of the build folder. In other words, the build folder looked like this:

- build
  |
  - index.js
  - shop.js
  ...

When I changed the order of entrypoints to ['index.js', 'pages/shop.js'], the original folder structure was maintained:

- build
  |
  - index.js
  - pages
    |
    - shop.js
  ...

Conclusion

ES modules are a great way to enforce modularity in a code base, and they allow bundlers to give you nice features like tree-shaking. Most, if not all, modern frameworks already revolve around using ES modules, so you may not even need to make a decision at this point. However if you're starting a project from scratch, I highly recommend that you organize your files using ES modules, even if you do not immediately plan on bundling your js. Modularity comes with other important benefits such as code isolation and often better readability, and ES modules provide us with a unified standard module system compared to some of the differing and sometimes incompatible module systems of the past. Don't hesitate to take advantage of this awesome feature!

Feel free to reach out to ask any questions, correct errors, or just say hi to me @cinexa7254 on Twitter. Thanks for your time!