Events Part 2 page
Learn about event propagation and event delegation.
Overview
In this part, we will:
- Learn about event propagation
- Learn about event delegation
Slides
Video
Propagation Demo
The following listens to click
on a nested body, div, and
anchor element in both the capture
and bubble
phase
and logs which handler is being dispatched.
See what happens:
- when different propagation methods are called
- when the DOM is modified during event dispatching
<style>
* {font-family: Consolas, Monaco, Menlo, monospace; font-size: 1.2em}
a {display: block; margin-left: 20px;}
div {margin-left: 20px;}
</style>
<body>
<div class="well">
<div>
<a href="#someHash"><a></a></a>
</div>
</div>
</body>
<script type="module">
/*
What happens if we:
- stopPropagation() within the body’s capture phase listener?
- stopImmediatePropagation() within the body’s capture phase listener?
- remove innerHTML within the body’s capture phase listener?
*/
var body = document.querySelector('body'),
div = document.querySelector('div'),
a = document.querySelector('a');
body.addEventListener('click', function(ev) {
console.log('<- body bubbled');
});
body.addEventListener('click', function(ev) {
// ev.stopImmediatePropagation();
// ev.preventDefault();
// this.innerHTML = "";
console.log('-> body captured');
}, true);
div.addEventListener('click', function() {
console.log('<- div bubbled');
});
div.addEventListener('click', function() {
console.log('-> div captured');
}, true);
a.addEventListener('click', function() {
console.log('<- a bubble phase');
});
a.addEventListener('click', function() {
console.log('-> a capture phase');
}, true);
</script>
Delegation Demo
The following shows using event delegation to listen to when any anchor is clicked in the page.
<style>
* {font-family: Consolas, Monaco, Menlo, monospace; font-size: 1.2em}
a {display: block; margin-left: 20px;}
div {margin-left: 20px;}
</style>
<body>
<div class="well">
<div>
<a href="#someHash"><a></a></a>
<a href="#thisHash"><a> <span><span> </span></span> </a></a>
</div>
</div>
</body>
<script type="module">
document.body.addEventListener("click", function(ev){
if( ev.target.matches("a") ) {
console.log("clicked on an anchor!");
}
}, false);
</script>
Notice that when the <span>
within the <a>
is is clicked,
nothing is logged. This is because the event.target
was the <span>
and not an <a>
.
<style>
* {font-family: Consolas, Monaco, Menlo, monospace; font-size: 1.2em}
a {display: block; margin-left: 20px;}
div {margin-left: 20px;}
</style>
<body>
<div class="well">
<div>
<a href="#someHash"><a></a></a>
<a href="#thisHash"><a> <span><span> </span></span> </a></a>
</div>
</div>
</body>
<script type="module">
document.body.addEventListener("click", function(ev){
var current = ev.target;
do {
if(current.matches("a")) {
console.log("clicked on an anchor!");
}
current = current.parentNode;
} while ( current && current !== ev.currentTarget )
}, false);
</script>
Setup
Run the following example in CodePen:
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<link rel="stylesheet" href="//code.jquery.com/qunit/qunit-1.12.0.css">
<script src="//code.jquery.com/qunit/qunit-1.12.0.js"></script>
<script src="//bitovi.github.io/academy/static/scripts/jquery-test.js"></script>
<link rel="stylesheet" href="//bitovi.github.io/academy/static/scripts/jquery-test.css">
<script type="module">
(function() {
$ = function(selector) {
if ( !(this instanceof $) ) {
return new $(selector);
}
var elements;
if (typeof selector === "string") {
elements = document.querySelectorAll(selector);
} else if ($.isArrayLike(selector)) {
elements = selector;
}
[].push.apply(this, elements);
};
$.extend = function(target, object) {
for (var prop in object) {
if (object.hasOwnProperty(prop)) {
target[prop] = object[prop];
}
}
return target;
};
// Static methods
$.extend($, {
isArray: function(obj) {
return Object.prototype.toString.call(obj) === "[object Array]";
},
isArrayLike: function(obj) {
return obj &&
typeof obj === "object" &&
( obj.length === 0 ||
typeof obj.length === "number" &&
obj.length > 0 &&
obj.length - 1 in obj );
},
each: function(collection, cb) {
if ($.isArrayLike(collection)) {
for (var i = 0; i < collection.length; i++) {
if (cb.call(this, i, collection[i]) === false) {
break;
}
}
} else {
for (var prop in collection) {
if (collection.hasOwnProperty(prop)) {
if (cb.call(this, prop, collection[prop]) === false) {
break;
}
}
}
}
return collection;
},
makeArray: function(arr) {
if ($.isArray(arr)) {
return arr;
}
var array = [];
$.each(arr, function(i, item) {
array[i] = item;
});
return array;
},
proxy: function(fn, context) {
return function() {
return fn.apply(context, arguments);
};
}
});
function makeTraverser(traverser) {
return function() {
var elements = [], args = arguments;
$.each(this, function(i, element) {
var els = traverser.apply(element, args);
if ($.isArrayLike(els)) {
elements.push.apply(elements, els);
} else if (els) {
elements.push(els);
}
});
return $(elements);
};
}
$.extend($.prototype, {
html: function(newHtml) {
if(arguments.length) {
return $.each(this, function(i, element) {
element.innerHTML = newHtml;
});
} else {
return this[0].innerHTML;
}
},
val: function(newVal) {
if(arguments.length) {
return $.each(this, function(i, element) {
element.value = newVal;
});
} else {
return this[0].value;
}
},
text: function(newText) {
if (arguments.length) {
return $.each(this, function(i, element) {
element.textContent = newText;
});
} else {
return this[0].textContent;
}
},
find: makeTraverser(function(selector) {
return this.querySelectorAll(selector);
}),
parent: makeTraverser(function() {
return this.parentNode;
}),
next: makeTraverser(function() {
return this.nextElementSibling;
}),
prev: makeTraverser(function() {
return this.previousElementSibling;
}),
children: makeTraverser(function() {
return this.children;
}),
attr: function(attrName, value) {
if (arguments.length == 2) {
return $.each(this, function(i, element) {
element.setAttribute(attrName, value);
});
} else {
return this[0] && this[0].getAttribute(attrName);
}
},
css: function(cssPropName, value) {
if (arguments.length == 2) {
return $.each(this, function(i, element) {
element.style[cssPropName] = value;
});
} else {
return this[0] &&
window.getComputedStyle(this[0])
.getPropertyValue(cssPropName);
}
},
addClass: function(className) {
return $.each(this, function(i, element) {
element.classList.add(className);
});
},
removeClass: function(className) {
return $.each(this, function(i, element) {
element.classList.remove(className);
});
},
width: function() {
var paddingLeft = parseInt(this.css("padding-left"), 10),
paddingRight = parseInt(this.css("padding-right"), 10);
return this[0].clientWidth - paddingLeft - paddingRight;
},
hide: function() {
return this.css("display", "none");
},
show: function() {
return this.css("display", "");
},
offset: function() {
var offset = this[0].getBoundingClientRect();
return {
top: offset.top + window.pageYOffset,
left: offset.left + window.pageXOffset
};
},
bind: function(eventName, handler) {
return $.each(this, function(i, element) {
element.addEventListener(eventName, handler, false);
});
},
unbind: function(eventName, handler) {
return $.each(this, function(i, element) {
element.removeEventListener(eventName, handler, false);
});
},
is: function(selector){ },
data: function(propName, value) { }
on: function(eventType, selector, handler) { },
off: function(eventType, selector, handler) { }
});
})();
</script>
Each exercise builds on the previous exercise. There is a completed solution at the end of this page.
Exercise: collection.is(selector) -> boolean
The problem
collection.is checks the current matched set of elements against a selector.
import "https://unpkg.com/jquery@3/dist/jquery.js";
var elements = $([
document.createElement("div"),
document.createElement("span"),
]);
console.log(elements.is("div")); //-> true
Click to see test code
QUnit.test("$.fn.is", function () {
expect(3);
var elements = $([
document.createElement("div"),
document.createElement("span"),
]);
ok(elements.is("div"), "is div");
ok(elements.is("span"), "is span");
ok(!elements.is("a"), "is a");
});
What you need to know
matches returns if an element matches a selector:
<div id="hello">Hello World</div> <script type="module"> console.log(hello.matches("div")); //-> true </script>
The solution
Click to see the solution
is: function(selector){
var matched = false;
$.each(this, function(i, element){
if( this.matches( selector) ) {
matched = true;
}
});
return matched;
},
Exercise: collection.data(key [, value])
The problem
collection.data stores arbitrary data associated with the matched elements or return the value at the named data store for the first element in the set of matched elements.
<div id="hello">Hello World</div>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";
$("#hello").data("foo", "bar");
console.log($("#hello").data("foo")); //-> "bar"
</script>
Click to see test code
QUnit.test("$.fn.data", function () {
$("#qunit-fixture").html('<div id="el">text</div>');
$("#el").data("foo", "bar");
equal($("#el").data("foo"), "bar", "got back bar");
});
What you need to know
Use WeakMap to store data associated with an object in such a way that when the object is removed the data will be also be removed from the
WeakMap
and available for garbage collection.var map = new WeakMap(); (function () { var key = { name: "key" }; var value = { name: "value" }; map.set(key, value); })(); setTimeout(function () { console.log(map); // In chrome, you can see the contents of the weakmap // and it will not have the key and value. }, 500);
The solution
Click to see the solution
data: (function(){
var data = new WeakMap();
return function(propName, value) {
if (arguments.length == 2) {
// set the data for every item in the collection
return $.each(this, function(i, el) {
var elData = data.get(el);
if (!elData) {
elData = {};
data.set(el, elData);
}
elData[propName] = value;
});
} else {
// return the data in the first value
var el = this[0], elData = data.get(el);
return elData && elData[propName];
}
};
})(),
Exercise: collection.on(eventType, selector, handler)
The problem
collection.on attaches a delegate event listener.
<ul id="root">
<li>First</li>
<li>Second</li>
</ul>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";
$("#root").on("click", "li", function () {
console.log("clicked an li");
});
</script>
Click to see test code
QUnit.test("$.fn.on", function () {
expect(3);
var handler = function () {
equal(this.nodeName.toLowerCase(), "li", "called back with an LI");
};
var $ul = $("#qunit-fixture")
.html(
`
<ul>
<li><span id="one"/></li>
<li><span id="two"/></li>
</ul>`
)
.children();
$ul.on("click", "li", handler);
clickIt($("#one")[0]);
clickIt($("#two")[0]);
$ul.html('<li><span id="three"></span></li>');
clickIt($("#three")[0]);
});
What you need to know
- Instead of binding the
handler
, you’ll need to bind adelegator
that will conditionally call thehandler
. - Use
.data
to store thedelegator
andhandler
in an object like{delegator, handler}
. That object should be stored in a data structure that looks like:$([element]).data("events"); //-> { // click: { li: [ {delegator, handler} ] } // }
The solution
Click to see the solution
on: function(eventType, selector, handler) {
// Create delegator function
var delegator = function(ev) {
var cur = ev.target;
do {
if ( $([ cur ]).is(selector) ) {
handler.call(cur, ev);
}
cur = cur.parentNode;
} while (cur && cur !== ev.currentTarget);
};
return $.each(this, function(i, element) {
// store delegators by event and selector in
// $.data
var events = $([ element ]).data("events"), eventTypeEvents;
if (!events) {
$([ element ]).data("events", events = {});
}
if (!(eventTypeEvents = events[eventType])) {
eventTypeEvents = events[eventType] = {};
}
if (!eventTypeEvents[selector]) {
eventTypeEvents[selector] = [];
}
eventTypeEvents[selector].push({
handler: handler,
delegator: delegator
});
element.addEventListener(eventType, delegator, false);
});
},
Exercise: collection.off(eventType, selector, handler)
The problem
collection.off stops listening for a delegate listener.
Click to see test code
QUnit.test("$.fn.off", function () {
expect(0);
var handler = function () {
equal(this.nodeName.toLowerCase(), "li", "called back with an LI");
};
var $ul = $("#qunit-fixture")
.html(
`
<ul>
<li><span id="one"/></li>
<li><span id="two"/></li>
</ul>`
)
.children();
$ul.on("click", "li", handler);
$ul.off("click", "li", handler);
clickIt($("#three")[0]);
});
What you need to know
- You will need to find the delegate for the handler passed to
.off()
and then call.removeEventListener
.
The solution
Click to see the solution
off: function(eventType, selector, handler) {
return $.each(this, function(i, element) {
// Find the delegator object for the handler
// and remove it.
var events = $([ element ]).data("events");
if (events[eventType] && events[eventType][selector]) {
var delegates = events[eventType][selector], i = 0;
while (i < delegates.length) {
if (delegates[i].handler === handler) {
element.removeEventListener(eventType, delegates[i].delegator, false);
delegates.splice(i, 1);
} else {
i++;
}
}
}
});
}
Completed solution
Click to see completed solution
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<link rel="stylesheet" href="//code.jquery.com/qunit/qunit-1.12.0.css">
<script src="//code.jquery.com/qunit/qunit-1.12.0.js"></script>
<script src="//bitovi.github.io/academy/static/scripts/jquery-test.js"></script>
<link rel="stylesheet" href="//bitovi.github.io/academy/static/scripts/jquery-test.css">
<script type="module">
(function() {
$ = function(selector) {
if ( !(this instanceof $) ) {
return new $(selector);
}
var elements;
if (typeof selector === "string") {
elements = document.querySelectorAll(selector);
} else if ($.isArrayLike(selector)) {
elements = selector;
}
[].push.apply(this, elements);
};
$.extend = function(target, object) {
for (var prop in object) {
if (object.hasOwnProperty(prop)) {
target[prop] = object[prop];
}
}
return target;
};
// Static methods
$.extend($, {
isArray: function(obj) {
return Object.prototype.toString.call(obj) === "[object Array]";
},
isArrayLike: function(obj) {
return obj &&
typeof obj === "object" &&
( obj.length === 0 ||
typeof obj.length === "number" &&
obj.length > 0 &&
obj.length - 1 in obj );
},
each: function(collection, cb) {
if ($.isArrayLike(collection)) {
for (var i = 0; i < collection.length; i++) {
if (cb.call(this, i, collection[i]) === false) {
break;
}
}
} else {
for (var prop in collection) {
if (collection.hasOwnProperty(prop)) {
if (cb.call(this, prop, collection[prop]) === false) {
break;
}
}
}
}
return collection;
},
makeArray: function(arr) {
if ($.isArray(arr)) {
return arr;
}
var array = [];
$.each(arr, function(i, item) {
array[i] = item;
});
return array;
},
proxy: function(fn, context) {
return function() {
return fn.apply(context, arguments);
};
}
});
function makeTraverser(traverser) {
return function() {
var elements = [], args = arguments;
$.each(this, function(i, element) {
var els = traverser.apply(element, args);
if ($.isArrayLike(els)) {
elements.push.apply(elements, els);
} else if (els) {
elements.push(els);
}
});
return $(elements);
};
}
$.extend($.prototype, {
html: function(newHtml) {
if(arguments.length) {
return $.each(this, function(i, element) {
element.innerHTML = newHtml;
});
} else {
return this[0].innerHTML;
}
},
val: function(newVal) {
if(arguments.length) {
return $.each(this, function(i, element) {
element.value = newVal;
});
} else {
return this[0].value;
}
},
text: function(newText) {
if (arguments.length) {
return $.each(this, function(i, element) {
element.textContent = newText;
});
} else {
return this[0].textContent;
}
},
find: makeTraverser(function(selector) {
return this.querySelectorAll(selector);
}),
parent: makeTraverser(function() {
return this.parentNode;
}),
next: makeTraverser(function() {
return this.nextElementSibling;
}),
prev: makeTraverser(function() {
return this.previousElementSibling;
}),
children: makeTraverser(function() {
return this.children;
}),
attr: function(attrName, value) {
if (arguments.length == 2) {
return $.each(this, function(i, element) {
element.setAttribute(attrName, value);
});
} else {
return this[0] && this[0].getAttribute(attrName);
}
},
css: function(cssPropName, value) {
if (arguments.length == 2) {
return $.each(this, function(i, element) {
element.style[cssPropName] = value;
});
} else {
return this[0] &&
window.getComputedStyle(this[0])
.getPropertyValue(cssPropName);
}
},
addClass: function(className) {
return $.each(this, function(i, element) {
element.classList.add(className);
});
},
removeClass: function(className) {
return $.each(this, function(i, element) {
element.classList.remove(className);
});
},
width: function() {
var paddingLeft = parseInt(this.css("padding-left"), 10),
paddingRight = parseInt(this.css("padding-right"), 10);
return this[0].clientWidth - paddingLeft - paddingRight;
},
hide: function() {
return this.css("display", "none");
},
show: function() {
return this.css("display", "");
},
offset: function() {
var offset = this[0].getBoundingClientRect();
return {
top: offset.top + window.pageYOffset,
left: offset.left + window.pageXOffset
};
},
bind: function(eventName, handler) {
return $.each(this, function(i, element) {
element.addEventListener(eventName, handler, false);
});
},
unbind: function(eventName, handler) {
return $.each(this, function(i, element) {
element.removeEventListener(eventName, handler, false);
});
},
is: function(selector){
var matched = false;
$.each(this, function(i, element){
if( element.matches( selector) ) {
matched = true;
}
});
return matched;
},
data: (function(){
var data = new WeakMap();
return function(propName, value) {
if (arguments.length == 2) {
return $.each(this, function(i, el) {
var elData = data.get(el);
if (!elData) {
elData = {};
data.set(el, elData);
}
elData[propName] = value;
});
} else {
var el = this[0], elData = data.get(el);
return elData && elData[propName];
}
};
})(),
on: function(eventType, selector, handler) {
// Create delegator function
var delegator = function(ev) {
var cur = ev.target;
do {
if ( $([ cur ]).is(selector) ) {
handler.call(cur, ev);
}
cur = cur.parentNode;
} while (cur && cur !== ev.currentTarget);
};
return $.each(this, function(i, element) {
// store delegators by event and selector in
// $.data
var events = $([ element ]).data("events"), eventTypeEvents;
if (!events) {
$([ element ]).data("events", events = {});
}
if (!(eventTypeEvents = events[eventType])) {
eventTypeEvents = events[eventType] = {};
}
if (!eventTypeEvents[selector]) {
eventTypeEvents[selector] = [];
}
eventTypeEvents[selector].push({
handler: handler,
delegator: delegator
});
element.addEventListener(eventType, delegator, false);
});
},
off: function(eventType, selector, handler) {
return $.each(this, function(i, element) {
// Find the delegator object for the handler
// and remove it.
var events = $([ element ]).data("events");
if (events[eventType] && events[eventType][selector]) {
var delegates = events[eventType][selector], i = 0;
while (i < delegates.length) {
if (delegates[i].handler === handler) {
element.removeEventListener(eventType, delegates[i].delegator, false);
delegates.splice(i, 1);
} else {
i++;
}
}
}
});
}
});
})();
</script>