1

Functions defined inside an ES6 module embedded in an HTML script are not available to that script. Thus if you have a statement such as:

<button onclick="doSomething();">Do something</button>

in your HTML and your doSomething() function lives inside an ES6 module embedded in the HTML script, you will get a "doSomething() is undefined" error when you run the script.

Use functions defined in ES6 module directly in html suggests a great solution to the immediate problem, recommending that you "bind" your function to the button by amending your HTML thus:

<button id="dosomethingbutton">Do something</button>

and using the module itself to create a linkage thus:

document.getElementById('dosomethingbutton').addEventListener('click', doSomething);

This works fine, but what if your original button was a bit more sophisticated and was parameterised? For example:

<button onclick="doSomething('withThisString');">Do Something with String</button>

The most that the "binding" can provide seems to be limited to the circumstances relating to the event - I can find no way of associating it with data. I'm completely stuck trying to find a solution to this one and assistance would be much appreciated.

I'd like to add that, while this problem might seem a bit obscure, I think it will be of interest to anyone migrating to Firebase 9. Amongst other changes, migration requires you to move your javascript code into ES6 modules (where the namespace is not directly available to the HTML DOM) and so it's likely that the simplest HTML will immediately hit these issues. Advice would be most welcome.

MartinJ
  • 333
  • 2
  • 13

2 Answers2

1

This works fine, but what if your original button was a bit more sophisticated and was parameterised?

There are a couple of solutions to that:

  1. A data-* attribute:

    <button id="the-button" data-string="withThisString">Do Something with String</button>
    
    document.getElementById("the-button").addEventListener("click", function() {
        doSomething(this.getAttribute("data-string"));
    });
    

    (More on this below.)

    or

  2. Binding the string when you bind the event

    <button id="the-button">Do Something with String</button>
    
    document.getElementById("the-button").addEventListener("click", () => {
        doSomething("withThisString");
    });
    

There are lots of variations on the above, and if you use doSomething with multiple buttons with different strings you can do #1 with a class and a loop rather than with an id, but that's the general idea.


Re the data-* attribute thing: If you wanted to, you could make this process entirely HTML-driven via data-* attributes and a single function that hooks things up. For instance, say you had these buttons:

<button data-click="doThisx@module1">Do This</button>
<button data-click="doThat@module2">Do That</button>
<button data-click="doTheOther@module3">Do The Other</button>

You could have a single reusable function to hook those up:

class EventSetupError extends Error {
    constructor(element, msg) {
        if (typeof element === "string") {
            [element, msg] = [msg, element];
        }
        super(msg);
        this.element = element;
    }
}
export async function setupModuleEventHandlers(eventName) {
    try {
        const attrName = `data-${eventName}`;
        const elements = [...document.querySelectorAll(`[${attrName}]`)];
        await Promise.all(elements.map(async element => {
            const attrValue = element.getAttribute(`data-${eventName}`);
            const [fname, modname] = attrValue ? attrValue.split("@", 2) : [];
            if (!fname || !modname) {
                throw new EventSetupError(
                    element,
                    `Invalid '${attrName}' attribute "${attrValue}"`
                );
            }
            // It's fine if we do import() more than once for the same module,
            // the module loader will return the same module
            const module = await import(`./${modname}.js`);
            const fn = module[fname];
            if (typeof fn !== "function") {
                throw new EventSetupError(
                    element,
                    `Invalid '${attrName}': no '${fname}' on module '${modname}' or it isn't a function`
                );
            }
            element.addEventListener(eventName, fn);
        }));
    } catch (error) {
        console.error(error.message, error.element);
    }
}

Using it to find and hook up click handlers:

import { setupModuleEventHandlers } from "./data-attr-event-setup.js";
setupModuleEventHandlers("click")
.catch(error => {
    console.error(error.message, error.element);
});

It's one-time plumbing but gives you the same attribute-based experience in the HTML (the event handlers could still get parameter information from another data-* attribute, or you could bake that into your setup function). (That example relies on dynamic import, but that's supported by recent versions of all major browsers and, to varying degrees, bundlers.

There are a couple of dozen ways to spin that and I'm not promoting it, just giving an example of the kind of thing you can readily do if you want.

But really, this is where libraries like React, Vue, Ember, Angular, Lit, etc. come into play.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • Infinitely obliged sir. I applied suggestion (2) and it works a treat. As a follow-up, do you think there's something missing from the ES6 standard? My converted Firebase 8 code looks a terrible mess. The clear button linkages defined in my HTML before introducing the ES6 module have now disappeared and the block of "binding" statements that now blights the top of my Javascript isn't a satisfactory replacement. – MartinJ Oct 26 '21 at 10:44
  • @MartinJ - It's not a JavaScript thing, it's a DOM/HTML thing. The issue is that `onxyz`-attribute-style event handlers can only call global functions. (If you really want to, you can define globals inside a module, but it kind of defeats the purpose of modules. You do it by assigning to properties on `globalThis` or `window` [on browsers].) It does seem like DOM/HTML could use something in this area and I know there's some tentative talk around it, but it's **very** tricky to define in a standard because there are a **lot** of conflicting opinions about how to do it. This gap is generally... – T.J. Crowder Oct 26 '21 at 11:52
  • ...filled by libraries and/or frameworks. But you can fill it yourself; I've updated the answer with an example of module-friendly attribute-driven event handling. – T.J. Crowder Oct 26 '21 at 11:52
  • @TJC Thanks for this. I knew enough about React to imagine that its virtualDOM might provide a solution to my issues. But my problem here is that it would take me a long time to get myself into shape to use its libraries. – MartinJ Oct 26 '21 at 14:44
0

Although T.J Crowder has already answered this question I thought I might add a few points that are difficult to squeeze in as comments.

Once I got further into my Firebase V9 conversion I began to find that some of the consequences of the "module namespacing" issue were quite profound. The example cited in my initial question is easily dealt with, as above, but I found that I also needed to work out what to do about "dynamic" HTML responding to variable circumstances derived from a database. In this case, where my javascript would originally have created a string containing a block of HTML such as:

realDiv = `
<div>
    <button onclick = "function fn1 (param1a, param1b,...);">button1</button>
    <button onclick = "function fn2 (param2a, param2b,...);">button2</button>
etc
</div>
`

and then thrown this into a "real" realdiv defined in the HTML skeleton of the application with a

document.getElementById("realdiv") = realDiv;

Now, for the reasons described above, once the javascript is in a module, this arrangement no longer works.

The pattern I learnt to adopt (thanks, once again to T.J Crowder) went along the following lines:

realDiv = `
<div>
    <button id = "button1" data-param1a="param1a" data-param1b="param1b";">button1</button>
    <button id = "button2" data-param2a="param2a" data-param2b="param2b";">button2</button>
etc
</div>
`

I would then throw the generated code into my HTML skeleton as before with

document.getElementById("realdiv") = readlDiv;

and now that you've got the code embedded into the DOM (and assuming that I've kept a count of the number of buttons you've generated) I would create bindings for them with a final bit of javascript like so:

for (let i = 0; i>buttonCount; i++) {
    document.getElementById('button' + i).onclick = function() { fn1 (this.getAttribute('data-param1a'), this.getAttribute('data-param1b') };
etc
}

I found that creating onclicks with this pattern was particularly helpful in maintaining clarity when I needed to make the onclick launch a number of different functions.

MartinJ
  • 333
  • 2
  • 13