Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Persistence of JQuery Functions

I am trying to set up an on-click callback for an HTML that causes another node to become visible. Along the way, I was surprised to find out that the following two statements are not equivalent:

$("#title").click($("#content").toggle);
$("#title").click(function() {
    $("#content").toggle();
}

The first statement ultimately results in a TypeError when the element is finally clicked, with a message of "undefined is not a function," which I surmised to indicate that whatever I was assigned to the onclick callback ended up being undefined and somehow doesn't persist in memory.

The workaround is simple (just use the statement of the second form), but what I really want to understand is why passing the toggle function as an object doesn't work when it finally gets called. I can see that the two are semantically different: the first executes the $("#content") call when binding the event and the other executes it when the event occurs, but I don't understand why that should matter.

In case it is relevant to the answer, the code in question is located inside of a function (that has presumably returned by the time the user can click anything).

like image 866
HardlyKnowEm Avatar asked May 15 '14 23:05

HardlyKnowEm


3 Answers

In the first example, you're passing the toggle function for jQuery to execute as the event handler.

However, when jQuery executes the event handler, it sets the value of this to be the DOM element the event fired on.

toggle, however, expects it to be the jQuery object (which it would be if called normally), as it uses this.animate() in its implementation.

This is why you see "undefined is not a function", as this.animate is "undefined", and you're trying to call it as a function.


It's important to appreciate the resolution of this inside a function is deferred until the function is executed. This means a single function can see a different this value between invocations. The value of this can be altered using bind(), new, call(), apply() or by referencing the object differently; for more info see here, or How does the "this" keyword work?

like image 165
Matt Avatar answered Nov 18 '22 08:11

Matt


The jQuery function, as in this -> $(), is just a function, think of it as

var $ = function(selector, context) {
   // do stuff with selector etc
}

That's really simplified, but when you're calling the jQuery function (as in $()) with a valid selector, it gets the DOM node and returns something like this.

[
    0        : <div id="title"></div>, 
    context  : document, 
    selector : "#title", 
    jquery   : "1.11.0",
    .....
    etc
]

this is the array-like object jQuery returns, and as you can see 0 is the native DOM node, and it's the reason we can do $('#title')[0] to get the native DOM node.

There is however something that one really can't see from a simple console.log, and that's the methods that are prototyped onto that array-like object, we could however use a for..in loop to see them in the console.

var title = $('#title');

for (var key in title) 
    console.log(key)

FIDDLE

This would return a long list of all the prototyped and non-prototyped methods available on this object

get
each
map
first
last
eq
extend
find
filter
not
is
has
closest
....
etc

Notice that these are all the jQuery methods added to the $() function with $.prototype, but jQuery uses a shorter name, $.fn, but it does the same thing.

So all the jQuery functions we know are added to the main $() function as properties, and the new keyword is used internally to return a new instance of the $() function with those prototyped properties, and that's why we can use dot notation, or for that matter bracket notation and chain on methods to the $() function, like this

$().find()
// or
$()[find]()

When objects are extended with prototyped properties like this, the value of this is also set inside the methods, so now that we understand a little bit about how it works, we can recreate a really simple jQuery version

var $ = function(selector, context) {
    if (this instanceof $) {

        this.context = context || document;
        this[0]      = this.context.querySelector(selector);

        return this;

    }else{

        return new $(selector, context);

    }
}

This is simplified a lot from how jQuery works, but in principle it's the same, when $() is called, it checks if it's an instance of itself, otherwise it creates a new instance with the new keyword and calls itself again as a new instance.
When it is a new instance, it gets the element and the other properties it needs, and returns those.

If we were to prototype on a method to that instance, we could chain it like jQuery does, so lets try that

$.prototype.css = function(style, value) {
    this[0].style[style] = value;
}

and now we can do this

$('#title').css('color', 'red');

we've almost created jQuery, only 10000 lines of code to go.

FIDDLE

Notice how we have to use this[0] to get the element, we don't have to do that in jQuery when we use something like click, we can just use this, so how does that work ?

Lets simplify that as well, as it's crucial to understand why the code in the question doesn't work

$.prototype.click = function(callback) {
    var element = this[0]; // we still need [0] to get the element

    element.addEventListener('click', callback.bind(element), false);
    return this;
}

What we did there was use bind() to set the value of this inside the callback function so we don't have to use this[0], we can simply use this.

FIDDLE

Now that's cool, but now we can no longer use any of the other methods we've created and prototyped to the object, as this is no longer the object, it's the DOM node, so this fails

 $('#element').click(function() {
     this.css('color', 'red'); // error, <div id="element".. has no css()

     // however this would work, as we now have the DOM node
     this.style.color = 'red';
 });

The reason it fails is because we now have the native DOM node, and not the jQuery object.

So finally to answer the question asked.
The reason this works ...

$("#title").click(function() {
    $("#content").toggle();
});

... is because you're calling the toggle() function, and the correct value of this is set, in this case it would be the jQuery object containing #content as toggle() has no callback that uses bind(), it simply passes the jQuery object, an object similar to what we can see at the top of this answer

Internally toggle() does

$.prototype.toggle = function() {
    this.animate();
}

see how it uses this directly whithout doing anything other than calling another jQuery function, it requires that this is a jQuery object, not a native DOM element.

Lets repeat that, toggle() requires that this inside the function is a jQuery object, it can not be anything other than a jQuery object.

-

Now lets move on back to click again, when you do

$("#title").click(function() {
     console.log(this)
});

the console would show the native DOM element, something like <div id="title"></div>

Now we can reference a named function instead

$("#title").click(myClickHandler);

function myClickHandler() {
     console.log(this)
});

and the result would be exactly the same, we would get the native DOM element in the console -> <div id="title"></div>, which is not suprising as this is exactly the same as the one above using an anonymous function.

What you're doing is referencing the toggle() function like this

$("#title").click($("#content").toggle);

It's exactly the same as the example above, but now you're referencing toggle(), and when called it will be called with the value of this set to the native DOM element in the click function, it would go like this

    $("#title").click($("#content").toggle);

    $.prototype.toggle = function() {
        console.log(this); // would still be <div id="title"></div>

        this.animate(); // fails as <div id="title"></div> has no animate()
    }

This is what is happening, toggle() is expecting this to be a jQuery object, but instead it gets the native DOM node for the element in the click handler.

Read that again, this inside the toggle() function would be the native #title element, which isn't even the correct element, as that's how javascript and jQuery works, see the long explanation above for how this is set in the prototyped methods etc.

like image 36
adeneo Avatar answered Nov 18 '22 08:11

adeneo


When you use this in a JS function, it refers to whatever object the function is currently being called on, not where it was defined. For instance, you can define a function and copy it onto another object, like this:

foo = {'name': 'foo'}; bar = {'name': 'bar'};
foo.test= function() { console.log(this.name); }
bar.test= foo.test;
foo.test(); // logs 'foo'
bar.test(); // logs 'bar'

When you run foo.test(), this is set to point at foo; but when you run the same function as bar.test(), this is set to bar instead. There is nothing in the function that knows it was originally part of foo, so you basically have two separate but identical functions, like this:

foo.test = function() { console.log(this.name); }
bar.test = function() { console.log(this.name); }

When you run $("#title").click($("#content").toggle);, a similar thing happens - you get a reference to the toggle function, and copy that function into jQuery's list of event handlers. When the callback runs, the $("#content") part is forgotten, just like the foo was above, so when the implementation in jQuery looks at this to see what you want to toggle, it will find the wrong thing.

Exactly what it finds instead has an extra little quirk: jQuery sets this on click handlers to be the DOM element that was clicked on (there are various ways in JS of explicitly telling a function what it should use as this). The exact error comes about because the implementation of toggle is expecting a jQuery object, not a native DOM object, but even if a jQuery object was set as this, it would be the wrong node: you clicked on $('#title'), but want to toggle $('#content'), and jQuery has no way of knowing that.

For completeness, to explain why $("#title").click(function() { $("#content").toggle(); } does work: here the function being saved in jQuery is an anonymous function, which doesn't make any use of this, so doesn't care what it gets set to when the callback finally fires. When the event runs (when you click) it calls toggle with an explicit context (the object returned by the $('#content') lookup), which is exactly what it's expecting.

like image 1
IMSoP Avatar answered Nov 18 '22 07:11

IMSoP