elm / compiler

Compiler for Elm, a functional language for reliable webapps.
https://elm-lang.org/
BSD 3-Clause "New" or "Revised" License
7.52k stars 662 forks source link

Compiled Elm code is not friendly to JS modules #1029

Closed rtfeldman closed 9 years ago

rtfeldman commented 9 years ago

Current JS best practices have abandoned the 1990s-era practice of slapping another <script> tag on the page, expecting that script to mutate the global window object, and moving on. Today, the best practice is to use a module system (e.g. RequireJS, AMD, ES6 modules) and a build tool (e.g. browserify, webpack) to combine them.

At the moment, compiled Elm code supports only the 90s approach: add Elm to the global object and move on. As such, there is no way to (for example) require an Elm module from within Node, short of an extra wrapping step. The best practice to avoid this is to add a snippet of boilerplate which detects the JS module environment and proceeds appropriately. Here is an example from a popular JS library.

This would enable the following, in either Node or in the browser (when using something like Browserify):

var Elm = require("scripts/elm.js");

...as opposed to the current "add it to the page and expect the Global to be there."

Concretely, this would mean adding the following boilerplate (adapted from the aforementioned Q snippet) to compiled Elm output:

 (function (definition) {
    "use strict";

    // This file will function properly as a <script> tag, or a module
    // using CommonJS and NodeJS or RequireJS module formats.  In
    // Common/Node/RequireJS, the module exports the Elm API and when
    // executed as a simple <script>, it creates an Elm global instead.

    // Montage Require
    if (typeof bootstrap === "function") {
        bootstrap("promise", definition);

    // CommonJS
    } else if (typeof exports === "object" && typeof module === "object") {
        module.exports = definition();

    // RequireJS
    } else if (typeof define === "function" && define.amd) {
        define(definition);

    // SES (Secure EcmaScript)
    } else if (typeof ses !== "undefined") {
        if (!ses.ok()) {
            return;
        } else {
            ses.makeElm = definition;
        }

    // <script>
    } else if (typeof window !== "undefined" || typeof self !== "undefined") {
        // Prefer window over self for add-on scripts. Use self for
        // non-windowed contexts.
        var global = typeof window !== "undefined" ? window : self;

        // Get the `window` object, save the previous Elm global
        // and initialize Elm as a global.
        var previousElm = global.Elm;
        global.Elm = definition();

        // Add a noConflict function so Elm can be removed from the
        // global namespace.
        global.Elm.noConflict = function () {
            global.Elm = previousElm;
            return this;
        };

    } else {
        throw new Error("This environment was not anticipated by Elm. Please file a bug at https://github.com/elm-lang/elm-compiler/issues");
    }

})(function () {
  var Elm = {};

  // Current emitted code goes here...

  return Elm;
});

This might seem like a lot, but:

  1. Minified, it adds about 0.6 KB. Considering this is the best practice by popular JS libraries, that seems to be a consensus acceptable overhead.
  2. It now means your Elm code "just works" in best-practice JS, in both the browser and on Node.
  3. When transitioning from an existing JS codebase to Elm, you can now introduce multiple Elm modules to the same page, piecemeal, by calling require multiple times. Each will be namespaced separately with no conflicts.
rgrempel commented 9 years ago

I think that this would be a very valuable approach to adopt.

mgold commented 9 years ago

Seems reasonable. Can this be done as part of the larger codegen overhaul?

laszlopandy commented 9 years ago

Wow. I don't know what SES is, or why we need the noConflict function. I have seen a much simpler version used by Bacon.js which handles Node, RequireJS and script tag with just a few lines: https://github.com/baconjs/bacon.js/blob/master/dist/Bacon.js#L3424

rtfeldman commented 9 years ago

noConflict is definitely not necessary. (In case it wasn't clear, it lets you include by adding <script> to a page that is already using a global called Elm without overriding that global.)

A trimmed-down implementation like Bacon's seems fine. I'd say the main thing is that Node, CommonJS, AMD, and <script> all "just work."

evancz commented 9 years ago

Can we close this down and open an issue on elm-make just outlining the smaller recommendation and linking here. When Joey and I get to this, we will use that snippet.