Closed amirouche closed 5 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?
As simple example using only morphdom would be helpful. My goal is to bind morphdom with @biwascheme.
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!
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:
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 :)
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.
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!
@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
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 :)
@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) {
}
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.
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 ;)
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
@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.
@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.
@howardroark well I wouldn’t rely on var events = $._data(toEl, "events");
either. There is a reason _data
is marked private.
@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.
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.
@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.
Thanks @AutoSponge ! You have certainly inspired me to do some experimenting :)
@howardroark This is how yo-yo works. See the index file there. It uses morphdom lifecycle hooks to update event listeners.
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
.
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.
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.
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!
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
},
}
}
@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?
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 :)
@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?
@snewcomer yeah, that would work too!
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
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.
@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!
How do you handle events using morphdom?