Home Efficient Module Bundling and Smart Imports
Post
Cancel

Efficient Module Bundling and Smart Imports

Traditionally, in Node.js projects, a common issue arises where imports consume a significant portion of each file, often comprising one-third or even half of the entire file. The problem stems from the repetitive nature of files, all starting with numerous lines of imports. This redundancy not only clutters the codebase but also imposes unnecessary cognitive load when reading these files. To address this, developers often seek ways to streamline and simplify the import process, improving code readability and maintainability, particularly in the context of CommonJS (CJS) and ECMAScript Modules (ESM):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"use strict";

const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const timers = require("node:timers");
const util = require("node:util");
const vm = require("node:vm");

const express = require("express");
const joi = require("joi");
const redis = require("redis");
const pg = require("pg");

fs.readFile("./index.js", (err, data) => {
  if (err) console.log({ error: err });
  else console.log({ data });
});
// ...
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import timers from "node:timers";
import util from "node:util";
import vm from "node:vm";

import * as express from "express";
import * as joi from "joi";
import * as redis from "redis";
import * as pg from "pg";

fs.readFile("./index.mjs", (err, data) => {
  if (err) console.log({ error: err });
  else console.log({ data });
});
// ...
...

Unifying Node Modules and npm Packages in JavaScript

Although modules are singletons in JavaScript, meaning that when we export a module and import it in multiple files, we still reference the same object (let’s call it node, but you are free to choose any name), combining modules into a unified object can simplify exports.

Let’s examine a loader that includes arrays with module names and loops over them to bundle them into a single object. We also add process to node to simplify its use. Instead of writing node.fs.promises, we simplify it to node.fsp. Additionally, accessing timers via dot notation like node.timers.promises might not work; we have to use timers/promises, so we simplify it as well.

This approach streamlines the export of node modules and npm packages for both CommonJS (CJS) and ECMAScript modules (ESM) with dynamic imports.

Certainly, it’s important to note that Object.create(null) is utilized to create an object without a prototype, resulting in a “pure” dictionary with no inherited properties or methods. This ensures a clean slate for the object.

Moreover, the exported object is safeguarded against modification or mutation after it’s exported through the use of Object.freeze(). This step guarantees that the structure and properties of the object remain immutable, providing stability and preventing unintended changes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// modules.js
"use strict";

const node = Object.create(null);
const async = [
  "perf_hooks",
  "async_hooks",
  "timers/promises",
  "timers",
  "events"
];
const multi = ["child_process", "worker_threads"];
const network = ["dns", "net", "tls", "http", "https", "http2", "dgram"];
const streams = ["stream", "fs", "crypto", "zlib", "readline"];
const tools = ["util", "path", "buffer", "os", "v8", "vm"];
const internals = [...async, multi, ...network, ...streams, ...tools];

for (const name of internals) node[name] = require(`node:${name}`);

node.process = process;
node.fsp = node.fs.promises;
node.timers.promises = node["timers/promises"];

module.exports = Object.freeze(node);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// modules.mjs
const node = Object.create(null);
const async = [
  "perf_hooks",
  "async_hooks",
  "timers/promises",
  "timers",
  "events"
];
const multi = ["child_process", "worker_threads"];
const network = ["dns", "net", "tls", "http", "https", "http2", "dgram"];
const streams = ["stream", "fs", "crypto", "zlib", "readline"];
const tools = ["util", "path", "buffer", "os", "v8", "vm"];

const internals = [...async, multi, ...network, ...streams, ...tools];

for (const name of internals) node[name] = await import(`node:${name}`);

node.process = process;
node.fsp = node.fs.promises;
node.timers.promises = node["timers/promises"];

Object.freeze(node);

export default node;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// packages.js
"use strict";

const node = require("./modules.js");
const npm = Object.create(null);

const pkgPath = node.path.join(node.process.cwd(), "package.json");
const pkg = require(pkgPath);

if (pkg.dependencies) {
  const modules = Object.keys(pkg.dependencies);
  for (const dep of modules) npm[dep] = require(dep);
}

module.exports = Object.freeze(npm);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// packages.mjs
import node from "./modules.mjs";

const npm = Object.create(null);
const pkgPath = node.path.join(node.process.cwd(), "package.json");
const pkg = JSON.parse(await node.fsp.readFile(pkgPath));

if (pkg.dependencies) {
  const modules = Object.keys(pkg.dependencies);
  for (const dep of modules) npm[dep] = await import(dep);
}

Object.freeze(npm);

export default npm;

Simplifying Import Structures in CommonJS and ECMAScript Modules

Now, with our organized exports, let’s take a look at how imports are structured for both CommonJS (CJS) and ESM (ECMAScript Modules):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// index.js
"use strict";

const {
  crypto,
  fs,
  http,
  os,
  path,
  timers,
  util,
  vm
} = require("./modules.js");
const { redis, pg } = require("/packages.js");

fs.readFile("./index.js", (err, data) => {
  if (err) console.log({ error: err });
  else console.log({ data });
});

const node = { crypto, fs, http, os, path, timers, util, vm };
const npm = { pg, redis };

console.log({ node, npm });
1
2
3
4
5
6
7
8
9
10
// index.mjs
import node from "./modules.mjs";
import npm from "./packages.mjs";

node.fs.readFile("./index.mjs", (err, data) => {
  if (err) console.log({ error: err });
  else console.log({ data });
});

console.log({ node, npm });

Indeed, with the organized structure of node.<name> and npm.<name>, navigating and locating modules or packages in your codebase becomes much more straightforward. This approach significantly reduces the effort needed to understand and read through the code.

Additionally, for those who value autocompletion in their IDEs, you can enhance the development experience by defining global namespaces in a global.d.ts file. This facilitates easier and more efficient coding, as the IDE can provide suggestions and auto-complete code snippets based on the predefined namespaces.

Conclusion

In conclusion, adopting this streamlined approach not only enhances code readability and developer experience but also seamlessly integrates with tools like ESLint to enforce clean and proper import practices. The result is a harmonious synergy that ensures a consistent and maintainable codebase, contributing to the establishment and upkeep of a high-quality code standard. It’s a definitive win-win for developers and the overall health of the codebase.

This post is licensed under CC BY 4.0 by the author.

Exploring the Transition to Serverless Architecture with AWS

Monolithic vs. Microservices Architecture with Lambda: How to Make the Switch