patrick-steele-idem / morphdom

Fast and lightweight DOM diffing/patching (no virtual DOM needed)
MIT License
3.16k stars 127 forks source link

Events #29

Closed amirouche closed 5 years ago

amirouche commented 8 years ago

How do you handle events using morphdom?

patrick-steele-idem commented 8 years ago

Hey @amirouche, morphdom provides just the functionality to transform a source DOM tree to match a target DOM tree (remove/add elements, remove/add attributes, etc.). morphdom doesn't do anything with adding and removing event listeners, but it does provide the hooks such as onNodeDiscarded and onBeforeMorphEl that you can use to do proper cleanup. Here's some relevant code that Marko Widgets uses to transform the DOM and bind client-side "widget" behavior to DOM nodes: https://github.com/marko-js/marko-widgets/blob/2511792a076b9b0fc536944d427998642e95c94c/lib/Widget.js#L547-L586

Does that answer your question?

amirouche commented 8 years ago

As simple example using only morphdom would be helpful. My goal is to bind morphdom with @biwascheme.

patrick-steele-idem commented 8 years ago

Hey @amirouche, unfortunately I am pretty swamped at the moment. I will try to offer some additional guidance... If you rerender a UI component, you should first properly destroy all of the widgets first. Then, after the UI component is rerendered and the DOM is updated using morphdom you would simple go through and initialize all of the widgets associated with the newly rendered UI component and when each widget initializes it will have the opportunity to bind event listeners. Just think of morphdom as a way to transform the DOM in a way that is better than simply using innerHTML. I hope that helps!

amirouche commented 8 years ago

I'm looking at a way to bind events using delegation, so that I don't really have to GC events handlers manually while avoiding memory leaks.

no hurry. Maybe someone will chime in :octocat:

patrick-steele-idem commented 8 years ago

Hey @amirouche, event delegation only works for events that bubble. You would need to attach event listeners at the root for all of the events that bubble. When an event bubbles up to the root and it is captured by your code then you will need to look at the event's target element to figure out how to delegate the event to the appropriate handler methods. Marko Widgets does the event delegation by adding special event attributes to map an event type to a handler method (e.g. data-w-onclick="..."). Here's the relevant code in Marko Widgets: https://github.com/marko-js/marko-widgets/blob/1532e21a3acf89f3dab9a6145bf9dfcb59a9b032/lib/marko-widgets-browser.js#L103-L142

You could just use Marko Widgets :)

dy commented 8 years ago

I also faced that, and should add that this issue is more seroius than it may seem.

First time I stumbled upon that in choo, when needed to implement popup, which creates container element in view, which was just replaced by choo’s view output.

Now I am trying to use morphdom in prama, where I use multirange, which takes <input> and creates a ghost <input> by cloning it, and defines properties on ghost element thereafter. Multirange slider is created by replacing some existing element via morphdom, and sometimes that element is already <input> of other kind. The result of that is that morphdom sometimes can replace old element, and sometimes to modify it, therefore I don’t really know when should I init multirange. I didn’t manage to figure out how can I sort that out with morphdom events. One workaround for that is using on-load, but all that is a headache.

Anyways event examples for morphdom would be really appreciated!

BTW during the debug found that morphdom does not update <input> to <textarea>. Probably it is a separate issue.

howardroark commented 8 years ago

I just got morphdom working in my project and... I guess I just sort of assumed that events would play into this. Whoops!

I will admit that my understanding of how event bubbling works is a bit fuzzy.

It would be awesome to see an example of how to retain a couple events assigned to an original element... Ideally involving jQuery.

Otherwise it works great!

patrick-steele-idem commented 8 years ago

@howardroark When updating the view using morphdom, I recommend unsubscribing from all jQuery event listeners and then reattaching event listeners after the DOM is updated. morphdom only guarantees that the original DOM will be modified to match the target DOM, but it doesn't do anything with event listeners.

As for event bubbling, the following might help clarify: http://markojs.com/blog/a-closer-look-at-marko-widgets/#how-does-event-delegation-work

howardroark commented 8 years ago

The way I am doing it involves rendering a bunch of views into a single element and then morphing that into the original. By the time I am at that step I have lost the context to run the event assignment logic per view. Really I was just hoping for a way to somehow copy what is there for each element somehow.

Thanks though, I'll have a look at the link :)

patrick-steele-idem commented 8 years ago

@howardroark

Really I was just hoping for a way to somehow copy what is there for each element somehow.

Not exactly sure what your use case is, but you can use the onBeforeElUpdated event to get access to the "from element" and the "to element" during the DOM tree traversal. You can copy over properties, remove/add event listeners, etc. at that time

onBeforeElUpdated: function(fromEl, toEl) {
}
howardroark commented 8 years ago

Yeah... I think that is the best route. Since I am using jQuery I can probably leverage $._data(element, "events") and rebind them all as it finds data.

howardroark commented 8 years ago

Well I can see that toEl has all the correct events in place... they never make it to fromEl though. Also, after running morphom all events are removed even if the element itself did not change.

I'll see about forking this and fiddling around to get them to persist somehow. I got enough motivation to make this work. Replacing innerHTML is making all my inputs unfocused ;)

howardroark commented 8 years ago

Ok I got something working if anyone is curious...

window.morphdomEventsDump = [];
morphdom(this.currentAncestorEl, this.el, {
    onBeforeElUpdated: function(fromEl, toEl) {
        var events = $._data(toEl, "events");
        if(events) {
            window.morphdomEventsDump.push(events);
            toEl.setAttribute('data-morphdomeventsdump', window.morphdomEventsDump.length - 1);
        }
    },
    onElUpdated: function(el) {
        var events = window.morphdomEventsDump[el.getAttribute('data-morphdomeventsdump')];
        if(typeof events != 'undefined') {
            for(var eventName in events) {
                for(var i = 0; i < events[eventName].length; i++) {
                    var eventObject = events[eventName][i];
                    $(el).on(eventObject.type, eventObject.selector, eventObject.handler);
                }
            }
        }
    }
})

Fore more context ... https://github.com/howardroark/TemplateView/commit/9dca846b4dfbcc69c9e97f36877ef569d20bfd0d

dy commented 8 years ago

@howardroark that works with jquery/EventEmitter/other event wrapper, which stores event callbacks somewhere. In pure DOM it is impossible to get complete list of listeners for a node.

howardroark commented 8 years ago

@dfcreative That's good to know. I was a bit skeptical about how nice that object was... didn't seem very "browsery" haha.

I think I should be safe since this setup uses backbone.js and jquery to put all events together.

dy commented 8 years ago

@howardroark well I wouldn’t rely on var events = $._data(toEl, "events"); either. There is a reason _data is marked private.

AutoSponge commented 8 years ago

@howardroark I would start experimenting with event delegation so you understand how it works. You can use event delegation with jquery, but most jquery-users attach handlers directly to the elements. That's how everyone used to do it. Here's an example from something I was working on:

document.addEventListener('change', (event) => {
  const target = event.target
  const [type, id] = target.id.split('--')
  const qty = target.value
  switch (type) {
    case ITEM_EDIT:
      store.dispatch(changeQuantity(id, qty, target))
      break
    default:
  }
}

This is fairly generic code that parses the id of the element that changed and passes the new quantity to a function. I also pass the target element so I can check for errors. If I come up with something else that could happen on a change event, it's easy to add that "action type" to the switch statement without duplicating any of the other code. It creates a convention that's easy for other developers to follow and extend.

This code is also very easy to test. I don't need the DOM to tests this part. I don't need this or the DOM to test my changeQuantity function and I can reuse the changeQuantity function to programmatically set the quantity when the application needs to override a value. Tightly coupling your business logic to your DOM implementation often leads to trouble when things change.

I find that one-off handlers (as is common in jquery-heavy projects) tend to create specialized handlers with few if any conventions between them. It's often easy to tell who wrote which handlers if you have more than one developer on the project.

howardroark commented 8 years ago

Thanks @AutoSponge ! Is this a common practice now...to have a single change or click event? I guess most events are just changing a model or a collection of models.

Edit: I think I may try taking backbone's events object for each view and throw them all in a central object which maps to each view's method. Then setup a global event for each type which matches a target's xpath to the global object... or something like that.

AutoSponge commented 8 years ago

@howardroark I started doing this for performance reasons (and to prevent memory leaks) back when IE couldn't garbage collect handlers after the DOM element was removed. While it's not completely necessary for the same reasons on newer browsers, the pattern still has benefits.

Some frameworks enforce this but abstract it, like CycleJS, which uses a "stream" of DOM events which components subscribe to. While it inverts the control of the events (pull vs push), it works by collecting all events of a certain type and handling them in a generic way until it gets to the component.

This Observer pattern may not be right for every app. For instance, if you're building isolated widgets that get instantiated on various pages or inside other apps, you need a different mechanism to handle intra-widget communication--you don't have a full-scale spa to depend on for state. In those situations, I like to use custom events and/or dedicated pub/sub.

Backbone's events work like an event emitter. I would encourage you to build a small proof-of-concept with the technique you described. I've actually done something similar and it worked really well. Then try it using an Observable library like RxJS to "streamify" the events. You'll learn a lot and start to see why certain decisions were made in CycleJS and similar projects.

howardroark commented 8 years ago

Thanks @AutoSponge ! You have certainly inspired me to do some experimenting :)

nichoth commented 8 years ago

@howardroark This is how yo-yo works. See the index file there. It uses morphdom lifecycle hooks to update event listeners.

mAAdhaTTah commented 7 years ago

Hey @amirouche, event delegation only works for events that bubble.

This isn't strictly true; you can use the capture phase to respond to non-bubbling events like focus.

rainerborene commented 6 years ago

Well, it depends how you've structured your front-end code. For example, I'm syncing views rendered on server side from Rails with morphdom which means no format.ejs or format.js in your controllers. And after running morphdom function I just call $.onmount() for binding events again. If you don't know this library you should definitely take a look at this guide.

snewcomer commented 5 years ago

I can see how binding event dynamically could be beneficial. I'll take a close look at this over the next few weeks after I get the benchmarks back up and running.

snewcomer commented 5 years ago

I think the hooks described hooks are the best way. For server side responses, re-attaching events is unnecessary (Phoenix LiveView). Moreover, in the benchmarks, morphdom is must faster than other libraries that do event copying (and probably other things). Closing for now!

zenflow commented 4 years ago

Just sharing my solution, which uses the onBeforeElUpdated hook to copy event listener properties (e.g. element.onclick) from source elements to target elements.

morphdom-events.js (below) exports a withEvents decorator function to use on your options object.

Example usage:

const options = withEvents({ ...myCustomOptions })
morphdom(fromNode, toNode, options)

morphdom-events.js:

// /* global HTMLElement */

// If we define `eventPropNames` this way, it will include a lot of not-really-necessary items.
// const eventPropNames = Object.keys(HTMLElement.prototype).filter(name => name.slice(0, 2) === 'on',)

const eventPropNames = [
  // taken from https://github.com/choojs/nanomorph/blob/f282b86336e0a0fc1aee95aaf2a242f94d72040d/lib/events.js
  'onclick',
  'ondblclick',
  'onmousedown',
  'onmouseup',
  'onmouseover',
  'onmousemove',
  'onmouseout',
  'onmouseenter',
  'onmouseleave',
  'ontouchcancel',
  'ontouchend',
  'ontouchmove',
  'ontouchstart',
  'ondragstart',
  'ondrag',
  'ondragenter',
  'ondragleave',
  'ondragover',
  'ondrop',
  'ondragend',
  'onkeydown',
  'onkeypress',
  'onkeyup',
  'onunload',
  'onabort',
  'onerror',
  'onresize',
  'onscroll',
  'onselect',
  'onchange',
  'onsubmit',
  'onreset',
  'onfocus',
  'onblur',
  'oninput',
  'oncontextmenu',
  'onfocusin',
  'onfocusout',

  // added
  'onanimationend',
  'onanimationiteration',
  'onanimationstart',
]

function updateEvents(fromEl, toEl) {
  var i, eventPropName
  for (i = 0; i < eventPropNames.length; i++) {
    eventPropName = eventPropNames[i]
    if (fromEl[eventPropName] !== toEl[eventPropName]) {
      fromEl[eventPropName] = toEl[eventPropName]
    }
  }
}

export default function withEvents(input = {}) {
  return {
    ...input,
    onBeforeElUpdated(fromEl, toEl) {
      if (input.onBeforeElUpdated) {
        const shouldContinue = input.onBeforeElUpdated.call(this, fromEl, toEl)
        if (!shouldContinue) {
          return false
        }
      }
      updateEvents(fromEl, toEl)
      return true
    },
  }
}
elclanrs commented 4 years ago

@zenflow thank you for that snippet. It worked for my use case. I was deleting items from a list and depending on the order I deleted them I would get different results, sometimes not deleting the item, or getting the wrong item. The item variable inside the event handler wasn't always in sync, but with your fix everything seems to work fine. I'm not sure why, probably related to the event not being applied to the right element after the DOM changes.

@patrick-steele-idem would you consider adding this functionality to the library? If not, zenflow, would you consider making a package?

zenflow commented 4 years ago

I'm not sure why

Here's the thing to realize: For every element rendered by morphdom it will either (1) if possible, keep the existing element and mutate it to match the given input element, and (2) otherwise, take and actually use the given input element. When morphdom mutates the element (to match input element) it doesn't bother with event listeners (because I guess performance: See https://github.com/patrick-steele-idem/morphdom/issues/29#issuecomment-476246285), so the element keeps it's original event listeners.

If not, zenflow, would you consider making a package?

@elclanrs I didn't end up using morphdom (or anything in it's place, actually) so I don't have much of a use for such a package. But if you create it I would be happy to review and contribute QA :)

snewcomer commented 4 years ago

@elclanrs I wouldn't be opposed to adding a util (based on the great example from @zenflow) to this library that the end user can import and apply in a hook. What do you think?

elclanrs commented 4 years ago

@snewcomer yeah, that would work too!

zenflow commented 4 years ago

end user can import and apply in a hook

@snewcomer I personally like the withEvents decorator-type interface for this utility, so end user doesn't need to worry about applying anything in a hook:

Random example code configuring options using withEvents:

const options = withEvents({ 
  onBeforeElUpdated: fromEl => fromEl.className !== 'skip-me',
  onBeforeElChildrenUpdated: fromEl => fromEl.className !== 'skip-me'
})
morphdom(fromNode, toNode, options)

Same example code but using updateEvents directly`:

const options = { 
  onBeforeElUpdated: (fromEl, toEl) => {
    const shouldUpdate = fromEl.className !== 'skip-me'
    if (shouldUpdate) {
      updateEvents(fromEl, toEl)
    }
    return shouldUpdate
  },
  onBeforeElChildrenUpdated: fromEl => fromEl.className !== 'skip-me'
}
morphdom(fromNode, toNode, options)

Either way I would be happy to make this contribution to this awesome library in a PR (with tests & documentation updates included), so please let me know which API you prefer :D

AutoSponge commented 4 years ago

You can't SSR this code. So, morphdom would have to put up guardrails to prevent someone from putting events in their dom elements. This is why bel and yo-yo were created--years ago. If done incorrectly, this could also break those libraries and should be considered a breaking change. While I stepped away from this project, I didn't want to swallow these last words of caution. But do what you want.

snewcomer commented 4 years ago

@zenflow If you felt inclined, a util is probably as far as we should take it. So it is optional and flexible depending on the use case. I like your decorator approach but I see that staying in user-land.

Would love a PR!