Responding to DOM events
In the preceding section, we saw how to schedule JavaScript code to run at a later time. This is done explicitly by other JavaScript code. Most of the time, our code runs in response to user interactions. In this section, we'll look at the common interface that's used not only by DOM events, but also by things such as network and web worker events. We'll also look at a technique for dealing with large volumes of similar events—called debouncing.
Event targets
The EventTarget
interface is used by many browser components, including DOM elements. It's how we dispatch events to elements as well as listen to events and respond by executing a callback function. It's actually a very straightforward interface that's easy to follow. This is crucial since many different types of components use this same interface for event management. We'll see as we progress through the book.
The same task queue mechanisms that execute the callback functions for the timers that we used in the preceding section are relevant for EventTarget
events. That is, if an event has taken place, a task to invoke the JavaScript interpreter with the appropriate callback is queued. The same limitations faced with using setTimeout()
are imposed here. Here's what a task queue looks like when there's long-running JavaScript code that's blocking user events:
In addition to attaching listener functions to event targets that react to user interaction, we can trigger these events manually, as the following code illustrates:
// A generic event callback, logs the event timestamp. function onClick(e) { console.log('click', new Date(e.timeStamp)); } // The element we're going to use as the event // target. var button = document.querySelector('button'); // Setup our "onClick" function as the // event listener for "click" events on this target. button.addEventListener('click', onClick); // In addition to users clicking the button, the // EventTarget interface lets us manually dispatch // events. button.dispatchEvent(new Event('click'));
It's good practice to name functions that are used in callbacks where possible. This way, when our code breaks, it's much easier to trace down the problem. It's not impossible with anonymous functions, it's just more time consuming. On the other hand, arrow functions are more concise and have more binding flexibility. Choose your trade-offs wisely.
Managing event frequency
One challenge with user interaction events is that there can be lots of them, in a very short amount of time. For instance, when the user moves the mouse around on the screen, hundreds of events are dispatched. If we had event targets listening for these events, the task queue would quickly fill up, and the user experience would bog down.
Even when we do have event listeners in place for high frequency events, such as mouse moves, we don't necessarily need to respond to all of them. For example, if there's 150 mouse move events that take place in 1-2 seconds, chances are, we only care about the last move—the most recent position of the mouse pointer. That is, the JavaScript interpreter is being invoked with our event callback code 149 times more than it needs to.
To deal with these types of event frequency scenarios, we can utilize a technique called debouncing. A debounced function means that if it's called in succession more than once within a given time frame, only the last call is actually used and the earlier calls are ignored. Let's walk through an example of how we can implement this:
// Keeps track of the number of "mousemove" events. var events = 0; // The "debounce()" takes the provided "func" an limits // the frequency at which it is called using "limit" // milliseconds. function debounce(func, limit) { var timer; return function debounced(...args) { // Remove any existing timers. clearTimeout(timer); // Call the function after "limit" milliseconds. timer = setTimeout(() => { timer = null; func.apply(this, args); }, limit); }; } // Logs some information about the mouse event. Also log // the total number of events. function onMouseMove(e) { console.log(`X ${e.clientX} Y ${e.clientY}`); console.log('events', ++events); } // Log what's being typed into the text input. function onInput(e) { console.log('input', e.target.value); } // Listen to the "mousemove" event using the debounced // version of the "onMouseMove()" function. If we // didn't wrap this callback with "debounce()" window.addEventListener('mousemove', debounce(onMouseMove, 300)); // Listen to the "input" event using the debounced version // of the "onInput()" function to prevent triggering events // on every keystroke. document.querySelector('input') .addEventListener('input', debounce(onInput, 250));
Using the debounce technique to avoid giving the CPU more work than necessary is an example of the conserve principle in action. By ignoring 149 events, we save (conserve) the CPU instructions that would otherwise be executed and provide no real value. We also save on any kind of memory allocation that would otherwise happen in these event handlers.
The JavaScript concurrency principles were introduced at the end of Chapter 1, Why JavaScript Concurrency?, and they'll be pointed out throughout the code examples in the remainder of the book.