This Time I Make Something Silly, Like a JS Framework as a jQuery Plugin
My most recent 2 web apps were made using React. I enjoyed React's benefits over jQuery for managing large amounts of data and updating the DOM accordingly. So naturally, I wanted to cheat a little the next time I was working on a project that only had jQuery.
There were 3 parts of a small html element that I needed to update whenever new data became available. jQuery is good at targeting the necessary elements and updating them, but in React all you need to do is this.setState() and the UI updates automatically.
So, in my laziness to write code, I wrote more code to make it easier to write code. Take the following as an example:
<div class="results-list-section">
<div class="results-list-header">
<span>7</span> results near "5432 Fake St"
</div>
<ul class="list">
<li>
<div class="name" data-item-id="1">First item</div>
<div class="more-stuff">More stuff</div>
</li>
<li>
<div class="name" data-item-id="2">Second item</div>
<div class="more-stuff">More stuff for the second item</div>
</li>
</ul>
</div>
The parts that update are the "7 results near 5432 Fake St" and the list. The list items are storing the item id in a data object, so there might be an event listener somewhere in there to handle clicks on each item's name. Let's say that in this case, clicking on the name opens up a modal for that item with more of the item's information.
So I made something silly to make this whole situation easier.
1. What would I like to be able to do?
Let's take updating the results-list-header div. We have a new set of results, with 4 this time. Also, we searched for 1234 Five St. So somewhere in our data we have this:
let count = results.count; // 4
let searchTerm = results.originalSearch; // '1234 Five St'
Normally, you do something like this:
$('.results-list-header').html(`<span>${count}</span> results near "${searchTerm}"`);
Template literals really help to make things easier to work with here. But the ease is only one-way. If we want the data used to render the html, we normally need to store it in the html. We did this with the list items and the data-item-id attributes. If we needed the count for some reason, or the search term, we could do something more like this:
$('.results-list-header').html(`<span>${count}</span> results near "${searchTerm}"`).data("data", {count, searchTerm});
It's all great, but I'm still just a little spoiled from working with JavaScript frameworks. If I update the data, the html doesn't update automatically, so I'm in charge in keeping both synced. So let's see what we would be able to do if this were a framework.
2. Define the API
First, I'll make the API. This will be called "ark", because, like an ark, it will be used to put everything inside of a jQuery plugin. Let's do the above 2 things with the made-up API instead.
$('.results-list-header').ark({
html: `
<span>{{count}}</span> results near {{searchTerm}}
`,
data: {
count,
searchTerm,
}
});
To update the html with new data, we call it again, modifying the data attribute:
$('.results-list-header').ark({
data: {
count: 4,
searchTerm: '1234 Five St',
}
});
For updating the data in shorter syntax, we can use mod(), since jQuery already is using data().
$('.results-list-header').mod({count: 4, searchTerm: '1234 Five St'}); // renders again!
To modify the data directly:
$('.results-list-header').ark().count = 15; // this re-renders the header automatically
And to access the data:
let count = $('.results-list-header').ark().count;
For the list:
$('.list').ark({
html: `
<li>
<div class="name">{{name}}</div>
<div class="more-stuff">{{description}}</div>
</li>
`,
data: [
{
id: 1,
name: "First item",
description: "More stuff",
},
{
id: 2,
name: "Second item",
description: "More stuff for the second item",
},
]
});
Here are a few examples of updating the list data or accessing it, causing automatic updates to the html when changes to the data are made:
// change item in array directly, causing render
$('.list').ark()[0].name = "First item name change";
// loop through array without having to loop through each html element
$('.list').ark().forEach((item)=>{});
// push new data to array
$('.list').ark().push({
id: 3,
name: "Third item",
description: "Added a third item",
});
// replace the data altogether and render a new list
$('.list').mod([
{
id: 1,
name: "New first item",
description: "Replacing the list data altogether",
},
{
id: 2,
name: "New second item",
description: "This is a new list.",
},
]);
Here's how events would be handled:
$('.list').ark({
html: `
<li>
<div class="name">{{name}}</div>
<div class="more-stuff">{{description}}</div>
</li>
`,
data: [
{
id: 1,
name: "First item",
description: "More stuff",
},
{
id: 2,
name: "Second item",
description: "More stuff for the second item",
},
],
onClick: {
".name": (event, data)=>console.log(data.id), // passes data for element that was clicked
},
});
The onClick object takes selectors as keys and maps them to functions. When the ".name" element is clicked, access to the element's data is returned in the event handler. This makes it possible to access data such as ids without storing them in the html as a data attribute.
That's enough examples for now. The readme on GitHub outlines more of the API, including the parts that would make it much more like a framework, such as nested arks. But for now, let's discuss how it's made.
3. The Implementation
The jQuery plugin boilerplate will look like this:
(function($) {
$.fn.ark = function(props){}
$.fn.mod = function(props){}
}(jQuery));
Now let's discuss what happens when we call ark() on a jQuery object.
$.fn.ark = function(props) {
if (this.length) {
for (let i = 0; i < this.length; i++) {
initArk(this.get(i));
}
} else {
// no elements matched the query
}
jQuery doesn't return the element, but rather a jQuery object. The jQuery object can have 0 or more elements associated with it, so we need to first loop through them all and initArk() on them. The get() function returns a plain javascript html element, which we pass to initArk();
let $els = []; // we'll need to keep track of each element to access its data later on
function initArk($el) {
/*
First we check the $els array to see if the
element is already stored there, and add it
if it's not
*/
let exists = false;
$els.forEach((el) =>{
if ($el.isSameNode(el)) { // isSameNode() saves the day!
$el = el;
exists = true;
}
});
if (!exists){
$els.push($el);
}
/*
Part of our process will be to attach the ark object to each element.
jQuery lets you use $.data(element, name, data) to attach data to elements.
*/
if ($.data($el, "ark")) {
// TODO: modify ark object
} else {
ark = new Ark($el, props, true);
$.data($el, "ark", ark);
}
}
Now let's show the important parts of the Ark class.
class Ark {
constructor($el, props, first){
this.props = props;
this.html = props.html;
this._data = props.data;
this.$el = $el;
this.writeHTML(this.processHTML(this._data));
if (first) {
this.processProps(props);
}
}
The main action is there. Upon creation, we store the data that's passed in, and we write html to the page after processing the data. processProps() will come into play later when we implement event handling.
There's nothing funny about the processHTML and writeHTML functions, except that they use the template rendering function I wrote and blogged about earlier.
processHTML(data) {
let html = '';
let els = [];
/*
Because data can be an array or an object, we need to check which one it is.
This is to support this functionality: $(listElement).mod([item1, item2, item3])
as well as $(singleElement).mod({name: 'new name'})
*/
if (data && data.length) {
for (let i = 0; i < data.length; i++) {
html += this.render(this.html, data[i]);
}
} else {
html += this.render(this.html, data);
}
return html;
}
writeHTML(html){
$(this.$el).html(html);
}
If that were all there were to it, we'd be done. But we still want to be able to read the data from html elements, and we also want to be able to add events.
Let's cover the mod plugin:
$.fn.mod = function(data) {
if (this.length) {
for (let i = 0; i < this.length; i++) {
return modArk(this.get(i));
}
}
function modArk($el) {
let exists = false;
$els.forEach((el) =>{
if ($el.isSameNode(el)) {
$el = el;
exists = true;
}
});
if (exists){
// access the ark we stored on the element
let ark = $.data($el, "ark");
let arkData = ark.data;
// set data to an empty object if arkData is null or undefined
if (data) {} else {
data = {};
}
// we keep properties from the ark object as before, such as "html" and "onClick",
// but overwrite the "data" with the new data we are adding with mod()
let keys = Object.keys(data);
keys.forEach((key)=>{
arkData[key] = data[key];
});
ark.data = arkData;
let dataObj = new Data(ark, ark.data);
return dataObj;
} else {
console.error("There is no ark for that element");
}
}
}
When we call mod(newData), we want the html element to re-render. However, looking at the code above, we didn't do anything about writing new HTML. That's what getters and setters are for. Our setter for data will take care of the calls to writeHTML() whenever it gets called.
/* In the Ark class body: */
set data(_data){
this._data = _data;
this.writeHTML(this.processHTML(this._data));
}
get data(){
return this._data;
}
The other things that happened in the mod plugin were that we returned a Data object. The Data class handles one specific case. Using the getters and setters above make it so that by doing data.name = "changed", the element re-renders and displays the new name, because the setter for data calls writeHTML(). However, when we call mod() without any arguments, we are supposed to return the data from the element.
$('.my-element').mod().name = 'Robert'; // I access the data and modify it
If we return the data using the getter, as in "return ark.data", then first the getter is called. The subsequent modification to the name attribute doesn't happen through the setter, because the setter is for the "data" member, not for attributes of the data. Technically, we are modifying "name", not "data", so the setter never gets called, and the template doesn't re-render.
So, to work around this, I created the Data class. This is how it works:
class Data {
constructor(ark, data) {
let keys = Object.keys(data);
this.data = data;
this.ark = ark;
for (let i = 0; i < keys.length; i++) {
let key = keys[i];
Object.defineProperty(this, key, {
get() {return data[key]},
set(value) {
let _data = ark.data;
_data[key] = value;
ark.data = _data;
}
});
}
}
}
When you create a new Data object, it loops through all the keys that were passed in. It then dynamically creates a getter and setter for each of the keys. So now, when you modify "name", you are actually invoking the setter called "name" in the Data object. The setter just so happens to then modify the appropriate data in the associated ark object, which does call the "data" setter and triggers a re-render.
I think that's it for now. I'll continue to discuss events and include a demo in the next update.
Comments:
Leave a Comment
Submit