This proposal describes the module system that is implemented in Helma NG. I call it Pythonic Modules because it is heavily inspired by the way modules are implemented in Python. It provides protection against name collisions by isolating module scopes, while being reasonably easy to implement in a server or standalone JavaScript runtime.
I have written about this in other places, but I try to rephrase its essential properties here in a more general way. I will for the purpose of this proposal refer to a generic JavaScript runtime that implements the pythonic module system. Note that while our JavaScript runtime uses the file system to store and access its modules, this is no requirement of this proposal.
Scripts are modules
For our JavaScript runtime, every script represents a module. This is true for all scripts, regardless of whether they are part of a core library or a user-written application. There is nothing special a script must contain in order to make it a module.
Module names
Modules are managed by the JavaScript runtime by looking for files within one or more directories which we call module directories. A module name translates to a file name by adding the .js extension to it. Thus, when our JavaScript runtime tries to load a module named A, it will look for a file called A.js in its module directories. Modules that live in subdirectories of a module directory are accessed using a dotted module name where each element in the module name corresponds ot an element in the file path. For example, a module named A.B.C will cause our JavaScript runtime to search its module directories for a file called A/B/C.js.
Every module has its own scope
This is maybe the most radical step away from JavaScript as we know it, since the shared global scope is one of JavaScript's more prominent features. But it is also one of the most critisized one, and one that will seriously hamper development of real large scale applications. As it turns out, giving each script its own top level scope is both easy and backwards compatible.
When our JavaScript runtime starts up, it creates the familiar global JavaScript object containing the Object, Array, Date, Math, etc. objects. However, whenever the JavaScript runtime loads a module, it doesn't use the global object but instead creates a new, empty JavaScript object to evaluate the module on. This object, which we call the module scope, has two important features: its parent scope is set to null, while its prototype is set to the shared global object. This makes sure module code will never pollute the shared global object (or any other module scope, for that matter), because it is the top-most object in its scope chain, but can still see the standard global objects through the module scope's prototype chain.
With this setup, modules code will never unintentionally pollute any other scope. Users of our JavaScript runtime can just write global functions and variables, even accidentally omitting the var keyword, without any risk of disturbing with other modules or global code.
Importing modules
Since modules are shielded from each other, there must be a well-defined way for one module to load and make use of another module. Our JavaScript runtime provides three global functions to allow one module to load and use another:
- import('modulename')
- This causes our JavaScript runtime to load the module with the given name and define a property in the calling module scope with the module name as property name and the module scope as property value.
- require('modulename')
- This causes our JavaScript runtime to load the module with the given name and return the module scope to the caller.
- include('modulename')
- This causes our JavaScript runtime to load the module with the given name and define a property in the calling module scope for each property the loaded module exports, as described in the next section.
Defining a module's exports
Since a module may contain properties that are not meant to be exported, our JavaScript runtime requires that modules explicitly define the properties to be visible to other modules. This is done by calling the global export() function, passing the names of exported top-level properties as arguments¹):
export('property1', 'property2', ...)
Visibility of loaded modules
One great feature of the separated module scopes is protection against name collisions as described above. Another, maybe equally important feature is the fact that imported modules and module properties are only visible to the very modules that imported them.
Module loading and caching
Our JavaScript runtime keeps a map of loaded modules. Before a module is loaded, the runtime first checks whether the module has already been loaded before. If so, the existing module scope is reused. Our runtime also makes sure module scopes are registered in the loaded module map before evaluating them in order to be able to deal with cyclic imports.
¹) Helma NG currently only consults the exported properties for include(), since this is the feature that is most prone to unintentional scope pollution.