Skip to content

Instantly share code, notes, and snippets.

@dfkaye
Last active April 9, 2017 14:46
Show Gist options
  • Save dfkaye/5356885 to your computer and use it in GitHub Desktop.
Save dfkaye/5356885 to your computer and use it in GitHub Desktop.
importScripts boilerplate pattern proposal alternatives for javascript

"JavaScript doesn't need more features; it just needs a couple of small things fixed" - Ryan Dahl

The CommonJS and AMD module syntaxes are unfriendly to each other, requiring boilerplate everywhere, which UMD tries to solve with more boilerplate.

The ES6 module syntax adds new keywords in strict mode, that then depend on a sharply modified cross-origin requests shims, and internal module management. An ES6 Module Transpiler aims to solve the not-yet-supporting environments problem with a source transformation step.

We don't need more syntax like imports x from 'x.js'. We don't need a module keyword that will break QUnit module() or Node.js modules. We don't need export let/get/set. We don't even need classes.

We just need a way to declare scripts to be imported to the actor that uses them. (We will need more things, but let's fix the first problem.)

Example Iterations

Leveraging importScripts()

The importScripts() function used in web Workers nearly has this right: the default sandboxing means symbols can be declared as if they are global but are accessible only within the Worker.

Node.js nearly has this right with respect to the require() function: it appears to be a global but is really local to the current module. Hence, module.require('dependency') and require('dependency') return the same result.

The main issue we're facing with portability to the web is the asynchronous resolution step. The importScripts() function in workers loads synchronously and accepts an arbitrary number of arguments - and returns no value. Node.js require(), on the other hand, expects a single argument and returns the exports property of the requested module.

For the moment, then, we'll name our importer as importScripts() and expect no return values. Under the covers, this method will create/modify the module object on which we'll add exportable properties and other capabilities.

However, we still have to address symbol leakage.

In cross-browser land, there are two solutions to controlling symbol scope (i.e., avoiding globals pollution): nested object trees (e.g., first.second.third), and immediately invoked function expressions (IIFEs). Nested object trees are unnecessary in environments that have control over script execution scopes (Node.js) and, because they are really attached to the global scope, can be clobbered. IIFEs provide a better way to management both globals pollution and accidental clobbering.

That said, let's look at a couple of examples that look like CommonJS but use wrapping methods to control scope, prevent leakage, etc.

module.define() method - similar but different wrt AMD

Instead of require or module.loader or System.load, it would be nice to do something like this, with an import call and a wrapped callback to a define() method on the module. We could borrow from Node.js the module.dependencies array or use module.require() inside the define() call:

example.js

importScripts('path/to/script.js', 'another/path/to/script.js');

module.define(function(module) {

    // dependencies
    var a = module.require('path/to/script.js');
    var b = module.require('another/path/to/script.js');

    module.exports = example;
    function example(args) {

        // now do something interesting
        for (var i = 0; i < args.length; i += 1) {
            log(args[i]);
        }
        
        return module_or_interesting_result;
    };

    // log() is local to this module
    function log(obj) {
        console.log(obj);
    };
    
});

Using an IIFE

We could use an IIFE, pushing the imports call to the end (similar to YUI), which avoids adding the module.define() function altogether. Using IIFEs in Node.js modules would change the convention slightly, but would more assuredly be cross-platform:

example2.js

;(function(module) {

    // dependencies
    var a = module.require('path/to/script.js');
    var b = module.require('another/path/to/script.js');
        
    module.exports = example;
    function example(args) {

        // now do something interesting
        for (var i = 0; i < args.length; i += 1) {
            log(args[i]);
        }
        
        return module_or_interesting_result;
    };
    
    // log() is local to this module
    function log(obj) {
        console.log(obj);
    };
    
}(
  importScripts('path/to/script.js', 'another/path/to/script.js');
));

Namespaces are for concatenated scripts

The previous examples fail with respect to dependency resolution because there are no identifiers for the dependencies when files are concatenated for single-request efficiency in the browser.

Instead of an IIFE, use a namespace declaration for the module being defined. The AMD requirejs build tool, r.js, inserts identifiers into concatenated files, specifically into the define() call - define('some-identifier', callback) - so why not enable us to do it directly?

Using namespaces in Node.js modules would change the convention slightly more, but would still assuredly be cross-platform, and allow us to move imports back to the top. It would also reduce the indentation:

example3.js

namespace('example3');

importScripts('path/to/script.js', 'another/path/to/script.js');

module.exports = example;
function example(args) {

    // get dependencies here instead of outside the exports?
    
    var a = module.require('path/to/script.js');
    var b = module.require('another/path/to/script.js');
    
    // now do something interesting
    for (var i = 0; i < args.length; i += 1) {
        log(args[i]);
    }
    
    return module_or_interesting_result;
};

// log() is local to this module
function log(obj) {
    console.log(obj);
};

Define locals on the module directly?

In the namespace example, without the IIFE, the log function is now 'global' in the browser environment. How do we hide it if we don't to export it or clobber it?

Use a namespace declaration as in example3, then define locals that are not to be exported directly on the module object instead:

example4.js

namespace('example4');

importScripts('path/to/script.js', 'another/path/to/script.js');

module.exports = example;
function example(args) {

    // get deps inside exports?
    
    var a = module.require('path/to/script.js');
    var b = module.require('another/path/to/script.js');
    
    // Here we get a handle the module's 'local' log function...
    var log = module.log;
    
    // now do something interesting
    for (var i = 0; i < args.length; i += 1) {
        log(args[i]);
    }
    
    return module_or_interesting_result;
};


// define the log helper function directly to this module

// log() is local to this module
module.log = log;
function log(obj) {
    console.log(obj);
};

Problems with examples 3 & 4?

Yes. Without the IIFE, the module is no longer guaranteed to be free from clobbering in the browser world. Perhaps a different fix is using the IIFE - thus resorting to indentations again - but with namespace and importScripts invoked before it. No need to attach locals to the module, but need to pass module into the IIFE, like this:

example5.js

namespace('example5');

importScripts('path/to/script.js', 'another/path/to/script.js');

;(function(module) {

    // get dependencies here outside...
    
    var a = module.require('path/to/script.js');
    var b = module.require('another/path/to/script.js');
            
    module.exports = example;
    function example(args) {
    
        // ... or from within exports?
        
        var a = module.require('path/to/script.js');
        var b = module.require('another/path/to/script.js');
        
        // now do something interesting
        for (var i = 0; i < args.length; i += 1) {
            log(args[i]);
        }
        
        return module_or_interesting_result;
    };
    
    // log() is local to this module
    function log(obj) {
        console.log(obj);
    };
    
}(module));

Which looks again like our modified AMD define(), or the first example, plus the namespace() call:

example6.js

namespace('example6');

importScripts('path/to/script.js', 'another/path/to/script.js');

module.define(function(module) {

    // etc.

});

But rather than introduce more potential globals, just add them to the module object:

example7.js

module.namespace('example7');

module.importScripts('path/to/script.js', 'another/path/to/script.js');

module.define(function(module) {

    // etc.
    
});

Even wackier

Instead of several functions, just assign the name, imports or dependencies array, and the define as a function directly to the module. Use name and imports so as not to overwrite Node.js's id and children/dependencies arrays:

example8.js

module.name = 'example8';

module.imports = ['path/to/script.js', 'another/path/to/script.js'];

module.define = function(module) {

    // etc.

};

Interesting, but once again there's a problem - we're directly modifying properties on the context object underlying the global module - so how will the module 'know' when to define itself and provide a new underlying context object in a concatenated script blob?

[addendum 5/6/2013] The all-assignment style of example8 looks like it should work, but its very assignment-oriented API clashes with the configure-by-method API style of most javascript today.

example9.js

Rather than expose module - and clobber what's already on it - we'll go back to global functions, and require that a defined module be named. The returned object or function will be the setter hook for the closure that actually defines the module scope once the imported dependencies are loaded.

importScripts('path/to/script.js', 'another/path/to/script.js');

module.define('example9')(function(module) {

    // etc.

});

Which is really the first example.js plus the naming step.

So Far (as of 4-18-2013 5-6-2013 5-15-2013)

None of these patterns feels quite right, but I think example7 example9 gets us closest (so far) to a relatively noise-free, declarative boilerplate, with separate calls for registering a module by id, declaring its dependencies to be imported, and safe-guarding against clobbering with the define function argument.

Benefits (or, Have we really improved anything?)

I want to say Yes this is better, but am still not sure.

  • The cross-platform shim can be implemented today. Here's a Node.js module-define shim on github which follows the pattern in example9. (This will probably be renamed. Meanwhile, a browser version will be posted on github... eventually.)
  • The source transformation step (r.js, browserify) is no longer required, for any modules written with the boilerplate.
  • You can reduce the requests as before, by concatenating files in dependency order, and reduce their size by minification.
  • IIFE closures are not needed, but define() function passing for enclosing local variables means we still have the indentation, and it could still hog memory and execute a bit more slowly. Maybe.

What about...? (or, Anticipated objections)

  • Ther's more indentation on the Node.js side ~ one level shouldn't hurt but, yes, it does mean that.
  • The importScripts() call and module.require() calls inside the module.define() scope mean duplication or redundancy on the Node.js side ~ This bothers me more than indentation, but if the goal is to reduce global pollution and safely run our modules independently without source transformations it seems another small price to pay - and yes, each extra price adds up.
  • SourceMaps ~ that's part of a build step (concat+min).
  • CoffeeScript ~ agreed, that should be supported, but CS has a couple things to improve first (ambiguous whitespace handling in math expressions, the class inheritance shim with super).
  • Nested Namespaces? ~ don't think that's a good idea as it introduces more coupling between modules and the loader mechanism which has to track and inspect modules.

Objections to new module properties (examples 1, 3, 4, 6, 7, 8)

  • Using module.something to define locals (as in examples 3 and 4) runs the risk of clobbering properties on the module with the same name ~ Actually, you're already doing that when you overwrite module.exports.
  • Using module.something to define anything on module means we're modifying an object we don't own ~ We're already modifying the module object by defining its capabilities, via module.exports, that we want to use elsewhere. The properties of the module object are already clobberable (disregarding seal, freeze, and writable for the moment). Just add or expose the same helper API on the current module provided by the environment or a shim and we're that much closer to "unity" javascript.

Other platforms?

  • Not sure whether any of this works in vert.x or platforms that run on rhino ~ Rhino 1.73 supports CommonJS syntax; vert.x TBD...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment