Reputation: 11542
I'm having some trouble with blur and click events in backbone. I have a view (code below) that creates a little search entry div with a button. I pop open this div and put focus on the entry field. If someone clicks off (blur) I notify a parent view to close this one. If they click on the button I'll initiate a search.
The blur behavior works fine, however when I click on the button I also get a blur event and can't get the click event. Have I got this structured right?
BTW, some other posts have suggested things like adding timers to the div in case its being closed before the click event fires. I can comment out the close completely and still only get the blur event. Do these only fire one at a time on some kind of first-com-first-served basis?
PB_SearchEntryView = Backbone.View.extend({
template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
events: {
"click button": "find",
"blur #part": "close"
},
initialize: function(args) {
this.dad = args.dad;
},
render: function(){
$(this.el).html(this.template());
return this;
},
close: function(event){ this.dad.close(); },
find: function() {
alert("Find!");
}
});
Upvotes: 2
Views: 12967
Reputation: 4366
Despite the question being 11 years old, it is still possible to run into this situation with current versions of Backbone, Underscore and jQuery, and the solution is also still the same.
Let us start with a reproduction of the problem in a runnable snippet. Below, I copied the code from the question and added some mock code. To reproduce, please click "Run code snippet", then manually focus the text input by clicking inside it, and then click the button. You will see that the input field and the button disappear again and the alert('Find!')
line does not run:
var PB_SearchEntryView = Backbone.View.extend({
template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
events: {
"click button": "find",
"blur #part": "close"
},
initialize: function(args) {
this.dad = args.dad;
},
render: function(){
$(this.el).html(this.template());
return this;
},
close: function(event){
this.dad.close();
},
find: function() {
alert("Find!");
}
});
var MockDadView = Backbone.View.extend({
initialize: function() {
this.search = new PB_SearchEntryView({
dad: this
}).render();
},
render: function() {
this.$el.append(this.search.el);
return this;
},
close: function() {
this.search.remove();
}
});
var view = new MockDadView().render();
view.$el.appendTo(document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/underscore-umd-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/backbone-min.js"></script>
The reason is as follows. An action by the user will often trigger many different types of events. In this case, clicking the button may be expected to trigger all of the following events:
blur
on the <input>
element.change
on the <input>
element (only if the user was typing into it just before clicking the button; in other words, if the blur
was preceded by an input
event).mousedown
on the <button>
element.mouseup
on the <button>
element.click
on the <button>
element.submit
on a parent <form>
element, if there is one (because we did not set the the type
attribute of the <button>
and it defaults to submit
).The above order is standardized, because this order is most useful for implementing user-friendly interactive forms. In particular, blur
and change
on the <input>
trigger before any events on the <button>
, because those events are potentially good opportunities to validate the user's input prior to taking further form processing steps. Likewise, the <button>
events trigger before the submit
of the entire <form>
, because you might want to do final whole-form validation or other preprocessing before finally actually submitting the form.
Handlers for these events are immediately invoked by the browser, and the call stack is allowed to unwind completely before the next user event is triggered. This is JavaScript's famous event loop at play. Hence, the following things happen when we click the button in the problematic snippet above:
blur
event fires.close
method of our PB_SearchEntryView
instance is invoked.close
method of our MockDadView
is invoked.remove
method of our PB_SearchEntryView
instance is invoked.PB_SearchEntryView
instance is removed from the DOM
and its event bindings are deleted (this is the default behavior of the remove
prototype method of Backbone.View
).click
event on the <button>
is never triggered, because the element has been removed from the DOM in step 5.click
event was triggered in step 7, the find
method of our PB_SearchEntryView
instance would still not be invoked, because the event binding was undone in step 5.)Hence, if we still want the find
method to be invoked after the blur
has been handled, we need to prevent the remove
method from being invoked in the meanwhile. A straightforward and valid way to do this, is to delay the call to this.dad.close
. A suitable delay is about 50 milliseconds; this is long enough to ensure that the click
event of the <button>
is triggered first and short enough that the user does not notice it. We can even cancel the delayed call in order to keep the search view open when the button is clicked. In the snippet below, I changed the close
and find
methods to illustrate how this can be done and added comments to explain the mechanics:
var PB_SearchEntryView = Backbone.View.extend({
template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
events: {
"click button": "find",
"blur #part": "close"
},
initialize: function(args) {
this.dad = args.dad;
},
render: function(){
$(this.el).html(this.template());
return this;
},
close: function(event){
// Short way to refer to this.dad.
var dad = this.dad;
// The method of this.dad we want to be
// invoked later. When saving it to a
// variable, we need to bind it so that
// the `this` variable points to this.dad
// when it is invoked.
var askDadToClose = dad.close.bind(dad);
// Invoke dad's close method with a 50 ms
// delay. Save a handle that enables us
// to cancel the call in the find method.
this.willClose = setTimeout(askDadToClose, 50);
},
find: function() {
alert("Find!");
// If you still want the search view to
// close after performing the search,
// remove the `if` block below.
if (this.willClose != null) {
clearTimeout(this.willClose);
delete this.willClose;
}
}
});
var MockDadView = Backbone.View.extend({
initialize: function() {
this.search = new PB_SearchEntryView({
dad: this
}).render();
},
render: function() {
this.$el.append(this.search.el);
return this;
},
close: function() {
this.search.remove();
}
});
var view = new MockDadView().render();
view.$el.appendTo(document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/underscore-umd-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/backbone-min.js"></script>
The question also asked whether the code was structured right. Below is a third version of the snippet, in which I made three changes that I think would be improvements:
PB_SearchEntryView
) no longer knows about its parent view. Instead of invoking a method on the parent, it simply triggers an event. It is up to other components of the application to listen for that event and handle it. This reduces coupling of components and generally makes applications easier to test and maintain. (For loose coupling of long-distance communication, I recommend using backbone.radio.)click
event on a <button>
, listen for a submit
event on a <form>
. This is more semantic and appropriate when submitting a form is exactly what we are doing. This also enables the user to trigger the same event handler by pressing the return key while typing into the <input>
element. This does require that the <button type=submit>
is inside a <form>
element, which is why we set the tagName
of the view to form
.var PB_SearchEntryView = Backbone.View.extend({
// This is a form, so it can be submitted.
tagName: 'form',
template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
events: {
// Handle submit rather than a button click.
"submit": "find",
"blur #part": "close"
},
initialize: function(args) {
// self-render on initialize
this.render();
},
render: function(){
this.$el.html(this.template());
return this;
},
close: function(event){
// Like before, we save a method for later,
// but this time it is our own trigger
// method and we bind two additional
// arguments. This view does not need to
// know what happens with its 'close' event.
var triggerClose = this.trigger.bind(this, 'close', this);
// Delay as before.
this.willClose = setTimeout(triggerClose, 50);
},
find: function(event) {
// Since this is now a proper submit
// handler, we need to prevent the default
// behavior of reloading the page.
event.preventDefault();
alert("Find!");
if (this.willClose != null) {
clearTimeout(this.willClose);
delete this.willClose;
}
}
});
var MockDadView = Backbone.View.extend({
initialize: function() {
// This view owns an instance of the above
// view and knows what to do with its
// 'close' event.
this.search = new PB_SearchEntryView();
this.search.on('close', this.close, this);
// self-render on initialize
this.render();
},
render: function() {
this.$el.append(this.search.el);
return this;
},
close: function() {
this.search.remove();
}
});
var view = new MockDadView();
view.$el.appendTo(document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/underscore-umd-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/backbone-min.js"></script>
There is a slight catch with using the above snippet, at least in Safari: if I submit the form by pretting the return key, the find
method is still invoked, but the form is also removed. I suspect Safari is triggering a new blur
event after closing the alert in this case. Solving that would be enough material for another question!
Upvotes: 0
Reputation: 4350
I am not sure what the problem was, but here is the jsbin code.
Upvotes: 0