How to circumvent RequireJS to load module with global?
Asked Answered
G

4

8

I'm trying to load a JS file from a bookmarklet. The JS file has this JS that wraps the module:

(function (root, factory) {
    if (typeof module === 'object' && module.exports) {
        // Node/CommonJS
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(factory);
    } else {
        // Browser globals
        root.moduleGlobal = factory();
    }
}(this, function factory() {
    // module script is in here

    return moduleGlobal;
}));

Because of this, if the webpage uses RequireJS, the script will not export a global when it loads. To get around this I temporarily set define to null, load the script, then reset define to its original value:

function loadScript(url, cb) {
    var s = document.createElement('script');
    s.src = url;
    s.defer = true;
    var avoidRequireJS = typeof define === 'function' && define.amd;
    if (avoidRequireJS) {
        var defineTmp = define;
        define = null;
    }
    s.onload = function() {
        if (avoidRequireJS) define = defineTmp;
        cb();
    };
    document.body.appendChild(s);
}

This works, but it seems to me like it could be problematic to change a global variable when other parts of the application could depend on it. Is there a better way to go about this?

Generous answered 18/5, 2017 at 3:1 Comment(3)
Is loading the script using XHR (AJAX) an option for you (need to enable CORS)? If so, you should be able to reassign define local to that function.Trigeminal
@DheerajV.S. That's an option. I hadn't thought of that. I can load the JS via AJAX and wrap it with (function(define){ ... })();. You can post the answer and I will accept it if I don't find something simpler.Generous
@DheerajV.S. Next I would just stick it in a new <script> element with the text attribute.Generous
T
5

You may fetch the script using XMLHttpRequest, jQuery.ajax or the new Fetch API.

This will allow you to manipulate the script and reassign define before executing it. Two options:

  1. Have the module export a global by wrapping the script with:

    (function(define){ ... })(null);
    
  2. Handle the module exports yourself by wrapping the script with:

    (function(define, module){ ... })((function() {
        function define(factory) {
            var exports = factory();
        }
        define.amd = true;
        return define;
    })());
    

You can then load it using a new <script> element or eval 😲.

Note that when using XHR, you may have to address CORS issues.

Trigeminal answered 22/5, 2017 at 7:11 Comment(0)
G
2

If you can use the AJAX method above, that will be best. But as stated, you will need to deal with CORS issues, which is not always trivial - even impossible if you do not control the origin server.

Here is a technique which uses an iframe to load the script in an isolated context, allowing the script to export its global object. We then grab the global object and copy it to the parent. This technique does not suffer from CORS restrictions.

(fiddle: https://jsfiddle.net/qu0pxesd/)

function loadScript (url, exportName) {
  var iframe = document.createElement('iframe');
  Object.assign(iframe.style, {
    position: 'fixed',
    top: '-9999em',
    width: '0px'
  });
  var script = document.createElement('script');
  script.onload = function () {
    window[exportName] = iframe.contentWindow[exportName];
    document.body.removeChild(iframe);
  }
  script.src = url;
  document.body.appendChild(iframe);
  iframe.contentWindow.document.open();
  iframe.contentWindow.document.appendChild(script);
  iframe.contentWindow.document.close();
}
loadScript('https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js', 'jQuery');

I ran a quick test to see if a memory leak would happen from deleting the iframe, and it appears to be memory safe. Here's the snapshot of loading a script 100 times, resulting in 100 different iframes and 100 different instances of jQuery loading.

enter image description here

The parent window's jQuery variable is continuously overwritten, meaning only the last one prevails and all previous references are cleaned up. This is not entirely scientific and you will need to do your own testing, but this should be safe enough to get you started.

Update: The above code requires that you know the name of the exported object, which is not always known. Some modules may export multiple variables too. For example, jQuery exports both $ and jQuery. The following fiddle illustrates a technique for solving this issue by copying any global objects which did not exist before the script was loaded:

https://jsfiddle.net/qu0pxesd/3/

Gar answered 25/5, 2017 at 7:36 Comment(0)
U
1

Which approach would work best really depends on the specific needs of the project. Context would determine which one I'd use.

Undefining define Temporarily

I'm mentioning it because you tried it.

DON'T DO THIS!

The approach of undefining define before you load your script and restoring it after is not safe. In the general case, it is possible for other code on the page to perform a require call that will resolve after you've undefined define and before you've defined it again. After you do document.body.appendChild(s); you're handing back control to the JavaScript engine, which is free to immediately execute scripts that were required earlier. If the scripts are AMD module, they'll either bomb or install themselves incorrectly.

Wrapping the Script

As Dheeraj V.S. suggests, you can wrap the script to make define locally undefined:

(function(define) { /* original module code */ }())

can work for trivial cases like the one you show in your question. However, cases where the script you try to load actually has dependencies on other libraries can cause issues when it comes to dealing with the dependencies. Some examples:

  1. The page loads jQuery 2.x but the script you are trying to load depends on a feature added in jQuery 3.x. Or the page loads Lodash 2 but the script needs Lodash 4, or vice-versa. (There are huge differences between Lodash 2 and 4.)

  2. The script needs a library that is not otherwise loaded by something else. So now you are responsible for producing the machinery that will load the library.

Using RequireJS Contexts

RequireJS is capable of isolating multiple configurations from one another by defining a new context. Your bookmarklet could define a new context that configures enough paths for the script you are trying to load to load itself and its dependencies:

var myRequire = require.config({
  // Within the scope of the page, the context name must be unique to 
  // your bookmarklet.
  context: "Web Designer's Awesome Bookmarklet",
  paths: {
    myScript: "https://...",
    jquery: "https://code.jquery.com/jquery-3.2.1.min.js",
  },
  map: {...},
  // Whatever else you may want.      
});

myRequire(["myScript"]);

When you use contexts like this, you want to save the return value of require.config because it is a require call that uses your context.

Creating a Bundle with Webpack

(Or you could use Browserify or some other bundler. I'm more familiar with Webpack.)

You could use Webpack to consume all the AMD modules necessary for the script you are trying to load to produce a bundle that exports its "module" as a global. At a minimum, you'll need something like this in your configuration:

// Tell Webpack what module constitutes the entry into the bundle.
entry: "./MyScript.js",
output: {
  // This is the name under which it will be available.
  library: "MyLibrary", 

  // Tell Webpack to make it globally available.
  libraryTarget: "global",

  // The final bundle will be ./some_directory/MyLibrary.js
  path: "./some_directory/",
  filename: "MyLibrary.js",
}

Once this is done, the bookmarklet only needs to insert a new script element that points to the produced bundle and no longer has to worry about wrapping anything or dealing with dependencies.

Uptown answered 24/5, 2017 at 16:57 Comment(0)
H
-2

If it were me, I would have the url provide the hint as to how to load the module. Instead of having just a "scripts/" directory -> I would make "scripts/amd/", "scripts/require/", etc. Then query the url for "amd", "require", etc. within your loadScript method... using, e.g.,

if (url.includes('amd')) {
    // do something
} else if (url.includes('require')) {
    // do something different
}

That should let you avoid the global var entirely. It might also provide a better structure for your app in general.

You could also return an object with a script property and loadType property that specifies amd, require, etc... but imho the first option would be the quickest and save you some additional typing.

Cheers

Hollington answered 21/5, 2017 at 20:20 Comment(1)
Because I am loading the script via bookmarklet, I am trying to load a single known script into an unknown environment (the webpage I request the script from may or may not have RequireJS or some other module loader that intercepts the script when it loads). This answer seem to assume I am trying to load an unknown script into my own web app.Generous

© 2022 - 2024 — McMap. All rights reserved.