Eric Allen
Interweb Alchemist
Cardinal Solutions / Front End Fridays
A brief continuation of our dive into how jQuery achieves some of its magic and some easy ways we can steal borrow that magic.
Example Repository:
https://github.com/ericrallen/vanilla.git
Clone the repository and checkout the workshop/1 branch
git clone https://github.com/ericrallen/vanilla.git
git checkout workshop/1
Disclaimer: We are going to gloss over and simplify some stuff here.
A lot of jQuery's plugins utilize objects to store options. Most plugins also allow developers to override some or all of these options via providing their own configuration object.
This configuration extension is achieved via using jQuery's $.extend() method, which will take any number of objects and merge their properties into the first object provided.
By default, $.extend() performs a shallow merge, but you can optionally pass a boolean value as the first parameter to tell jQuery you'd like to perform a deep merge.
The first object passed to $.extend() will be mutated, so you'll frequently see people pass an empty object literal ({}) as the first object so that no mutation occurs.
Ugh, still boring...
//extend an object with N other objects where N > 0
$v.extend = function() {
//initialize empty array to store references to objects
//currently in the `arguments` Array-like Object
var objs = [];
//iterate through our arguments Array-like object
for(var a = 0; a < arguments.length; a++) {
//add each argument to our `objs` array
objs[a] = arguments[a];
}
//take the first object from the arguments
//store it as our container
//remove it from the array using `Array.prototype.shift()`
var container = objs.shift();
//iterate through the rest of our argument objects
objs.forEach(function(item) {
//iterate through properties in item object
for(var prop in item) {
//make sure this is an enumerable property
if(item.hasOwnProperty(prop)) {
//set container object's `prop`
//to the value of extension object's `prop`
container[prop] = item[prop];
}
}
}, this);
//return the merged objects as a single object
return container;
};
In jQuery, jQuery.extend() allows you extend any object with any other objects.
jQuery.fn.extend(), on the other hand, is used to actually extend the jQuery.fn prototype. This allows you to add your own methods to the prototype or overwrite other methods.
There are some other interesting things going on here, too, like the Array.prototype.shift() method.
Array.prototype.shift() removes the first item from an array and returns it.
This is handy for situations like this where you want to do something different with the first item of the array but also want to loop over the other items.
We're also using Object.prototype.hasOwnProperty() to see if property is enumerable or not while iterating over an object using a for...in loop.
You may have noticed that this is a shallow extension.
We'll need to do some more work to make it deep.
First, We need to let the developer specify they would like this to be a deep merge.
//by default we'll use a shallow merge
var deep = false;
//logic to convert arguments to objs array goes here
//we've already got this in our method
if(typeof objs[0] === 'boolean') {
deep = objs.shift();
}
//logic iterating over our objs array goes here
We use Array.prototype.shift() again to remove the boolean value from our objs Array because we won't need it in the Array anymore.
Second, we'll need to adjust our merging logic if deep is true.
If we need to perform a deep merge, we need to know if the property we are merging exists and will need to use recursion when merging properties that are also objects to make sure that they are also merged deeply.
//iterate through the rest of our argument objects
objs.forEach(function(item) {
//iterate through properties in item object
for(var prop in item) {
//make sure this is an enumerable property
if(item.hasOwnProperty(prop)) {
//if this isn't a deep merge
if(!deep) {
//set container object's `prop`
container[prop] = item[prop];
//we need to go deep
} else {
//DEEP MERGE LOGIC HERE
}
}
}
}, this);
//check for enumerable property container object
if(container.hasOwnProperty(prop)) {
//if this property is an object
if(typeof item[prop] === 'object') {
//recursive deep merge
container[prop] = $.extend(
deep,
(container[prop]) ? container[prop] : {},
item[prop]
);
//otherwise, set the container object's property
} else {
container[prop] = item[prop];
}
//if property didn't exist on container
} else {
//define new property on container with property's value
Object.defineProperty(container, prop, {
value: item[prop],
writable: true,
enumerable: true,
configurable: true
});
}
BONUS: How could we utilize our $v.extend() method to also allow us to have $v.fn.extend() work like jQuery.fn.extend() and extend our prototype?
If you've never used jQuery.find(), you should definitely be using it.
jQuery.find() takes the previous jQuery collection and uses it as the context for finding the elements that match the selector passed to the .find() method.
You may have seen a selector written like this $('#id a') well, this selector ends up being remapped to $('#id').find('a'), so using .find() can actually save a small amount of execution time. In older browsers, the amount of time saved can be quite large.
Well, we'll need to take the $v.elements Array and create a new array of the result of .querySelectorAll() on each of those elements passing in the value that was sent to .find()
//replace the current this.elements
//with a new collection created by running
//`querySelectorAll()` from each item in collection
// and looking for the specified selector
$v.fn.find = function(str) {
//store current collection
var elements = this.elements;
//reset this.elements to empty Array
this.elements = [];
//iterate through current collection
elements.forEach(function(item) {
//add the result of `.querySelectorAll()` for the selector
//to the this.elements Array
this.elements = this.elements.concat(
[].slice.call(item.querySelectorAll(str))
);
}, this);
//return reference to $v object for method chaining
return this;
};
Array.prototype.concat() is a method that appends one Array to another.
In this case we use it to take the $v.elements Array and add the results of each call of .querySelectorAll().
BONUS: Try to take our $v() constructor method and allow it to receive space-separated selector lists and have it use $v.fn.find() to generate the correct collection of elements.
jQuery provides several methods for dealing with HTML Attributes, including: attr(), data(), and prop()
Each method does certain things differently, we won't be digging into or recreating the functionality for every case, though.
//set or retrieve a data-* attribute value
$.fn.data = function(attribute, value) {
//make sure an attribute name was passed
if(typeof attr !== 'undefined') {
//by default we'll want to prepend "data-"
var prepend = true;
//if the attribute name already starts with "data-"
if(attribute && attribute.indexOf('data-') === 0) {
prepend = false;
}
//call the .attr() method and make
//sure we prepend "data-" to attribute if needed
return this.attr(
(prepend) ? 'data-' + attribute : attribute,
value
);
//no attribute provided, skip it
} else {
//return reference to $v for method chaining
return this;
}
};
//method for getting/setting an attribute's value
$v.fn.attr = function(attribute, value) {
//if value was provided
if(typeof value !== 'undefined') {
//iterate through collection of elements
this.elements.forEach(function(item, index, array) {
//if the value being passed is null, we want to remove the attribute
if(value === null) {
array[index].removeAttribute(attribute);
//if the value isn't null, we want to set the attribute
} else {
array[index].setAttribute(attribute, value);
}
}, this);
//return reference to `$v` for method chaining
return this;
//if value wasn't provided return value
//getter version of this method not eligible for method chaining
} else {
//if attribute name was provided
if(typeof attribute !== 'undefined') {
//return value of `attribute` for first item in collection
return this.elements[0].getAttribute(attribute);
}
}
};
//method for getting/setting a boolean attribute's value
$v.fn.prop = function(attribute, value) {
//if value was provided
if(typeof value !== 'undefined') {
//make sure we're dealing with a Boolean
value = !!value;
//iterate through collection of elements
this.elements.forEach(function(item, index, array) {
//if the value being passed is null, remove the attribute
if(value === null) {
array[index].removeAttribute(attribute);
//if the value isn't null, we want to set the attribute
} else {
array[index].attribute = value;
}
}, this);
//return reference to `$v` for method chaining
return this;
//if value wasn't provided return the actual value
//getter version of this method not eligible for method chaining
} else {
//if attribute name was provided
if(typeof attribute !== 'undefined') {
//return boolean value of `attribute` for first item in collection
return !!this.elements[0][attribute];
}
}
};
BONUS: What if we wanted a convenience method like $v.fn.data() for other namespaced attributes like aria-* attributes ($v.fn.aria())?
By Eric Allen
Basic introduction to how a bit of how jQuery works under the hood and how we can use those concepts to create our own, jQuery-like library to help people who are less comfortable with JavaScript but comfortable with jQuery start to feel less intimidated by vanilla JavaScript. This builds upon the concepts discussed in Workshop[0] and deals with how jQuery deals with attributes, properties, extending Objects, and querying the DOM within specific contexts using .find().