Creating jqLite Plugins In AngularJS
Yesterday, I looked at how jQuery is integrated with AngularJS and, what that means for developers who want to leverage jQuery within their AngularJS directives. This got me thinking about plugins. Many of us are used to writing jQuery plugins; but, what if we don't want to include jQuery? Can we write JQLite plugins? It doesn't mention anything in the AngularJS documentation; but, it turns out, writing a JQLite plugin is very similar to writing a jQuery plugin.
Run this demo in my JavaScript Demos project on GitHub.
When using jQuery, we [typically] write plugins by adding functions to the jQuery prototype, which is more widely known as the "fn" object. If we're not using jQuery - if we're only using jqLite - the principle is the same. If we want to write jqLite plugins, we have to add them to the jqLite prototype.
AngularJS doesn't provide a short-hand notation, like "fn", for the jqLite prototype. As such, we have to explicitly reference the jqLite prototype as exposed through angular.element:
angular.element.prototype
Once we have this, we can write plugins in the same way that we used to, for jQuery. Within each prototype method, "this" refers to the current jqLite collection. If you return a collection from a plugin, you have to wrap that collection in a new jqLite object so we don't break method chaining.
To experiment with this idea, I decided to refactor my demo from yesterday using nothing by jqLite. Since yesterday's demo used jQuery plugins like .is(), .filter(), and .appendTo(), it means that I would have to recreate this plugins as jqLite plugins.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Creating jqLite Plugins In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
Creating jqLite Plugins In AngularJS
</h1>
<p>
<em>Start clicking, bro.</em>
</p>
<ul bn-demo>
<!-- Dynamically populated. -->
</ul>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.6.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I add an element to the point of a click and then randomly select one of the
// existing elements in the list.
app.directive(
"bnDemo",
function() {
// Return the directive configuration.
return({
link: link,
restrict: "A"
});
// ---
// PUBLIC METHODS.
// ---
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes ) {
element.on(
"click",
function handleClickEvent( event ) {
// If the user clicked on an existing LI, then don't change
// the contents of the container, just select the target.
if ( angular.element( event.target ).is( "li" ) ) {
// Select the target element.
element.children()
.removeClass( "selected" )
.filter( event.target )
.addClass( "selected" )
;
return;
}
// Create a new element at the click position.
angular.element( "<li></li>" )
.xyo( event.pageX, event.pageY, -25 )
.appendTo( element )
;
// Select a random element in the list.
element.children()
.removeClass( "selected" )
.random()
.addClass( "selected" )
;
}
);
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// Define jqLite plugins. Without jQuery, we can still define custom AngularJS
// JQLite plugins using the same approach - defining methods on the "prototype"
// of JQLite. Remember, in jQuery, the ".fn" property is just a convenient
// reference to the jQuery.prototype object.
app.run(
function createJQLitePlugins() {
// Get a short-hand reference to the element method.
var JQLite = angular.element;
// I safely compare the new nodes by wrapping them in JQLite containers
// first. This way, they may or may not be raw DOM node references.
function safeEquals( a, b ) {
return( JQLite( a )[ 0 ] === JQLite( b )[ 0 ] );
}
// I append the current collection to the target element.
JQLite.prototype.appendTo = function( target ) {
JQLite( target ).append( this );
return( this );
};
// I filter the current collection using the given operator. Currently
// supports DOM reference of function (which must return true).
JQLite.prototype.filter = function( operator ) {
// If the operator is not a function, normalize it so that it is
// a function that returns true if the current element matches the
// given target.
if ( ! angular.isFunction( operator ) ) {
var target = JQLite( operator );
operator = function compareNode( node ) {
return( safeEquals( node, target ) );
};
}
var subset = [];
angular.forEach(
this,
function checkNodeMatch( value ) {
if ( operator( value ) === true ) {
subset.push( value );
}
}
);
// Make sure to wrap the DOM list in a JQLite object.
return( JQLite( subset ) );
};
// I check to see if the first element in the collection matches the given
// selector. Currently supports DOM element, node name, class syntax.
// --
// ex: .is( targetElement )
// ex: .is( "ul" )
// ex: .is( ".some-class" )
JQLite.prototype.is = function( selector ) {
// If no elements, can't possibly match.
if ( ! this.length ) {
return( false );
}
// If the value is not a string, assume DOM node.
if ( ! angular.isString( selector ) ) {
return( safeEquals( this, selector ) );
// If starts with "." assume class notation.
} else if ( selector.charAt( 0 ) === "." ) {
return( this.hasClass( selector.slice( 1 ) ) );
// Else, assume node name.
} else {
return( this[ 0 ].nodeName === selector.toUpperCase() );
}
};
// I select a random element in the current collection.
JQLite.prototype.random = function() {
return( this.eq( Math.floor( this.length * Math.random() ) ) );
};
// I position the elements using the given X and Y coordinates. If
// provided, the optional offset is applied to the X and Y coordinates
// of each element.
JQLite.prototype.xyo = function( x, y, offset ) {
this.css({
left: ( ( x + ( offset || 0 ) ) + "px" ),
top: ( ( y + ( offset || 0 ) ) + "px" )
});
return( this );
};
}
);
</script>
</body>
</html>
As you can see, I am defining my jqLite plugins in a .run() block. This way, they will be defined once the AngularJS application has been bootstrapped, which will make them available to all of my directives.
In the AngularJS world, it has become trendy to stop using jQuery; but, that doesn't mean we have to abandon the features that make jQuery so powerful. If there's something missing from jqLite, such as an .appendTo() method, you can write a jqLite plugin for it.
Want to use code from this post? Check out the license.
Reader Comments
Wow this was very useful post, made me re-think my approach to jqLight.
Thanks
@Mike,
Cool, my man. Glad you found this interesting.
Nice post. Thanks.
I thought adding method using <CommonClass>.prototype was evil ...
I would use Object.defineProperty() instead, no ?
Something like that :
Object.defineProperty(jqLite.prototype, 'myMethod', function(){
return {
value: function(){},
enumerable: false,
...
};
})
I mean "using <CommonClass>.prototype.myMethod = function(){} was evil..."
man this is quite helpful post , never thought of defining JQuery plugins again in JQlite , but now I reckon I'll consider this more often
@M'sieur,
I believe that the only benefit of using Object.defineProperty() would be if you want to explicitly control how the method is seen. But, to be honest, I don't have much experience with Object.defineProperty() as I've never really understood the need for it. Meaning, it doesn't solve a problem that I am *actually having* ... at least, not that I can see.
But, I'm open to understanding it better.
@Yazan,
Thanks my man! I know people hate on jQuery, but I think there's a lot we can learn from it.
@Ben,
Hi Ben, you are right. In this simple case, it does not change anything. In the past, I have been in trouble with manually updating the prototypes. By this time, I have red it was best pratice to use defineProperty() over manually updating the prototype ...
As far as I can tell, defineProperty() has two benefits :
1. It avoids this kind of behavior :
var obj = {};
obj.foo = 'foo';
Object.prototype.bar = function(){
console.log('bar')
};
for(var i in obj){
console.log(i + ': ' + obj[i])
}
... that will give you :
foo: foo
bar: function (){console.log('bar')}
... which is probably not what you expected. ;)
2. It lets you make dynamic setter/getter :
function myClass() {
var _foo;
Object.defineProperty(this, 'foo', {
set: function(v){_foo = 2 * v;},
get: function(){return 'my value is ' + _foo;}
});
}
var obj = new myClass();
obj.foo = 3;
console.log(obj.foo);
... that will give you :
"my value is 6";
This post helped me a lot at the time : https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty