9

I am developing a JavaScript module, which knows nothing about the environment in which it will be used in.

And, technically speaking, I want to implement the next function:

onceAppended(element, callback);

element is an HTMLElement and the parent of this element may be unknown during the module initialization. callback is a function, which must be triggered once element appears on the page.

Callback must be called immediately if the element is appended to the document. In case element is not appended yet, function will trigger callback once element appears on the document.

The problem is, we can detect element append event with using DOMNodeInserted mutation event. But mutation events are now deprecated. And it seems that MutationObserver can't handle this task, can it?

Here is my code snippet:

function onceAppended (element, callback) {
    let el = element,
        listener;
    while (el.parentNode)
        el = el.parentNode;
    if (el instanceof Document) {
        callback();
        return;
    }
    if (typeof MutationObserver === "undefined") { // use deprecated method
        element.addEventListener("DOMNodeInserted", listener = (ev) => {
            if (ev.path.length > 1 && ev.path[ev.length - 2] instanceof Document) {
                element.removeEventListener("DOMNodeInserted", listener);
                callback();
            }
        }, false);
        return;
    }
    // Can't MutationObserver detect append event for the case?
}
ZitRo
  • 1,163
  • 15
  • 24
  • @wOxxOm Could you please complete my function in case you know the subject? I've tried to implement this with MutationObservers, and got no results. – ZitRo Jul 26 '16 at 11:44
  • I don't see why MutationObserver can't handle this task. What have you tried? I guess you'll have to attach the observer to document and check every added node. It will be very ineffective, though. So maybe you can override `appendChild`, `replaceChild` and other relevant functions in the prototypes of HTMLElement and Node. – wOxxOm Jul 26 '16 at 11:50
  • See also [Alternative to DOMNodeInserted](http://stackoverflow.com/a/10343915) – wOxxOm Jul 26 '16 at 11:52
  • @wOxxOm Attaching the observer to the document is one of the possible solutions. But you can imagine the amount of events it would need to process. I am looking for a better, more performance-friendly solutions, if there are any. – ZitRo Jul 26 '16 at 11:53
  • @wOxxOm [UPD] And overriding standard events, I believe, will have side effects. – ZitRo Jul 26 '16 at 11:59

2 Answers2

2

Unfortunately there is no way to do this exactly the same way as with DOMNodeInserted because none of the MutationObserver events tell you when an element's parent changes.

Instead, you'll have to place the observer on the document.body and check each node that gets appended. If you want to run your callback whenever any node is appended, that's easy. If you only want it to run when certain nodes are appended, then you'll have to keep a reference to those nodes somewhere.

let elements = [];
elements[0] = document.createElement('div');
elements[1] = document.createElement('span');
elements[2] = document.createElement('p');
elements[3] = document.createElement('a');

const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

const observer = new MutationObserver(function(mutations) {
  // 'addedNodes' is an array of nodes that were appended to the DOM.
  // Checking its length let's us know if we've observed a node being added
  if (mutations[0].addedNodes.length > 0) {

    // 'indexOf' let's us know if the added node is in our reference array
    if (Array.prototype.indexOf.call(mutations[0].addedNodes[0], elements) > -1) {

      // Run the callback function with a reference to the element
      callback(mutations[0].addedNodes[0]);
    }
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

function callback(element) {
  console.log(element);
}

document.body.appendChild(elements[2]); // => '<p></p>'
elements[2].appendChild(elements[3]);   // => '<a></a>'

As you can see the callback is triggered for nodes appended anywhere within document.body. If you want callback() to run whenever any element is appended, just take out the second check if the element exists in your reference array.

skyline3000
  • 7,639
  • 2
  • 24
  • 33
  • Thank you! This method is good enough to solve the task, but however it's big cons is performance. Having thousands elements added to the page (like forming a new table) the callback is called thousand times. – ZitRo Jul 26 '16 at 17:18
1

By taking a wOxxOm's hint about the Alternative to DOMNodeInserted, and skyline3000's answer I have developed two methods of this task solution. The first method onceAppended is fast, but it has a delay of around 25ms before callback is triggered. The second method triggers callback right after the element is inserted, but it may be slow when a lot of elements append in the application.

The solution is available on GitHub and as an npm ES6 module. Below are the plain code of the two solutions.

Method 1 (using CSS animations)

function useDeprecatedMethod (element, callback) {
    let listener;
    return element.addEventListener(`DOMNodeInserted`, listener = (ev) => {
        if (ev.path.length > 1 && ev.path[ev.length - 2] instanceof Document) {
            element.removeEventListener(`DOMNodeInserted`, listener);
            callback();
        }
    }, false);
}

function isAppended (element) {
    while (element.parentNode)
        element = element.parentNode;
    return element instanceof Document;
}

/**
 * Method 1. Asynchronous. Has a better performance but also has an one-frame delay after element is
 * appended (around 25ms delay) of callback triggering.
 * This method is based on CSS3 animations and animationstart event handling.
 * Fires callback once element is appended to the document.
 * @author ZitRo (https://github.com/ZitRos)
 * @see https://stackoverflow.com/questions/38588741/having-a-reference-to-an-element-how-to-detect-once-it-appended-to-the-document (StackOverflow original question)
 * @see https://github.com/ZitRos/dom-onceAppended (Home repository)
 * @see https://www.npmjs.com/package/dom-once-appended (npm package)
 * @param {HTMLElement} element - Element to be appended
 * @param {function} callback - Append event handler
 */
export function onceAppended (element, callback) {

    if (isAppended(element)) {
        callback();
        return;
    }

    let sName = `animation`, pName = ``;

    if ( // since DOMNodeInserted event is deprecated, we will try to avoid using it
        typeof element.style[sName] === `undefined`
        && (sName = `webkitAnimation`) && (pName = "-webkit-")
            && typeof element.style[sName] === `undefined`
        && (sName = `mozAnimation`) && (pName = "-moz-")
            && typeof element.style[sName] === `undefined`
        && (sName = `oAnimation`) && (pName = "-o-")
            && typeof element.style[sName] === `undefined`
    ) {
        return useDeprecatedMethod(element, callback);
    }

    if (!document.__ONCE_APPENDED) {
        document.__ONCE_APPENDED = document.createElement('style');
        document.__ONCE_APPENDED.textContent = `@${ pName }keyframes ONCE_APPENDED{from{}to{}}`;
        document.head.appendChild(document.__ONCE_APPENDED);
    }

    let oldAnimation = element.style[sName];
    element.style[sName] = `ONCE_APPENDED`;
    element.addEventListener(`animationstart`, () => {
        element.style[sName] = oldAnimation;
        callback();
    }, true);

}

Method 2 (using MutationObserver)

function useDeprecatedMethod (element, callback) {
    let listener;
    return element.addEventListener(`DOMNodeInserted`, listener = (ev) => {
        if (ev.path.length > 1 && ev.path[ev.length - 2] instanceof Document) {
            element.removeEventListener(`DOMNodeInserted`, listener);
            callback();
        }
    }, false);
}

function isAppended (element) {
    while (element.parentNode)
        element = element.parentNode;
    return element instanceof Document;
}

/**
 * Method 2. Synchronous. Has a lower performance for pages with a lot of elements being inserted,
 * but triggers callback immediately after element insert.
 * This method is based on MutationObserver.
 * Fires callback once element is appended to the document.
 * @author ZitRo (https://github.com/ZitRos)
 * @see https://stackoverflow.com/questions/38588741/having-a-reference-to-an-element-how-to-detect-once-it-appended-to-the-document (StackOverflow original question)
 * @see https://github.com/ZitRos/dom-onceAppended (Home repository)
 * @see https://www.npmjs.com/package/dom-once-appended (npm package)
 * @param {HTMLElement} element - Element to be appended
 * @param {function} callback - Append event handler
 */
export function onceAppendedSync (element, callback) {

    if (isAppended(element)) {
        callback();
        return;
    }

    const MutationObserver =
        window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

    if (!MutationObserver)
        return useDeprecatedMethod(element, callback);

    const observer = new MutationObserver((mutations) => {
        if (mutations[0].addedNodes.length === 0)
            return;
        if (Array.prototype.indexOf.call(mutations[0].addedNodes, element) === -1)
            return;
        observer.disconnect();
        callback();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

}

Both of this methods has the same usage, which differs only in function names:

import { onceAppended } from "dom-once-appended"; // or onceAppendedSync

function myModule () {
    let sampleElement = document.createElement("div");
    onceAppended(sampleElement, () => { // or onceAppendedSync
        console.log(`Sample element is appended!`);
    });
    return sampleElement;
}

// somewhere else in the sources (example)
let element = myModule();
setTimeout(() => document.body.appendChild(element), 200);
Community
  • 1
  • 1
ZitRo
  • 1,163
  • 15
  • 24