From Nothing to Something: The Evolution of Frontend Modularity
As we know, for a long time, the JavaScript language lacked the concept of modularity. It wasn't until the advent of Node.js that JavaScript made its way into the server-side.
When faced with complex business scenarios involving file systems, networks, and operating systems, modularity became indispensable. Thus, Node.js and the CommonJS specification complemented each other, drawing the attention of developers.
It is evident that CommonJS was initially designed for server-side applications; therefore, CommonJS does not belong to the frontend.
However, as a vehicle for the frontend language JavaScript, it has had a profound impact on the subsequent popularization of frontend modularity, laying a solid foundation for its development.
At the beginning, JavaScript was created as a scripting language for simple tasks like form validation. Since the code was minimal, early JavaScript was directly embedded within <script>
tags, as shown below:
<!-- index.html -->
<script>
var name = 'morrain';
var age = 18;
</script>
As business requirements grew more complex and Ajax emerged, frontend functionalities expanded, leading to a rapid increase in code volume. Developers began to write JavaScript code in separate .js
files, decoupling it from HTML, as demonstrated here:
<!-- index.html -->
<script src="./mine.js"></script>
// mine.js
var name = 'morrain';
var age = 18;
With more developers joining the project, an increasing number of JavaScript files were introduced:
<!-- index.html -->
<script src="./mine.js"></script>
<script src="./a.js"></script>
<script src="./b.js"></script>
// mine.js
var name = 'morrain';
var age = 18;
// a.js
var name = 'lilei';
var age = 15;
// b.js
var name = 'hanmeimei';
var age = 13;
At this point, problems began to arise. Before ES6, JavaScript lacked a module system and the concept of closed scopes. The variables declared in the three JavaScript files existed in the global scope. Conflicts between variables maintained by different developers became unavoidable, and global variable pollution started to haunt developers.
To address the issue of global variable pollution, developers began using namespaces. Since naming conflicts could arise, they added namespaces to their variables, as shown below:
<!-- index.html -->
<script src="./mine.js"></script>
<script src="./a.js"></script>
<script src="./b.js"></script>
// mine.js
app.mine = {};
app.mine.name = 'morrain';
app.mine.age = 18;
// a.js
app.moduleA = {};
app.moduleA.name = 'lilei';
app.moduleA.age = 15;
// b.js
app.moduleB = {};
app.moduleB.name = 'hanmeimei';
app.moduleB.age = 13;
At this point, the prototype of modularization began to emerge. Using namespaces alleviated naming conflicts to some extent.
For example, the developer of module B could easily access the name from module A using app.moduleA.name
, but could also modify it freely, without module A being aware of these changes. This was clearly not ideal.
Smart developers started leveraging JavaScript's function scope and closures to solve this problem:
// mine.js
app.mine = (function() {
var name = 'morrain';
var age = 18;
return {
getName: function() {
return name;
}
};
})();
// a.js
app.moduleA = (function() {
var name = 'lilei';
var age = 15;
return {
getName: function() {
return name;
}
};
})();
// b.js
app.moduleB = (function() {
var name = 'hanmeimei';
var age = 13;
return {
getName: function() {
return name;
}
};
})();
Now, module B could retrieve the name from module A using app.moduleA.getName()
, while each module's variables were kept inside their respective functions, preventing modifications from other modules.
This design exhibited initial characteristics of modularization: each module maintained private data and provided public interfaces for other modules to use.
However, this approach still lacked elegance and perfection. For instance, in the code above, module B could access module A's data, but module A could not access module B's data. This limitation stemmed from the loading order of the three modules, leading to interdependencies that became hard to manage as the frontend application scaled.
In summary, the frontend needs modularization, which should not only address global variable pollution and data protection but also effectively manage dependencies between modules.
Since JavaScript requires modularization to resolve the issues discussed earlier, a modularization specification is necessary. CommonJS was proposed to address these problems. The purpose of this specification is to provide consistency, much like the syntax of a programming language. Let’s explore it in detail.
Node.js applications are composed of modules, with each file acting as a separate module that has its own scope. Variables, functions, and classes defined within a file are private and not visible to other files.
// a.js
var name = 'morrain';
var age = 18;
In the code above, a.js
is a module in a Node.js application, and the variables name
and age
declared within it are private to that module, meaning they cannot be accessed from other files.
The CommonJS specification also defines two special variables that can be used within each module: require
and module
.
require
is used to load other modules.module
represents the current module as an object that holds information about it. Theexports
property ofmodule
is used to store the interface or variables that the module exports. The values accessed from a module throughrequire
are those exported usingexports
.
// a.js
var name = 'morrain';
var age = 18;
module.exports.name = name;
module.exports.getAge = function() {
return age;
};
// b.js
var a = require('./a.js');
console.log(a.name); // 'morrain'
console.log(a.getAge()); // 18
For convenience, Node.js provides a private variable called exports
for each module, which points to module.exports
. This can be understood as Node.js automatically adding the following code at the beginning of each module:
var exports = module.exports;
Thus, the previous code can also be simplified:
// a.js
var name = 'morrain';
var age = 18;
exports.name = name;
exports.getAge = function() {
return age;
};
It is important to note that exports
is a private local variable within the module and only points to module.exports
. Directly assigning a new value to exports
will not work; it will cause exports
to no longer point to module.exports
, as shown below:
// a.js
var name = 'morrain';
var age = 18;
exports = name; // This line is ineffective
If a module's exported interface is a single value, module.exports
can be used for export:
// a.js
var name = 'morrain';
module.exports = name;
The basic function of the require
command is to read and execute a JavaScript file, returning the module's exports
object. If the specified module cannot be found, it throws an error.
When a module is loaded for the first time, Node.js caches it. Subsequent loads of the same module will retrieve the module.exports
property directly from the cache.
// a.js
var name = 'morrain';
var age = 18;
exports.name = name;
exports.getAge = function() {
return age;
};
// b.js
var a = require('./a.js');
console.log(a.name); // 'morrain'
a.name = 'rename';
var b = require('./a.js');
console.log(b.name); // 'rename'
As shown above, when the module A
is required a second time, it does not reload or execute module A
again; instead, it directly returns the result from the first require
, which is module A
's module.exports
.
Additionally, it's important to understand that the loading mechanism of CommonJS modules means that require
imports a copy of the exported values. This implies that changes made internally to the module do not affect the exported values.
// a.js
var name = 'morrain';
var age = 18;
exports.name = name;
exports.age = age;
exports.setAge = function(newAge) {
age = newAge;
};
// b.js
var a = require('./a.js');
console.log(a.age); // 18
a.setAge(19);
console.log(a.age); // 18
Having understood the CommonJS specification, we can see that writing modules that conform to CommonJS primarily relies on the three elements: require
, exports
, and module
. Each JavaScript file can be viewed as a module, as illustrated below:
// a.js
var name = 'morrain';
var age = 18;
exports.name = name;
exports.getAge = function () {
return age;
};
// b.js
var a = require('./a.js');
console.log('a.name=', a.name);
console.log('a.age=', a.getAge());
// index.js
var b = require('./b.js');
console.log('b.name=', b.name);
We can encapsulate module code within an immediately invoked function expression (IIFE), passing require
, exports
, and module
as parameters to facilitate module loading. Here’s how this can be implemented:
(function(module, exports, require) {
// b.js
var a = require('./a.js');
console.log('a.name=', a.name);
console.log('a.age=', a.getAge());
var name = 'lilei';
var age = 15;
exports.name = name;
exports.getAge = function () {
return age;
}
})(module, module.exports, require);
Once you grasp this principle, converting project code that adheres to the CommonJS module specification into code supported by browsers becomes straightforward. Many tools, such as Browserify and Webpack, are built on this principle.
Taking Webpack as an example, let’s see how it supports the CommonJS specification. During the build process, Webpack bundles the content of various module files into a single JavaScript file, using an immediately invoked anonymous function form, making it easy to run directly in the browser:
// bundle.js
(function (modules) {
// Implementation of module management
})({
'a.js': function (module, exports, require) {
// Content of a.js
},
'b.js': function (module, exports, require) {
// Content of b.js
},
'index.js': function (module, exports, require) {
// Content of index.js
}
});
Next, we need to implement the logic for module management. According to the CommonJS specification, loaded modules are cached, so we need an object to store the loaded modules and a require
function to load them. When loading, we create a module
object and add an exports
property to receive the exported content of the module:
// bundle.js
(function (modules) {
// Implementation of module management
var installedModules = {};
/**
* Logic for loading modules
* @param {String} moduleName The name of the module to load
*/
var require = function (moduleName) {
// If already loaded, return directly
if (installedModules[moduleName]) return installedModules[moduleName].exports;
// If not loaded, create a module and place it in installedModules
var module = installedModules[moduleName] = {
moduleName: moduleName,
exports: {}
};
// Execute the module to be loaded
modules[moduleName].call(module.exports, module, module.exports, require);
return module.exports;
};
return require('index.js');
})({
'a.js': function (module, exports, require) {
// Content of a.js
},
'b.js': function (module, exports, require) {
// Content of b.js
},
'index.js': function (module, exports, require) {
// Content of index.js
}
});
As illustrated, the core principles of CommonJS are satisfied. The implementation process is straightforward and far less complex than one might initially think.
Now that we are quite familiar with the CommonJS specification, let's discuss its limitations in the context of browsers. The basic function of the require
command is to read and execute a JavaScript file, returning the module's exports
object. This approach works well on the server side, where loading and executing a file takes negligible time. Module loading is synchronous, so when the require
command completes, the file has also executed and successfully returned the exported values.
However, this specification is inherently unsuitable for browsers. In a browser, each file load requires a network request. If the network is slow, this can lead to significant delays. The browser will wait for the require
to return, blocking subsequent code execution, which can affect page rendering and possibly lead to the page freezing.
To address this issue, several frontend modularization specifications have been developed, including:
-
AMD (Asynchronous Module Definition)
AMD was designed to solve the synchronous loading problem of CommonJS. It employs an asynchronous loading mechanism, allowing modules to be loaded non-blocking in the browser. For example, RequireJS is a library that implements the AMD specification. -
UMD (Universal Module Definition)
UMD combines the features of both AMD and CommonJS, aiming for module universality. It can work in both CommonJS environments (like Node.js) and AMD environments (like browsers). -
ES Modules (ESM)
ES6 introduced a native module system usingimport
andexport
syntax. ES modules are statically loaded, support asynchronous loading, and offer better performance optimizations, making them the standard modularization solution for modern JavaScript applications. -
SystemJS
SystemJS is a dynamic module loader that supports ES6 modules, AMD, and CommonJS. It allows developers to flexibly switch between different module systems. -
Webpack and Other Bundling Tools
Bundling tools like Webpack, Rollup, and Parcel resolve loading delays by bundling modules into a single file. These tools can handle different types of modules and support tree-shaking optimization to reduce the final bundle size.
Each of these solutions has its own advantages and disadvantages, allowing developers to choose the most appropriate modularization specification based on project requirements and the environment.