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>
&lt;body&gt;
<div class="well">
    &lt;div&gt;
    <a href="#someHash">&lt;a&gt;&lt;/a&gt;</a>
    &lt;/div&gt;
</div>
&lt;/body&gt;
<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>
&lt;body&gt;
<div class="well">
    &lt;div&gt;
    <a href="#someHash">&lt;a&gt;&lt;/a&gt;</a>
    <a href="#thisHash">&lt;a&gt; <span>&lt;span&gt;  &lt;/span&gt;</span> &lt;/a&gt;</a>
    &lt;/div&gt;
</div>
&lt;/body&gt;
<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>
&lt;body&gt;
<div class="well">
    &lt;div&gt;
    <a href="#someHash">&lt;a&gt;&lt;/a&gt;</a>
    <a href="#thisHash">&lt;a&gt; <span>&lt;span&gt;  &lt;/span&gt;</span> &lt;/a&gt;</a>
    &lt;/div&gt;
</div>
&lt;/body&gt;
<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.

Bonus 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;
    },

Bonus 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];
        }
      };
    })(),

Bonus 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 a delegator that will conditionally call the handler.
  • Use .data to store the delegator and handler 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);
      });
    },

Bonus 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++;
            }
          }
        }
      });
    }

Complete 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>