The purpose of this article is to demonstrate how to write “elegant” code in pure Javascript using OOP skills. When we say "elegant," we mean well-designed, easily extensible, easily maintainable, easily testable, responsibility-driven designed code.
As we know, Javascript is a multiparadigm programming language. This language encapsulates functional, imperative, event-driven, aspect-oriented, and prototype-based Object-Oriented paradigms in itself. In the last few years, the ECMA standard has greatly changed, now it allows us to write “elegant” code.
Most of us use only functional Javascript in most cases, but after reading this, I hope it will motivate you to use object-oriented aspects in JS.
Below are JS topics discussed in this article:
- Modular components and import/export
- Async/await
- Promises ( via fetch)
- Dividing into correct modules
- Prototype-oriented extensibility
- Creating abstract classes
- OOP ( Object-oriented programming rules)
The purpose of the project
The app we’re going to create will call an API and render its results in UI. You can download source code here.
Simple description
We have an index.html page, and when it opens, it will send a request to the API we call. This will take only required information from the API, and using pagination, it will render it. Every item contains an image, and when clicking on that image, we will see more details about the object.
PS: We didn’t realize caching here. As an interesting challenge, you can implement it.
After clicking on the image, we open the detail.html page.
Let’s get started!
For rendering data inside index.html, we have a special entry point. It is an initialize.js file.
import { UrlBuilder } from '/js/lib/extensions/urlBuilderExtensions.js'
import RestAPI from '/js/lib/restAPI.js'
import DOMRender from '/js/lib/domRender.js'
import PaginationRender from '/js/lib/paginationRender.js'
import SearchParser from '/js/lib/searchParser.js'
export { UrlBuilder, RestAPI, DOMRender, PaginationRender, SearchParser }
For the calling page we will type:
import { UrlBuilder, RestAPI, PaginationRender, DOMRender, SearchParser } from './js/initialize.js'
We have five main modules in our application:
- UrlBuilder - helps us to create a URL dynamically
- RestAPI – sends requests
- PaginationRender- for pagination
- DomRender – helper for working with DOM
- SearchParser- url parameter handler
Let's start with UrlBuilder. The main purpose of that class is to manage and generate URLs for our application. It is also responsible for generating URL parts in the correct order. For example, it avoids writing segments after query strings.
import String from './extensions/stringExtensions.js'
export default class UrlBuilder {
#url = new String().empty();
#segmentSeparator = "/";
#withQueryString = "?";
#andwithQueryString = "&";
constructor(baseUrl) {
this.#url = baseUrl;
}
segment(segment) {
this.#url += this._makeSegment(segment);
return this;
}
withQueryString(key, value) {
this.#url += this._makewithQueryString(key, value);
return this;
}
build() {
return this.#url;
}
_alreadyHaswithQueryString() {
return this.#url.indexOf(this.#withQueryString) > 0;
}
_makeSegment(segment) {
if (this._alreadyHaswithQueryString()) throw new Error("Url building is invalid! Use segments before query string!!");
return this.#segmentSeparator + segment;
}
_makewithQueryString(qStr, val) {
if (this._alreadyHaswithQueryString()) return this.#andwithQueryString.concat(qStr, "=", val);
else return this.#withQueryString.concat(qStr, "=", val)
}
}
For adapting UrlBuilder to the project, we have added extensions. These allow us to build URLs easily.
import UrlBuilder from '../urlBuilder.js'
//most used param, so as prototype
UrlBuilder.prototype.id = function(id) {
return this.segment(id);
}
UrlBuilder.prototype.posts = function() {
return this.segment("posts");
}
UrlBuilder.prototype.comments = function() {
return this.segment("comments");
}
UrlBuilder.prototype.users = function() {
return this.segment("users");
}
export {
UrlBuilder
}
You can use URL builder inside the application, like what is pictured below:
//prepare url
let url = new UrlBuilder(baseUrl).users().build();
console.log(url);
//console pəncrəsində bu görünür: https://jsonplaceholder.typicode.com/users
For working with pagination, we have SearchParser class. Its responsibility is handling parameters in URL. We need these types of parameters when rendering pagination elements. For example, mysite/index.html?page=1 should render first six elements in a page.
export default class SearchParser {
#key = null;
constructor(key) {
this._searchContent = location.search;
this.#key = key == undefined ? "page" : key;
}
get Key() {
return this._searchContent.substring(1, this._searchContent.indexOf("="));
}
get Value() {
let searchValue = this._searchContent.substr(this._searchContent.indexOf("=") + 1);
if (this.Key == this.#key) {
let value = 1;
if (searchValue != null && searchValue != undefined) {
value = parseInt(searchValue);
}
return value;
} else return 1;
}
isPaginatable() {
return this.Key == this.#key;
}
}
For sending requests and configuring search parameters, we have the RESTAPI class. The purpose of this class is to send async requests to the given URL. (I implemented this class partially to send only requests.)
export default class RestAPI {
async queryDataAsync(url) {
return await fetch(url).then(rspns => rspns.json());
}
async commandDataAsync(url, params) {
//not realized yet.
await fetch(url, params).then(response => response.json())
}
}
After getting a response to our request, we need to call DomRender to render information. This class can render information directly or “scan recursively." We implemented another DataRender class as an abstract class. This is how JS creates abstract classes:
export default class DataRender {
_data = [];
getData() {
throw new Error("method is not implemented yet");
}
render() {
throw new Error("method is not implemented yet");
}
setData(data) {
throw new Error("method is not implemented yet");
}
}
DomRender is a class that can render data without pagination functionality.
import DataRender from './dataRender.js';
import ProfileBuilder from './profileBuilder.js'
export default class DOMRender extends DataRender {
constructor(data) {
super();
this._data = data;
}
getData() {
return this._data;
}
setData(data) {
this._data = data;
}
_renderRecursively(obj, arr, root) {
for (let key in obj) {
if (typeof obj[key] == "object") {
this._renderRecursively(obj[key], arr, key);
} else {
if (typeof root != "string") {
arr.set(key, obj[key]);
} else {
arr.set(root + "-" + key, obj[key]);
}
}
}
}
_makeSelector(obj) {
let keyValuePair = {
key: Object.keys(obj)[0],
value: Object.values(obj)[0]
}
return keyValuePair;
}
_renderPartially(selector, ...props) {
for (let dataItem of this._data) {
let containerSelector = this._makeSelector(dataItem);
let profile = new ProfileBuilder(containerSelector).addPhoto('img/user.png');
props.forEach((x) => {
profile = profile.addElement(x, dataItem[x]);
});
$(selector).append(profile.build());
}
}
_renderAll(selector) {
let dictionary = new Map();
for (let dataItem of this._data) {
this._renderRecursively(dataItem, dictionary, this._data);
let containerSelector = this._makeSelector(dataItem);
let profile = new ProfileBuilder(containerSelector).addPhoto('img/user.png');
dictionary.forEach((val, key) => {
profile.addElement(key, val);
});
$(selector).append(profile.build());
}
}
render(selector, ...props) {
if (props.length == 0) {
this._renderAll(selector);
} else {
this._renderPartially(selector, ...props);
}
}
}
DomRender uses ProfileBuilder to fill marked div containers. This is something like a component in asp.net.
export default class ProfileBuilder {
#domComponent = $("<div class='profile-container'></div>");
constructor(marker) {
let _class = "profile-container";
this.#domComponent = $(`<div class='${_class}'></div>`);
if (marker != undefined && marker != null && marker != "") {
this.#domComponent.attr(`data-${marker.key}`, marker.value);
}
}
addPhoto(src) {
let img = $(`<img src='${src}' class='profile-img'>`);
this.#domComponent.append(img);
return this;
}
addElement(key, text) {
let domItem = $(`<div class='profile-item profile-${key}'></div>`)
.html(`<p><span class='profile-item-key profile-item-${key}'>${key}</span> >span class='profile-item-value profile-item-${text}'>${text} </span></p>`);
this.#domComponent.append(domItem);
return this;
}
build() {
return this.#domComponent;
}
}
The steps to render data are:
//prepare url
let url = new UrlBuilder(baseUrl).users().build();
let searchHelper = new SearchParser();
//prepare api
let restAPI = new RestAPI();
//send query
let response = await restAPI.queryDataAsync(url);
//prepare to render
let domRender = new DOMRender(response);
//render all information without pagination
domRender.render("#container", "name", "email");
Here is what we have after rendering the data:
As you can see, we see the API data without pagination. Now, we create the PaginationRender class for rendering six elements per pagination item.
export default class PaginationRender {
#itemsPerPage = 6;
#data = [];
#totalPages = 0;
constructor(dataRender) {
this._dataRender = dataRender;
this.#data = dataRender.getData();
this.#totalPages = Math.ceil(this.#data.length / this.#itemsPerPage);
}
render(selector, page, ...props) {
let from = (page - 1) * this.#itemsPerPage;
let to = from + this.#itemsPerPage;
let filteredData = this.#data.filter(x => x.id >= (from + 1) && x.id <= to);
this._dataRender.setData(filteredData);
this._dataRender.render(selector, ...props);
this._renderPagination();
}
_renderPagination() {
let paginationContainer = $("<div class='pagination-container'></div>");
for (let i = 1; i <= this.#totalPages; i++) {
let linkElement = $(`<a class='btn btn-pagination' href='?page=${i}'>${i}</a>`);
paginationContainer.append(linkElement);
}
$(document.body).append(paginationContainer);
}
}
The purpose of PaginationRender is to extend DataReader. It delegates data render and makes it reuse code from the DataReader family.
//prepare to render
// let pagination = new DOMRender(response);
let pagination = new PaginationRender(new DOMRender(response));
pagination.render("#container", searchHelper.Value, "name", "email");
Without the 3rd and 4th argument, it renders all information.
You can see how pagination is formalized on the HTML page:
At the end of the index page, we have a special code fragment for click functionality. There is no need to create a separate js file for it, because it directly depended on index.html (in this example).
import { UrlBuilder, RestAPI, PaginationRender, DOMRender, SearchParser } from './js/initialize.js'
$(document).ready(async function() {
const baseUrl = "https://jsonplaceholder.typicode.com";
//prepare url
let url = new UrlBuilder(baseUrl).users().build();
console.log(url);
//console pəncrəsində bu görünür: https://jsonplaceholder.typicode.com/users
let searchHelper = new SearchParser();
//prepare api
let restAPI = new RestAPI();
//send query
let response = await restAPI.queryDataAsync(url);
//prepare to render
// let pagination = new DOMRender(response);
let pagination = new PaginationRender(new DOMRender(response));
pagination.render("#container", searchHelper.Value, "name", "email");
$('img').click(function() {
let itemId = $(this).parent().attr('data-id');
$(location).attr('href', `detail.html?user=${itemId}`);
})
});
Let's see how the details page will look:
import { UrlBuilder, RestAPI, DOMRender, SearchParser } from './js/initialize.js'
$(document).ready(async function() {
const baseUrl = "https://jsonplaceholder.typicode.com";
let searchHelper = new SearchParser("user");
//prepare url
let url = new UrlBuilder(baseUrl).users().id(searchHelper.Value).build();
//prepare api
let restAPI = new RestAPI();
//send query
let response = await restAPI.queryDataAsync(url);
//prepare dom
let domRender = new DOMRender(new Array(response));
domRender.render("#container");
});
And the final rendering for details: