Blog-Archiv

Samstag, 30. Dezember 2017

ES6 Modules Test Environment

ES6 modules are the crown of the ECMAScript 2015 specification. Together with block-scope (const, let) they provide the only real encapsulation-mechanism of that language version. Modules let us control the dependencies of our scripts (import), and what is visible of them (export).

Modules are →

  • file-based;
    a module imports another one by stating a path relative to its own file-system location (slash-separated);
    one file .js is one script is one module

  • singletons by nature, you can not instantiate them;
    the browser loads a module just once per page (tab, window);
    thinking in Java, all fields and functions inside a module would be static

  • → an encapsulation-mechanism;
    other modules can not see fields and functions that have not been exported

  • read-only views of exported fields (variables, constants);
    you can see an exported field of module A in module B when you imported it, but you can not change its value

Don't confuse modules with classes. Modules provide control for deployment and dependency-loading, classes provide state when instantiated to objects. Modules are meant to contain classes, or be factories for objects of classes.

ES6 modules are already available in HTML5 browsers. This article describes how you can create a test-environment (without node-js!) for studying them.

Make Modules Work in Browsers

For a deeper insight (CORS, loading order of script elements) read the article "ECMAScript modules in browsers".

  1. When Firefox, or lower than Safari 10.1, Chrome 61 or Edge 16, configure browser to support modules
    • Firefox: enter address about:config, switch dom.moduleScripts.enabled to true; Firefox also runs fine with file:/ protocol (loading page from local disk)
    • Chrome refuses to load modules when using the file:/ protocol ("blocked by CORS", "strict MIME type checking"), so you need to deploy your page-resources to some HTTP server)

  2. Set attribute type = "module" in all HTML script elements that import modules

This works for both the newest desktop- and mobile-browsers.

Example Sources

Here are the files and directories used in the following. The file es6-modules.html is the test page. It imports es6-modules.js in a <script "type = module"> element. The file es6-modules.js is a module, and it imports all other *.js files listed here, that means it depends on them.

es6-modules.html
es6-modules.js
modules
counter.js
cycle-1.js
cycle-2.js
io
output.js
math
mathematics.js

You can try out an example similar to this on my homepage (unfortunately I can not deploy modules on this Blog server:-).

HTML

es6-modules.html

Following is HTML using modules. It defines two buttons demonstrating different ways to integrate module functions:

  1. "Page-driven module call"
    When you want to call a module-function directly from a button callback, you will be confronted with the separation between module scripts and traditional scripts. It is not possible to import modules into a normal script. Thus you need to write a <script "type = module"> element that imports the required modules and sets the needed functions onto some global page-object that is reachable from callbacks, as done in const page = {} and page.foobar = foobar. Mind that you can not create the const page = {} in a <script "type = module"> element, because then it would be a local constant!

  2. "Module-driven module call"
    The other way is to write an adapter <script "type = module"> that imports the required modules and installs all callbacks itself, as done in document.getElementById("foobarTestButton").addEventListener("click", foobar). This is most likely the better way to go.
    <div>
      <button onclick="page.foobar()">Page-driven module call</button>
      <button id="foobarTestButton">Module-driven module call</button>
    </div>
    
    <fieldset>
      <legend>Success Messages</legend>
      <p id="successMessages"></p>
    </fieldset>
    
    <fieldset>
      <legend>Error Messages</legend>
      <p id="errorMessages"></p>
    </fieldset>
    
    
    <script>
        const page = {}
    </script>
    
    <script type="module">
        import foobar from "./es6-modules.js"
        
        page.foobar = foobar
    </script>
    
    <script type="module">
        import foobar from "./es6-modules.js"
        
        const foobarTestButton = document.getElementById("foobarTestButton")
        foobarTestButton.addEventListener("click", foobar)
    </script>
    
    <script type="module">
        import { configure } from "./modules/io/output.js"
        
        configure("successMessages", "errorMessages")
    </script>

Two buttons are created here. The first one has a programmed callback that calls page.foobar(). I named this "Page-driven".
The second button has an id, but no callback. The callback is installed in a script element below. I named this "Module-driven".

Next there are two output-areas "Success Messages" and "Error Messages". They will get configured to be the targets for output.js module.

The subsequent script elements do what is required to use module functions in a page-driven and a module-driven way. The first one is a non-module script, it establishes the global page object. The next one imports a module and sets the foobar module-function into the page object, to be available for the first button. The following script installs a module-function as callback into the second button. The last script element finally configures the output.js module to write to the output-areas with ids "successMessages" and "errorMessages".

Mind that currently import-paths must be written as from "./es6-modules.js", giving a bare from "es6-modules.js" would fail.
It is not a problem to import a module several times in different script-elements, the browser would load each module just once.

Library Modules

counter.js

This is a stateful module that holds a counter, and a function that can increment it. The counter field state itself can not be changed from outside (although exported), but is visible (because exported). To change it, modules must call the increment() function. There is no way to reset the counter except a browser page reload.

export let counter = 0

export function increment() {
    counter++;
}

cycle*.js

These are two modules demonstrating the ES6-ability to resolve cyclic dependencies. Module cycle-1.js defines foo() calling bar() in cycle-2.js, that again calls foo(). If there weren't preventions against recursion by holding a private state, these two functions would actually call each other in an endless loop!

cycle-1.js
import { bar } from "./cycle-2.js"

let state = "idle"

export function foo() {
    if (state === "running")
        return  /* avoid recursion */
        
    state = "running"
    try {
        bar()
    }
    finally {
        state = "idle"
    }
}
cycle-2.js
import { foo } from "./cycle-1.js"

let running = false

export function bar() {
    if (running)
        return  /* avoid recursion */
        
    running = true
    try {
        foo()
    }
    finally {
        running = false
    }
}

output.js

A stateful module that contains functions for appending success- and error-messages to specified elements. That means the module must be parameterized with the according element-ids, which can be done by calling configure(newSuccessElementId, newErrorElementId). See the last script element in es6-modules.html which does this.

let successElementId = "success"
let errorElementId = "error"

function message(text, elementId) {
    const outputElement = document.getElementById(elementId)
    outputElement.innerHTML += ( text+"<br>" )
}

export function configure(newSuccessElementId, newErrorElementId) {
    if (newSuccessElementId)
        successElementId = newSuccessElementId
        
    if (newErrorElementId)
        errorElementId = newErrorElementId
}

export function success(text) {
    message(text, successElementId)
}

export function error(text) {
    message(text, errorElementId)
}

mathematics.js

This is an example for a stateless module that contains lots of functions around a specific topic like mathematics.

export function add(first, second) {
    return first + second
}

export function multiply(first, second) {
    return first * second
}

Page Module

es6-modules.js

Imports and uses all other modules. Its foobar() function performs various tests with them. You can launch it by clicking on one of the buttons in the HTML page. Outputs will go to the configured areas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { success, error } from "./modules/io/output.js"
import { counter, increment } from "./modules/counter.js"
import * as mathematics from "./modules/math/mathematics.js"
import { foo as cycleTest } from "./modules/cycle-1.js"

export default function foobar() {

    /* use module "counter" */
    
    try {
        counter = 99  /* module fields are readonly! */
    }
    catch (errorMessage) {
        error(errorMessage)
    }
    
    try {
        const oldValue = counter
        increment()
        if (oldValue + 1 !== counter)
            error("counter didn't increment: "+counter)
        else
            success("counter after increment: "+counter)
    }
    catch (errorMessage) {
        error(errorMessage)
    }
    
    /* use module "mathematics" */
    
    const oneAndTwo = mathematics.add(1, 2)
    if (oneAndTwo !== 3)
        error("1 + 2 is not 3: "+oneAndTwo)
    else
        success("add() worked: 1 + 2 == 3")
        
    const twoThreeTimes = mathematics.multiply(2, 3)
    if (twoThreeTimes !== 6)
        error("2 * 3 is not 6: "+twoThreeTimes)
    else
        success("multiply() worked: 2 * 3 == 6")
    
    /* use cyclic modules */
    
    try {
        cycleTest()
        success("foo() is available despite cyclic dependency")
    }
    catch (errorMessage) {
        error(errorMessage)
    }
}

The import { success, error } from "./modules/io/output.js" imports just the needed functions. The configure() call already has been done in es6-modules.html, and only there the ids of the message-elements should be duplicated.

We import { counter, increment } from "./modules/counter.js" to also see the field counter. Subsequently writing to that field gets tested, this results in an exception (→ fields are exported as read-only views!).

The module mathematics.js is an example for a collection of many functions around mathematics, and let's assume that we also need to use most of them. Thus mathematics.js is imported using the asterisk-wildcard, to be seen in in import * as mathematics from "modules/math/mathematics.js". Mind that the "as alias" is required when importing that way!

Finally one of the cyclic modules gets imported. I rename the foo function to cycleTest using an alias. When no exception gets thrown on calling cycleTest(), the dependency-cycle was resolved correctly by ES6.

The export default function foobar() statement exports the function foobar() in a way that it can be imported without { destructuring }, to be seen in es6-modules.html. Mind that just one default export is possible per module! Further it is not possible to use export default together with var, let or const.

The code of foobar() performs various tests on the imported module fields and functions. It should be easy to see what it does, so I won't explain it in further detail.

Resume

We should keep in mind that modules can hold state, but that state would be the same for all components inside the HTML page. As soon as you implement a TABLE module, and you have more than one TABLE instances in your page that use this module, they would compete for the state (singleton!). Remember that static methods in Java never should refer to any field other than its parameters. ES6 modules have to be regarded as "static" implementations (in the sense of Java), and you should use them as factories for class-instances that represent state-holding objects, and only in that class all functionality should be implemented. (Thinking further, that everything in classes is public, i.e. no encapsulation at all, what remains from the beautiful encapsulation-mechanism of modules, non-exported fields and functions not being visible? Nothing.)

It took me much more time to make this example work in Firefox and Chrome than writing it. I had to experiment a lot with import statements and file extensions. Finding out that absolute paths are supported by ES6, but are absolute to the file-system root (what else?), was a little frustrating. Why would we need such? It binds ES6 projects to the file system of a certain machine! We are hardcoding the directory-structure of our library also with relative path imports, and this is hard enough to maintain (imagine that directory structure once changes!). The third import alternative (to relative and absolute paths) is "module specifiers". But not even the specification states how this should be organized.

Topic of my next Blog will be: which of the (too) many provided export/import possibilities actually make sense?




Keine Kommentare: