area17 / a17-behaviors

JavaScript framework to attach JavaScript events and interactions to DOM Nodes
MIT License
15 stars 5 forks source link

Feature/abortcontroller #5

Closed 13twelve closed 1 year ago

13twelve commented 1 year ago

Use the AbortController()'s ability to remove multiple event listeners on abort() to auto remove event listeners on behavior destroy().

See this article.

A common thing we see with behaviors is lots of this.$foo.removeEventListener('bar', this.baz); in a behavior destroy() method. Which is the intended use case, but, its easy to forget one and they're a pain to maintain - its a chore.

Being able to automatically remove event listeners on behavior destroy() would be nice. Another benefit of using an abort signal is that you could automatically remove anonymous functions, should you need to use those.

The easiest way would be to for each behavior to have a this.abortController and then every addEventListener would need { signal: this.abortController.signal } as its 3rd param, eg:

this.$btn.addEventListener('click', this.handleClick, { signal: this.abortController.signal });

But, as @kylegoines points out - developers probably won't notice they can use it.


The nicest thing we could have would be:

this.$btn.addEventListener('click', this.handleClick);

And behaviours somehow automagically appends { signal: this.abortController.signal } to the listener (also take into account that you may want { passive: true } or some other options. I don't know of a way to do this kind of fiddling with the methods/prototype of a DOM node only if its a named thing inside a behavior - @m4n1ok, @joecritch any idea?


This branch updates the existing getChild and getChildren methods (backwards compatible), adding on and off methods to the selection and children of the selection.

this.$btns = this.getChildren('btn'); // makes a collection using behavior's `this.getChildren('btn')`
// or
this.$btn = this.getChild('btn'); // makes a collection using behavior's `this.getChildren('btn')`
// or
this.$btns = this.getChildren(document.querySelector('button')) // makes a collection based on a single DOM node
// or
this.$btns = this.getChild(document.querySelectorAll('button')) // makes a collection based on a single DOM nodelist
// or
this.$btns = this.getChild(window) // makes a collection based on `window`

Where:

console.log(this.$btns); // NodeList(2) [button, button, on: ƒ, off: ƒ]
console.log(typeof this.$btns); // object
console.log(this.$btns[0]); // a DOM node (as as any nodelist)

And then you can add event listeners to a collection:

// and then
this.$btns.on('click', this.handleClick); // adds a click listener to all items in the collection with function `this.handleClick`
// or 
this.$btns.on('click', () => {
    console.log('hello world');  // adds a click listener to all items in the collection with anonymous function
});
// can also pass options
this.$btns.on('click', this.handleClick, { passive: true }); // adds a click listener to all items in the collection with function `this.handleClick` and `passive: true` option
// or select single items and add
this.$btns[0].on('click', this.handleClick); // adds a click listener to the first item in the collection with function `this.handleClick`

These will be be automatically cleaned up on behavior destroy(). (if you pass a custom abort controller signal as an option, it won't auto destroy)

You could also:

// also
window.addEventListener('resize', () => {
    console.log('resize');
}, {
    signal: this.__abortController.signal
});

And this would also be cleaned up on behavior destroy().

Should you also want to manually remove listeners you can:

this.$btns.off('click', this.handleClick); // removes all `click` listeners with the function `this.handleClick`
this.$btns.off('click'); // removes all `click` listeners, regardless of their associated functions
this.$btns.off(); // removes all event listeners, regardless of their type and associated function
this.$btns[0].off('click', this.handleClick); // removes the listener from just the first element

If you select an element that was previously in a selection, it will have on and off methods:

this.$btns = this.getChildren('btn');
console.log(typeof this.$node.querySelector('button').on); // function

Which means you can do:

this.$btns.on('click', (event) => {
    event.currentTarget.off('click); // will remove all 'click' listeners from the clicked button
});

Listeners won't be added twice:

this.$btns.on('click', this.handleClick);
this.$btns.on('click', this.handleClick); // won't add the handler twice

Using on and off as not to confuse with addEventListener and removeEventListener. On individual nodes, native addEventListener and removeEventListener are still available.


To do:

mrdoinel commented 1 year ago

Not sure we need a polyfill as it is widely supported : https://caniuse.com/abortcontroller

13twelve commented 1 year ago

so AbortController support with addEventListener isn't as widely support as AbortController with fetch. But, there is a simple polyfill to match support, which I'm not going to include in behaviors but will mention in the wiki.