Imagine you're a collector, we don't care what it is you collect; it could be matchbox cars, real cars or matchboxes. You do, however, care about cataloguing your collection and sharing its details with other collectors (after first checking your locks are secure). You've spent some time thinking about normalizing the data representing your collection for inclusion in a database and crafted a secure mechanism which allows you to update your collection online. Brilliant! Now it comes to displaying your collection to your peers, how would you do that?
You could output that contents of the Database in the form of a table; each item in the collection would be a row in that table. But then you think about how else you could display it and start to get creative. You could show a card for each item, should you have pictures (and mostly, you do), you could do something fancy with a masonry layout and get them to auto flow as well. Thankfully Bootstrap v4 comes with Masonry support built-in, so you're good to go.
You know the items in your collection very well, you've spent some time thinking about them and collecting them after all. And your work on the normalized Database means that you know what their characteristics are. You've heard about Object-Oriented Programming (OOP), so why not turn your hand to instantiating each item in your collection as an object — now that we're getting serious, let's decide what you're collecting. To make things as easy as possible, let's decide upon matchbox toy cars.
This process of using a class to create concrete objects is called instantiation. The class acts like template for the object, with the object being an instance of the class.
To some extent identifying matchbox cars is dead easy, they have writing on the bottom after all. There's the name of the vehicle, the year of production; sometimes there's a number. There is also where they were built. You have some duplicates, so you'll need a field for a description so that you can distinguish between the copies. You've invested some time taking pictures and uploading them to an S3 bucket, some you've even taken multiple images of, so you'll need an array of image URIs as well, which you can display in a slideshow. That array might be empty though, as you don't take a picture immediately after cataloguing them.
In the bad old days, JavaScript wasn't a class-based object-oriented language (and perhaps it still isn't), it was prototype-based, but what does that mean? JS classes used to be written as functions so that you could write your Matchbox car like this:
/** * Matchbox Car. * * @constructor * @param {String} id - The unique from the Database. * @param {String} model - The name on the bottom. * @param {String} num - The number on the bottom. * @param {String} brand - The brand, from the bottom. * @param {Number} year - The year of production. * @param {String} location - Where the model was made. * @param {String} description - A description of the model. */ function MatchboxCar( id, model, num, brand, year, location, description ) { this.id = id; this.model = model; this.num = num; this.brand = brand; this.year = year; this.location = location; this.description = description; this.images = []; }
Writing it that way is not ideal though, all the details of the car are available to anyone with the developer console open - I know you display the details in the card, but bear with me here. It seems incorrect that all those fields are visible to, and manipulatable by, any Tom, Dick or Harry - some things should remain private. You know your fellow collectors and can imagine how much they'd relish pointing out a fault in your collection, so you decide to protect the data and make the attributes of your objects private. The protection of internal class variables is by no means a bullet-proof way of avoiding your fellow collectors taking the Mickey, but it'll have to do. With this in mind, you decide to add getters and setters to the instantiated class, but you're mindful that only the image field needs a setter. The image field is an array, so you need a way to add new images to the object after the object has been initialized, so you add the add_image
setter to your class. Once they're created, you only allow your items to be changed in limited ways and, in some instances, they don't need to be changed at all once they've been instantiated. This change gives rise to this code:
/** * Matchbox Car. * * @constructor * @param {String} id - The unique from the Database. * @param {String} model - The name on the bottom. * @param {String} num - The number on the bottom. * @param {String} brand - The brand, from the bottom. * @param {Number} year - The year of production. * @param {String} location - Where the model was made. * @param {String} description - A description of the model. */ function MatchboxCar(id, model, num, brand, year, location, description) { Object.defineProperty(this, "id", { get: function() { return id; } }); Object.defineProperty(this, "model", { get: function() { return model; } }); Object.defineProperty(this, "num", { get: function() { return num; } }); Object.defineProperty(this, "brand", { get: function() { return brand; } }); Object.defineProperty(this, "year", { get: function() { return year; } }); Object.defineProperty(this, "location", { get: function() { return location; } }); Object.defineProperty(this, "description", { get: function() { return description; } }); var images = []; Object.defineProperty(this, "images", { get: function() { return images; }, set: function(uri) { this.images.push(uri); } }); }
Having classes like this is all well and good, but what do you do with them once you've got them. Well, the purpose of the script is to show off your collection, so you need to display them. You decide to add a function (these are sometimes called methods in OOP) called display
to the prototype of your object. This function is called with a target, so you can define where the items should be inserted within the Document Object Model (DOM). This is shown in below:
/** * Display item. * * @param {String} Target - The target for insertion. */ MatchboxCar.prototype.display = function(target) { var card = document.createElement("div"); card.setAttribute("class", "card"); if (this.images.length) { var carousel = document.createElement("div"); carousel.setAttribute("class", "carousel slide"); carousel.setAttribute("data-ride", "carousel"); carousel.setAttribute("id", "Model" + this.id); var carouselInner = document.createElement("div"); carouselInner.setAttribute("class", "carousel-inner"); this.images.forEach(function(uri, index) { var carouselItem = document.createElement("div"); carouselItem.setAttribute("class", !index ? "carousel-item active" : "carousel-item"); var img = document.createElement("img"); img.setAttribute("class", "d-block w-100"); img.setAttribute("src", uri); carouselItem.appendChild(img); carouselInner.appendChild(carouselItem); carousel.appendChild(carouselInner); }.bind(this)); card.appendChild(carousel); } var domTarget = document.getElementById(target); domTarget.appendChild(card); var cardBody = document.createElement("div"); cardBody.setAttribute("class", "card-body"); card.appendChild(cardBody); var hFive = document.createElement("h5"); hFive.textContent = this.model; var br = document.createElement("br"); hFive.appendChild(br); var yearSmall = document.createElement("small"); yearSmall.setAttribute("class", "text-muted"); yearSmall.textContent = this.year; hFive.appendChild(yearSmall); cardBody.appendChild(hFive); if (this.num || this.brand || this.location) { var dl = document.createElement("dl"); cardBody.appendChild(dl); if (this.num) { var DTnum = document.createElement("dt"); DTnum.textContent = "Number"; dl.appendChild(DTnum); var DDnum = document.createElement("dd"); DDnum.textContent = this.num; dl.appendChild(DDnum); } if (this.brand) { var DTbrand = document.createElement("dt"); DTbrand.textContent = "Brand"; dl.appendChild(DTbrand); var DDbrand = document.createElement("dd"); DDbrand.textContent = this.brand; dl.appendChild(DDbrand); } if (this.location) { var DTlocation = document.createElement("dt"); DTlocation.textContent = "Made in"; dl.appendChild(DTlocation); var DDlocation = document.createElement("dd"); DDlocation.textContent = this.location; dl.appendChild(DDlocation); } } if (this.description) { var p = document.createElement("p"); p.textContent = this.description; cardBody.appendChild(p); } };
Once you've clocked that the display
method is creating and manipulating many HTML elements, you decide to make some helper methods for creating and setting the attributes of those elements; this is the updated code:
/** * Create element and set attributes. * * @param {Object} obj - The attributes of the element. * @param {string} el - The element to be created, defaults to Content Division. */ MatchboxCar.prototype.createElemWithAttributes = function(obj, el) { el = el || "div"; var element = document.createElement(el); for (var key in obj) { if (obj.hasOwnProperty(key)) { element.setAttribute(key, obj[key]); } } return element; }; /** * Create a dt/dd pair and append to target. * * @param {String} DT - The Description Term. * @param {String} DD - The Description Details. * @param {String} DL - The Description List. */ MatchboxCar.prototype.createDefinitionPair = function(dt, dd, dl) { var DT = document.createElement("dt"); DT.textContent = dt; dl.appendChild(DT); var DD = document.createElement("dd"); DD.textContent = dd; dl.appendChild(DD); }; /** * Display item. * * @param {String} Target - The target for insertion. */ MatchboxCar.prototype.display = function(target) { var card = this.createElemWithAttributes({ "class": "card" }); if (this.images.length) { var carousel = this.createElemWithAttributes({ "class": "carousel slide", "data-ride": "carousel", "id": "Model" + this.id }); var carouselInner = this.createElemWithAttributes({ "class": "carousel-inner" }); this.images.forEach(function(uri, index) { var carouselItem = this.createElemWithAttributes({ "class": !index ? "carousel-item active" : "carousel-item" }); var img = this.createElemWithAttributes({ "class": "d-block w-100", "src": uri }, "img"); carouselItem.appendChild(img); carouselInner.appendChild(carouselItem); carousel.appendChild(carouselInner); }.bind(this)); card.appendChild(carousel); } var domTarget = document.getElementById(target); domTarget.appendChild(card); var cardBody = this.createElemWithAttributes({ "class": "card-body" }); card.appendChild(cardBody); var hFive = document.createElement("h5"); hFive.textContent = this.model; var br = document.createElement("br"); hFive.appendChild(br); var yearSmall = this.createElemWithAttributes({ "class": "text-muted" }, "small"); yearSmall.textContent = this.year; hFive.appendChild(yearSmall); cardBody.appendChild(hFive); if (this.num || this.brand || this.location) { var dl = document.createElement("dl"); cardBody.appendChild(dl); if (this.num) { this.createDefinitionPair("Number", this.num, dl); } if (this.brand) { this.createDefinitionPair("Brand", this.brand, dl); } if (this.location) { this.createDefinitionPair("Made in", this.location, dl); } } if (this.description) { var p = document.createElement("p"); p.textContent = this.description; cardBody.appendChild(p); } };
You're really quite pleased with your efforts, but you've just been offered another collector's collection of cars for a rock-bottom bargain price and decide to take it — it is a steal at that price. Sure there are cars you've already got, but some of them are in better condition. You read through their list, hand over the cash and collect them later that day (after forgetting to tell your significant other the real price - the seller is more than happy to doctor the invoice for you). You get them home and immediately see that they were less discerning than you and had collected Dinky cars as well.
After getting over your shock, you clock that it's not all that bad and decide to expand your collection to include the new models. Their lack of discernment also opens up a whole new avenue for your obsession to go down. But what to do about your Database and lovely JavaScript class. Displaying Dinky cars using your MatchboxCar class seems wrong, and there is the odd difference to take into account too. The problem of the Database is easy enough to overcome as you add another field for the maker, and maybe another for the new number (more of which later).
What to do about displaying them, though? You could create a DinkyCar class, but that would duplicate significant chunks of the code from MatchboxCar. Instead, you decide that you need an ancestor class called ToyCar from which both the MatchboxCar and DinkyCar inherit some variables and functions. Those classes with specific variables and functions can add them, as required.
/** * Toy Car. * * @constructor * @param {String} manufacturer - Who made the model. * @param {String} id - The unique from the Database. * @param {String} model - The name on the bottom. * @param {String} num - The number on the bottom. * @param {String} brand - The brand, from the bottom. * @param {Number} year - The year of production. * @param {String} location - Where the model was made. * @param {String} description - A description of the model. */ function ToyCar(manufacturer, id, model, num, brand, year, location, description) { Object.defineProperty(this, "manufacturer", { get: function() { return manufacturer; } }); Object.defineProperty(this, "id", { get: function() { return id; } }); Object.defineProperty(this, "model", { get: function() { return model; } }); Object.defineProperty(this, "num", { get: function() { return num; } }); Object.defineProperty(this, "brand", { get: function() { return brand; } }); Object.defineProperty(this, "year", { get: function() { return year; } }); Object.defineProperty(this, "location", { get: function() { return location; } }); Object.defineProperty(this, "description", { get: function() { return description; } }); var images = []; Object.defineProperty(this, "images", { get: function() { return images; }, set: function(uri) { this.images.push(uri); } }); } /** * Default createHeader method for ToyCar. */ ToyCar.prototype.createHeader = function(){ return null; }; /** * Add image. * * @param {Object} obj - The attributes of the element. * @param {string} el - The element to be created, defaults to Content Division. */ ToyCar.prototype.createElemWithAttributes = function(obj, el) { el = el || "div"; var element = document.createElement(el); for (var key in obj) { if (obj.hasOwnProperty(key)) { element.setAttribute(key, obj[key]); } } return element; }; /** * Create a dl and populate * * @param {String} node - The DOM element to which we should add the definition list */ ToyCar.prototype.createDefinitionList = function(target) { if (this.num || this.brand || this.location) { var dl = document.createElement("dl"); target.appendChild(dl); this.num && this.createDefinitionPair("Number", this.num, dl); this.brand && this.createDefinitionPair("Brand", this.brand, dl); this.location && this.createDefinitionPair("Made in", this.location, dl); } } /** * Create a dt/dd pair and append to target. * * @param {String} DT - The Description Term. * @param {String} DD - The Description Details. * @param {String} DL - The Description List. */ ToyCar.prototype.createDefinitionPair = function(dt, dd, dl) { var DT = document.createElement("dt"); DT.textContent = dt; dl.appendChild(DT); var DD = document.createElement("dd"); DD.textContent = dd; dl.appendChild(DD); }; /** * Display item. * * @param {String} Target - The target for insertion. */ ToyCar.prototype.display = function(target) { var card = this.createElemWithAttributes({ "class": "card" }); card.appendChild(this.createHeader()); if (this.images.length) { var carousel = this.createElemWithAttributes({ "class": "carousel slide", "data-ride": "carousel", "id": "Model" + this.id }); var carouselInner = this.createElemWithAttributes({ "class": "carousel-inner" }); this.images.forEach(function(uri, index) { var carouselItem = this.createElemWithAttributes({ "class": !index ? "carousel-item active" : "carousel-item" }); var img = this.createElemWithAttributes({ "class": "d-block w-100", "src": uri }, "img"); carouselItem.appendChild(img); carouselInner.appendChild(carouselItem); carousel.appendChild(carouselInner); }.bind(this)); card.appendChild(carousel); } var domTarget = document.getElementById(target); domTarget.appendChild(card); var cardBody = this.createElemWithAttributes({ "class": "card-body" }); card.appendChild(cardBody); var hFive = document.createElement("h5"); hFive.textContent = this.model; var br = document.createElement("br"); hFive.appendChild(br); var yearSmall = this.createElemWithAttributes({ "class": "text-muted" }, "small"); yearSmall.textContent = this.year; hFive.appendChild(yearSmall); cardBody.appendChild(hFive); this.createDefinitionList(cardBody); if (this.description) { var p = document.createElement("p"); p.textContent = this.description; cardBody.appendChild(p); } };
Your decision to avoid using the model number as the primary key for the Database is supported when you start to look at the data for Dinky cars. It seems that there was a renumbering introduced in 1954 for some models, as such you want to add these new numbers, but only to the Dinky car objects. You also want to distinguish whether Matchbox or Dinky made the model car, so you add a createHeader
function to the ToyCar object's prototype, which returns nothing. Both the MatchboxCar and the DinkyCar classes flesh out this stub of a function; with MatchboxCar returning a header with a green background, and DinkyCar returning a title with a red background.
/** * Matchbox Car. * * @constructor * @param {String} id - The unique from the Database. * @param {String} model - The name on the bottom. * @param {String} num - The number on the bottom. * @param {String} brand - The brand, from the bottom. * @param {Number} year - The year of production. * @param {String} location - Where the model was made. * @param {String} description - A description of the model. */ function MatchboxCar(manufacturer, id, model, num, brand, year, location, description) { ToyCar.call(this, manufacturer, id, model, num, brand, year, location, description); } MatchboxCar.prototype = Object.create(ToyCar.prototype); MatchboxCar.prototype.constructor = MatchboxCar; MatchboxCar.prototype.createHeader = function(){ var cardHeader = this.createElemWithAttributes({ "class": "card-header text-white bg-success font-weight-bold" }); cardHeader.textContent = this.manufacturer; return cardHeader; }; /** * Dinky Car. * * @constructor * @param {String} id - The unique from the Database. * @param {String} model - The name on the bottom. * @param {String} num - The number on the bottom. * @param {String} num - The number after 1954. * @param {String} brand - The brand, from the bottom. * @param {Number} year - The year of production. * @param {String} location - Where the model was made. * @param {String} description - A description of the model. */ function DinkyCar(manufacturer, id, model, num, num_new, brand, year, location, description) { ToyCar.call(this, manufacturer, id, model, num, brand, year, location, description); Object.defineProperty(this, "num_new", { get: function() { return num_new; } }); } DinkyCar.prototype = Object.create(ToyCar.prototype); DinkyCar.prototype.constructor = DinkyCar; /** * Overwrites the createHeader method from ToyCar. */ DinkyCar.prototype.createHeader = function(){ var cardHeader = this.createElemWithAttributes({ "class": "card-header text-white bg-danger font-weight-bold" }); cardHeader.textContent = this.manufacturer; return cardHeader; }; /** * Create a dl and populate * * @param {String} node - The DOM element to which we should add the definition list */ DinkyCar.prototype.createDefinitionList = function(target) { if (this.num || this.num_new || this.brand || this.location) { var dl = document.createElement("dl"); target.appendChild(dl); this.num && this.createDefinitionPair("Number", this.num, dl); this.num_new && this.createDefinitionPair("Re-numbered", this.num_new, dl); this.brand && this.createDefinitionPair("Brand", this.brand, dl); this.location && this.createDefinitionPair("Made in", this.location, dl); } }
You've managed to include the four main concepts of OOP in the development of your ToyCar class. You've encapsulated the variables and functions within several classes. You've abstracted the variables of the object; protecting those variables which need to remain private. Your child classes inherit from a parent class. Finally, you've created some polymorphism in that both the MatchboxCar and DinkyCar classes override the createHeader
stub function of the ToyCar class. Smart old stick aren't you?
The above approach should work in many, if not all, browsers. But ES2016, and later, introduced some syntactic sugar to JS classes, and we'll look at refactoring our final iteration now.
We use the #
prefix to denote private variables rather than creating getters and setters - though we do need to be aware that ancestors of our parent class will still need to access those private variables using a getter. This method will save a significant amount of code, but does mean we need to be cautious. While the hash notation has not yet been accepted into the standard, it is widely used, and many JavaScript engines have adopted it.
class ToyCar { #id #model #num #brand #year #location #description #images constructor(id, model, num, brand, year, location, description){ this.#id = id this.#model = model this.#num = num this.#brand = brand this.#year = year this.#location = location this.#description = description this.#images = [] } get num() { return this.#num } get brand() { return this.#brand } get location() { return this.#location } addImage(url){ this.#images.push(url) } createHeader = () => `` createDefinitionPair = (dt, dd) => dd ? ` <dt>${dt}</dt> <dd>${dd}</dd> ` : `` createDefinitionList = () => ` <dl> ${this.createDefinitionPair('Number', this.#num)} ${this.createDefinitionPair('Brand', this.#brand)} ${this.createDefinitionPair('Made in', this.#location)} </dl> ` createCarousel = () => `<div class="carousel slide" data-ride="carousel" id="Model${this.#id}"> <div class="carousel-inner"> ${this.#images.map((img, i) => ` <div class="${!i ? 'carousel-item active' : 'carousel-item'}"> <img class="d-block w-100" src="${img}"> </div> `).join('')} </div> </div> ` display(target) { const markup = ` <div class="card"> ${this.createHeader()} ${this.#images.length && this.createCarousel()} <div class="card-body"> <h5> ${this.#model} <br> <small class="text-muted"> ${this.#year} </small> </h5> ${this.createDefinitionList()} <p> ${this.#description} </p> </div> </div> ` const domTarget = document.getElementById(target) domTarget.insertAdjacentHTML('afterbegin', markup) } } class MatchboxCar extends ToyCar { #manufacturer constructor(...args) { super(...args.splice(1)) this.#manufacturer = [...args].shift() } createHeader = () => ` <div class="card-header text-white bg-success font-weight-bold"> ${this.#manufacturer} </div> ` } class DinkyCar extends ToyCar { #num_new #manufacturer constructor(manufacturer, id, model, num, num_new, brand, year, location, description) { super(id, model, num, brand, year, location, description) this.#manufacturer = manufacturer this.#num_new = num_new } createHeader = () => ` <div class="card-header text-white bg-danger font-weight-bold"> ${this.#manufacturer} </div> ` createDefinitionList = () => ` <dl> ${this.createDefinitionPair('Number', this.num)} ${this.createDefinitionPair('Number', this.#num_new)} ${this.createDefinitionPair('Brand', this.brand)} ${this.createDefinitionPair('Made in', this.location)} </dl> ` }
We can also make use of template literals to remove the imperative style of creating and manipulating DOM elements. Rather than use append
or appendChild
as we have previously, we can instead use insertAdjacentHTML
meaning we can avoid innerHTML
manipulation. Quite apart from saving significant amounts of imperative code, this method allows much more readable code - you can understand what's happening simply by reading the code, just so long as you have a reasonable understanding of HTML.
We're also taking advantage of a shortcode for replacing the if
operator by using the logical AND (&&
) to decide if something should be displayed, we've done the same on previous iterations - but it's quite a nice way of avoiding extra code. This method of determining the conditional rendering of elements seems to have stemmed from React, and takes advantage of the fact that statements are evaluated from left to right: if the first condition resolves to true, then the following code is invoked.
That's not to say that we don't take advantage of the tertiary operator also. The createDefinitionList
method failed when it came to rendering DT/DD pairs of elements with null values, and I can only think that that was down to something about the getters in the parent class. This issue is worth further research.
The MatchboxCar class, which extends or inherits from ToyCar, plays fast and loose with its arguments as we only need to pass a subset of the initial constructor arguments to the parent class, all the while retaining the first argument - for the manufacturer variable. DinkyCar class also calls the ToyCar constructor, but in that instance, the new_num
variable is nested within the arguments, so we take a more traditional approach to passing arguments to its super constructor one by one.
We can take advantage of Export and Import directives to further improve the legibility of our code. If we split up our classes into separate files, then we can export and import them only as and when required. We do need to be careful to tell the browser to be patient though, so we can inform the JavaScript engine that we're working with modules by using the type attribute on the script element and setting it to the type module
. This modularisation does lead to far more clean looking code, but will fail on earlier browsers, so it might be worth using something like Rollup - but as things stand this lovely code is only going to work well on Chrome. Firefox doesn't yet support private fields, you see - I dare say it will soon, but at present, it doesn't. Fingers crossed for the future though!
I've now spent an entertaining weekend figuring out how to use Rollup and Babel to create a bundled file which will work on IE11 and other browsers. IE11 does not support the details/summary combination though, so I've included some CSS and a jQuery plugin by Mathias Bynens which will only be loaded if the user visits the page in IE11. All other evergreen browsers should also have no issues with the private fields or methods used, as Babel will transpile our bleeding-edge JavaScript into conformant JavaScript. I wouldn't say I like this approach, but in this instance, the weekend was well spent as this should provide me and you with a boilerplate solution for future projects. Feel free to borrow the same approach if it helps you. The minimal package.json
, rollup.config.js
and bable.config.js
files in the repository should set you right.
I hope you've enjoyed reading this as much as I have enjoyed writing it - it's going to be a chapter in my next book, but I thought it'd work well as a standalone piece in its own right. The code is on GitHub and the working solution is on repl.it so please do have a play. I've come an awful long way since answering, "OOP is a solution looking for a problem". Something I said when asked to explain what OOP was way back when in an interview - what a plonker! We've looked at the four main Object-oriented concepts (as explained to a 6-year-old).
I've like to thank both Dr Magdalena Pietka-Eddleston (The Evil Doctor Magma) and Paweł Dawczak for their advice and patience while reading this, they've both been really helpful and have made this much more understandable. The joys of a 70's education mean that I know nothing of the rules of English in a formal sense, knowing people who actually understand the rules is unbelievably helpful!
No comments:
Post a Comment