Finding Elements page

Learn how to create a basic jQuery constructor function that can find elements in the page and manipulate them.

Overview

We will learn to:

  • Find elements in the document
  • Create the $ function
  • Create text/html/val functions
  • Find elements from an element and create collection.find
  • Create a find function

Video

Slides

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>
<script type="module">
(function() {
  $ = function(selector) {

  };

  $.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);
      };
    }
  });

  $.extend($.prototype, {
      html: function(newHtml) { },
      val: function(newVal) { },
      text: function(newText) { },
      find: function(selector) { }
  });

})();
</script>

Each exercise builds on the previous exercise. There is a completed solution at the end of this page.

Exercise: new $(selector) -> collection

The problem

The $ function creates instances of a jQuery collection of elements. We will change $() to be able to work without new later. But for now, $ will be treated as a constructor function. It should return an instance of $ with:

  • A length property equaling the number of elements matched by the selector.
  • Enumerated properties (ex: "0", "1", "2", etc) whose value is a matched element.

Click to see test code

QUnit.test('new $(selector)', function(){
    document.getElementById('qunit-fixture').innerHTML = `
        <ul id="contacts">
            <li class="contact"/>
            <li class="contact"/>
        </ul>`;

    var $contacts = new $('#contacts li.contact');
    equal( $contacts.length,  2, 'There are 2 contacts');

    equal( $contacts[0].nodeName.toLowerCase(), 'li', 'got an li');
    equal( $contacts[1].className, 'contact', 'got a .contact');

    ok($contacts instanceof $, '$ is an instance of my_jquery');
});

What you need to know

  • document.querySelectorAll returns a NodeList of elements matching the selector.
    <ul class='dogs'>
      <li>fido</li>
      <li>rover</li>
    </ul>
    <ul class='cats'>
      <li>sparkles</li>
    </ul>
    <script type="module">
    var elements = document.querySelectorAll(".dogs li");
    console.log(elements) // logs [<li>, <li>]
    </script>
    
  • The [] member operator can create enumerated properties:
    var obj = {};
    obj["0"] = "x";
    obj["1"] = "y";
    
  • Array.prototype.push implementation looks like:
    Array.prototype.push = function(){
      var length = this.length || 0;
      for(var i = 0; i < arguments.length; i++) {
          this[length + i] = arguments[i]
      }
      this.length = length + arguments.length;
    }
    

The solution

Click to see the solution

  $ = function(selector) {
    var elements = document.querySelectorAll(selector);
    [].push.apply(this, elements);
  };

Exercise: collection.html( [newHtml] )

The problem

collection.html either:

  • gets the HTML contents of the first element in the set of matched elements.

    <ul class='dogs'>
      <li><b>f</b>ido</li>
      <li>rover</li>
    </ul>
    <ul class='cats'>
      <li>sparkles</li>
    </ul>
    <script type="module">
    import "https://unpkg.com/jquery@3/dist/jquery.js";
    var dogs = $('.dogs li');
    console.log( dogs.html() ) //logs "<b>f</b>ido"
    </script>
    
  • sets the HTML contents of each element in the set of matched elements and returns the collection.

    <ul class='dogs'>
      <li>fido</li>
      <li>rover</li>
    </ul>
    <ul class='cats'>
      <li>sparkles</li>
    </ul>
    <script type="module">
    import "https://unpkg.com/jquery@3/dist/jquery.js";
    var dogs = $('.dogs li');
    dogs.html('<b>rover</b>') //-> dogs
    </script>
    

Many of jQuery's methods either get or set depending on the number of arguments.

Click to see test code

QUnit.test('$.fn.html', function(){
    new $('#qunit-fixture').html(`
        <ul id="contacts">
            <li class="contact"></li>
            <li class="contact"></li>
        </ul>`);
    new $('.contact').html('Hi There');

    equal(new $('.contact').html(), 'Hi There',
        'First contact html set correctly');
    equal(new $('.contact:nth-child(2)').html(), 'Hi There',
        'Second contact html set correctly');
});

What you need to know

The solution

Click to see the solution

      html: function(newHtml) {
        if(arguments.length) {
          return $.each(this, function(i, element) {
            element.innerHTML = newHtml;
          });
        } else {
          return this[0].innerHTML;
        }
      },

Exercise: collection.val( [newValue] )

The problem

collection.val gets the current value of the first element in the set of matched elements or sets the value of every matched element.

<input type="text" class="first" value="first"/>
<input type="text" class="second" value="second"/>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";

console.log( $(".first").val() ) //log "first"
$(".second").val( "SECOND" );
</script>

Click to see test code

QUnit.test('$.fn.val', function(){

    new $('#qunit-fixture').html(`
        <input type="text" class="age"/>
        <input type="text" class="age"/>`);


    equal( new $('.age').val(), '',
        'Input is initially empty');

    new $('.age').val('Hi There');

    equal( new $('.age').val(), 'Hi There',
        'First .age value set correctly');

    equal( new $('.age:nth-child(2)').val(), 'Hi There',
        'Second .age value set correctly');
});

What you need to know

  • The .value property can be used to read and write an input element's value.

The solution

Click to see the solution

      val: function(newVal) {
        if(arguments.length) {
          return $.each(this, function(i, element) {
            element.value = newVal;
          });
        } else {
          return this[0].value;
        }
      },

Exercise: $(selector)

The problem

Lets remove the need to use new when using $.

Click to see test code

QUnit.test('$(selector)', function(){
    document.getElementById('qunit-fixture').innerHTML = `
        <ul id="contacts">
            <li class="contact"/>
            <li class="contact"/>
        </ul>`;

    var $contacts = $('#contacts li.contact');
    equal( $contacts.length,  2, 'There are 2 contacts');

    equal( $contacts[0].nodeName.toLowerCase(), 'li', 'got an li');
    equal( $contacts[1].className, 'contact', 'got a .contact');

    ok($contacts instanceof $, 'instanceof $ without new');
});

What you need to know

  • Use instanceof to tell if something is an instance of something else:

    const Animal = function(name){
      this.name = name;
    }
    const sponge = new Animal("bob")
    console.log( sponge instanceof Animal ) //-> true
    

The solution

Click to see the solution

  $ = function(selector) {
    if ( !(this instanceof $) ) {
      return new $(selector);
    }
    var elements = document.querySelectorAll(selector);
    [].push.apply(this, elements);
  };

Exercise: collection.text( [newText] )

The problem

collection.text get the combined text contents of each element in the set of matched elements, including their descendants, or sets the text contents of the matched elements.

<ul class='dogs'>
    <li>fido</li>
    <li>rover</li>
</ul>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";
var $ul = $(".dogs");
console.log($ul.text()) //logs "\n\tfido\n\trover\n"

var dogs = $('.dogs li');
dogs.text('<b>rover</b>') //-> dogs
</script>

Click to see test code

QUnit.test('$.fn.text', function(){

    $('#qunit-fixture').html('Hi <span>there</span>.');

    equal( $('#qunit-fixture').text(), 'Hi there.',
        'The text is right');

    $('#qunit-fixture span').text('<input/>');

    equal( $('#qunit-fixture input').length, 0,
        'there\'s no input');

    equal( $('#qunit-fixture span').text(), '<input/>',
        'The text is what we sent' );
});

What you need to know

  • textContent can get and set the text content of an element.

For an extra challenge, don't use textContent and recursively collect the textContent. For that, you'll need to know:

  • Use document.createTextNode to create a text node.

  • Text nodes have a nodeType of 3. You can use Node.TEXT_NODE to get this value.

  • Read nodeValue to get the value of a Text node.

  • Use appendChid to add a text node to another element.

    var textNode = document.createTextNode(
      "My favorite element is <script>");
    
    console.log(textNode.nodeType === Node.TEXT_NODE)
    // logs true
    
    console.log(textNode.nodeValue)
    // logs "My favorite element is <script>"
    
    document.body.appendChild(textNode);
    

The solution

Click to see the solution

      text: function(newText) {
        if (arguments.length) {
          return $.each(this, function(i, element) {
            element.textContent = newText;
          });
        } else {
          return this[0].textContent;
        }
      },

Click to see the extra challenge solution

  var getText = function(childNodes) {
    var text = "";
    $.each(childNodes, function(i, child) {
      if (child.nodeType === 3) {
        text += child.nodeValue;
      } else {
        text += getText(child.childNodes);
      }
    });
    return text;
  };
      text: function(newText) {
        if (arguments.length) {
          this.html("");
          return $.each(this, function(i, element) {
            const text = document.createTextNode(newText);
            element.appendChild( text );
          });
        } else {
          return getText(this[0]);
        }
      },

Exercise: collection.find( selector )

The problem

collection.find gets the descendants of each element in the current set of matched elements filtered by a selector.

<ul class="level-1">
  <li class="item-i">I</li>
  <li class="item-ii">II
    <ul class="level-2">
      <li class="item-a">A</li>
      <li class="item-b">B
        <ul class="level-3">
          <li class="item-1">1</li>
          <li class="item-2">2</li>
          <li class="item-3">3</li>
        </ul>
      </li>
      <li class="item-c">C</li>
    </ul>
  </li>
  <li class="item-iii">III</li>
</ul>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";

$( "li.item-ii" )
    .find( "li" )
    .css( "border", "solid 1px red" );
</script>

Click to see test code

QUnit.test('$.fn.find', function(){
    var $ul = $('#qunit-fixture')
        .html('<ul><li/><li/></ul><ul><li/><li/></ul>')
        .find('ul');

    equal($ul.length, 2, 'got 2 uls');
    equal($ul.find('li').length, 4, 'got four lis');
});

What you need to know

  • parentNode.querySelectorAll can be used to find all elements that match a selector who are descendants of the parentNode element.

  • The spread syntax can be used to push multiple items to an array.

    const letters = ["a","b"];
    const lettersToAdd = ["x","y"];
    letters.push(...lettersToAdd);
    
    console.log(letters)
    // logs ["a","b","c","d"]
    

    This can be done with Array.prototype.push.apply in older browsers.

  • You need to make $() accept an array of nodes similar to how jQuery does:

    <div id="first">First</div>
    <span id="second">Second</div>
    <script type="module">
    import "https://unpkg.com/jquery@3/dist/jquery.js";
    
    $([first, second])
      .css( "border", "solid 1px red" );
    </script>
    

The solution

Click to see the solution

  $ = 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);
  };
      find: function(selector) {
        var elements = [];
        $.each(this, function(i, element){
            var result = element.querySelectorAll(selector);
            elements.push(...result);
            // Or elements.push.apply(elements, result);
        })
        return new $(elements);
      }

Bonus Exercise: Eliminate duplicate code in .html, .val, and .text

The problem

Use meta programming techniques to reduce the duplicate code in the .html, .val, and .text functions.

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

What you need to know

  • You can call a function that returns a function.
    var makeLogger = function(text) {
      return function(){
        console.log(text);
      }
    }
    var logMe = makeLogger("me");
    logMe() //logs "me"
    

The solution

Click to see the solution

function makeSimpleGetterSetter(prop) {
    return function(value){
        if(arguments.length) {
            return $.each(this, function(i, element) {
                element[prop] = value;
            });
        } else {
            return this[0][prop];
        }
    }
}
      html: makeSimpleGetterSetter("innerHTML"),
      val: makeSimpleGetterSetter("value"),
      text: makeSimpleGetterSetter("textContent"),

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

  $.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: function(selector) {
        var elements = [];
        $.each(this, function(i, element){
            var result = element.querySelectorAll(selector);
            elements.push(...result);
            // Or elements.push.apply(elements, result);
        })
        return new $(elements);
      }
  });

})();
</script>