1
\$\begingroup\$

In terms of performance, what is the best way to bind mouseover/mouseout event on dynamically created elements?

This is what I have but I'm not sure it is ok for performance:

document.addEventListener('mouseover', (e) => {
    if (e.target.getAttribute('data-tooltip')) {
        // Show
    }
});
document.addEventListener('mouseout', (e) => {
    if (e.target.getAttribute('data-tooltip')) {
        // Hide
    }
});

Maybe something like debouncing should be added or more optimized way exists?

This is how I would bind events for non-dynamic elements:

document.querySelectorAll('[data-tooltip]').forEach(item => {
    item.addEventListener('mouseover', this.show);
    item.addEventListener('mouseout', this.hide);
});

Please note that I'm using Vanilla JavaScript and no libraries.

This is the markup that can trigger a tooltip over the element:

<span data-tooltip="top" title="Tooltip text here.">Tooltips</span>

The show() and hide() class methods generate a div with text from the tooltip title attribute. I know there are many CSS only methods to create a tooltip, but they use data attributes, which lack the a11y part.

My intention is to be able to show default title attributes for people without JavaScript and show fancy tooltip to people with enabled JS and temporary "empty" the title attribute while mouseovered to prevent default browser behavior.

// Tooltips
class Tooltips {
    // Init and Bind events dinamically
    static init() {
        document.addEventListener('mouseover', (e) => {
            if (e.target.getAttribute('data-tooltip')) {
                this.show(e.target);
            }
        });
        document.addEventListener('mouseout', (e) => {
            if (e.target.getAttribute('data-tooltip')) {
                this.hide(e.target);
            }
        });
    }

    // Show
    static show(item) {
        this._lastActive = 'tooltip-'+ this.toHex(item.getAttribute('title'));
        this._tempTitlte = item.getAttribute('title');
        item.setAttribute('title', '');

        // If tooltip exists, just show it
        if (document.getElementById(this._lastActive)) {
            document.getElementById(this._lastActive).setAttribute('aria-hidden', 'false');
            return true;
        }

        // Create the tooltip
        const tooltip = document.createElement('div');
        tooltip.setAttribute('class', 'site-tooltip site-tooltip-'+ item.getAttribute('data-tooltip'));
        tooltip.setAttribute('id', this._lastActive);
        tooltip.innerHTML = this._tempTitlte;
        tooltip.setAttribute('aria-hidden', 'false');
        document.body.appendChild(tooltip);

        // Position it
        const top = item.offsetTop - tooltip.offsetHeight - 10; // 10px for tooltip arrow
        const left = item.offsetLeft + item.offsetWidth / 2 - tooltip.offsetWidth / 2;
        tooltip.setAttribute('style', 'left: '+ left +'px; top: '+ top +'px;');
    }

    // Hide
    static hide(item) {
        document.getElementById(this._lastActive).setAttribute('aria-hidden', 'true');
        item.setAttribute('title', this._tempTitlte);
    }
  
    // string to hex
    static toHex(str) {
        var result = '';
        for (var i = 0; i < str.length; i++) {
            result += str.charCodeAt(i).toString(16);
        }
        return result;
    }
}

// Document ready
document.addEventListener('DOMContentLoaded', function() {
    // Let us create an element
    const tooltip = document.createElement('button');
    tooltip.setAttribute('data-tooltip', 'top');
    tooltip.setAttribute('title', 'Fancy tooltip text');
    tooltip.innerHTML = 'Show Tooltip';
    document.body.appendChild(tooltip);
  
    // Init tooltips
    Tooltips.init();
});
.site-tooltip{
    position: absolute;
    padding: 7px 10px 9px;
    font-size: 14px;
    line-height: 17px;
    background-color: #000;
    color: #fff;
    border-radius: 3px;
    display: none;
    z-index: 99;
    max-width: 250px;
    box-sizing: border-box;
}
.site-tooltip:after{
    content: "";
    display: block;
    z-index: 98;
    width: 14px;
    height: 14px;
    transform: rotate(45deg);
    -webkit-transform: rotate(45deg);
    position: absolute;
    background-color: #000;
    border-radius: 3px;
}
[data-tooltip] {
    cursor: help;
}
.site-tooltip-top:after{left: 50%; bottom: -5px; margin-left: -7px;}

.site-tooltip[aria-hidden="false"]{
    display: block;
}
<br/>
<br/>
<br/>
<br/>
Some spacing
<br/>
<br/>
<br/>
<br/>
<br/>

\$\endgroup\$
  • 2
    \$\begingroup\$ You sure that you need JS for this? What does the markup look like? \$\endgroup\$ – user10711 Jan 12 '17 at 19:44
  • 1
    \$\begingroup\$ I agree with @squint. I've downvoted the question for lack of context. Please show a real usage scenario (ideally as a live demo — press Ctrl-M in the question editor to make one). \$\endgroup\$ – 200_success Jan 12 '17 at 19:45
  • \$\begingroup\$ Assuming you do need JS, why not bind directly to the newly created elements and use mouseenter/mouseleave instead? That should give you best performance overall. \$\endgroup\$ – user10711 Jan 12 '17 at 19:47
  • \$\begingroup\$ Updated question with details and why I want to do it with JS. @squint this was my other idea, but I have many JS functionalities like Tabs, Tooltips, Popups and so on, which need their own events and in case of loading whole page with ajax I have to bind all those events again. \$\endgroup\$ – Nikolay Mihaylov Jan 12 '17 at 19:54
  • 1
    \$\begingroup\$ The title says "dynamically created elements", but I don't see any evidence of that in your example. \$\endgroup\$ – 200_success Jan 12 '17 at 20:04
3
\$\begingroup\$

Binding handlers to the document for event types that repeatedly fire should certainly be done cautiously. Even with minimal code being executed, it's still wasteful computation when most interactions don't actually need the behavior.

If you'd like to avoid this, simply bind directly to the newly created elements.


Reuse your handlers

First, define your functions so that you can reuse them instead of repeatedly creating new ones. This can actually be done with a single handler for both event types, but I'll keep it as two for now.

const ttip_enter = (e) => {
    if (e.target.getAttribute('data-tooltip')) {
        // Show
    }
};
const ttip_leave = (e) => {
    if (e.target.getAttribute('data-tooltip')) {
        // Hide
    }
};

Bind when new elements are created

Now whenever you create new elements, fetch all elements with the data-tooltip attribute and bind the handlers.

for (var tt of document.querySelectorAll("span[data-tooltip]")) {
  tt.addEventListener("mouseenter", ttip_enter)
  tt.addEventListener("mouseout", ttip_leave)
}

Notice that I bound to mouseenter and mouseleave instead. Those don't bubble, so if you end up having nested elements in the tooltip element, the handler won't get called multiple times.


Double-binding concerns

Now you may be concerned about double binding the handlers for existing elements that are already bound. Don't be. The .addEventListener method ensures that the same function reference won't be bound more than once to the same element/event/captures combination.

That's part of the reason why it was important to reference the functions and reuse them. If you passed different references to similar looking functions, you'd have the problem of the same behavior being bound multiple times.

\$\endgroup\$
  • \$\begingroup\$ i didn't know that about double binding, cool! but if only one element is being created at a time i'd think it would make moer sense to bind to just the new element instead of looping all of them when you know the other ones already have that event. \$\endgroup\$ – I wrestled a bear once. Jan 12 '17 at 20:10
  • 1
    \$\begingroup\$ @Iwrestledabearonce.: Absolutely agree. From the OP's comment above, it sounds like the whole page content is being replaced. This should definitely be done as locally as possible. If the OP already has direct access to the element, then no DOM selection/looping will be needed. \$\endgroup\$ – user10711 Jan 12 '17 at 20:14
1
\$\begingroup\$

whether or not a debouncer will improve performance depends on what is happening in the handler and how often you expect the event will be fired.

keep in mind, a debouncer will add a bunch of extra code that will have to execute on every event handler that uses it, which will actually degrade performance.

If all you're doing is showing and hiding an element on mouseover, forget about the debouncer. I would do it exactly as you have it.

If you're doing intensive processing in your mouseovers and you notice a lag while using it in the browser then a debouncer might be useful.

\$\endgroup\$
0
\$\begingroup\$

Thank you all for your suggestions. This is a solution I came up with, it does not rely on dynamic event binding. Any thoughts?

// SiteGlobal
class SiteGlobal {
    static initClasses() {
        Popups.init();
        Tooltips.init();
    }
}

// Tooltips
class Tooltips {
    static init() {
        document.querySelectorAll('[data-tooltip]').forEach(item => {
            item.addEventListener('mouseover', this.show);
            item.addEventListener('mouseout', this.hide);
        });
    }
}

// Popups
class Popups {
    static init() {
        // Show events
        document.querySelectorAll('[data-popup]').forEach(item => {
            item.addEventListener('click', (e) => {
                e.preventDefault();
                // Show
            });
        });

        // Hide events
        document.querySelectorAll('.site-popup-close, .site-popup-overlay').forEach(item => {
            item.addEventListener('click', (e) => {
                // Hide
            });
        });

        // Hide on escape press
        window.addEventListener('keyup', (e) => e.keyCode == 27 && this.hide());
    }
}

And finally in my Ajax callback:

.then(function(json) {
    params.callback(json);

    // Rebind the events
    SiteGlobal.initClasses();
});

The idea is to rebind the events for all elements, both dynamically created ones and elements who already existed in the DOM. And if Squint is right about the quote below, everything should be fine right?

The .addEventListener method ensures that the same function reference won't be bound more than once to the same element/event/captures combination.

\$\endgroup\$
  • \$\begingroup\$ Yes, that should be fine, assuming this.show and this.hide are referencing the same pair of function references whenever Tooltips.init() is called. I would still use mouseenter/mouseleave though in place of mouseover/mouseout. Also, be aware that .forEach() called on a NodeList will not work in many browsers, unless you patch it on NodeList.prototype. It should work simply to do: if (!NodeList.prototype.forEach) NodeList.prototype.forEach = Array.prototype.forEach. It may also be needed on HTMLCollection.prototype. Not sure. \$\endgroup\$ – user10711 Jan 13 '17 at 18:07
  • \$\begingroup\$ ...regarding your second sentence, I would say that your solution is dynamic event binding. What you were doing before was a single bind and using event delegation, which doesn't do any binding after the initial handler being bound to document. \$\endgroup\$ – user10711 Jan 13 '17 at 18:11
  • \$\begingroup\$ @squint thank you for the feedback, I totally agree about the mousenter/mouseleave events. Regarding forEach not working in old browsers I'm using es6 shim for them. \$\endgroup\$ – Nikolay Mihaylov Jan 13 '17 at 20:50
  • \$\begingroup\$ ES6 shim adds methods to DOM collections? That would be surprising for an ECMAScript shim. \$\endgroup\$ – user10711 Jan 13 '17 at 20:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.