No, they're not, but they might seem that way when you first meet them. Thanks to Hack Reactor, I no longer think of them that way. After a deep dive and some BackBone, you won't either. Here's what I'm going to cover:
- Why: What makes events so special
- How: How a basic event system works
- Backbone: A pitfall of the Event API
- Bonus round: DOM Event API
If you're comfortable with eventing systems, you'll probably want to skip to section three.
Imagine: JavaScript is single-threaded. Things happen, and you can't interrupt them. You can only queue your own programs up to go next.
In a model like this, the web would not be very reactive. Instead of responding to user interaction, a web page would only be able to follow a standard, regimented sequence. JavaScript is single-threaded, so it needs a way to interrupt the flow of control so that certain actions have specified reactions. These actions are events.
Let's look at it more anthropomorphically: I develop my code to face the web, but the web does not notice me. No API was written to target my code. I need a way to let it know that it exists! Events once more fit the bill - adding an event listener lets the browser know that I, too, have something to say.
An event occurs on an object when something happens, that simply. There are many types: focus, change, mouse, etc.
Callback functions can be registered to an event, in which case the event's occurence will trigger a change in flow-of-control. This allows code to be fired immediately, in response to an event. It creates responsive pages.
In an eventing system, an event listener is registered to an object's event. This listener will 'hear' the event and invoke its callback.
Let's see an example:
body = document.getElementsByTagName('body')[0]
body.addEventListener 'click', ->
console.log 'I\'m Mr. Body!'
(This may not work in your browser, but later examples should be supported.)
This adds a listener to the body element's click event. When the event occurs, the callback given the listener will be invoked.
Try it: if you click the body, it should introduce itself in your console.
Now, we can react to user and data-driven events.
Let's make a small eventing system to go over the basics and help us notice some trends later on. There are four important components to eventing:
-
The object that emits the event - this is typically a DOM node or class instance within your code.
-
The event being listened for - in the DOM, this might be
click
,mouseover
, orchange
; in your code, it might be any change to data structure. -
The callback to be invoked - this is a function that should execute when the event is heard.
-
The context in which it should be invoked - this is used by custom eventing systems to explicitly set the
this
keyword in the callback invocation.
There is another component, useCapture, which is covered in the Bonus Round
When an event listener is registered, all of this will need to be stored, so that triggering the event on the object will invoke the callback within the correct context.
When an event is triggered, it will be done so on the object:
body.dispatchEvent new MouseEvent 'click'
Listeners are registered to the object. The object will need to store listeners in order to alert them to events. Most every object will need this implemented, so a real use should use a mix-in.
event = (obj) ->
The object should store registered listeners.
@_events ||= []
We need a way to add to this array,
@registerListener = (event, callback, context) ->
@_events.push
event: event
callback: callback
context: context
And alert listeners to an event, performing their registered actions,
@trigger = (event) ->
for listener in @_events
((listener) ->
if listener.event is event
listener.callback.call listener.context
)(listener)
We could improve this a bit by storing listeners to arrays hashed to event names. Backbone.Events is implemented this way, so we'll leave out of our example.
Now, we just need to make sure to trigger the event when appropriate. The DOM (and frameworks) do this for us.
This example is still a little lot naive. It presupposes the deletion of listeners, amongst other particulars. However, it diverts the flow-of-control, effectively triggering a callback when push is called.
There are two major takeaways from this implementation, which should be exhibited by all eventing systems:
- The callback needs to finish before control returns to the originating script. This is the basic principle of eventing that allows responsive code on the web.
Let me prove it to you, because this is so important:
var html = document.getElementsByTagName('html')[0]
html.addEventListener 'click', ->
console.log window.i
for i in [0..5]
window.i = i
html.dispatchEvent new MouseEvent 'click'
Or in jQuery:
$('html').on 'change', ->
console.log window.i
for i in [0..5]
window.i = i
$('html').trigger 'change'
The console should log 0 through 5 - the loops did not continue until the callbacks were completed.
- Events' callbacks are invoked in the order in which their listeners were attached - this might not be so obvious until you realize that events are not magic. Listeners (in JavaScript implementations) are just stored in arrays. This is even guaranteed in DOM events by the w3 Events specs.
Now that we're through with the preliminaries, let's look at some live code to get a better feel. Backbone does especially well as a JavaScript implementation of eventing:
-
Eventing is provided as a mix-in - any object can be extended to handle events. The Event API provides this for the DOM, but it isn't written in JavaScript, so we can't inspect it.
-
Events' callbacks are guaranteed to run in the order in which their listeners were attached - as discussed, this simply conforms to the DOM Event spec.
-
Event listeners added during a callback will not be respected until the next triggered event - this is, unfortunately, also provided for in the DOM Event specs, so it is to be expected.
Here is how backbone registers events. This will not compile: I've only put in bits of the code for clarity.
on: function(name, callback, context) {
Events are hashed by name for faster retrieval
this._events || (this._events = {});
var events = this._events[name] || (this._events[name] = []);
Events are pushed to the end of their event name's array. This ensures that callbacks are invoked in order of registration.
events.push({callback: callback, context: context, ctx: context || this});
A bit better than our example, right?
In backbone, event listeners are often assigned in an object's initialization. This presents problems with our order of registration.
Models are typically instantiated before their Views. If they both register listeners in their initialization, the model's callbacks will fire first. Herein lies a potential problem. Take this example:
- A model's collection fires an
add
event. - The model updates a tally based on the collection's new state, and triggers a
change
event so that it's view presents this. - The model's view updates to reflect the new tally.
- The collection's view updates to reflect the new element.
In a case like this, the collection's view should conceptually have updated first. However, the order of instantiation prevented this, as models must be initialized before their views. Unfortunately, a listener added during the event will not be triggered.
According to the W3 specification (which Backbone mimics):
...the implementation must determine the current target's candidate event listeners. This must be the list of all event listeners that have been registered on the current target in their order of registration. [HTML5] defines the ordering of listeners registered through event handler attributes. Once determined, the candidate event listeners must not be changed. Adding or removing listeners does not affect the current target's candidate event listeners.
Let me rephrase this: when an event is triggered on a node:
- The list of listeners is memoized
- That list is called, in order of registration.
- The event bubbles up to the next node
Step 2 precludes any listeners added in a callback from being invoked on the current event.
As with the Web API, one way to avoid this is to add a listener further up the event's propagation chain. How frustrating.
Ideally, we would want to register an event on the model after it's view is initialized. Why not invoke a registration method on the model from its views initialization?
class root extends BackBone.Model
register: ->
(@get 'subordinate').on 'add', (model), ->
class rootView extends BackBone.View
intitialize:
(@model.get 'subordinate').on 'add', (model), ->
@model.register()
Now we've guaranteed the order of callbacks: view, then model. Beautiful. I think. I hope you do too.
DOM events go through three phases: capture, target, and bubble. We typically register to the capture and target phases, and default events are registered to the bubble phase. Let's look at the differences using an example page,
<html><body><div><li></li></div></body></html>
with an event triggered on the <li>
:
- Capture
This phase occurs first. In this phase, The DOM is traversed from the defaultView (a.k.a. <html>
) to our node, with a capture phase of the event being triggered on every event on the way down. This allows us to implement a callback order different from order of registration (wicked awesome).
More technically: the event propagates from the defaultView to the event target, triggering a capture phase at each node visited.
In our example, the capture phase will first be initiated, in order, on <html>
, <body>
, <div>
, then <li>
.
- Target
This phase occurs second, and is most familiar. Normally registered listeners' callbacks are invoked from this phase. It occurs after the event is finished on our node.
More technically: the target phase occurse once the event has reached the event target.
In our example, the target phase will occur only on <li>
.
- Bubble
This phase occurs last, and JavaScript cannot register listeners to this phase. It occurs after the target phase, and traverses the DOM in the opposite order of capture.
More technically: the event propogates from the event target to the defaultView, triggering a bubble phase at each node visited.
In our example, the bubble phase will first be initiated, in order, on <li>
, <div>
, <body>
, then <html>
.
Backbone provides an implementation allowing model events to bubble up its collection, but wraps all the phases into one, so you can register listeners triggered by subordinate models. I think this is wonderful.
The Event API will, by default, register listeners to the target phase. Listeners can be registered to the capture phase instead by passing true
as the third argument when adding your listener:
node.addEventListener 'click', ->, true
Remember that listeners registered to the capture phase will have their callbacks invoked first if a subordinate node triggers the event. The target phase will only fire after all capture phases.
The Event API provides also allows us to skip the bubble phase, using e.stopPropagation()
(or e.bubbles = false
).
If only we could assign to these distinct phases in backbone, our instantiation problem would have presented no problem...
Some events also have default behaviors that will be invoked unless they are explicitly stopped (e.preventDefault
). Backspace, for example, will navigate one page back (or, in backbone, pop a state off the history). These typically occur in the bubble phase.
If you want more, I'd suggest looking at the Event API and the w3 specs.
This post can be viewed as a gist: eventsAreMagic.litcoffee