shortercode / Doom.js

Simple DOM utility library for web apps
3 stars 2 forks source link

DOM Events #14

Open jcf120 opened 8 years ago

jcf120 commented 8 years ago

I've got some code that is dependent on an element having already been rendered in the DOM, but don't necessarily know when it will be be inserted. I thought it might be handy to have DOM manipulation events. E.g.:

var elem = Doom.create({
    onInsert: function() {
        console.log(elem.offsetWidth);
    }
});

What d'ya reckon?

shortercode commented 8 years ago

mmm definitely doable, although the mutation observer API isn't the friendliest thing in the world.

Possible thoughts:

a promisified version of requestAnimationFrame with an optional timeout/deadline parameter, perhaps useful in render dependant code

Promise: function nextframe(timeout< Number: optional >)
Doom.nextframe().then(function () {
     console.log("will render!");
})

DOM mutation events based on the custom elements v1 spec

Doom.create({
    connectedCallback: function () {
        console.log(this, "inserted into DOM");
    },
    disconnectedCallback: function () {
        console.log(this, "removed from DOM");
    },
    // list of attribute names that trigger attributeChangedCallback
    observedAttributes: ["disabled", "partymode"], 
    attributeChangedCallback: function (attrName, oldVal, newVal) {
        console.log(this, "changed attribute");
    }
});

alternatively we could have something a little simpler like onelementinserted, onelementremoved, onelementattributechanged or perhaps a subproperty called dom events ( which might allow things to be a little tidier in terms of the mutationobserver creation )

jcf120 commented 8 years ago

I can't guarantee that the element will be inserted into the DOM, so nextframe might be a little inefficient. Nice though.

Sadly custom elements don't look so well supported yet...

Slight complication with the mutation observer: suppose the element is to be indirectly connected to the document, i.e. it already has a parent, that isn't yet connected. In the mutation, we'd have to test the children of the added node too. Maybe that's not too costly.

shortercode commented 8 years ago

well my thought on nextframe was more that after being inserted or changing a property you could call next frame to execute render dependent code. For instance if you needed the next css frame to execute before doing something.

As for the custom elements I wasn't actually suggesting using custom elements, just filching the method names for the sake of continuity. We would still use mutation observers underneath.

As for the "parent can only watch child" issue, that's kinda tough I didn't realize they only watched down the tree(pretty crappy really). Presuming we just said screw it and only watch the document body even then we'd be replicating the reason the original mutation events were depreciated; every child insert/removal is VERY expensive, document.innerHTML = "hello" for instance may produce 5000 removal events and 1 add event... Which we'd have to filter for our one useful event!

Having a quick look around and a think the potential options (and issues)are:

jcf120 commented 8 years ago

Ha yes, guess we should avoid that!

Hadn't noticed modify's removeChild until you drew my attention to it just now. Nice. Yeah, it seems a bit much to ask the dev to do everything through Doom. Easy to slip up.

I suppose the real answer to my problem is to structure my UI logic better, such that it knows when it's presented. That in combination with your requestAnimationFrame note.

shortercode commented 8 years ago

There's also HTMLElement Doom.remove(HTMLElement element, Number delay <optional>) which is quite useful. But yeah anyway in terms of asking the developer to use Doom in their own code it isn't too much of an ask... but other libraries they use won't be using Doom methods. So yeah that plan doesn't exactly fly!

So I guess the only guaranteed way is polling, but we could use a mix to get a slight performance boost for Doom methods I guess?

shortercode commented 8 years ago

Okay probably shouldn't be pasting this in the issue but whatever, written a chunk of code that allows you to register connection and disconnection events for an element. Requires the developer to be a little careful, as it has to keep references to elements internally... so leaving events registered to dead elements will leak all over the place. Not much we can do about that though.

Implementation uses only the poll method, so should be fine with people not using Doom methods for moving stuff around. Does have a slight flaw that if you move an element around more than once during the poll interval (16ms, so SHOULD be roughly once per frame) only one event will be called.

Anyway, what are your thoughts?

var observedElements = [];
var timerRef = null;

/*
    Returns our status obect, which is a snapshot of the element at the
    last update. If it does not exist yet, and shouldCreate is set then it
    will be created. Will start the timer if it isn't running yet.
*/
function getstatus (element, shouldCreate) {
    var i = 0;
    var l = observedElements.length;
    var status = null;

    for (; i < l; i++)
    {
        if (observedElements[i].element === element) {
            status = observedElements[i];
            break;
        }
    }

    if (!status && shouldCreate) {
        status = {
            element: element,
            parent: element.parentNode,
            connectHandler: [],
            disconnectHandler: []
        };
        observedElements.push(status);
        start();
    }

    return status;
}

/*
    Adds a new connect callback to the current element, also prevents the same
    handler being registered twice
*/
function addConnectionHandler (element, callback) {
    var status = getstatus(element, true);

    if (status.connectHandler.indexOf(callback) === -1)
        status.connectHandler.push(callback);
}

/*
    Adds a new disconnect callback to the current element, also prevents the same
    handler being registered twice
*/
function addDisconnectionHandler (element, callback) {
    var status = getstatus(element, true);

    if (status.disconnectHandler.indexOf(callback) === -1)
        status.disconnectHandler.push(callback);
}

/*
    Removes a connect callback from the current element, will destroy the status
    object if it's the last handler. Also will stop the status poll if its the
    last element being disabled
*/
function removeConnectionHandler (element, callback) {
    var status = getstatus(element, false);

    if (status) {
        var index = status.connectHandler.indexOf(callback);
        if (index !== -1) {
            if (status.connectHandler.length === 1 && status.disconnectHandler.length === 0) {
                ignoreElement(element);
            } else {
                status.connectHandler.splice(index, 1);
            }
        }
    }
}

/*
    Removes a disconnect callback from the current element, will destroy the status
    object if it's the last handler. Also will stop the status poll if its the
    last element being disabled
*/
function removeDisconnectionHandler (element, callback) {
    var status = getstatus(element, false);

    if (status) {
        var index = status.disconnectHandler.indexOf(callback);
        if (index !== -1) {
            if (status.connectHandler.length === 0 && status.disconnectHandler.length === 1) {
                ignoreElement(element);
            } else {
                status.disconnectHandler.splice(index, 1);
            }
        }
    }
}

/*
    Executes a collection of handlers, with the element as the context. Can pass
    an eventObject for extra information.
*/
function fireEvent (element, handlers, eventObject) {
    for (var i = 0, l = handlers.length; i < l; i++) {
        try {
            handlers[i].call(element, eventObject);
        } catch (e) {
            console.error(e);
        }
    }
}

/*
    Removes an element from the observation list, will stop the timer if its the
    last element on the list.
*/
function ignoreElement (element) {
    var i = 0;
    var l = observedElements.length;

    for (; i < l; i++)
    {
        if (observedElements[i].element === element) {
            observedElements.splice(i, 1);
            break;
        }
    }

    if (observedElements.length === 0)
        stop();
}

/*
    Checks for modifications to elements on the observation list and calls the
    relevent handlers.
*/
function poll () {
    var i = 0;
    var l = observedElements.length;
    var status = null;
    var element = null;

    for (; i < l; i++) {
        status = observedElements[i];
        element = status.element;

        if (status.parent !== element.parentNode) {
            fireEvent(element, status.disconnectHandler);
            if (element.parentNode) {
                fireEvent(element, status.connectHandler);
            }
            status.parent = element.parentNode;
        }
    }
}

/*
    Starts the timer if it isn't running.
*/
function start () {
    if (timerRef)
        return;

    timerRef = setInterval(poll, 16);
}

/*
    Stops the timer if it is running.
*/
function stop () {
    if (!timerRef)
        return;

    clearInterval(timerRef);
    timerRef = null;
}
jcf120 commented 8 years ago

Oooo, cool. Some thoughts:

At the moment you're detecting direct parent changes right? Which isn't necessarily the same thing as document (dis)connects. (body.contains?) I don't easily see how to efficiently handle indirect (dis)connects. We could propagate the observation up through the watched element's parents - sounds expensive though.

You reckon polling once a frame will have less overhead than triggering polls via MutationObserver? Might not be so bad with your on/off switch.

shortercode commented 8 years ago

Yeah I was aiming for the element being added / removed from the parent rather than the parent document changing.

For "indirect" I presume you mean if like the elements parent was inserted into another element? I think generally we should avoid the bubbling the event upward. If you did want to watch the element above change then you should watch the element above instead! Although I do see a use case for seeing if the root element changes. Say an element is inside a document fragment, then the fragment is inserted into the document and we need to update the size or something. Thinking we can use contains like you sat to detect if the element is still a child during the poll.

Method for getting the root element

function getRoot(element) {
    while (element.parentNode) // elements with no parent return null
        element = element.parentNode;

    return element;
}

Updated status object

status = {
    element: element,
    parent: element.parentNode,
    root: getRoot(element),
    connectHandler: [],
    disconnectHandler: [],
    adoptedHanlder: []
}

Updated poll method, checks to see if the element still is a descendant of its root

function poll () {
    var i = 0;
    var l = observedElements.length;
    var status = null;
    var element = null;

    for (; i < l; i++) {
        status = observedElements[i];
        element = status.element;

        if (status.root.contains(element) === false) {
            fireEvent(element, status.adoptedHandler);
            status.root = getRoot(element);
        }

        if (status.parent !== element.parentNode) {
            fireEvent(element, status.disconnectHandler);
            if (element.parentNode) {
                fireEvent(element, status.connectHandler);
            }
            status.parent = element.parentNode;
        }
    }
}

I don't think MutationObservers is suitable for 2 reasons:

  1. We would have to register the document.body to the observer, and watch for all childNode changes and filter the events for our observed elements. Which involves the browser having to deal with lots of events, with big propagation chains.
  2. Watching the document.body would not trigger events on document fragments or loose elements, and we have no way of detecting when we should watch a fragment or even a way to get a reference to it.

I think polling will work fairly well, presuming the developer isn't watching every element on the page... It just has to work through a flat list and run some quick checks. Running it at 60Hz uses more execution time than I'd like but we can only reduce it with increased latency. I guess if we're worried about excessive time usage requestAnimationFrame might be more suitable.

jcf120 commented 8 years ago

I think my original hope/intention has been lost somewhere along the way: A convenient way to know whether something has been attached to the document, so I can execute post-render dependant code. Watching parents was just a means to an end. Document fragments and loose elements aren't rendered, so I'm not really concerned with them.

"If you did want to watch the element above change then you should watch the element above instead!" If I've got to think about which elements need watching, beyond the element with the render specific code, then the convenience is lost. I was hoping to build a contained alloy that would be rendered correctly, without the need to consider where/when it was being used.

Example flow:

"Say an element is inside a document fragment, then the fragment is inserted into the document and we need to update the size or something." You've made me realise that I hadn't been considering reshapes/resizes. I guess that's my real want - a reshape event.

Don't get me wrong, this polling method is neat for what it is, but I'm thinking I proposed a feature before I understood what I needed from it.

jcf120 commented 8 years ago

I'd recommend not writing anything until I'm sure of what I'm after! I may end up writing a project specific solution, that we can later consider incorporating into Doom if it's worth it.

shortercode commented 8 years ago

Sorry if I dashed your hopes and dreams a little ;) I do think this feature has some potential, after all I did base the proposed implementation on the custom element spec which has been heavily developed by the community.

If you still think it's helpful I'm pretty sure the "adoptedCallback" implementation will do exactly what you want, also it's nearly finished tbh. The code I posted on this issue was most of the work, and I've already created a branch to turn it into something useful so not much more work is required.

Doom.create({
    style: "width: 50px",
    adoptedCallback: function () {
        Doom.modify({
            element: this,
            style: "width: " + this.parentNode.getBoundingClientRect().width + "px"
        });
    }
});

etc etc

think it needs about 30 minutes to an hour to finish it

shortercode commented 8 years ago

Implemented and tested, candidate available in pull request